secure-code-agent 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. secure_code_agent-0.2.0.dist-info/METADATA +328 -0
  2. secure_code_agent-0.2.0.dist-info/RECORD +33 -0
  3. secure_code_agent-0.2.0.dist-info/WHEEL +5 -0
  4. secure_code_agent-0.2.0.dist-info/entry_points.txt +3 -0
  5. secure_code_agent-0.2.0.dist-info/licenses/LICENSE +21 -0
  6. secure_code_agent-0.2.0.dist-info/top_level.txt +1 -0
  7. secure_code_audit/__init__.py +3 -0
  8. secure_code_audit/baseline.py +110 -0
  9. secure_code_audit/cli.py +258 -0
  10. secure_code_audit/config.py +115 -0
  11. secure_code_audit/findings.py +165 -0
  12. secure_code_audit/git_tools.py +82 -0
  13. secure_code_audit/instructions.py +141 -0
  14. secure_code_audit/remediation.py +168 -0
  15. secure_code_audit/renderers.py +253 -0
  16. secure_code_audit/sarif.py +221 -0
  17. secure_code_audit/scanners/__init__.py +50 -0
  18. secure_code_audit/scanners/bandit_scanner.py +83 -0
  19. secure_code_audit/scanners/base.py +194 -0
  20. secure_code_audit/scanners/builtin_rules.py +183 -0
  21. secure_code_audit/scanners/checkov_scanner.py +69 -0
  22. secure_code_audit/scanners/gitleaks_scanner.py +86 -0
  23. secure_code_audit/scanners/hadolint_scanner.py +107 -0
  24. secure_code_audit/scanners/npm_audit_scanner.py +101 -0
  25. secure_code_audit/scanners/osv_scanner.py +108 -0
  26. secure_code_audit/scanners/pip_audit_scanner.py +83 -0
  27. secure_code_audit/scanners/scorecard_scanner.py +156 -0
  28. secure_code_audit/scanners/semgrep_scanner.py +119 -0
  29. secure_code_audit/scanners/trivy_scanner.py +87 -0
  30. secure_code_audit/scanners/trufflehog_scanner.py +91 -0
  31. secure_code_audit/scoring.py +280 -0
  32. secure_code_audit/standards.py +391 -0
  33. secure_code_audit/suppressions.py +175 -0
@@ -0,0 +1,183 @@
1
+ """Built-in regex rules — high-confidence, low-false-positive patterns that
2
+ catch what the big scanners miss or that we want to ship even when no
3
+ external scanner is installed.
4
+
5
+ The rules are opinionated and SMALL. We are not building a parallel
6
+ Semgrep. Each rule:
7
+ · targets a single CWE
8
+ · is documented inline (when it's useful + when it false-positives)
9
+ · is covered by at least one true-positive and one false-positive fixture
10
+ in tests/fixtures/builtin_rules/
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ from pathlib import Path
16
+ from typing import NamedTuple
17
+
18
+ from secure_code_audit.config import Config
19
+ from secure_code_audit.findings import Confidence, Finding, Severity
20
+ from secure_code_audit.git_tools import in_scope, is_excluded
21
+ from secure_code_audit.scanners.base import Scanner
22
+
23
+
24
+ class _Rule(NamedTuple):
25
+ rule_id: str
26
+ pattern: re.Pattern[str]
27
+ file_globs: tuple[str, ...] # restrict to certain languages (e.g. ".py")
28
+ description: str
29
+
30
+
31
+ # --- the rule set ---------------------------------------------------------
32
+ # Each pattern is tuned for ≥90% precision on real-world code. Update with
33
+ # a counter-example in tests/fixtures/ if you tighten or loosen.
34
+
35
+ _RULES: tuple[_Rule, ...] = (
36
+ _Rule(
37
+ rule_id="sca.python.eval",
38
+ # eval/exec with a non-string-literal first arg. Allow eval('lit').
39
+ pattern=re.compile(
40
+ r"\b(?:eval|exec)\s*\(\s*(?!['\"][^'\"]*['\"]\s*\))",
41
+ re.MULTILINE,
42
+ ),
43
+ file_globs=(".py",),
44
+ description="eval()/exec() with non-literal input — arbitrary code execution.",
45
+ ),
46
+ _Rule(
47
+ rule_id="sca.python.yaml.unsafe_load",
48
+ pattern=re.compile(
49
+ r"\byaml\.load\s*\(\s*(?!.*Loader\s*=\s*(?:yaml\.)?SafeLoader)",
50
+ re.MULTILINE,
51
+ ),
52
+ file_globs=(".py",),
53
+ description="yaml.load() without SafeLoader — unsafe deserialization.",
54
+ ),
55
+ _Rule(
56
+ rule_id="sca.python.requests.verify_false",
57
+ pattern=re.compile(
58
+ r"\brequests\.(?:get|post|put|patch|delete|head|options|request)\s*\([^)]*verify\s*=\s*False",
59
+ re.MULTILINE | re.DOTALL,
60
+ ),
61
+ file_globs=(".py",),
62
+ description="requests.* called with verify=False — TLS cert validation disabled.",
63
+ ),
64
+ _Rule(
65
+ rule_id="sca.python.subprocess.shell_true",
66
+ pattern=re.compile(
67
+ r"\bsubprocess\.(?:run|call|check_call|check_output|Popen)\s*\([^)]*shell\s*=\s*True",
68
+ re.MULTILINE | re.DOTALL,
69
+ ),
70
+ file_globs=(".py",),
71
+ description="subprocess with shell=True — command-injection risk.",
72
+ ),
73
+ _Rule(
74
+ rule_id="sca.python.fstring_sql",
75
+ # Look for f-strings containing SELECT/INSERT/UPDATE/DELETE
76
+ # interpolated into .execute( / .executemany(. We require both the
77
+ # f-string AND the execute-call to be within ~200 chars to keep
78
+ # false positives down.
79
+ pattern=re.compile(
80
+ r"\.(?:execute|executemany)\s*\(\s*f['\"](?:[^'\"]*?\b(?:SELECT|INSERT|UPDATE|DELETE|MERGE)\b[^'\"]*?\{[^}]+\})",
81
+ re.IGNORECASE | re.MULTILINE,
82
+ ),
83
+ file_globs=(".py",),
84
+ description="f-string SQL in .execute()/.executemany() — possible SQL injection.",
85
+ ),
86
+ _Rule(
87
+ rule_id="sca.python.hashlib.md5_sha1_security",
88
+ # MD5/SHA-1 — false-positives in non-security use are common. We
89
+ # only flag when usedforsecurity is not explicitly False.
90
+ pattern=re.compile(
91
+ r"\bhashlib\.(?:md5|sha1)\s*\((?![^)]*usedforsecurity\s*=\s*False)",
92
+ re.MULTILINE,
93
+ ),
94
+ file_globs=(".py",),
95
+ description="MD5/SHA-1 without usedforsecurity=False — weak hash for security context.",
96
+ ),
97
+ _Rule(
98
+ rule_id="sca.web.dangerously_set_inner_html",
99
+ pattern=re.compile(
100
+ r"dangerouslySetInnerHTML\s*=\s*\{\{\s*__html\s*:",
101
+ re.MULTILINE,
102
+ ),
103
+ file_globs=(".tsx", ".jsx", ".ts", ".js"),
104
+ description="React dangerouslySetInnerHTML — XSS surface unless input is trusted/sanitized.",
105
+ ),
106
+ _Rule(
107
+ rule_id="sca.web.cors_wildcard",
108
+ # Headers/middleware setting Allow-Origin '*'. Pair with
109
+ # Allow-Credentials: true → spec violation + XSCSRF surface.
110
+ pattern=re.compile(
111
+ r"(?i)Access-Control-Allow-Origin\s*['\"]?\s*[:=]\s*['\"]?\*",
112
+ ),
113
+ file_globs=(".py", ".js", ".ts", ".tsx", ".go", ".rb", ".java"),
114
+ description="CORS Allow-Origin: * — review for credentialed-cookies exposure.",
115
+ ),
116
+ _Rule(
117
+ rule_id="sca.shell.curl_pipe_sh",
118
+ pattern=re.compile(
119
+ r"\bcurl\b[^|;\n]+\|\s*(?:bash|sh|zsh)\b",
120
+ re.MULTILINE,
121
+ ),
122
+ file_globs=(".sh", "Dockerfile"),
123
+ description="curl ... | sh — unauthenticated remote-script execution.",
124
+ ),
125
+ )
126
+
127
+
128
+ class BuiltinRulesScanner(Scanner):
129
+ name = "builtin_rules"
130
+ binary = "" # in-process scanner
131
+
132
+ def is_available(self) -> bool:
133
+ return True # no external binary needed
134
+
135
+ def binary_version(self) -> str | None:
136
+ from secure_code_audit import __version__
137
+ return f"builtin/{__version__}"
138
+
139
+ def run(self, target: Path, config: Config) -> list[Finding]:
140
+ findings: list[Finding] = []
141
+ for path in self._candidate_files(target, config):
142
+ try:
143
+ text = path.read_text(encoding="utf-8", errors="ignore")
144
+ except OSError:
145
+ continue
146
+ for rule in _RULES:
147
+ if not self._applies_to(path, rule):
148
+ continue
149
+ for m in rule.pattern.finditer(text):
150
+ line_start = text.count("\n", 0, m.start()) + 1
151
+ line_end = text.count("\n", 0, m.end()) + 1
152
+ snippet = m.group(0)[:200]
153
+ findings.append(self._make_finding(
154
+ rule_id=rule.rule_id,
155
+ message=rule.description,
156
+ file_path=path,
157
+ line_start=line_start,
158
+ line_end=line_end if line_end != line_start else None,
159
+ code_snippet=snippet,
160
+ severity=Severity.HIGH,
161
+ confidence=Confidence.MEDIUM,
162
+ ))
163
+ return findings
164
+
165
+ def _applies_to(self, path: Path, rule: _Rule) -> bool:
166
+ name = path.name
167
+ for glob in rule.file_globs:
168
+ if glob.startswith("."):
169
+ if name.endswith(glob):
170
+ return True
171
+ elif name == glob:
172
+ return True
173
+ return False
174
+
175
+ def _candidate_files(self, target: Path, config: Config):
176
+ for path in target.rglob("*"):
177
+ if not path.is_file():
178
+ continue
179
+ if is_excluded(path, target, config.exclude_patterns):
180
+ continue
181
+ if not in_scope(path, config.include_extensions):
182
+ continue
183
+ yield path
@@ -0,0 +1,69 @@
1
+ """Checkov — IaC scanning (Terraform / CloudFormation / Helm / k8s / Dockerfile).
2
+
3
+ Invocation:
4
+ checkov -d <target> --output sarif --output-file-path <tmp> --quiet --soft-fail
5
+
6
+ `--soft-fail` makes checkov exit 0 even on findings; we want our own
7
+ gate logic to drive exit codes, not checkov's. SARIF goes through the
8
+ canonical ingest.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import tempfile
13
+ from dataclasses import replace
14
+ from pathlib import Path
15
+
16
+ from secure_code_audit.config import Config
17
+ from secure_code_audit.findings import Category, Confidence, Finding, Severity
18
+ from secure_code_audit.sarif import ingest as sarif_ingest
19
+ from secure_code_audit.scanners.base import Scanner
20
+
21
+
22
+ class CheckovScanner(Scanner):
23
+ name = "checkov"
24
+ binary = "checkov"
25
+ default_category = Category.CONFIG_IAC
26
+
27
+ def run(self, target: Path, config: Config) -> list[Finding]:
28
+ if not self.is_available():
29
+ return [self._unavailable_finding(target)]
30
+
31
+ sc_cfg = self.cfg(config)
32
+ # Checkov writes to a directory and names the file itself.
33
+ # Use a temp directory so cleanup is straightforward.
34
+ with tempfile.TemporaryDirectory() as tmpdir:
35
+ tmpdir_path = Path(tmpdir)
36
+ args = [
37
+ self.binary, "-d", str(target),
38
+ "--output", "sarif",
39
+ "--output-file-path", str(tmpdir_path),
40
+ "--quiet", "--soft-fail",
41
+ ]
42
+ args.extend(sc_cfg.extra_args)
43
+
44
+ r = self._exec(args, cwd=target, timeout_seconds=sc_cfg.timeout_seconds,
45
+ allowed_exits=(0, 1, 2))
46
+ if r.returncode == 124:
47
+ return [self._make_finding(
48
+ rule_id=f"{self.name}.tool_timeout",
49
+ message=f"checkov timed out: {r.stderr[:200]}",
50
+ file_path=target, line_start=0, line_end=None, code_snippet=None,
51
+ severity=Severity.INFORMATIONAL, confidence=Confidence.HIGH,
52
+ )]
53
+
54
+ # Checkov writes results.sarif into the output directory.
55
+ sarif_path = tmpdir_path / "results_sarif.sarif"
56
+ if not sarif_path.exists():
57
+ # Older versions write `results.sarif` instead.
58
+ alt = tmpdir_path / "results.sarif"
59
+ sarif_path = alt if alt.exists() else sarif_path
60
+ if not sarif_path.exists():
61
+ return []
62
+
63
+ ingested = sarif_ingest(sarif_path, default_scanner="checkov")
64
+ # All checkov findings are config_iac by rule family. Pin
65
+ # the scanner field for cross-scanner dedupe clarity.
66
+ return [
67
+ replace(f, scanner="checkov", category=Category.CONFIG_IAC)
68
+ for f in ingested
69
+ ]
@@ -0,0 +1,86 @@
1
+ """Gitleaks — history-aware secret scanning.
2
+
3
+ Invocation:
4
+ gitleaks detect --source=<target> --no-banner \
5
+ --redact --report-format=json --report-path=<tmp>
6
+
7
+ We always pass --redact so the raw secret never reaches the report JSON.
8
+ The fingerprint hashes the raw match site so dedupe still works.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import tempfile
14
+ from pathlib import Path
15
+
16
+ from secure_code_audit.config import Config
17
+ from secure_code_audit.findings import Category, Confidence, Finding, Severity
18
+ from secure_code_audit.scanners.base import Scanner
19
+
20
+
21
+ class GitleaksScanner(Scanner):
22
+ name = "gitleaks"
23
+ binary = "gitleaks"
24
+ default_category = Category.SECRETS
25
+
26
+ def run(self, target: Path, config: Config) -> list[Finding]:
27
+ if not self.is_available():
28
+ return [self._unavailable_finding(target)]
29
+
30
+ sc_cfg = self.cfg(config)
31
+ with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as tmp:
32
+ report_path = Path(tmp.name)
33
+ try:
34
+ args = [
35
+ self.binary, "detect",
36
+ "--source", str(target),
37
+ "--no-banner",
38
+ "--redact",
39
+ "--report-format", "json",
40
+ "--report-path", str(report_path),
41
+ ]
42
+ args.extend(sc_cfg.extra_args)
43
+ # Gitleaks exits 1 when findings exist; 0 = clean; >1 = error.
44
+ r = self._exec(args, cwd=target, timeout_seconds=sc_cfg.timeout_seconds,
45
+ allowed_exits=(0, 1))
46
+ if r.returncode not in (0, 1):
47
+ return [self._make_finding(
48
+ rule_id=f"{self.name}.tool_error",
49
+ message=f"gitleaks failed: {r.stderr[:300]}",
50
+ file_path=target, line_start=0, line_end=None, code_snippet=None,
51
+ severity=Severity.INFORMATIONAL, confidence=Confidence.HIGH,
52
+ )]
53
+ if not report_path.exists() or report_path.stat().st_size == 0:
54
+ return []
55
+ try:
56
+ payload = json.loads(report_path.read_text(encoding="utf-8"))
57
+ except json.JSONDecodeError:
58
+ return []
59
+ return self._parse(payload, target)
60
+ finally:
61
+ report_path.unlink(missing_ok=True)
62
+
63
+ def _parse(self, payload: list[dict], target: Path) -> list[Finding]:
64
+ if not isinstance(payload, list):
65
+ return []
66
+ findings: list[Finding] = []
67
+ for hit in payload:
68
+ rule_id = str(hit.get("RuleID") or hit.get("Rule") or "unknown")
69
+ file_path = Path(hit.get("File") or "")
70
+ line = int(hit.get("StartLine") or 0)
71
+ redacted_match = str(hit.get("Match") or hit.get("Secret") or "").strip()
72
+ # Gitleaks --redact returns the match string with the secret
73
+ # replaced by REDACTED — we surface that exact string in the
74
+ # report.
75
+ findings.append(self._make_finding(
76
+ rule_id=f"gitleaks.{rule_id}",
77
+ message=f"{hit.get('Description') or rule_id}: {redacted_match}".strip(),
78
+ file_path=file_path,
79
+ line_start=line,
80
+ line_end=int(hit.get("EndLine") or line) if hit.get("EndLine") else None,
81
+ code_snippet=redacted_match[:200] or None,
82
+ severity=Severity.CRITICAL,
83
+ confidence=Confidence.HIGH,
84
+ category=Category.SECRETS,
85
+ ))
86
+ return findings
@@ -0,0 +1,107 @@
1
+ """Hadolint — Dockerfile linter.
2
+
3
+ Invocation:
4
+ hadolint --no-fail --format json <Dockerfile> [<Dockerfile> ...]
5
+
6
+ Hadolint emits a JSON array — one entry per finding with file/line/code
7
+ (rule id like DL3001) / level / message. We walk the target tree for
8
+ Dockerfiles and pass them all to one hadolint invocation.
9
+
10
+ Only a subset of DL/SC rules have security implications; we map those
11
+ in standards.py (see SECURITY_RELEVANT_RULES). Style-only rules
12
+ (DL3007, DL3008 unpinned-apt) are tagged config_iac at LOW severity.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ from dataclasses import replace
18
+ from pathlib import Path
19
+
20
+ from secure_code_audit.config import Config
21
+ from secure_code_audit.findings import Category, Confidence, Finding, Severity
22
+ from secure_code_audit.git_tools import is_excluded
23
+ from secure_code_audit.scanners.base import Scanner
24
+
25
+
26
+ # Hadolint rule ids whose semantics are security-relevant. The category
27
+ # stays config_iac but severity is bumped over the default LOW.
28
+ _HIGH_SECURITY: dict[str, Severity] = {
29
+ "DL3002": Severity.HIGH, # USER root
30
+ "DL3004": Severity.MEDIUM, # do not use sudo
31
+ "DL3025": Severity.MEDIUM, # use JSON form for CMD/ENTRYPOINT (shell injection surface)
32
+ "DL4006": Severity.MEDIUM, # set SHELL with pipefail
33
+ "SC2086": Severity.MEDIUM, # unquoted variable (shell-injection)
34
+ "SC2046": Severity.MEDIUM, # unquoted command substitution
35
+ "DL3023": Severity.MEDIUM, # COPY --from points to its own FROM alias
36
+ "DL3033": Severity.MEDIUM, # specify version with yum install -y
37
+ "DL3008": Severity.LOW, # pin apt versions
38
+ "DL3009": Severity.LOW, # delete apt lists after install
39
+ "DL3015": Severity.LOW, # use --no-install-recommends
40
+ "DL3018": Severity.LOW, # pin apk versions
41
+ }
42
+
43
+
44
+ class HadolintScanner(Scanner):
45
+ name = "hadolint"
46
+ binary = "hadolint"
47
+ default_category = Category.CONFIG_IAC
48
+
49
+ def run(self, target: Path, config: Config) -> list[Finding]:
50
+ if not self.is_available():
51
+ return [self._unavailable_finding(target)]
52
+
53
+ dockerfiles = self._find_dockerfiles(target, config.exclude_patterns)
54
+ if not dockerfiles:
55
+ return []
56
+
57
+ sc_cfg = self.cfg(config)
58
+ args = [self.binary, "--no-fail", "--format", "json"]
59
+ args.extend(str(p) for p in dockerfiles)
60
+ args.extend(sc_cfg.extra_args)
61
+
62
+ r = self._exec(args, cwd=target, timeout_seconds=sc_cfg.timeout_seconds,
63
+ allowed_exits=(0,))
64
+ if r.returncode == 124:
65
+ return [self._make_finding(
66
+ rule_id=f"{self.name}.tool_timeout",
67
+ message=f"hadolint timed out: {r.stderr[:200]}",
68
+ file_path=target, line_start=0, line_end=None, code_snippet=None,
69
+ severity=Severity.INFORMATIONAL, confidence=Confidence.HIGH,
70
+ )]
71
+ if not r.stdout.strip():
72
+ return []
73
+
74
+ try:
75
+ payload = json.loads(r.stdout)
76
+ except json.JSONDecodeError:
77
+ return []
78
+ if not isinstance(payload, list):
79
+ return []
80
+
81
+ return [self._parse_one(item) for item in payload if isinstance(item, dict)]
82
+
83
+ def _find_dockerfiles(self, target: Path, excludes) -> list[Path]:
84
+ out: list[Path] = []
85
+ for path in target.rglob("Dockerfile*"):
86
+ if not path.is_file():
87
+ continue
88
+ if is_excluded(path, target, excludes):
89
+ continue
90
+ out.append(path)
91
+ return out
92
+
93
+ def _parse_one(self, item: dict) -> Finding:
94
+ code = str(item.get("code") or "unknown")
95
+ severity = _HIGH_SECURITY.get(code, Severity.LOW)
96
+ finding = self._make_finding(
97
+ rule_id=f"hadolint.{code}",
98
+ message=str(item.get("message") or code),
99
+ file_path=Path(item.get("file") or ""),
100
+ line_start=int(item.get("line") or 0),
101
+ line_end=None,
102
+ code_snippet=None,
103
+ severity=severity,
104
+ confidence=Confidence.HIGH,
105
+ category=Category.CONFIG_IAC,
106
+ )
107
+ return finding
@@ -0,0 +1,101 @@
1
+ """npm audit — Node SCA.
2
+
3
+ Invocation:
4
+ npm audit --json --omit=dev [--audit-level=low]
5
+
6
+ npm audit exits nonzero when findings exist; we accept 0+1.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from pathlib import Path
12
+
13
+ from secure_code_audit.config import Config
14
+ from secure_code_audit.findings import Category, Confidence, Finding, Severity
15
+ from secure_code_audit.scanners.base import Scanner
16
+
17
+
18
+ _NPM_SEVERITY: dict[str, Severity] = {
19
+ "critical": Severity.CRITICAL,
20
+ "high": Severity.HIGH,
21
+ "moderate": Severity.MEDIUM,
22
+ "low": Severity.LOW,
23
+ "info": Severity.INFORMATIONAL,
24
+ }
25
+
26
+
27
+ class NpmAuditScanner(Scanner):
28
+ name = "npm_audit"
29
+ binary = "npm"
30
+ default_category = Category.DEPENDENCIES
31
+
32
+ def run(self, target: Path, config: Config) -> list[Finding]:
33
+ if not self.is_available():
34
+ return [self._unavailable_finding(target)]
35
+
36
+ pkg_dirs = self._discover_package_dirs(target, config.exclude_patterns)
37
+ if not pkg_dirs:
38
+ return [self._make_finding(
39
+ rule_id=f"{self.name}.no_package_lock",
40
+ message="No package-lock.json found in scope; npm audit skipped.",
41
+ file_path=target, line_start=0, line_end=None, code_snippet=None,
42
+ severity=Severity.INFORMATIONAL, confidence=Confidence.HIGH,
43
+ category=Category.DEPENDENCIES,
44
+ )]
45
+
46
+ sc_cfg = self.cfg(config)
47
+ findings: list[Finding] = []
48
+ for pkg_dir in pkg_dirs:
49
+ args = [self.binary, "audit", "--json", "--omit=dev"]
50
+ args.extend(sc_cfg.extra_args)
51
+ r = self._exec(args, cwd=pkg_dir, timeout_seconds=sc_cfg.timeout_seconds,
52
+ allowed_exits=(0, 1))
53
+ if r.returncode == 124 or not r.stdout.strip():
54
+ continue
55
+ try:
56
+ payload = json.loads(r.stdout)
57
+ except json.JSONDecodeError:
58
+ continue
59
+ findings.extend(self._parse(payload, pkg_dir))
60
+ return findings
61
+
62
+ def _discover_package_dirs(self, root: Path, excludes: tuple[str, ...]) -> list[Path]:
63
+ out: list[Path] = []
64
+ for path in root.rglob("package-lock.json"):
65
+ rel = str(path.relative_to(root).as_posix())
66
+ if any(rel.startswith(e) or f"/{e}" in f"/{rel}" for e in excludes):
67
+ continue
68
+ out.append(path.parent)
69
+ return out
70
+
71
+ def _parse(self, payload: dict, pkg_dir: Path) -> list[Finding]:
72
+ findings: list[Finding] = []
73
+ vulns = payload.get("vulnerabilities") or {}
74
+ manifest = pkg_dir / "package-lock.json"
75
+ for pkg_name, info in vulns.items():
76
+ severity_str = (info.get("severity") or "moderate").lower()
77
+ severity = _NPM_SEVERITY.get(severity_str, Severity.MEDIUM)
78
+ via = info.get("via") or []
79
+ advisories = [v for v in via if isinstance(v, dict)]
80
+ if not advisories:
81
+ # Indirect-only entry; collapse to a single finding.
82
+ findings.append(self._make_finding(
83
+ rule_id=f"npm_audit.{pkg_name}.indirect",
84
+ message=f"{pkg_name}: vulnerable via transitive dep.",
85
+ file_path=manifest, line_start=0, line_end=None, code_snippet=None,
86
+ severity=severity, confidence=Confidence.HIGH,
87
+ category=Category.DEPENDENCIES,
88
+ ))
89
+ continue
90
+ for adv in advisories:
91
+ rule_id = f"npm_audit.{adv.get('source') or adv.get('name') or pkg_name}"
92
+ msg = adv.get("title") or adv.get("name") or pkg_name
93
+ url = adv.get("url") or ""
94
+ findings.append(self._make_finding(
95
+ rule_id=rule_id,
96
+ message=f"{pkg_name}: {msg} {url}".strip(),
97
+ file_path=manifest, line_start=0, line_end=None, code_snippet=None,
98
+ severity=severity, confidence=Confidence.HIGH,
99
+ category=Category.DEPENDENCIES,
100
+ ))
101
+ return findings
@@ -0,0 +1,108 @@
1
+ """OSV-Scanner — multi-ecosystem SCA via osv.dev.
2
+
3
+ Invocation:
4
+ osv-scanner --format=json --recursive <target>
5
+
6
+ Output: JSON with `results[].packages[].vulnerabilities[]` each carrying
7
+ id (CVE / GHSA / OSV-), severity, summary, references.
8
+
9
+ Overlaps with pip-audit + npm-audit by design; the fingerprint dedupe
10
+ across `(canonical_cwe, file_path, code_snippet)` ensures we don't
11
+ double-count the same upstream advisory.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ from pathlib import Path
17
+
18
+ from secure_code_audit.config import Config
19
+ from secure_code_audit.findings import Category, Confidence, Finding, Severity
20
+ from secure_code_audit.scanners.base import Scanner
21
+
22
+
23
+ _OSV_SEVERITY: dict[str, Severity] = {
24
+ "CRITICAL": Severity.CRITICAL,
25
+ "HIGH": Severity.HIGH,
26
+ "MODERATE": Severity.MEDIUM,
27
+ "MEDIUM": Severity.MEDIUM,
28
+ "LOW": Severity.LOW,
29
+ "UNKNOWN": Severity.MEDIUM,
30
+ }
31
+
32
+
33
+ class OsvScanner(Scanner):
34
+ name = "osv_scanner"
35
+ binary = "osv-scanner"
36
+ default_category = Category.DEPENDENCIES
37
+
38
+ def run(self, target: Path, config: Config) -> list[Finding]:
39
+ if not self.is_available():
40
+ return [self._unavailable_finding(target)]
41
+
42
+ sc_cfg = self.cfg(config)
43
+ args = [
44
+ self.binary,
45
+ "--format=json", "--recursive",
46
+ str(target),
47
+ ]
48
+ args.extend(sc_cfg.extra_args)
49
+
50
+ # osv-scanner exits 1 on findings, 0 on clean, 127 on bad usage,
51
+ # 128 on internal error.
52
+ r = self._exec(args, cwd=target, timeout_seconds=sc_cfg.timeout_seconds,
53
+ allowed_exits=(0, 1))
54
+ if r.returncode == 124:
55
+ return [self._make_finding(
56
+ rule_id=f"{self.name}.tool_timeout",
57
+ message=f"osv-scanner timed out: {r.stderr[:200]}",
58
+ file_path=target, line_start=0, line_end=None, code_snippet=None,
59
+ severity=Severity.INFORMATIONAL, confidence=Confidence.HIGH,
60
+ )]
61
+ if not r.stdout.strip():
62
+ return []
63
+
64
+ try:
65
+ payload = json.loads(r.stdout)
66
+ except json.JSONDecodeError:
67
+ return []
68
+
69
+ return self._parse(payload, target)
70
+
71
+ def _parse(self, payload: dict, target: Path) -> list[Finding]:
72
+ out: list[Finding] = []
73
+ for result in payload.get("results", []):
74
+ source = (result.get("source") or {}).get("path") or ""
75
+ file_path = Path(source) if source else target
76
+ for pkg_block in result.get("packages", []):
77
+ pkg = pkg_block.get("package") or {}
78
+ pkg_name = pkg.get("name", "?")
79
+ pkg_ver = pkg.get("version", "?")
80
+ for vuln in pkg_block.get("vulnerabilities", []) or []:
81
+ vid = str(vuln.get("id") or "UNKNOWN")
82
+ summary = (vuln.get("summary") or "").strip()[:300]
83
+ severity_str = self._extract_severity(vuln)
84
+ severity = _OSV_SEVERITY.get(severity_str, Severity.MEDIUM)
85
+ out.append(self._make_finding(
86
+ rule_id=f"osv_scanner.{vid}",
87
+ message=f"{pkg_name} {pkg_ver} — {vid}: {summary}",
88
+ file_path=file_path,
89
+ line_start=0, line_end=None, code_snippet=None,
90
+ severity=severity,
91
+ confidence=Confidence.HIGH,
92
+ category=Category.DEPENDENCIES,
93
+ ))
94
+ return out
95
+
96
+ def _extract_severity(self, vuln: dict) -> str:
97
+ """OSV format puts severity in two places: a top-level
98
+ `database_specific.severity` (string) and a `severity[]` array
99
+ with CVSS vectors. Prefer the string when present."""
100
+ ds = (vuln.get("database_specific") or {}).get("severity")
101
+ if isinstance(ds, str):
102
+ return ds.upper()
103
+ sev_arr = vuln.get("severity") or []
104
+ if isinstance(sev_arr, list) and sev_arr:
105
+ # CVSS string in score; we don't parse the vector — fall back
106
+ # to MEDIUM as a safe default.
107
+ return "MEDIUM"
108
+ return "UNKNOWN"