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.
- secure_code_agent-0.2.0.dist-info/METADATA +328 -0
- secure_code_agent-0.2.0.dist-info/RECORD +33 -0
- secure_code_agent-0.2.0.dist-info/WHEEL +5 -0
- secure_code_agent-0.2.0.dist-info/entry_points.txt +3 -0
- secure_code_agent-0.2.0.dist-info/licenses/LICENSE +21 -0
- secure_code_agent-0.2.0.dist-info/top_level.txt +1 -0
- secure_code_audit/__init__.py +3 -0
- secure_code_audit/baseline.py +110 -0
- secure_code_audit/cli.py +258 -0
- secure_code_audit/config.py +115 -0
- secure_code_audit/findings.py +165 -0
- secure_code_audit/git_tools.py +82 -0
- secure_code_audit/instructions.py +141 -0
- secure_code_audit/remediation.py +168 -0
- secure_code_audit/renderers.py +253 -0
- secure_code_audit/sarif.py +221 -0
- secure_code_audit/scanners/__init__.py +50 -0
- secure_code_audit/scanners/bandit_scanner.py +83 -0
- secure_code_audit/scanners/base.py +194 -0
- secure_code_audit/scanners/builtin_rules.py +183 -0
- secure_code_audit/scanners/checkov_scanner.py +69 -0
- secure_code_audit/scanners/gitleaks_scanner.py +86 -0
- secure_code_audit/scanners/hadolint_scanner.py +107 -0
- secure_code_audit/scanners/npm_audit_scanner.py +101 -0
- secure_code_audit/scanners/osv_scanner.py +108 -0
- secure_code_audit/scanners/pip_audit_scanner.py +83 -0
- secure_code_audit/scanners/scorecard_scanner.py +156 -0
- secure_code_audit/scanners/semgrep_scanner.py +119 -0
- secure_code_audit/scanners/trivy_scanner.py +87 -0
- secure_code_audit/scanners/trufflehog_scanner.py +91 -0
- secure_code_audit/scoring.py +280 -0
- secure_code_audit/standards.py +391 -0
- secure_code_audit/suppressions.py +175 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""SARIF 2.1.0 emit + ingest.
|
|
2
|
+
|
|
3
|
+
Spec: https://www.oasis-open.org/standard/sarif-v2-1-0/
|
|
4
|
+
SARIF JSON schema: https://github.com/oasis-tcs/sarif-spec/blob/main/sarif-2.1/schemas/sarif-schema-2.1.0.json
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Iterable
|
|
11
|
+
|
|
12
|
+
from secure_code_audit import __version__
|
|
13
|
+
from secure_code_audit.findings import Confidence, Finding, Severity
|
|
14
|
+
from secure_code_audit.standards import cwe_url
|
|
15
|
+
|
|
16
|
+
_SARIF_LEVEL = {
|
|
17
|
+
Severity.CRITICAL: "error",
|
|
18
|
+
Severity.HIGH: "error",
|
|
19
|
+
Severity.MEDIUM: "warning",
|
|
20
|
+
Severity.LOW: "note",
|
|
21
|
+
Severity.INFORMATIONAL: "none",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def emit(findings: Iterable[Finding]) -> dict:
|
|
26
|
+
"""Build a SARIF 2.1.0 document from canonical findings."""
|
|
27
|
+
findings = list(findings)
|
|
28
|
+
|
|
29
|
+
# Build the rules array per-canonical-rule (rule_id is local; dedupe).
|
|
30
|
+
rule_meta: dict[str, dict] = {}
|
|
31
|
+
results: list[dict] = []
|
|
32
|
+
|
|
33
|
+
for f in findings:
|
|
34
|
+
rid = f.rule_id
|
|
35
|
+
if rid not in rule_meta:
|
|
36
|
+
rule_meta[rid] = _rule(f)
|
|
37
|
+
results.append(_result(f))
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
"$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schemas/sarif-schema-2.1.0.json",
|
|
41
|
+
"version": "2.1.0",
|
|
42
|
+
"runs": [{
|
|
43
|
+
"tool": {
|
|
44
|
+
"driver": {
|
|
45
|
+
"name": "secure-code-agent",
|
|
46
|
+
"informationUri": "https://github.com/marshallguillory86/secure-code-agent",
|
|
47
|
+
"version": __version__,
|
|
48
|
+
"rules": list(rule_meta.values()),
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
"results": results,
|
|
52
|
+
}],
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _rule(f: Finding) -> dict:
|
|
57
|
+
rule: dict = {
|
|
58
|
+
"id": f.rule_id,
|
|
59
|
+
"name": f.rule_id,
|
|
60
|
+
"shortDescription": {"text": f.short_desc or f.message[:120]},
|
|
61
|
+
"defaultConfiguration": {"level": _SARIF_LEVEL[f.severity]},
|
|
62
|
+
"properties": {
|
|
63
|
+
"category": f.category.value,
|
|
64
|
+
"scanner": f.scanner,
|
|
65
|
+
"owasp_top10": f.owasp_top10,
|
|
66
|
+
"asvs": f.asvs_section,
|
|
67
|
+
"nist_ssdf": f.nist_ssdf,
|
|
68
|
+
"cwe_top25": f.cwe_top25,
|
|
69
|
+
},
|
|
70
|
+
}
|
|
71
|
+
if f.canonical_cwe:
|
|
72
|
+
rule["properties"]["cwe"] = [f.canonical_cwe]
|
|
73
|
+
rule["helpUri"] = cwe_url(f.canonical_cwe)
|
|
74
|
+
return rule
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _result(f: Finding) -> dict:
|
|
78
|
+
region: dict = {"startLine": max(1, f.line_start)}
|
|
79
|
+
if f.line_end and f.line_end != f.line_start:
|
|
80
|
+
region["endLine"] = f.line_end
|
|
81
|
+
if f.code_snippet:
|
|
82
|
+
region["snippet"] = {"text": f.code_snippet}
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
"ruleId": f.rule_id,
|
|
86
|
+
"level": _SARIF_LEVEL[f.severity],
|
|
87
|
+
"message": {"text": f.message},
|
|
88
|
+
"fingerprints": {"secure-code-agent/v1": f.fingerprint},
|
|
89
|
+
"properties": {
|
|
90
|
+
"confidence": f.confidence.value,
|
|
91
|
+
"category": f.category.value,
|
|
92
|
+
"is_new": f.is_new,
|
|
93
|
+
"suppressed": f.suppressed,
|
|
94
|
+
},
|
|
95
|
+
"locations": [{
|
|
96
|
+
"physicalLocation": {
|
|
97
|
+
"artifactLocation": {"uri": f.file_path.as_posix()},
|
|
98
|
+
"region": region,
|
|
99
|
+
}
|
|
100
|
+
}],
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def write(findings: Iterable[Finding], output: Path) -> None:
|
|
105
|
+
output.write_text(json.dumps(emit(findings), indent=2), encoding="utf-8")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# ----- ingest -------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
_SARIF_LEVEL_TO_SEVERITY: dict[str, Severity] = {
|
|
111
|
+
"error": Severity.HIGH,
|
|
112
|
+
"warning": Severity.MEDIUM,
|
|
113
|
+
"note": Severity.LOW,
|
|
114
|
+
"none": Severity.INFORMATIONAL,
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def ingest(sarif_path: Path, default_scanner: str = "external_sarif") -> list[Finding]:
|
|
119
|
+
"""Parse an external SARIF file (CodeQL, Snyk, Trivy, etc.) into canonical
|
|
120
|
+
Findings. We trust the SARIF's own rule metadata for severity + CWE;
|
|
121
|
+
standards.lookup() may still enhance via the local map."""
|
|
122
|
+
payload = _read_sarif(sarif_path)
|
|
123
|
+
if payload is None:
|
|
124
|
+
return []
|
|
125
|
+
|
|
126
|
+
out: list[Finding] = []
|
|
127
|
+
for run in payload.get("runs", []):
|
|
128
|
+
out.extend(_findings_from_run(run, default_scanner))
|
|
129
|
+
return out
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _read_sarif(path: Path) -> dict | None:
|
|
133
|
+
try:
|
|
134
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
135
|
+
except (OSError, json.JSONDecodeError):
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _findings_from_run(run: dict, default_scanner: str) -> list[Finding]:
|
|
140
|
+
driver = (run.get("tool") or {}).get("driver") or {}
|
|
141
|
+
scanner = (driver.get("name") or default_scanner).lower().replace(" ", "_")
|
|
142
|
+
rules = {r.get("id"): r for r in (driver.get("rules") or [])}
|
|
143
|
+
|
|
144
|
+
findings: list[Finding] = []
|
|
145
|
+
for result in run.get("results", []):
|
|
146
|
+
findings.append(_finding_from_result(result, rules, scanner))
|
|
147
|
+
return findings
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _finding_from_result(result: dict, rules: dict, scanner: str) -> Finding:
|
|
151
|
+
from secure_code_audit.findings import Category
|
|
152
|
+
from secure_code_audit.standards import is_top25, lookup
|
|
153
|
+
|
|
154
|
+
rule_id = result.get("ruleId") or "unknown"
|
|
155
|
+
rule = rules.get(rule_id, {})
|
|
156
|
+
msg = (result.get("message") or {}).get("text") or rule_id
|
|
157
|
+
|
|
158
|
+
severity = _severity_from_result(result, rule)
|
|
159
|
+
cwe = _cwe_from_rule(rule)
|
|
160
|
+
file_path, line_start, line_end, snippet = _location_from_result(result)
|
|
161
|
+
|
|
162
|
+
entry = lookup(scanner, rule_id)
|
|
163
|
+
canonical_cwe = cwe or (entry.canonical_cwe if entry else None)
|
|
164
|
+
|
|
165
|
+
fingerprint = Finding.make_fingerprint(
|
|
166
|
+
canonical_cwe=canonical_cwe,
|
|
167
|
+
rule_id=rule_id,
|
|
168
|
+
file_path=file_path,
|
|
169
|
+
code_snippet=snippet,
|
|
170
|
+
)
|
|
171
|
+
return Finding(
|
|
172
|
+
rule_id=rule_id,
|
|
173
|
+
scanner=scanner,
|
|
174
|
+
fingerprint=fingerprint,
|
|
175
|
+
canonical_cwe=canonical_cwe,
|
|
176
|
+
owasp_top10=entry.owasp_top10 if entry else None,
|
|
177
|
+
asvs_section=entry.asvs_section if entry else None,
|
|
178
|
+
nist_ssdf=entry.nist_ssdf if entry else None,
|
|
179
|
+
category=entry.category if entry else Category.CODE_VULNERABILITIES,
|
|
180
|
+
severity=severity,
|
|
181
|
+
confidence=Confidence.MEDIUM,
|
|
182
|
+
file_path=file_path,
|
|
183
|
+
line_start=line_start,
|
|
184
|
+
line_end=line_end if line_end and line_end != line_start else None,
|
|
185
|
+
code_snippet=snippet,
|
|
186
|
+
message=msg,
|
|
187
|
+
short_desc=entry.short_desc if entry else None,
|
|
188
|
+
fix_hint=entry.fix_hint if entry else None,
|
|
189
|
+
cwe_top25=is_top25(canonical_cwe),
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _severity_from_result(result: dict, rule: dict) -> Severity:
|
|
194
|
+
level = (
|
|
195
|
+
result.get("level")
|
|
196
|
+
or rule.get("defaultConfiguration", {}).get("level")
|
|
197
|
+
or "warning"
|
|
198
|
+
)
|
|
199
|
+
return _SARIF_LEVEL_TO_SEVERITY.get(level, Severity.MEDIUM)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _cwe_from_rule(rule: dict) -> str | None:
|
|
203
|
+
props = rule.get("properties") or {}
|
|
204
|
+
cwe = props.get("cwe")
|
|
205
|
+
if isinstance(cwe, list) and cwe:
|
|
206
|
+
return cwe[0]
|
|
207
|
+
if isinstance(cwe, str):
|
|
208
|
+
return cwe
|
|
209
|
+
return None
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _location_from_result(result: dict) -> tuple[Path, int, int | None, str | None]:
|
|
213
|
+
locs = result.get("locations") or [{}]
|
|
214
|
+
phys = (locs[0].get("physicalLocation") or {}) if locs else {}
|
|
215
|
+
file_uri = (phys.get("artifactLocation") or {}).get("uri") or ""
|
|
216
|
+
region = phys.get("region") or {}
|
|
217
|
+
line_start = int(region.get("startLine") or 0)
|
|
218
|
+
line_end = int(region.get("endLine") or line_start) if region else None
|
|
219
|
+
snippet = (region.get("snippet") or {}).get("text")
|
|
220
|
+
file_path = Path(file_uri) if file_uri else Path(".")
|
|
221
|
+
return file_path, line_start, line_end, snippet
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Scanner subprocesses. Each module is a single-purpose adapter:
|
|
2
|
+
in: scanner invocation + target path
|
|
3
|
+
out: list[Finding] in the canonical schema (see secure_code_audit.findings).
|
|
4
|
+
|
|
5
|
+
The registry below is the lookup the CLI uses to enumerate active scanners.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Callable, Iterable
|
|
10
|
+
|
|
11
|
+
from secure_code_audit.findings import Finding
|
|
12
|
+
|
|
13
|
+
from secure_code_audit.scanners.base import Scanner
|
|
14
|
+
from secure_code_audit.scanners.bandit_scanner import BanditScanner
|
|
15
|
+
from secure_code_audit.scanners.builtin_rules import BuiltinRulesScanner
|
|
16
|
+
from secure_code_audit.scanners.checkov_scanner import CheckovScanner
|
|
17
|
+
from secure_code_audit.scanners.gitleaks_scanner import GitleaksScanner
|
|
18
|
+
from secure_code_audit.scanners.hadolint_scanner import HadolintScanner
|
|
19
|
+
from secure_code_audit.scanners.npm_audit_scanner import NpmAuditScanner
|
|
20
|
+
from secure_code_audit.scanners.osv_scanner import OsvScanner
|
|
21
|
+
from secure_code_audit.scanners.pip_audit_scanner import PipAuditScanner
|
|
22
|
+
from secure_code_audit.scanners.scorecard_scanner import ScorecardScanner
|
|
23
|
+
from secure_code_audit.scanners.semgrep_scanner import SemgrepScanner
|
|
24
|
+
from secure_code_audit.scanners.trivy_scanner import TrivyScanner
|
|
25
|
+
from secure_code_audit.scanners.trufflehog_scanner import TruffleHogScanner
|
|
26
|
+
|
|
27
|
+
SCANNERS: dict[str, type[Scanner]] = {
|
|
28
|
+
# Tier-1 — Python / Node / secrets / SAST
|
|
29
|
+
"bandit": BanditScanner,
|
|
30
|
+
"builtin_rules": BuiltinRulesScanner,
|
|
31
|
+
"gitleaks": GitleaksScanner,
|
|
32
|
+
"npm_audit": NpmAuditScanner,
|
|
33
|
+
"pip_audit": PipAuditScanner,
|
|
34
|
+
"semgrep": SemgrepScanner,
|
|
35
|
+
# Tier-2 — IaC / containers / supply chain / multi-ecosystem SCA / verified secrets
|
|
36
|
+
"checkov": CheckovScanner,
|
|
37
|
+
"hadolint": HadolintScanner,
|
|
38
|
+
"osv_scanner": OsvScanner,
|
|
39
|
+
"scorecard": ScorecardScanner,
|
|
40
|
+
"trivy": TrivyScanner,
|
|
41
|
+
"trufflehog": TruffleHogScanner,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def all_scanner_names() -> list[str]:
|
|
46
|
+
return list(SCANNERS.keys())
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def get(name: str) -> type[Scanner] | None:
|
|
50
|
+
return SCANNERS.get(name)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Bandit — Python SAST.
|
|
2
|
+
|
|
3
|
+
Invocation:
|
|
4
|
+
bandit -r <target> -f json -ll -ii [--exclude <pattern>]
|
|
5
|
+
|
|
6
|
+
Output: JSON with results[] array. Each result has filename, line_number,
|
|
7
|
+
test_id (B102, B608, ...), issue_text, issue_severity, issue_confidence,
|
|
8
|
+
and code (the offending snippet).
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from secure_code_audit.config import Config
|
|
16
|
+
from secure_code_audit.findings import Confidence, Finding, Severity
|
|
17
|
+
from secure_code_audit.scanners.base import Scanner
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class BanditScanner(Scanner):
|
|
21
|
+
name = "bandit"
|
|
22
|
+
binary = "bandit"
|
|
23
|
+
|
|
24
|
+
def run(self, target: Path, config: Config) -> list[Finding]:
|
|
25
|
+
if not self.is_available():
|
|
26
|
+
return [self._unavailable_finding(target)]
|
|
27
|
+
|
|
28
|
+
sc_cfg = self.cfg(config)
|
|
29
|
+
args = [
|
|
30
|
+
self.binary, "-r", str(target),
|
|
31
|
+
"-f", "json",
|
|
32
|
+
"--severity-level", "low",
|
|
33
|
+
"--confidence-level", "low",
|
|
34
|
+
]
|
|
35
|
+
for pat in config.exclude_patterns:
|
|
36
|
+
args.extend(["--exclude", pat])
|
|
37
|
+
args.extend(sc_cfg.extra_args)
|
|
38
|
+
|
|
39
|
+
# Bandit exits nonzero on findings — accept 0 + 1.
|
|
40
|
+
r = self._exec(args, cwd=target, timeout_seconds=sc_cfg.timeout_seconds,
|
|
41
|
+
allowed_exits=(0, 1))
|
|
42
|
+
if r.returncode == 124:
|
|
43
|
+
return [self._timeout_finding(target, r.stderr)]
|
|
44
|
+
if not r.stdout.strip():
|
|
45
|
+
return [self._error_finding(target, "bandit emitted no output")]
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
payload = json.loads(r.stdout)
|
|
49
|
+
except json.JSONDecodeError as e:
|
|
50
|
+
return [self._error_finding(target, f"bandit JSON parse failure: {e}")]
|
|
51
|
+
|
|
52
|
+
findings: list[Finding] = []
|
|
53
|
+
for result in payload.get("results", []):
|
|
54
|
+
rule_id = result.get("test_id") or result.get("test_name") or "unknown"
|
|
55
|
+
findings.append(self._make_finding(
|
|
56
|
+
rule_id=rule_id,
|
|
57
|
+
message=str(result.get("issue_text") or "").strip(),
|
|
58
|
+
file_path=Path(result.get("filename", "")),
|
|
59
|
+
line_start=int(result.get("line_number") or 0),
|
|
60
|
+
line_end=int(result.get("line_range", [0])[-1] or 0)
|
|
61
|
+
if isinstance(result.get("line_range"), list)
|
|
62
|
+
else None,
|
|
63
|
+
code_snippet=str(result.get("code") or "").strip() or None,
|
|
64
|
+
severity=Severity.from_string(result.get("issue_severity", "")),
|
|
65
|
+
confidence=Confidence.from_string(result.get("issue_confidence", "")),
|
|
66
|
+
))
|
|
67
|
+
return findings
|
|
68
|
+
|
|
69
|
+
def _timeout_finding(self, target: Path, stderr: str) -> Finding:
|
|
70
|
+
return self._make_finding(
|
|
71
|
+
rule_id=f"{self.name}.tool_timeout",
|
|
72
|
+
message=f"bandit timed out: {stderr}",
|
|
73
|
+
file_path=target, line_start=0, line_end=None, code_snippet=None,
|
|
74
|
+
severity=Severity.INFORMATIONAL, confidence=Confidence.HIGH,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
def _error_finding(self, target: Path, message: str) -> Finding:
|
|
78
|
+
return self._make_finding(
|
|
79
|
+
rule_id=f"{self.name}.tool_error",
|
|
80
|
+
message=message,
|
|
81
|
+
file_path=target, line_start=0, line_end=None, code_snippet=None,
|
|
82
|
+
severity=Severity.INFORMATIONAL, confidence=Confidence.HIGH,
|
|
83
|
+
)
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Scanner protocol + subprocess helpers."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
from secure_code_audit.config import Config, scanner_cfg
|
|
12
|
+
from secure_code_audit.findings import Category, Confidence, Finding, Severity
|
|
13
|
+
from secure_code_audit.standards import StandardsEntry, is_top25, lookup
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Scanner(ABC):
|
|
17
|
+
"""Base class for all scanner adapters.
|
|
18
|
+
|
|
19
|
+
Subclasses implement `_run_subprocess()` and `_parse_output()` (or
|
|
20
|
+
override `run()` entirely for built-in / in-process scanners).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
name: str # canonical id used in config + reports
|
|
24
|
+
binary: str # name of the executable on PATH
|
|
25
|
+
version_flag: str = "--version"
|
|
26
|
+
default_category: Category = Category.CODE_VULNERABILITIES
|
|
27
|
+
|
|
28
|
+
# ----- availability ----------------------------------------------------
|
|
29
|
+
|
|
30
|
+
def is_available(self) -> bool:
|
|
31
|
+
return shutil.which(self.binary) is not None
|
|
32
|
+
|
|
33
|
+
def binary_version(self) -> Optional[str]:
|
|
34
|
+
if not self.is_available():
|
|
35
|
+
return None
|
|
36
|
+
try:
|
|
37
|
+
r = subprocess.run(
|
|
38
|
+
[self.binary, self.version_flag],
|
|
39
|
+
check=False, capture_output=True, text=True, timeout=10,
|
|
40
|
+
)
|
|
41
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
42
|
+
return None
|
|
43
|
+
out = (r.stdout or r.stderr or "").strip().splitlines()
|
|
44
|
+
return out[0] if out else None
|
|
45
|
+
|
|
46
|
+
# ----- main entrypoint ------------------------------------------------
|
|
47
|
+
|
|
48
|
+
@abstractmethod
|
|
49
|
+
def run(self, target: Path, config: Config) -> list[Finding]:
|
|
50
|
+
"""Execute the scanner against target. MUST NOT raise.
|
|
51
|
+
|
|
52
|
+
Errors are converted to a single informational finding so the
|
|
53
|
+
audit pipeline never dies on a single scanner failing.
|
|
54
|
+
"""
|
|
55
|
+
...
|
|
56
|
+
|
|
57
|
+
# ----- subprocess helpers ---------------------------------------------
|
|
58
|
+
|
|
59
|
+
def _exec(
|
|
60
|
+
self,
|
|
61
|
+
args: list[str],
|
|
62
|
+
cwd: Path,
|
|
63
|
+
timeout_seconds: int,
|
|
64
|
+
allowed_exits: tuple[int, ...] = (0,),
|
|
65
|
+
) -> subprocess.CompletedProcess:
|
|
66
|
+
"""Run a scanner subprocess with sanitized env, no shell.
|
|
67
|
+
|
|
68
|
+
Some scanners (npm audit, pip-audit) exit nonzero on findings; pass
|
|
69
|
+
`allowed_exits` to mark those as success."""
|
|
70
|
+
env = self._sanitized_env()
|
|
71
|
+
try:
|
|
72
|
+
r = subprocess.run(
|
|
73
|
+
args,
|
|
74
|
+
cwd=str(cwd),
|
|
75
|
+
env=env,
|
|
76
|
+
shell=False,
|
|
77
|
+
check=False,
|
|
78
|
+
capture_output=True,
|
|
79
|
+
text=True,
|
|
80
|
+
timeout=timeout_seconds,
|
|
81
|
+
)
|
|
82
|
+
except subprocess.TimeoutExpired as exc:
|
|
83
|
+
return subprocess.CompletedProcess(
|
|
84
|
+
args=exc.cmd or args,
|
|
85
|
+
returncode=124,
|
|
86
|
+
stdout="",
|
|
87
|
+
stderr=f"timeout after {timeout_seconds}s",
|
|
88
|
+
)
|
|
89
|
+
if r.returncode not in allowed_exits and r.returncode != 0:
|
|
90
|
+
return r # caller decides how to handle
|
|
91
|
+
return r
|
|
92
|
+
|
|
93
|
+
@staticmethod
|
|
94
|
+
def _sanitized_env() -> dict[str, str]:
|
|
95
|
+
"""A minimal env for subprocesses — keep PATH and locale, drop the rest.
|
|
96
|
+
Prevents accidental secret-leak into the scanner process via env."""
|
|
97
|
+
keep = ("PATH", "HOME", "LANG", "LC_ALL", "TMPDIR", "TEMP", "TMP")
|
|
98
|
+
return {k: v for k, v in os.environ.items() if k in keep}
|
|
99
|
+
|
|
100
|
+
# ----- finding construction helper ------------------------------------
|
|
101
|
+
|
|
102
|
+
def _make_finding(
|
|
103
|
+
self,
|
|
104
|
+
*,
|
|
105
|
+
rule_id: str,
|
|
106
|
+
message: str,
|
|
107
|
+
file_path: Path,
|
|
108
|
+
line_start: int,
|
|
109
|
+
line_end: Optional[int],
|
|
110
|
+
code_snippet: Optional[str],
|
|
111
|
+
severity: Optional[Severity] = None,
|
|
112
|
+
confidence: Optional[Confidence] = None,
|
|
113
|
+
category: Optional[Category] = None,
|
|
114
|
+
cwe_override: Optional[str] = None,
|
|
115
|
+
scanner_name: Optional[str] = None,
|
|
116
|
+
) -> Finding:
|
|
117
|
+
"""Construct a canonical Finding from scanner-emitted bits, layering
|
|
118
|
+
in the standards mapping. Scanner-emitted severity wins over the
|
|
119
|
+
map's default; confidence falls back to the map; category is set
|
|
120
|
+
per the map unless explicitly overridden."""
|
|
121
|
+
|
|
122
|
+
scanner_label = scanner_name or self.name
|
|
123
|
+
entry: Optional[StandardsEntry] = lookup(scanner_label, rule_id)
|
|
124
|
+
|
|
125
|
+
# Mapping fallback to wildcard (handled inside lookup).
|
|
126
|
+
canonical_cwe = cwe_override or (entry.canonical_cwe if entry else None)
|
|
127
|
+
owasp_top10 = entry.owasp_top10 if entry else None
|
|
128
|
+
asvs_section = entry.asvs_section if entry else None
|
|
129
|
+
nist_ssdf = entry.nist_ssdf if entry else None
|
|
130
|
+
chosen_cat = category or (entry.category if entry else self.default_category)
|
|
131
|
+
chosen_sev = severity or (entry.severity if entry else Severity.MEDIUM)
|
|
132
|
+
chosen_conf = confidence or (entry.confidence if entry else Confidence.MEDIUM)
|
|
133
|
+
short_desc = entry.short_desc if entry else None
|
|
134
|
+
fix_hint = entry.fix_hint if entry else None
|
|
135
|
+
|
|
136
|
+
fingerprint = Finding.make_fingerprint(
|
|
137
|
+
canonical_cwe=canonical_cwe,
|
|
138
|
+
rule_id=rule_id,
|
|
139
|
+
file_path=file_path,
|
|
140
|
+
code_snippet=code_snippet,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
return Finding(
|
|
144
|
+
rule_id=rule_id,
|
|
145
|
+
scanner=scanner_label,
|
|
146
|
+
fingerprint=fingerprint,
|
|
147
|
+
canonical_cwe=canonical_cwe,
|
|
148
|
+
owasp_top10=owasp_top10,
|
|
149
|
+
asvs_section=asvs_section,
|
|
150
|
+
nist_ssdf=nist_ssdf,
|
|
151
|
+
category=chosen_cat,
|
|
152
|
+
severity=chosen_sev,
|
|
153
|
+
confidence=chosen_conf,
|
|
154
|
+
file_path=file_path,
|
|
155
|
+
line_start=line_start,
|
|
156
|
+
line_end=line_end,
|
|
157
|
+
code_snippet=code_snippet,
|
|
158
|
+
message=message,
|
|
159
|
+
short_desc=short_desc,
|
|
160
|
+
fix_hint=fix_hint,
|
|
161
|
+
cwe_top25=is_top25(canonical_cwe),
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
def _unavailable_finding(self, target: Path) -> Finding:
|
|
165
|
+
"""Informational finding emitted when the binary isn't on PATH."""
|
|
166
|
+
return Finding(
|
|
167
|
+
rule_id=f"{self.name}.tool_unavailable",
|
|
168
|
+
scanner=self.name,
|
|
169
|
+
fingerprint=f"unavailable.{self.name}",
|
|
170
|
+
canonical_cwe=None,
|
|
171
|
+
owasp_top10=None,
|
|
172
|
+
asvs_section=None,
|
|
173
|
+
nist_ssdf=None,
|
|
174
|
+
category=Category.POLICY_DOCS,
|
|
175
|
+
severity=Severity.INFORMATIONAL,
|
|
176
|
+
confidence=Confidence.HIGH,
|
|
177
|
+
file_path=target,
|
|
178
|
+
line_start=0,
|
|
179
|
+
line_end=None,
|
|
180
|
+
code_snippet=None,
|
|
181
|
+
message=f"{self.binary} not on PATH; {self.name} scan skipped.",
|
|
182
|
+
short_desc=None,
|
|
183
|
+
fix_hint=f"Install {self.binary} to enable {self.name} coverage.",
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
# ----- shared utility -------------------------------------------------
|
|
187
|
+
|
|
188
|
+
def cfg(self, config: Config) -> "ScannerConfig":
|
|
189
|
+
"""Convenience accessor."""
|
|
190
|
+
return scanner_cfg(config, self.name)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# Re-export so the registry import in __init__.py is clean.
|
|
194
|
+
from secure_code_audit.config import ScannerConfig # noqa: E402
|