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,83 @@
|
|
|
1
|
+
"""pip-audit — Python SCA via PyPI Advisory DB / OSV.
|
|
2
|
+
|
|
3
|
+
Invocation:
|
|
4
|
+
pip-audit -r requirements.txt --format=json
|
|
5
|
+
|
|
6
|
+
Output: JSON with dependencies[] each having vulns[] with id, fix_versions,
|
|
7
|
+
description.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from secure_code_audit.config import Config
|
|
15
|
+
from secure_code_audit.findings import Category, Confidence, Finding, Severity
|
|
16
|
+
from secure_code_audit.scanners.base import Scanner
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
_REQ_NAMES: tuple[str, ...] = (
|
|
20
|
+
"requirements.txt",
|
|
21
|
+
"requirements-dev.txt",
|
|
22
|
+
"requirements/base.txt",
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class PipAuditScanner(Scanner):
|
|
27
|
+
name = "pip_audit"
|
|
28
|
+
binary = "pip-audit"
|
|
29
|
+
default_category = Category.DEPENDENCIES
|
|
30
|
+
|
|
31
|
+
def run(self, target: Path, config: Config) -> list[Finding]:
|
|
32
|
+
if not self.is_available():
|
|
33
|
+
return [self._unavailable_finding(target)]
|
|
34
|
+
|
|
35
|
+
req_files = [target / r for r in _REQ_NAMES if (target / r).exists()]
|
|
36
|
+
if not req_files:
|
|
37
|
+
return [self._make_finding(
|
|
38
|
+
rule_id=f"{self.name}.no_requirements",
|
|
39
|
+
message="No requirements.txt found at target root; pip-audit skipped.",
|
|
40
|
+
file_path=target, line_start=0, line_end=None, code_snippet=None,
|
|
41
|
+
severity=Severity.INFORMATIONAL, confidence=Confidence.HIGH,
|
|
42
|
+
category=Category.DEPENDENCIES,
|
|
43
|
+
)]
|
|
44
|
+
|
|
45
|
+
sc_cfg = self.cfg(config)
|
|
46
|
+
findings: list[Finding] = []
|
|
47
|
+
for req in req_files:
|
|
48
|
+
args = [self.binary, "-r", str(req), "--format=json"]
|
|
49
|
+
args.extend(sc_cfg.extra_args)
|
|
50
|
+
r = self._exec(args, cwd=target, timeout_seconds=sc_cfg.timeout_seconds,
|
|
51
|
+
allowed_exits=(0, 1))
|
|
52
|
+
if r.returncode == 124:
|
|
53
|
+
findings.append(self._make_finding(
|
|
54
|
+
rule_id=f"{self.name}.tool_timeout",
|
|
55
|
+
message=f"pip-audit timed out on {req.name}: {r.stderr}",
|
|
56
|
+
file_path=req, line_start=0, line_end=None, code_snippet=None,
|
|
57
|
+
severity=Severity.INFORMATIONAL, confidence=Confidence.HIGH,
|
|
58
|
+
))
|
|
59
|
+
continue
|
|
60
|
+
if not r.stdout.strip():
|
|
61
|
+
continue
|
|
62
|
+
try:
|
|
63
|
+
payload = json.loads(r.stdout)
|
|
64
|
+
except json.JSONDecodeError:
|
|
65
|
+
continue
|
|
66
|
+
|
|
67
|
+
deps = payload.get("dependencies", payload if isinstance(payload, list) else [])
|
|
68
|
+
for dep in deps:
|
|
69
|
+
name = dep.get("name", "?")
|
|
70
|
+
version = dep.get("version", "?")
|
|
71
|
+
for vuln in dep.get("vulns", []) or []:
|
|
72
|
+
vuln_id = vuln.get("id", "UNKNOWN")
|
|
73
|
+
fixes = vuln.get("fix_versions") or []
|
|
74
|
+
desc = (vuln.get("description") or "").strip()
|
|
75
|
+
fix_note = f" Fix in: {', '.join(fixes)}" if fixes else " No fix available."
|
|
76
|
+
findings.append(self._make_finding(
|
|
77
|
+
rule_id=f"pip_audit.{vuln_id}",
|
|
78
|
+
message=f"{name} {version} — {vuln_id}: {desc[:200]}{fix_note}",
|
|
79
|
+
file_path=req, line_start=0, line_end=None, code_snippet=None,
|
|
80
|
+
severity=Severity.HIGH, confidence=Confidence.HIGH,
|
|
81
|
+
category=Category.DEPENDENCIES,
|
|
82
|
+
))
|
|
83
|
+
return findings
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""OpenSSF Scorecard — repo + supply-chain hygiene.
|
|
2
|
+
|
|
3
|
+
Special case among scanners:
|
|
4
|
+
· Operates on a REMOTE repo URL, not a local path.
|
|
5
|
+
· Needs the GH_TOKEN env var (or equivalent) to query GitHub APIs.
|
|
6
|
+
· Best run on the repo root, not on a subdirectory.
|
|
7
|
+
|
|
8
|
+
Invocation:
|
|
9
|
+
scorecard --repo=<github-url> --format=json --show-details
|
|
10
|
+
|
|
11
|
+
Output: JSON with `checks[]` array, each having `name`, `score` (0-10
|
|
12
|
+
or -1 for inconclusive), `reason`, `details[]`, and `documentation.url`.
|
|
13
|
+
|
|
14
|
+
We map each Scorecard check into `supply_chain` (or `policy_docs` for
|
|
15
|
+
documentation-style checks). Score → severity mapping:
|
|
16
|
+
· score < 0 → INFORMATIONAL (inconclusive — check couldn't run)
|
|
17
|
+
· score < 3 → HIGH (clearly failing)
|
|
18
|
+
· score < 7 → MEDIUM (warning)
|
|
19
|
+
· score < 10 → LOW (acceptable but not perfect)
|
|
20
|
+
· score == 10 → no finding emitted (passed cleanly)
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import subprocess
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
from secure_code_audit.config import Config
|
|
29
|
+
from secure_code_audit.findings import Category, Confidence, Finding, Severity
|
|
30
|
+
from secure_code_audit.scanners.base import Scanner
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# Checks whose semantics are "documentation present" rather than
|
|
34
|
+
# supply-chain integrity.
|
|
35
|
+
_POLICY_DOCS_CHECKS = {"Security-Policy", "License", "CII-Best-Practices"}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ScorecardScanner(Scanner):
|
|
39
|
+
name = "scorecard"
|
|
40
|
+
binary = "scorecard"
|
|
41
|
+
default_category = Category.SUPPLY_CHAIN
|
|
42
|
+
|
|
43
|
+
def run(self, target: Path, config: Config) -> list[Finding]:
|
|
44
|
+
if not self.is_available():
|
|
45
|
+
return [self._unavailable_finding(target)]
|
|
46
|
+
|
|
47
|
+
repo_url = self._infer_repo_url(target)
|
|
48
|
+
if repo_url is None:
|
|
49
|
+
return [self._make_finding(
|
|
50
|
+
rule_id=f"{self.name}.no_remote",
|
|
51
|
+
message="Scorecard requires a remote GitHub URL (origin remote not found).",
|
|
52
|
+
file_path=target, line_start=0, line_end=None, code_snippet=None,
|
|
53
|
+
severity=Severity.INFORMATIONAL, confidence=Confidence.HIGH,
|
|
54
|
+
category=Category.SUPPLY_CHAIN,
|
|
55
|
+
)]
|
|
56
|
+
|
|
57
|
+
sc_cfg = self.cfg(config)
|
|
58
|
+
args = [
|
|
59
|
+
self.binary,
|
|
60
|
+
f"--repo={repo_url}",
|
|
61
|
+
"--format=json", "--show-details",
|
|
62
|
+
]
|
|
63
|
+
args.extend(sc_cfg.extra_args)
|
|
64
|
+
|
|
65
|
+
r = self._exec(args, cwd=target, timeout_seconds=sc_cfg.timeout_seconds,
|
|
66
|
+
allowed_exits=(0, 1, 2))
|
|
67
|
+
if r.returncode == 124:
|
|
68
|
+
return [self._make_finding(
|
|
69
|
+
rule_id=f"{self.name}.tool_timeout",
|
|
70
|
+
message=f"scorecard timed out: {r.stderr[:200]}",
|
|
71
|
+
file_path=target, line_start=0, line_end=None, code_snippet=None,
|
|
72
|
+
severity=Severity.INFORMATIONAL, confidence=Confidence.HIGH,
|
|
73
|
+
)]
|
|
74
|
+
if not r.stdout.strip():
|
|
75
|
+
return []
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
payload = json.loads(r.stdout)
|
|
79
|
+
except json.JSONDecodeError:
|
|
80
|
+
return []
|
|
81
|
+
|
|
82
|
+
return self._parse(payload, target)
|
|
83
|
+
|
|
84
|
+
def _infer_repo_url(self, target: Path) -> str | None:
|
|
85
|
+
"""Run `git remote get-url origin` in target. Returns the URL
|
|
86
|
+
normalized to https://github.com/<owner>/<repo> form, or None
|
|
87
|
+
if no GitHub origin is present."""
|
|
88
|
+
try:
|
|
89
|
+
r = subprocess.run(
|
|
90
|
+
["git", "remote", "get-url", "origin"],
|
|
91
|
+
cwd=str(target),
|
|
92
|
+
check=False, capture_output=True, text=True, timeout=5,
|
|
93
|
+
)
|
|
94
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
95
|
+
return None
|
|
96
|
+
url = (r.stdout or "").strip()
|
|
97
|
+
if not url:
|
|
98
|
+
return None
|
|
99
|
+
# Normalize SSH form to HTTPS form so scorecard accepts it.
|
|
100
|
+
if url.startswith("git@github.com:"):
|
|
101
|
+
url = "https://github.com/" + url[len("git@github.com:"):]
|
|
102
|
+
if url.endswith(".git"):
|
|
103
|
+
url = url[:-4]
|
|
104
|
+
if not url.startswith("https://github.com/"):
|
|
105
|
+
return None
|
|
106
|
+
return url
|
|
107
|
+
|
|
108
|
+
def _parse(self, payload: dict, target: Path) -> list[Finding]:
|
|
109
|
+
out: list[Finding] = []
|
|
110
|
+
for check in payload.get("checks", []):
|
|
111
|
+
name = str(check.get("name") or "Unknown")
|
|
112
|
+
score = check.get("score")
|
|
113
|
+
reason = (check.get("reason") or "").strip()
|
|
114
|
+
doc = (check.get("documentation") or {}).get("url") or ""
|
|
115
|
+
|
|
116
|
+
severity = self._score_to_severity(score)
|
|
117
|
+
if severity is None:
|
|
118
|
+
continue # passed cleanly, no finding
|
|
119
|
+
|
|
120
|
+
category = (Category.POLICY_DOCS
|
|
121
|
+
if name in _POLICY_DOCS_CHECKS
|
|
122
|
+
else Category.SUPPLY_CHAIN)
|
|
123
|
+
msg = f"OpenSSF Scorecard `{name}` scored {score}/10: {reason}"
|
|
124
|
+
if doc:
|
|
125
|
+
msg = f"{msg} [{doc}]"
|
|
126
|
+
|
|
127
|
+
out.append(self._make_finding(
|
|
128
|
+
rule_id=f"scorecard.{name}",
|
|
129
|
+
message=msg,
|
|
130
|
+
file_path=target,
|
|
131
|
+
line_start=0,
|
|
132
|
+
line_end=None,
|
|
133
|
+
code_snippet=None,
|
|
134
|
+
severity=severity,
|
|
135
|
+
confidence=Confidence.HIGH,
|
|
136
|
+
category=category,
|
|
137
|
+
))
|
|
138
|
+
return out
|
|
139
|
+
|
|
140
|
+
@staticmethod
|
|
141
|
+
def _score_to_severity(score) -> Severity | None:
|
|
142
|
+
if score is None:
|
|
143
|
+
return None
|
|
144
|
+
try:
|
|
145
|
+
s = int(score)
|
|
146
|
+
except (TypeError, ValueError):
|
|
147
|
+
return None
|
|
148
|
+
if s < 0:
|
|
149
|
+
return Severity.INFORMATIONAL
|
|
150
|
+
if s == 10:
|
|
151
|
+
return None
|
|
152
|
+
if s < 3:
|
|
153
|
+
return Severity.HIGH
|
|
154
|
+
if s < 7:
|
|
155
|
+
return Severity.MEDIUM
|
|
156
|
+
return Severity.LOW
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Semgrep — multi-language SAST.
|
|
2
|
+
|
|
3
|
+
Invocation:
|
|
4
|
+
semgrep --config=auto --sarif --metrics=off --error <target>
|
|
5
|
+
|
|
6
|
+
Output: SARIF 2.1.0. We piggyback the canonical SARIF parser since other
|
|
7
|
+
scanners (CodeQL, Snyk, Trivy) emit the same format.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import tempfile
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from secure_code_audit.config import Config
|
|
16
|
+
from secure_code_audit.findings import Category, Confidence, Finding, Severity
|
|
17
|
+
from secure_code_audit.scanners.base import Scanner
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SemgrepScanner(Scanner):
|
|
21
|
+
name = "semgrep"
|
|
22
|
+
binary = "semgrep"
|
|
23
|
+
default_category = Category.CODE_VULNERABILITIES
|
|
24
|
+
|
|
25
|
+
def run(self, target: Path, config: Config) -> list[Finding]:
|
|
26
|
+
if not self.is_available():
|
|
27
|
+
return [self._unavailable_finding(target)]
|
|
28
|
+
|
|
29
|
+
sc_cfg = self.cfg(config)
|
|
30
|
+
config_arg = "auto" # registry-curated pack
|
|
31
|
+
if not sc_cfg.online:
|
|
32
|
+
# Operator opted out of online registry — fall back to bundled
|
|
33
|
+
# rules. Semgrep ships a small offline set under p/python +
|
|
34
|
+
# p/javascript when --config points to those names.
|
|
35
|
+
config_arg = "p/security-audit"
|
|
36
|
+
|
|
37
|
+
with tempfile.NamedTemporaryFile(suffix=".sarif", delete=False) as tmp:
|
|
38
|
+
sarif_path = Path(tmp.name)
|
|
39
|
+
try:
|
|
40
|
+
args = [
|
|
41
|
+
self.binary,
|
|
42
|
+
"--config", config_arg,
|
|
43
|
+
"--sarif", "--metrics=off",
|
|
44
|
+
"--output", str(sarif_path),
|
|
45
|
+
str(target),
|
|
46
|
+
]
|
|
47
|
+
args.extend(sc_cfg.extra_args)
|
|
48
|
+
r = self._exec(args, cwd=target, timeout_seconds=sc_cfg.timeout_seconds,
|
|
49
|
+
allowed_exits=(0, 1))
|
|
50
|
+
if r.returncode == 124:
|
|
51
|
+
return [self._make_finding(
|
|
52
|
+
rule_id=f"{self.name}.tool_timeout",
|
|
53
|
+
message=f"semgrep timed out: {r.stderr[:200]}",
|
|
54
|
+
file_path=target, line_start=0, line_end=None, code_snippet=None,
|
|
55
|
+
severity=Severity.INFORMATIONAL, confidence=Confidence.HIGH,
|
|
56
|
+
)]
|
|
57
|
+
if not sarif_path.exists():
|
|
58
|
+
return []
|
|
59
|
+
try:
|
|
60
|
+
payload = json.loads(sarif_path.read_text(encoding="utf-8"))
|
|
61
|
+
except json.JSONDecodeError:
|
|
62
|
+
return []
|
|
63
|
+
return self._parse_sarif(payload, target)
|
|
64
|
+
finally:
|
|
65
|
+
sarif_path.unlink(missing_ok=True)
|
|
66
|
+
|
|
67
|
+
# -- minimal SARIF parser; a fuller one lives in sarif.py for ingest --
|
|
68
|
+
def _parse_sarif(self, payload: dict, target: Path) -> list[Finding]:
|
|
69
|
+
findings: list[Finding] = []
|
|
70
|
+
for run in payload.get("runs", []):
|
|
71
|
+
rules = {
|
|
72
|
+
r.get("id"): r
|
|
73
|
+
for r in (run.get("tool", {}).get("driver", {}).get("rules", []) or [])
|
|
74
|
+
}
|
|
75
|
+
for result in run.get("results", []):
|
|
76
|
+
rule_id = result.get("ruleId") or "unknown"
|
|
77
|
+
rule = rules.get(rule_id, {})
|
|
78
|
+
level = result.get("level") or rule.get("defaultConfiguration", {}).get("level") or "warning"
|
|
79
|
+
msg = (result.get("message") or {}).get("text") or rule.get("shortDescription", {}).get("text") or rule_id
|
|
80
|
+
|
|
81
|
+
locs = result.get("locations") or []
|
|
82
|
+
if not locs:
|
|
83
|
+
continue
|
|
84
|
+
loc = locs[0]
|
|
85
|
+
phys = loc.get("physicalLocation") or {}
|
|
86
|
+
file_uri = (phys.get("artifactLocation") or {}).get("uri") or ""
|
|
87
|
+
region = phys.get("region") or {}
|
|
88
|
+
line_start = int(region.get("startLine") or 0)
|
|
89
|
+
line_end = int(region.get("endLine") or line_start)
|
|
90
|
+
snippet = (region.get("snippet") or {}).get("text")
|
|
91
|
+
|
|
92
|
+
# SARIF severity: "error" → HIGH, "warning" → MEDIUM, "note" → LOW.
|
|
93
|
+
if level == "error":
|
|
94
|
+
severity = Severity.HIGH
|
|
95
|
+
elif level == "note":
|
|
96
|
+
severity = Severity.LOW
|
|
97
|
+
else:
|
|
98
|
+
severity = Severity.MEDIUM
|
|
99
|
+
|
|
100
|
+
# Pull CWE from rule properties.cwe if present.
|
|
101
|
+
cwe = None
|
|
102
|
+
props = rule.get("properties") or {}
|
|
103
|
+
if isinstance(props.get("cwe"), list) and props["cwe"]:
|
|
104
|
+
cwe = props["cwe"][0]
|
|
105
|
+
elif isinstance(props.get("cwe"), str):
|
|
106
|
+
cwe = props["cwe"]
|
|
107
|
+
|
|
108
|
+
findings.append(self._make_finding(
|
|
109
|
+
rule_id=rule_id,
|
|
110
|
+
message=msg,
|
|
111
|
+
file_path=Path(file_uri) if file_uri else target,
|
|
112
|
+
line_start=line_start,
|
|
113
|
+
line_end=line_end if line_end != line_start else None,
|
|
114
|
+
code_snippet=snippet,
|
|
115
|
+
severity=severity,
|
|
116
|
+
confidence=Confidence.MEDIUM,
|
|
117
|
+
cwe_override=cwe,
|
|
118
|
+
))
|
|
119
|
+
return findings
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Trivy — containers / filesystem / IaC / Kubernetes scanner.
|
|
2
|
+
|
|
3
|
+
Invocation:
|
|
4
|
+
trivy fs --format sarif --output <tmp> --quiet --no-progress <target>
|
|
5
|
+
|
|
6
|
+
Trivy's `fs` subcommand covers filesystem scanning for vulnerable deps
|
|
7
|
+
(via its own DB ingest of OSV/GHSA/NVD), secrets, misconfigurations
|
|
8
|
+
(Terraform / CloudFormation / Dockerfile / Helm / k8s), and license
|
|
9
|
+
issues. We focus on the security-relevant categories: vulnerabilities,
|
|
10
|
+
misconfigs, secrets.
|
|
11
|
+
|
|
12
|
+
SARIF output goes through `secure_code_audit.sarif.ingest()` so we get
|
|
13
|
+
canonical Findings with the same normalization as every other SARIF
|
|
14
|
+
source.
|
|
15
|
+
|
|
16
|
+
Trivy emits a single SARIF file per scan with results across all
|
|
17
|
+
categories — the standards mapping table routes findings into the
|
|
18
|
+
right `Category` based on rule properties.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import tempfile
|
|
23
|
+
from dataclasses import replace
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from secure_code_audit.config import Config
|
|
27
|
+
from secure_code_audit.findings import Category, Confidence, Finding, Severity
|
|
28
|
+
from secure_code_audit.sarif import ingest as sarif_ingest
|
|
29
|
+
from secure_code_audit.scanners.base import Scanner
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class TrivyScanner(Scanner):
|
|
33
|
+
name = "trivy"
|
|
34
|
+
binary = "trivy"
|
|
35
|
+
default_category = Category.CONFIG_IAC
|
|
36
|
+
|
|
37
|
+
def run(self, target: Path, config: Config) -> list[Finding]:
|
|
38
|
+
if not self.is_available():
|
|
39
|
+
return [self._unavailable_finding(target)]
|
|
40
|
+
|
|
41
|
+
sc_cfg = self.cfg(config)
|
|
42
|
+
with tempfile.NamedTemporaryFile(suffix=".sarif", delete=False) as tmp:
|
|
43
|
+
sarif_path = Path(tmp.name)
|
|
44
|
+
try:
|
|
45
|
+
args = [
|
|
46
|
+
self.binary, "fs",
|
|
47
|
+
"--format", "sarif",
|
|
48
|
+
"--output", str(sarif_path),
|
|
49
|
+
"--quiet", "--no-progress",
|
|
50
|
+
# Skip license findings — we focus on security only.
|
|
51
|
+
"--scanners", "vuln,secret,misconfig",
|
|
52
|
+
str(target),
|
|
53
|
+
]
|
|
54
|
+
args.extend(sc_cfg.extra_args)
|
|
55
|
+
|
|
56
|
+
r = self._exec(args, cwd=target, timeout_seconds=sc_cfg.timeout_seconds,
|
|
57
|
+
allowed_exits=(0, 1))
|
|
58
|
+
if r.returncode == 124:
|
|
59
|
+
return [self._make_finding(
|
|
60
|
+
rule_id=f"{self.name}.tool_timeout",
|
|
61
|
+
message=f"trivy timed out: {r.stderr[:200]}",
|
|
62
|
+
file_path=target, line_start=0, line_end=None, code_snippet=None,
|
|
63
|
+
severity=Severity.INFORMATIONAL, confidence=Confidence.HIGH,
|
|
64
|
+
)]
|
|
65
|
+
if not sarif_path.exists() or sarif_path.stat().st_size == 0:
|
|
66
|
+
return []
|
|
67
|
+
|
|
68
|
+
ingested = sarif_ingest(sarif_path, default_scanner="trivy")
|
|
69
|
+
# Trivy tags its rules with a category prefix (CVE-, AVD-, etc.);
|
|
70
|
+
# route them into the right Category bucket.
|
|
71
|
+
return [self._route_category(f) for f in ingested]
|
|
72
|
+
finally:
|
|
73
|
+
sarif_path.unlink(missing_ok=True)
|
|
74
|
+
|
|
75
|
+
def _route_category(self, f: Finding) -> Finding:
|
|
76
|
+
"""Trivy bundles three rule families into one SARIF output.
|
|
77
|
+
Route by rule_id prefix so the scoring layer puts them in the
|
|
78
|
+
right category."""
|
|
79
|
+
rid = f.rule_id.upper()
|
|
80
|
+
if rid.startswith("CVE-") or rid.startswith("GHSA-"):
|
|
81
|
+
return replace(f, category=Category.DEPENDENCIES, scanner="trivy")
|
|
82
|
+
if rid.startswith("AVD-") or "MISCONFIG" in rid:
|
|
83
|
+
return replace(f, category=Category.CONFIG_IAC, scanner="trivy")
|
|
84
|
+
if "SECRET" in rid or rid.startswith("AWS") or rid.startswith("PRIVATE-KEY"):
|
|
85
|
+
return replace(f, category=Category.SECRETS, scanner="trivy",
|
|
86
|
+
severity=Severity.CRITICAL)
|
|
87
|
+
return replace(f, scanner="trivy")
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""TruffleHog — secret scanning with verifiers.
|
|
2
|
+
|
|
3
|
+
Invocation:
|
|
4
|
+
trufflehog filesystem --json --no-update --only-verified <target>
|
|
5
|
+
|
|
6
|
+
`--only-verified` is the default — high-precision matches where
|
|
7
|
+
TruffleHog's verifier confirmed the secret is currently live against
|
|
8
|
+
the upstream service. Operators can opt into unverified findings via
|
|
9
|
+
`scanners.trufflehog.extra_args: ["--no-only-verified"]` in config.
|
|
10
|
+
|
|
11
|
+
Output: JSONL (one JSON object per line) — distinct from gitleaks
|
|
12
|
+
which writes a single JSON array.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from secure_code_audit.config import Config
|
|
20
|
+
from secure_code_audit.findings import Category, Confidence, Finding, Severity
|
|
21
|
+
from secure_code_audit.scanners.base import Scanner
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TruffleHogScanner(Scanner):
|
|
25
|
+
name = "trufflehog"
|
|
26
|
+
binary = "trufflehog"
|
|
27
|
+
default_category = Category.SECRETS
|
|
28
|
+
|
|
29
|
+
def run(self, target: Path, config: Config) -> list[Finding]:
|
|
30
|
+
if not self.is_available():
|
|
31
|
+
return [self._unavailable_finding(target)]
|
|
32
|
+
|
|
33
|
+
sc_cfg = self.cfg(config)
|
|
34
|
+
args = [
|
|
35
|
+
self.binary, "filesystem",
|
|
36
|
+
"--json", "--no-update",
|
|
37
|
+
"--only-verified",
|
|
38
|
+
str(target),
|
|
39
|
+
]
|
|
40
|
+
args.extend(sc_cfg.extra_args)
|
|
41
|
+
|
|
42
|
+
r = self._exec(args, cwd=target, timeout_seconds=sc_cfg.timeout_seconds,
|
|
43
|
+
allowed_exits=(0, 183)) # 183 = findings present
|
|
44
|
+
if r.returncode == 124:
|
|
45
|
+
return [self._make_finding(
|
|
46
|
+
rule_id=f"{self.name}.tool_timeout",
|
|
47
|
+
message=f"trufflehog timed out: {r.stderr[:200]}",
|
|
48
|
+
file_path=target, line_start=0, line_end=None, code_snippet=None,
|
|
49
|
+
severity=Severity.INFORMATIONAL, confidence=Confidence.HIGH,
|
|
50
|
+
)]
|
|
51
|
+
if not r.stdout.strip():
|
|
52
|
+
return []
|
|
53
|
+
|
|
54
|
+
out: list[Finding] = []
|
|
55
|
+
for line in r.stdout.splitlines():
|
|
56
|
+
line = line.strip()
|
|
57
|
+
if not line:
|
|
58
|
+
continue
|
|
59
|
+
try:
|
|
60
|
+
hit = json.loads(line)
|
|
61
|
+
except json.JSONDecodeError:
|
|
62
|
+
continue
|
|
63
|
+
out.append(self._parse_one(hit))
|
|
64
|
+
return out
|
|
65
|
+
|
|
66
|
+
def _parse_one(self, hit: dict) -> Finding:
|
|
67
|
+
detector = str(hit.get("DetectorName") or hit.get("Detector") or "unknown")
|
|
68
|
+
# SourceMetadata.Data.Filesystem.file / line
|
|
69
|
+
meta = (((hit.get("SourceMetadata") or {}).get("Data") or {}).get("Filesystem") or {})
|
|
70
|
+
file_path = Path(meta.get("file") or "")
|
|
71
|
+
line = int(meta.get("line") or 0)
|
|
72
|
+
verified = bool(hit.get("Verified"))
|
|
73
|
+
# The redacted match is what TruffleHog emits when --only-verified
|
|
74
|
+
# is on — it strips the raw secret bytes.
|
|
75
|
+
match = str(hit.get("Redacted") or "<verified>").strip()
|
|
76
|
+
|
|
77
|
+
# Verified secrets are CRITICAL; unverified (if operator opted in
|
|
78
|
+
# via --no-only-verified in extra_args) are HIGH.
|
|
79
|
+
severity = Severity.CRITICAL if verified else Severity.HIGH
|
|
80
|
+
|
|
81
|
+
return self._make_finding(
|
|
82
|
+
rule_id=f"trufflehog.{detector}",
|
|
83
|
+
message=f"{detector}: {match}".strip(),
|
|
84
|
+
file_path=file_path,
|
|
85
|
+
line_start=line,
|
|
86
|
+
line_end=None,
|
|
87
|
+
code_snippet=match[:200],
|
|
88
|
+
severity=severity,
|
|
89
|
+
confidence=Confidence.HIGH,
|
|
90
|
+
category=Category.SECRETS,
|
|
91
|
+
)
|