diff --git a/apps/backend/analysis/security_scanner.py b/apps/backend/analysis/security_scanner.py index 77016612e..915c75d6e 100644 --- a/apps/backend/analysis/security_scanner.py +++ b/apps/backend/analysis/security_scanner.py @@ -25,6 +25,7 @@ import json import logging +import os import subprocess from dataclasses import dataclass, field from pathlib import Path @@ -370,6 +371,11 @@ def _run_dependency_audits( if (project_dir / "composer.lock").exists(): self._run_composer_audit(project_dir, result) + # osv-scanner for ecosystems without a dedicated audit tool + # (JVM, .NET, Elixir, Swift, Dart, Go modules) + if self._has_osv_manifests(project_dir): + self._run_osv_scanner(project_dir, result) + def _run_npm_audit(self, project_dir: Path, result: SecurityScanResult) -> None: """Run npm audit for JavaScript projects.""" try: @@ -656,6 +662,105 @@ def _run_composer_audit( except Exception as e: result.scan_errors.append(f"composer audit error: {str(e)}") + # Manifests handled by osv-scanner for ecosystems that have no dedicated + # audit runner above (npm/pip/cargo/composer manifests are excluded to + # avoid double-reporting) + OSV_MANIFESTS = ( + "pom.xml", + "gradle.lockfile", + "buildscript-gradle.lockfile", + "packages.lock.json", + "mix.lock", + "Package.resolved", + "pubspec.lock", + "go.mod", + ) + + # Vendor/build directories skipped when searching for nested manifests + _MANIFEST_SKIP_DIRS = frozenset( + { + ".git", + "node_modules", + ".venv", + "venv", + "__pycache__", + "target", + "build", + "dist", + "vendor", + } + ) + + def _has_osv_manifests(self, project_dir: Path) -> bool: + """Check for manifests osv-scanner should audit (recursively).""" + manifest_names = set(self.OSV_MANIFESTS) + for _root, dirs, files in os.walk(project_dir): + dirs[:] = [d for d in dirs if d not in self._MANIFEST_SKIP_DIRS] + if manifest_names.intersection(files): + return True + return False + + def _run_osv_scanner(self, project_dir: Path, result: SecurityScanResult) -> None: + """Run osv-scanner against known-vulnerability database.""" + try: + # osv-scanner exits non-zero when vulns are found; parse stdout + cmd = ["osv-scanner", "--format", "json", "-r", "."] + + proc = subprocess.run( + cmd, + cwd=project_dir, + capture_output=True, + text=True, + encoding="utf-8", + timeout=300, + ) + + if proc.stdout: + try: + osv_output = json.loads(proc.stdout) + self._parse_osv_output(osv_output, result) + except json.JSONDecodeError: + result.scan_errors.append("Failed to parse osv-scanner output") + + except FileNotFoundError: + logger.debug("osv-scanner not available") + except subprocess.TimeoutExpired: + result.scan_errors.append("osv-scanner timed out") + except Exception as e: + result.scan_errors.append(f"osv-scanner error: {str(e)}") + + @staticmethod + def _osv_severity(vuln: dict) -> str: + """Map an OSV vulnerability payload to a severity level.""" + raw = str(vuln.get("database_specific", {}).get("severity", "")).lower() + if raw in ("critical", "high", "medium", "low"): + return raw + if raw == "moderate": + return "medium" + # Unknown severity on a known vulnerability: treat as high + return "high" + + def _parse_osv_output(self, osv_output: dict, result: SecurityScanResult) -> None: + """Convert osv-scanner JSON results into vulnerabilities.""" + for scan_result in osv_output.get("results", []): + source = scan_result.get("source", {}).get("path", "") + file = Path(source).name if source else None + for package in scan_result.get("packages", []): + pkg_name = package.get("package", {}).get("name", "?") + for vuln in package.get("vulnerabilities", []): + result.vulnerabilities.append( + SecurityVulnerability( + severity=self._osv_severity(vuln), + source="osv_scanner", + title=( + f"Vulnerable dependency: {pkg_name} " + f"({vuln.get('id', '?')})" + ), + description=vuln.get("summary", ""), + file=file, + ) + ) + def _run_predictive_scan( self, project_dir: Path, result: SecurityScanResult ) -> None: diff --git a/apps/backend/security/language_rules.py b/apps/backend/security/language_rules.py index 5c7ab9e32..54cb8df23 100644 --- a/apps/backend/security/language_rules.py +++ b/apps/backend/security/language_rules.py @@ -6,6 +6,10 @@ Defines language-specific security tools and common security patterns. """ +# Shared guidance strings (JVM languages) +_USE_SECURE_RANDOM_JVM = "Use java.security.SecureRandom for random numbers" + + # ============================================================================= # LANGUAGE SECURITY SCANNERS # ============================================================================= @@ -59,6 +63,39 @@ "clang-tidy", # Clang-based linter (includes security checks) "flawfinder", # C/C++ security weakness scanner }, + "java": { + "spotbugs", # Java bytecode static analysis (with find-sec-bugs) + "semgrep", # Multi-language security scanner + "osv-scanner", # Known-vulnerability scanner for Maven/Gradle deps + }, + "kotlin": { + "detekt", # Kotlin static analysis + "semgrep", # Multi-language security scanner + "osv-scanner", # Known-vulnerability scanner for Maven/Gradle deps + }, + "csharp": { + "security-scan", # Security Code Scan for .NET + "semgrep", # Multi-language security scanner + "osv-scanner", # Known-vulnerability scanner for NuGet deps + }, + "elixir": { + "sobelow", # Phoenix/Elixir security scanner + "credo", # Elixir static analysis + "osv-scanner", # Known-vulnerability scanner for Hex deps + }, + "swift": { + "swiftlint", # Swift linter + "osv-scanner", # Known-vulnerability scanner for SwiftPM deps + }, + "scala": { + "scalafix", # Scala linter/refactoring tool + "semgrep", # Multi-language security scanner + "osv-scanner", # Known-vulnerability scanner for sbt/Maven deps + }, + "dart": { + "dart-analyze", # Dart static analysis (dart analyze) + "osv-scanner", # Known-vulnerability scanner for pub deps + }, } @@ -288,6 +325,145 @@ "Validate and sanitize all user inputs", ], }, + "java": { + "dangerous_functions": [ + "Runtime.exec", # Command injection risk + "ProcessBuilder", # Command injection risk + "ObjectInputStream", # Deserialization vulnerability + "XMLDecoder", # Deserialization vulnerability + "Class.forName", # Reflection-based code loading + "ScriptEngine.eval", # Code injection + ], + "unsafe_patterns": [ + "createStatement", # SQL injection, use PreparedStatement + "DocumentBuilderFactory", # XXE unless secure processing enabled + "TrustAllCerts", # Disabled TLS validation + "MD5", # Weak hash algorithm + "SHA1", # Weak hash algorithm + ], + "secure_alternatives": [ + "Use PreparedStatement with parameterized queries", + "Enable FEATURE_SECURE_PROCESSING on XML factories", + _USE_SECURE_RANDOM_JVM, + "Avoid Java serialization; prefer JSON with strict typing", + "Validate and sanitize all user inputs", + ], + }, + "kotlin": { + "dangerous_functions": [ + "Runtime.exec", # Command injection risk + "ProcessBuilder", # Command injection risk + "ObjectInputStream", # Deserialization vulnerability + "ScriptEngine.eval", # Code injection + ], + "unsafe_patterns": [ + "!!", # Non-null assertion, runtime crash risk + "createStatement", # SQL injection, use PreparedStatement + "TrustAllCerts", # Disabled TLS validation + ], + "secure_alternatives": [ + "Use safe calls (?.) and requireNotNull instead of !!", + "Use PreparedStatement with parameterized queries", + _USE_SECURE_RANDOM_JVM, + "Validate and sanitize all user inputs", + ], + }, + "csharp": { + "dangerous_functions": [ + "Process.Start", # Command injection risk + "BinaryFormatter", # Deserialization vulnerability + "Assembly.Load", # Code loading risk + "XmlDocument.Load", # XXE risk without secure resolver + ], + "unsafe_patterns": [ + "SqlCommand", # SQL injection without parameters + "MD5", # Weak hash algorithm + "SHA1", # Weak hash algorithm + "Random", # Not cryptographically secure + "unsafe", # Memory safety bypass + ], + "secure_alternatives": [ + "Use SqlParameter for parameterized queries", + "Use System.Text.Json instead of BinaryFormatter", + "Use RandomNumberGenerator for secure random numbers", + "Set XmlResolver = null to prevent XXE", + "Validate and sanitize all user inputs", + ], + }, + "elixir": { + "dangerous_functions": [ + "Code.eval_string", # Code injection + ":os.cmd", # Command injection + "System.cmd", # Command injection with user input + ":erlang.binary_to_term", # Deserialization vulnerability + ], + "unsafe_patterns": [ + "String.to_atom", # Atom table exhaustion (DoS) + "raw: true", # Raw SQL, injection risk + "Plug.Conn.put_resp_header", # Header injection if unvalidated + ], + "secure_alternatives": [ + "Use String.to_existing_atom instead of String.to_atom", + "Use Ecto parameterized queries instead of raw SQL", + "Use Plug.Crypto for secure tokens and comparison", + "Validate and sanitize all user inputs", + ], + }, + "swift": { + "dangerous_functions": [ + "NSTask", # Command execution + "Process", # Command execution with user input + "unsafeBitCast", # Type safety bypass + "UnsafeMutablePointer", # Memory safety bypass + ], + "unsafe_patterns": [ + "try!", # Crash on error + "as!", # Crash on failed cast + "String(format:", # Format string risk with user input + ], + "secure_alternatives": [ + "Use do/catch or try? instead of try!", + "Use conditional casts (as?) with unwrapping", + "Use SecRandomCopyBytes for secure random numbers", + "Validate and sanitize all user inputs", + ], + }, + "scala": { + "dangerous_functions": [ + "sys.process", # Command injection risk + "ObjectInputStream", # Deserialization vulnerability + "Class.forName", # Reflection-based code loading + ], + "unsafe_patterns": [ + "createStatement", # SQL injection, use PreparedStatement + "asInstanceOf", # Unchecked cast + "null", # Prefer Option + ], + "secure_alternatives": [ + "Use PreparedStatement or a typed query DSL", + "Use Option instead of null", + _USE_SECURE_RANDOM_JVM, + "Validate and sanitize all user inputs", + ], + }, + "dart": { + "dangerous_functions": [ + "Process.run", # Command injection risk + "Process.start", # Command injection risk + "dart:mirrors", # Reflection-based code loading + ], + "unsafe_patterns": [ + "Random()", # Not cryptographically secure + "!", # Null assertion, runtime crash risk + "Uri.http", # Cleartext transport constructor + ], + "secure_alternatives": [ + "Use Random.secure() for security-sensitive randomness", + "Use null-aware operators instead of null assertions", + "Use HTTPS for all network calls", + "Validate and sanitize all user inputs", + ], + }, } diff --git a/tests/test_security.py b/tests/test_security.py index 2fedc0ea6..985339afb 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -1657,3 +1657,31 @@ def test_c_cpp_have_rules(self): assert "gets" in rules["dangerous_functions"] assert "system" in rules["dangerous_functions"] assert any("fsanitize" in alt for alt in rules["secure_alternatives"]) + + def test_jvm_dotnet_tail_languages_have_scanners(self): + """Every detectable compiled/scripting-tail language has scanners.""" + from security.profile import get_security_scanners + + for lang in ("java", "kotlin", "csharp", "elixir", "swift", "scala", "dart"): + scanners = get_security_scanners(lang) + assert scanners, f"{lang} has no security scanners" + assert "osv-scanner" in scanners + + def test_jvm_dotnet_tail_languages_have_rules(self): + """New language rule entries carry dangerous functions and guidance.""" + from security.profile import get_security_rules + + expectations = { + "java": ("Runtime.exec", "createStatement"), + "kotlin": ("Runtime.exec", "!!"), + "csharp": ("BinaryFormatter", "SqlCommand"), + "elixir": ("Code.eval_string", "String.to_atom"), + "swift": ("unsafeBitCast", "try!"), + "scala": ("sys.process", "asInstanceOf"), + "dart": ("Process.run", "Random()"), + } + for lang, (dangerous, unsafe) in expectations.items(): + rules = get_security_rules(lang) + assert dangerous in rules["dangerous_functions"] + assert unsafe in rules["unsafe_patterns"] + assert rules["secure_alternatives"] diff --git a/tests/test_security_scanner.py b/tests/test_security_scanner.py index 02dc86d20..c3d006729 100644 --- a/tests/test_security_scanner.py +++ b/tests/test_security_scanner.py @@ -657,3 +657,92 @@ def test_composer_audit_output_parsing(self, mock_run, scanner, temp_dir): assert vuln.severity == "high" assert "vendor/package" in vuln.title assert vuln.cwe == "CVE-2024-0001" + + def test_has_osv_manifests_positive(self, scanner, temp_dir): + """JVM/.NET/Elixir manifests trigger osv-scanner.""" + (temp_dir / "mix.lock").write_text("%{}") + assert scanner._has_osv_manifests(temp_dir) is True + + def test_has_osv_manifests_nested(self, scanner, temp_dir): + """Nested manifests in monorepos trigger osv-scanner.""" + service = temp_dir / "services" / "api" + service.mkdir(parents=True) + (service / "go.mod").write_text("module api") + assert scanner._has_osv_manifests(temp_dir) is True + + def test_has_osv_manifests_ignores_vendor_dirs(self, scanner, temp_dir): + """Manifests inside vendor directories do not trigger osv-scanner.""" + nested = temp_dir / "node_modules" / "some-pkg" + nested.mkdir(parents=True) + (nested / "pom.xml").write_text("") + assert scanner._has_osv_manifests(temp_dir) is False + + def test_has_osv_manifests_negative(self, scanner, temp_dir): + """Manifests covered by dedicated audits do not trigger osv-scanner.""" + (temp_dir / "package-lock.json").write_text("{}") + (temp_dir / "Cargo.lock").write_text("") + (temp_dir / "composer.lock").write_text("{}") + assert scanner._has_osv_manifests(temp_dir) is False + + @patch("subprocess.run") + def test_osv_scanner_output_parsing(self, mock_run, scanner, temp_dir): + """Test parsing osv-scanner JSON output.""" + mock_run.return_value = MagicMock( + stdout=json.dumps( + { + "results": [ + { + "source": {"path": "/project/pom.xml"}, + "packages": [ + { + "package": { + "name": "org.example:demo", + "ecosystem": "Maven", + }, + "vulnerabilities": [ + { + "id": "GHSA-xxxx-yyyy", + "summary": "RCE in demo library", + }, + { + "id": "GHSA-aaaa-bbbb", + "summary": "ReDoS in demo library", + "database_specific": { + "severity": "MODERATE" + }, + }, + ], + } + ], + } + ] + } + ), + stderr="", + returncode=1, + ) + + result = SecurityScanResult() + scanner._run_osv_scanner(temp_dir, result) + + assert len(result.vulnerabilities) == 2 + vuln = result.vulnerabilities[0] + assert vuln.source == "osv_scanner" + # No severity in payload: defaults to high + assert vuln.severity == "high" + assert "org.example:demo" in vuln.title + assert "GHSA-xxxx-yyyy" in vuln.title + assert vuln.file == "pom.xml" + # OSV MODERATE maps to medium + assert result.vulnerabilities[1].severity == "medium" + + @patch("subprocess.run") + def test_osv_scanner_missing_tool_is_silent(self, mock_run, scanner, temp_dir): + """Missing osv-scanner binary skips the scan without errors.""" + mock_run.side_effect = FileNotFoundError() + + result = SecurityScanResult() + scanner._run_osv_scanner(temp_dir, result) + + assert result.vulnerabilities == [] + assert result.scan_errors == []