| | |
| | |
| |
|
| | """ |
| | Unit tests for the Security Scanner service |
| | """ |
| |
|
| | import unittest |
| | from unittest.mock import patch, MagicMock, mock_open |
| | import os |
| | import sys |
| | import json |
| | from pathlib import Path |
| |
|
| | |
| | project_root = Path(__file__).resolve().parent.parent |
| | sys.path.insert(0, str(project_root)) |
| |
|
| | from src.services.security_scanner import SecurityScanner |
| |
|
| |
|
| | class TestSecurityScanner(unittest.TestCase): |
| | """Test cases for the SecurityScanner class""" |
| |
|
| | def setUp(self): |
| | """Set up test fixtures""" |
| | self.scanner = SecurityScanner() |
| | self.test_repo_path = "/test/repo" |
| | |
| | @patch('os.path.exists') |
| | @patch('subprocess.run') |
| | def test_scan_python_dependencies(self, mock_run, mock_exists): |
| | """Test scan_python_dependencies method""" |
| | |
| | mock_exists.return_value = True |
| | |
| | |
| | with patch('builtins.open', mock_open(read_data="requests==2.25.1\ndjango==2.2.0\n")): |
| | |
| | mock_process = MagicMock() |
| | mock_process.returncode = 0 |
| | mock_process.stdout = json.dumps({ |
| | "vulnerabilities": [ |
| | { |
| | "package_name": "django", |
| | "vulnerable_spec": "<2.2.28", |
| | "installed_version": "2.2.0", |
| | "description": "Django before 2.2.28 has a potential directory traversal via ../ in the file name.", |
| | "id": "CVE-2022-34265", |
| | "cvss_v3_score": "7.5" |
| | } |
| | ] |
| | }) |
| | mock_run.return_value = mock_process |
| | |
| | |
| | result = self.scanner.scan_python_dependencies(self.test_repo_path) |
| | |
| | |
| | self.assertEqual(len(result['vulnerabilities']), 1) |
| | self.assertEqual(result['vulnerability_count'], 1) |
| | self.assertEqual(result['vulnerabilities'][0]['package'], 'django') |
| | self.assertEqual(result['vulnerabilities'][0]['installed_version'], '2.2.0') |
| | self.assertEqual(result['vulnerabilities'][0]['vulnerability_id'], 'CVE-2022-34265') |
| | self.assertEqual(result['vulnerabilities'][0]['severity'], 'high') |
| | |
| | @patch('os.path.exists') |
| | @patch('subprocess.run') |
| | def test_scan_javascript_dependencies(self, mock_run, mock_exists): |
| | """Test scan_javascript_dependencies method""" |
| | |
| | mock_exists.return_value = True |
| | |
| | |
| | mock_process = MagicMock() |
| | mock_process.returncode = 0 |
| | mock_process.stdout = json.dumps({ |
| | "vulnerabilities": { |
| | "lodash": [ |
| | { |
| | "name": "lodash", |
| | "severity": "high", |
| | "via": [ |
| | { |
| | "source": 1065, |
| | "name": "lodash", |
| | "dependency": "lodash", |
| | "title": "Prototype Pollution", |
| | "url": "https://npmjs.com/advisories/1065", |
| | "severity": "high", |
| | "range": "<4.17.12" |
| | } |
| | ], |
| | "effects": [], |
| | "range": "<4.17.12", |
| | "nodes": ["node_modules/lodash"], |
| | "fixAvailable": true |
| | } |
| | ] |
| | } |
| | }) |
| | mock_run.return_value = mock_process |
| | |
| | |
| | result = self.scanner.scan_javascript_dependencies(self.test_repo_path) |
| | |
| | |
| | self.assertEqual(len(result['vulnerabilities']), 1) |
| | self.assertEqual(result['vulnerability_count'], 1) |
| | self.assertEqual(result['vulnerabilities'][0]['package'], 'lodash') |
| | self.assertEqual(result['vulnerabilities'][0]['severity'], 'high') |
| | self.assertEqual(result['vulnerabilities'][0]['title'], 'Prototype Pollution') |
| | |
| | @patch('os.path.exists') |
| | @patch('subprocess.run') |
| | def test_scan_go_dependencies(self, mock_run, mock_exists): |
| | """Test scan_go_dependencies method""" |
| | |
| | mock_exists.return_value = True |
| | |
| | |
| | mock_process = MagicMock() |
| | mock_process.returncode = 0 |
| | mock_process.stdout = json.dumps({ |
| | "Vulns": [ |
| | { |
| | "ID": "GO-2020-0015", |
| | "Details": "Improper certificate validation in crypto/x509", |
| | "Affected": [ |
| | { |
| | "Module": { |
| | "Path": "golang.org/x/crypto", |
| | "Versions": [ |
| | { |
| | "Fixed": "v0.0.0-20200221170555-0f29369cfe45" |
| | } |
| | ] |
| | }, |
| | "Packages": [ |
| | { |
| | "Path": "golang.org/x/crypto/cryptobyte", |
| | "Symbols": ["String.ReadASN1"] |
| | } |
| | ] |
| | } |
| | ], |
| | "References": [ |
| | { |
| | "Type": "FIX", |
| | "URL": "https://go.dev/cl/219877" |
| | }, |
| | { |
| | "Type": "REPORT", |
| | "URL": "https://go.dev/issue/36837" |
| | }, |
| | { |
| | "Type": "WEB", |
| | "URL": "https://nvd.nist.gov/vuln/detail/CVE-2020-7919" |
| | } |
| | ], |
| | "Description": "Due to improper bounds checking, maliciously crafted X.509 certificates can cause a panic in certificate verification.", |
| | "CVEs": ["CVE-2020-7919"], |
| | "Severity": "MODERATE" |
| | } |
| | ] |
| | }) |
| | mock_run.return_value = mock_process |
| | |
| | |
| | result = self.scanner.scan_go_dependencies(self.test_repo_path) |
| | |
| | |
| | self.assertEqual(len(result['vulnerabilities']), 1) |
| | self.assertEqual(result['vulnerability_count'], 1) |
| | self.assertEqual(result['vulnerabilities'][0]['package'], 'golang.org/x/crypto') |
| | self.assertEqual(result['vulnerabilities'][0]['vulnerability_id'], 'GO-2020-0015') |
| | self.assertEqual(result['vulnerabilities'][0]['severity'], 'medium') |
| | |
| | @patch('os.path.exists') |
| | @patch('subprocess.run') |
| | def test_scan_rust_dependencies(self, mock_run, mock_exists): |
| | """Test scan_rust_dependencies method""" |
| | |
| | mock_exists.return_value = True |
| | |
| | |
| | mock_process = MagicMock() |
| | mock_process.returncode = 0 |
| | mock_process.stdout = json.dumps({ |
| | "vulnerabilities": { |
| | "RUSTSEC-2020-0071": { |
| | "advisory": { |
| | "id": "RUSTSEC-2020-0071", |
| | "package": "smallvec", |
| | "title": "Buffer overflow in SmallVec::insert_many", |
| | "description": "Affected versions of smallvec did not properly calculate capacity when inserting multiple elements, which could result in a buffer overflow.", |
| | "date": "2020-12-02", |
| | "aliases": ["CVE-2021-25900"], |
| | "categories": ["memory-corruption"], |
| | "keywords": ["buffer-overflow", "heap-overflow"], |
| | "cvss": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", |
| | "related": [] |
| | }, |
| | "versions": { |
| | "patched": [">=1.6.1"], |
| | "unaffected": ["<1.0.0"] |
| | }, |
| | "affected": { |
| | "arch": [], |
| | "os": [], |
| | "functions": ["smallvec::SmallVec::insert_many"] |
| | } |
| | } |
| | }, |
| | "warnings": [] |
| | }) |
| | mock_run.return_value = mock_process |
| | |
| | |
| | result = self.scanner.scan_rust_dependencies(self.test_repo_path) |
| | |
| | |
| | self.assertEqual(len(result['vulnerabilities']), 1) |
| | self.assertEqual(result['vulnerability_count'], 1) |
| | self.assertEqual(result['vulnerabilities'][0]['package'], 'smallvec') |
| | self.assertEqual(result['vulnerabilities'][0]['vulnerability_id'], 'RUSTSEC-2020-0071') |
| | self.assertEqual(result['vulnerabilities'][0]['title'], 'Buffer overflow in SmallVec::insert_many') |
| | self.assertEqual(result['vulnerabilities'][0]['severity'], 'critical') |
| | |
| | @patch('os.path.exists') |
| | @patch('subprocess.run') |
| | def test_scan_python_code(self, mock_run, mock_exists): |
| | """Test scan_python_code method""" |
| | |
| | mock_exists.return_value = True |
| | |
| | |
| | mock_process = MagicMock() |
| | mock_process.returncode = 0 |
| | mock_process.stdout = json.dumps({ |
| | "results": [ |
| | { |
| | "filename": "test.py", |
| | "line_number": 42, |
| | "issue_severity": "HIGH", |
| | "issue_confidence": "HIGH", |
| | "issue_text": "Possible hardcoded password: 'super_secret'", |
| | "test_id": "B105", |
| | "test_name": "hardcoded_password_string" |
| | } |
| | ] |
| | }) |
| | mock_run.return_value = mock_process |
| | |
| | |
| | with patch.object(self.scanner, '_find_files', return_value=['/test/repo/test.py']): |
| | |
| | result = self.scanner.scan_python_code(self.test_repo_path) |
| | |
| | |
| | self.assertEqual(len(result['vulnerabilities']), 1) |
| | self.assertEqual(result['vulnerability_count'], 1) |
| | self.assertEqual(result['vulnerabilities'][0]['file'], 'test.py') |
| | self.assertEqual(result['vulnerabilities'][0]['line'], 42) |
| | self.assertEqual(result['vulnerabilities'][0]['severity'], 'high') |
| | self.assertEqual(result['vulnerabilities'][0]['message'], "Possible hardcoded password: 'super_secret'") |
| | |
| | @patch('os.path.exists') |
| | @patch('subprocess.run') |
| | def test_scan_javascript_code(self, mock_run, mock_exists): |
| | """Test scan_javascript_code method""" |
| | |
| | mock_exists.return_value = True |
| | |
| | |
| | mock_process = MagicMock() |
| | mock_process.returncode = 0 |
| | mock_process.stdout = json.dumps([ |
| | { |
| | "filePath": "/test/repo/test.js", |
| | "messages": [ |
| | { |
| | "ruleId": "security/detect-eval-with-expression", |
| | "severity": 2, |
| | "message": "eval() with variable content can allow an attacker to run arbitrary code.", |
| | "line": 10, |
| | "column": 1, |
| | "nodeType": "CallExpression" |
| | } |
| | ], |
| | "errorCount": 1, |
| | "warningCount": 0, |
| | "fixableErrorCount": 0, |
| | "fixableWarningCount": 0 |
| | } |
| | ]) |
| | mock_run.return_value = mock_process |
| | |
| | |
| | with patch.object(self.scanner, '_find_files', return_value=['/test/repo/test.js']): |
| | |
| | result = self.scanner.scan_javascript_code(self.test_repo_path) |
| | |
| | |
| | self.assertEqual(len(result['vulnerabilities']), 1) |
| | self.assertEqual(result['vulnerability_count'], 1) |
| | self.assertEqual(result['vulnerabilities'][0]['file'], 'test.js') |
| | self.assertEqual(result['vulnerabilities'][0]['line'], 10) |
| | self.assertEqual(result['vulnerabilities'][0]['severity'], 'high') |
| | self.assertEqual(result['vulnerabilities'][0]['message'], "eval() with variable content can allow an attacker to run arbitrary code.") |
| | |
| | def test_scan_repository(self): |
| | """Test scan_repository method""" |
| | |
| | self.scanner.scan_python_dependencies = MagicMock(return_value={ |
| | 'vulnerabilities': [{'package': 'django', 'vulnerability_id': 'CVE-2022-34265', 'severity': 'high'}], |
| | 'vulnerability_count': 1 |
| | }) |
| | self.scanner.scan_python_code = MagicMock(return_value={ |
| | 'vulnerabilities': [{'file': 'test.py', 'line': 42, 'severity': 'high'}], |
| | 'vulnerability_count': 1 |
| | }) |
| | self.scanner.scan_javascript_dependencies = MagicMock(return_value={ |
| | 'vulnerabilities': [{'package': 'lodash', 'severity': 'high'}], |
| | 'vulnerability_count': 1 |
| | }) |
| | self.scanner.scan_javascript_code = MagicMock(return_value={ |
| | 'vulnerabilities': [{'file': 'test.js', 'line': 10, 'severity': 'high'}], |
| | 'vulnerability_count': 1 |
| | }) |
| | |
| | |
| | result = self.scanner.scan_repository(self.test_repo_path, ['Python', 'JavaScript']) |
| | |
| | |
| | self.assertEqual(len(result), 2) |
| | self.assertIn('Python', result) |
| | self.assertIn('JavaScript', result) |
| | |
| | |
| | self.assertEqual(result['Python']['dependency_vulnerabilities']['vulnerability_count'], 1) |
| | self.assertEqual(result['Python']['code_vulnerabilities']['vulnerability_count'], 1) |
| | self.assertEqual(result['Python']['total_vulnerabilities'], 2) |
| | |
| | |
| | self.assertEqual(result['JavaScript']['dependency_vulnerabilities']['vulnerability_count'], 1) |
| | self.assertEqual(result['JavaScript']['code_vulnerabilities']['vulnerability_count'], 1) |
| | self.assertEqual(result['JavaScript']['total_vulnerabilities'], 2) |
| | |
| | |
| | self.scanner.scan_python_dependencies.assert_called_once_with(self.test_repo_path) |
| | self.scanner.scan_python_code.assert_called_once_with(self.test_repo_path) |
| | self.scanner.scan_javascript_dependencies.assert_called_once_with(self.test_repo_path) |
| | self.scanner.scan_javascript_code.assert_called_once_with(self.test_repo_path) |
| | |
| | @patch('os.walk') |
| | def test_find_files(self, mock_walk): |
| | """Test _find_files method""" |
| | |
| | mock_walk.return_value = [ |
| | ('/test/repo', ['dir1'], ['file1.py', 'file2.js']), |
| | ('/test/repo/dir1', [], ['file3.py']) |
| | ] |
| | |
| | |
| | python_files = self.scanner._find_files(self.test_repo_path, '.py') |
| | |
| | |
| | self.assertEqual(len(python_files), 2) |
| | self.assertIn('/test/repo/file1.py', python_files) |
| | self.assertIn('/test/repo/dir1/file3.py', python_files) |
| | |
| | @patch('os.path.exists') |
| | def test_check_tool_availability(self, mock_exists): |
| | """Test _check_tool_availability method""" |
| | |
| | mock_exists.side_effect = [True, False] |
| | |
| | |
| | result1 = self.scanner._check_tool_availability('tool1') |
| | result2 = self.scanner._check_tool_availability('tool2') |
| | |
| | |
| | self.assertTrue(result1) |
| | self.assertFalse(result2) |
| | |
| | @patch('subprocess.run') |
| | def test_run_command(self, mock_run): |
| | """Test _run_command method""" |
| | |
| | mock_process = MagicMock() |
| | mock_process.returncode = 0 |
| | mock_process.stdout = "Test output" |
| | mock_run.return_value = mock_process |
| | |
| | |
| | returncode, output = self.scanner._run_command(['test', 'command']) |
| | |
| | |
| | self.assertEqual(returncode, 0) |
| | self.assertEqual(output, "Test output") |
| | mock_run.assert_called_once() |
| | |
| | def test_map_cvss_to_severity(self): |
| | """Test _map_cvss_to_severity method""" |
| | |
| | low = self.scanner._map_cvss_to_severity(3.5) |
| | medium = self.scanner._map_cvss_to_severity(5.5) |
| | high = self.scanner._map_cvss_to_severity(8.0) |
| | critical = self.scanner._map_cvss_to_severity(9.5) |
| | |
| | |
| | self.assertEqual(low, 'low') |
| | self.assertEqual(medium, 'medium') |
| | self.assertEqual(high, 'high') |
| | self.assertEqual(critical, 'critical') |
| |
|
| |
|
| | if __name__ == "__main__": |
| | unittest.main() |