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,280 @@
1
+ """Scoring + gate evaluation.
2
+
3
+ Implements the model documented in docs/scoring.md:
4
+ · finding_score = severity × confidence × category × top25_bonus
5
+ · category_subtotal = Σ finding_score per category
6
+ · category_normalized = subtotal / sqrt(LOC / 1000)
7
+ · category_grade = clamp(5.0 - (normalized × 0.5), 0, 5)
8
+ · overall = min(category_grades)
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import math
13
+ from dataclasses import dataclass, field
14
+ from typing import Iterable
15
+
16
+ from secure_code_audit.findings import Category, Confidence, Finding, Severity
17
+
18
+ # --- weights ---------------------------------------------------------------
19
+
20
+ SEVERITY_WEIGHT: dict[Severity, float] = {
21
+ Severity.CRITICAL: 10.0,
22
+ Severity.HIGH: 4.0,
23
+ Severity.MEDIUM: 1.5,
24
+ Severity.LOW: 0.5,
25
+ Severity.INFORMATIONAL: 0.0,
26
+ }
27
+
28
+ CONFIDENCE_WEIGHT: dict[Confidence, float] = {
29
+ Confidence.HIGH: 1.00,
30
+ Confidence.MEDIUM: 0.75,
31
+ Confidence.LOW: 0.50,
32
+ }
33
+
34
+ CATEGORY_WEIGHT: dict[Category, float] = {
35
+ Category.SECRETS: 1.5,
36
+ Category.CODE_VULNERABILITIES: 1.5,
37
+ Category.AUTH_AUTHZ: 1.5,
38
+ Category.CRYPTO: 1.5,
39
+ Category.DEPENDENCIES: 1.0,
40
+ Category.CONFIG_IAC: 1.0,
41
+ Category.SUPPLY_CHAIN: 0.8,
42
+ Category.LOGGING_OBSERVABILITY: 0.8,
43
+ Category.POLICY_DOCS: 0.5,
44
+ }
45
+
46
+ CWE_TOP25_BONUS = 1.25
47
+
48
+
49
+ # --- letter-grade boundaries (mirrors maintainability-agent) ---------------
50
+
51
+ _LETTER_GRADE_TABLE = (
52
+ (4.85, "A+"),
53
+ (4.50, "A"),
54
+ (4.00, "A-"),
55
+ (3.50, "B+"),
56
+ (3.00, "B"),
57
+ (2.50, "B-"),
58
+ (2.00, "C"),
59
+ (1.00, "D"),
60
+ )
61
+
62
+
63
+ def letter_grade(score: float) -> str:
64
+ for threshold, grade in _LETTER_GRADE_TABLE:
65
+ if score >= threshold:
66
+ return grade
67
+ return "F"
68
+
69
+
70
+ # --- per-finding score -----------------------------------------------------
71
+
72
+ def finding_score(f: Finding) -> float:
73
+ """Score one finding per the documented formula. Suppressed findings
74
+ score 0 (they're excluded by the caller before this normally fires,
75
+ but the defensive check is cheap)."""
76
+ if f.suppressed:
77
+ return 0.0
78
+ base = (
79
+ SEVERITY_WEIGHT[f.severity]
80
+ * CONFIDENCE_WEIGHT[f.confidence]
81
+ * CATEGORY_WEIGHT[f.category]
82
+ )
83
+ if f.cwe_top25:
84
+ base *= CWE_TOP25_BONUS
85
+ return base
86
+
87
+
88
+ # --- per-category aggregation ---------------------------------------------
89
+
90
+ def category_subtotal(findings: Iterable[Finding], category: Category) -> float:
91
+ return sum(
92
+ finding_score(f) for f in findings if f.category == category and not f.suppressed
93
+ )
94
+
95
+
96
+ def normalize(subtotal: float, loc_scanned: int) -> float:
97
+ """sqrt(LOC/1000) dampener — see docs/scoring.md for the rationale."""
98
+ if loc_scanned <= 0:
99
+ return subtotal
100
+ return subtotal / math.sqrt(max(loc_scanned, 1) / 1000)
101
+
102
+
103
+ def category_grade(normalized: float) -> float:
104
+ return max(0.0, min(5.0, 5.0 - (normalized * 0.5)))
105
+
106
+
107
+ # --- overall score ---------------------------------------------------------
108
+
109
+ @dataclass(frozen=True)
110
+ class ScoreReport:
111
+ """Per-category + overall score breakdown. Renderers consume this directly."""
112
+
113
+ per_category: dict[Category, float] # category → 0.0-5.0 grade
114
+ per_category_count: dict[Category, int] # category → unsuppressed finding count
115
+ per_severity_count: dict[Severity, int] # severity → unsuppressed finding count
116
+ overall: float # 0.0-5.0
117
+ letter: str # A+, A, A-, B+, ...
118
+ worst_category: Category | None # which category drove the grade
119
+ loc_scanned: int # for the report header
120
+
121
+ def as_table(self) -> list[tuple[str, str, float, int]]:
122
+ """[(category_name, grade_letter, grade_score, finding_count), ...]
123
+ sorted worst → best for the operator report."""
124
+ rows = []
125
+ for cat in Category:
126
+ grade = self.per_category.get(cat, 5.0)
127
+ count = self.per_category_count.get(cat, 0)
128
+ rows.append((cat.value, letter_grade(grade), grade, count))
129
+ rows.sort(key=lambda r: r[2]) # worst first
130
+ return rows
131
+
132
+
133
+ def score(findings: Iterable[Finding], loc_scanned: int) -> ScoreReport:
134
+ findings = list(findings)
135
+ per_category: dict[Category, float] = {}
136
+ per_category_count: dict[Category, int] = {}
137
+ per_severity_count: dict[Severity, int] = dict.fromkeys(Severity, 0)
138
+
139
+ for cat in Category:
140
+ subtotal = category_subtotal(findings, cat)
141
+ normalized = normalize(subtotal, loc_scanned)
142
+ per_category[cat] = category_grade(normalized)
143
+ per_category_count[cat] = sum(
144
+ 1 for f in findings if f.category == cat and not f.suppressed
145
+ )
146
+
147
+ for f in findings:
148
+ if not f.suppressed:
149
+ per_severity_count[f.severity] += 1
150
+
151
+ if findings:
152
+ worst_category = min(per_category.items(), key=lambda kv: kv[1])[0]
153
+ else:
154
+ worst_category = None
155
+
156
+ overall = min(per_category.values()) if per_category else 5.0
157
+ return ScoreReport(
158
+ per_category=per_category,
159
+ per_category_count=per_category_count,
160
+ per_severity_count=per_severity_count,
161
+ overall=overall,
162
+ letter=letter_grade(overall),
163
+ worst_category=worst_category,
164
+ loc_scanned=loc_scanned,
165
+ )
166
+
167
+
168
+ # --- gate evaluation -------------------------------------------------------
169
+
170
+ @dataclass(frozen=True)
171
+ class GateResult:
172
+ passed: bool
173
+ reasons: tuple[str, ...] # human-readable trip reasons
174
+ tripped: tuple[str, ...] = field(default_factory=tuple)
175
+
176
+
177
+ def evaluate_gates(
178
+ findings: list[Finding],
179
+ report: ScoreReport,
180
+ gate_config: dict,
181
+ ) -> GateResult:
182
+ """Apply the configured gates. Any tripped gate → passed=False.
183
+
184
+ gate_config is the `gates` block of the loaded config. See
185
+ secure-code-agent.schema.json for shape; missing keys are treated
186
+ as 'gate not configured' (i.e. doesn't trip)."""
187
+
188
+ reasons: list[str] = []
189
+ tripped: list[str] = []
190
+
191
+ for check in (
192
+ _gate_fail_on_severity,
193
+ _gate_fail_on_category,
194
+ _gate_fail_on_new,
195
+ _gate_min_score,
196
+ _gate_max_unsuppressed,
197
+ ):
198
+ check(findings, report, gate_config, tripped, reasons)
199
+
200
+ return GateResult(
201
+ passed=not tripped,
202
+ reasons=tuple(reasons),
203
+ tripped=tuple(tripped),
204
+ )
205
+
206
+
207
+ # ----- per-gate evaluators -------------------------------------------------
208
+ # Each appends to the shared `tripped` and `reasons` lists. Splitting these
209
+ # keeps evaluate_gates() at cognitive complexity <= 15 (we ship the
210
+ # maintainability standard and dogfood it here).
211
+
212
+ def _gate_fail_on_severity(
213
+ findings: list[Finding], report: ScoreReport, gate_config: dict,
214
+ tripped: list[str], reasons: list[str],
215
+ ) -> None:
216
+ fail_on = {s.lower() for s in gate_config.get("fail_on_severity", [])}
217
+ if not fail_on:
218
+ return
219
+ offenders = [f for f in findings if not f.suppressed and f.severity.value in fail_on]
220
+ if offenders:
221
+ tripped.append("fail_on_severity")
222
+ reasons.append(f"{len(offenders)} finding(s) at severity in {sorted(fail_on)}")
223
+
224
+
225
+ def _gate_fail_on_category(
226
+ findings: list[Finding], report: ScoreReport, gate_config: dict,
227
+ tripped: list[str], reasons: list[str],
228
+ ) -> None:
229
+ fail_cats = {c.lower() for c in gate_config.get("fail_on_category", [])}
230
+ if not fail_cats:
231
+ return
232
+ offenders = [f for f in findings if not f.suppressed and f.category.value in fail_cats]
233
+ if offenders:
234
+ tripped.append("fail_on_category")
235
+ reasons.append(f"{len(offenders)} finding(s) in categories {sorted(fail_cats)}")
236
+
237
+
238
+ def _gate_fail_on_new(
239
+ findings: list[Finding], report: ScoreReport, gate_config: dict,
240
+ tripped: list[str], reasons: list[str],
241
+ ) -> None:
242
+ # Informational findings (tool_unavailable, parse errors, etc.) never
243
+ # trip the gate — they're awareness signals, not security defects.
244
+ if not gate_config.get("fail_on_new"):
245
+ return
246
+ new_findings = [
247
+ f for f in findings
248
+ if f.is_new and not f.suppressed and f.severity is not Severity.INFORMATIONAL
249
+ ]
250
+ if new_findings:
251
+ tripped.append("fail_on_new")
252
+ reasons.append(f"{len(new_findings)} new finding(s) since baseline")
253
+
254
+
255
+ def _gate_min_score(
256
+ findings: list[Finding], report: ScoreReport, gate_config: dict,
257
+ tripped: list[str], reasons: list[str],
258
+ ) -> None:
259
+ min_score = gate_config.get("min_score")
260
+ if min_score is None or report.overall >= min_score:
261
+ return
262
+ tripped.append("min_score")
263
+ reasons.append(
264
+ f"overall score {report.overall:.2f} below required minimum {min_score}"
265
+ )
266
+
267
+
268
+ def _gate_max_unsuppressed(
269
+ findings: list[Finding], report: ScoreReport, gate_config: dict,
270
+ tripped: list[str], reasons: list[str],
271
+ ) -> None:
272
+ caps = gate_config.get("max_unsuppressed", {})
273
+ for sev_str, cap in caps.items():
274
+ sev = Severity.from_string(sev_str)
275
+ count = report.per_severity_count.get(sev, 0)
276
+ if count > cap:
277
+ tripped.append(f"max_unsuppressed.{sev_str}")
278
+ reasons.append(
279
+ f"{count} unsuppressed {sev.value} finding(s) exceeds cap {cap}"
280
+ )
@@ -0,0 +1,391 @@
1
+ """Standards taxonomy — CWE, OWASP Top 10, OWASP ASVS, NIST SSDF.
2
+
3
+ Maps scanner rule ids to canonical standards. The mapping table is the
4
+ source of truth for: dedupe (canonical_cwe), category routing, severity
5
+ weighting, and remediation-prompt context.
6
+
7
+ The map lives as Python data (not JSON-on-disk) for v0.1 so it ships in
8
+ the wheel without a packaging tweak. v0.2 may externalize to JSON to
9
+ support operator-defined rule packs.
10
+
11
+ See docs/standards.md for the citations.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass
16
+ from typing import Optional
17
+
18
+ from secure_code_audit.findings import Category, Confidence, Severity
19
+
20
+
21
+ # --- CWE Top 25 (2025) -----------------------------------------------------
22
+ # Source: https://cwe.mitre.org/top25/
23
+ # Used as a 1.25× scoring multiplier — see docs/scoring.md.
24
+
25
+ CWE_TOP25_2025: frozenset[str] = frozenset({
26
+ "CWE-79", "CWE-787", "CWE-89", "CWE-352", "CWE-22",
27
+ "CWE-125", "CWE-78", "CWE-416", "CWE-862", "CWE-434",
28
+ "CWE-94", "CWE-20", "CWE-77", "CWE-287", "CWE-269",
29
+ "CWE-502", "CWE-200", "CWE-863", "CWE-918", "CWE-119",
30
+ "CWE-476", "CWE-798", "CWE-190", "CWE-400", "CWE-306",
31
+ })
32
+
33
+
34
+ # --- OWASP Top 10 (2021) bucket id → URL ----------------------------------
35
+ OWASP_TOP10_2021_URL = "https://owasp.org/Top10/2021/"
36
+
37
+ OWASP_TOP10_2021: dict[str, str] = {
38
+ "A01": "A01:2021-Broken Access Control",
39
+ "A02": "A02:2021-Cryptographic Failures",
40
+ "A03": "A03:2021-Injection",
41
+ "A04": "A04:2021-Insecure Design",
42
+ "A05": "A05:2021-Security Misconfiguration",
43
+ "A06": "A06:2021-Vulnerable and Outdated Components",
44
+ "A07": "A07:2021-Identification and Authentication Failures",
45
+ "A08": "A08:2021-Software and Data Integrity Failures",
46
+ "A09": "A09:2021-Security Logging and Monitoring Failures",
47
+ "A10": "A10:2021-Server-Side Request Forgery (SSRF)",
48
+ }
49
+
50
+
51
+ # --- Standards mapping entry ----------------------------------------------
52
+ @dataclass(frozen=True)
53
+ class StandardsEntry:
54
+ canonical_cwe: Optional[str]
55
+ owasp_top10: Optional[str]
56
+ asvs_section: Optional[str]
57
+ nist_ssdf: Optional[str]
58
+ category: Category
59
+ severity: Severity # default; scanner-emitted severity overrides
60
+ confidence: Confidence # default
61
+ short_desc: str
62
+ fix_hint: Optional[str] = None
63
+
64
+
65
+ # --- The mapping table -----------------------------------------------------
66
+ # Indexed by (scanner_name, rule_id). Keys must be lowercase scanner name +
67
+ # exact rule id as the scanner emits it.
68
+ #
69
+ # Adding a rule:
70
+ # 1. Add the (CWE id, OWASP id, ASVS section, SSDF practice) tuple.
71
+ # 2. Cite the scanner's docs URL in `references=` if non-obvious.
72
+ # 3. Add an entry to the matching tier in docs/scanners.md.
73
+ # 4. Add a fixture in tests/fixtures/<scanner>/.
74
+
75
+ _MAP: dict[tuple[str, str], StandardsEntry] = {
76
+
77
+ # ----- Bandit ----------------------------------------------------------
78
+ # Source: https://bandit.readthedocs.io/en/latest/plugins/index.html
79
+ ("bandit", "B102"): StandardsEntry(
80
+ canonical_cwe="CWE-78", owasp_top10="A03", asvs_section="V5.3.8",
81
+ nist_ssdf="PW.5.1", category=Category.CODE_VULNERABILITIES,
82
+ severity=Severity.HIGH, confidence=Confidence.MEDIUM,
83
+ short_desc="Use of exec() — arbitrary code execution risk.",
84
+ fix_hint="Eliminate exec() entirely. If dynamic dispatch is required, use a typed registry / function map.",
85
+ ),
86
+ ("bandit", "B301"): StandardsEntry(
87
+ canonical_cwe="CWE-502", owasp_top10="A08", asvs_section="V5.5.1",
88
+ nist_ssdf="PW.5.1", category=Category.CODE_VULNERABILITIES,
89
+ severity=Severity.HIGH, confidence=Confidence.HIGH,
90
+ short_desc="pickle.loads() on possibly-untrusted input — arbitrary code execution.",
91
+ fix_hint="Replace pickle with JSON for data, or a signed/encrypted envelope for trusted state transfer.",
92
+ ),
93
+ ("bandit", "B303"): StandardsEntry(
94
+ canonical_cwe="CWE-327", owasp_top10="A02", asvs_section="V6.2.5",
95
+ nist_ssdf="PW.4.1", category=Category.CRYPTO,
96
+ severity=Severity.MEDIUM, confidence=Confidence.HIGH,
97
+ short_desc="MD5/SHA-1 used. Insecure for security purposes (collisions).",
98
+ fix_hint="Use SHA-256 or BLAKE2 for non-password hashing. Use Argon2id for password hashing.",
99
+ ),
100
+ ("bandit", "B305"): StandardsEntry(
101
+ canonical_cwe="CWE-327", owasp_top10="A02", asvs_section="V6.2.2",
102
+ nist_ssdf="PW.4.1", category=Category.CRYPTO,
103
+ severity=Severity.HIGH, confidence=Confidence.HIGH,
104
+ short_desc="Use of insecure cipher mode (ECB).",
105
+ fix_hint="Use authenticated encryption (AES-GCM or ChaCha20-Poly1305). Never ECB.",
106
+ ),
107
+ ("bandit", "B501"): StandardsEntry(
108
+ canonical_cwe="CWE-295", owasp_top10="A07", asvs_section="V9.2.1",
109
+ nist_ssdf="PW.4.1", category=Category.CRYPTO,
110
+ severity=Severity.HIGH, confidence=Confidence.HIGH,
111
+ short_desc="Requests call with verify=False — TLS cert validation disabled.",
112
+ fix_hint="Remove verify=False. If self-signed cert is required, pass the trusted CA bundle explicitly.",
113
+ ),
114
+ ("bandit", "B602"): StandardsEntry(
115
+ canonical_cwe="CWE-78", owasp_top10="A03", asvs_section="V5.3.8",
116
+ nist_ssdf="PW.5.1", category=Category.CODE_VULNERABILITIES,
117
+ severity=Severity.HIGH, confidence=Confidence.HIGH,
118
+ short_desc="subprocess with shell=True and shell metacharacters — command injection.",
119
+ fix_hint="Pass argv as a list and use shell=False. Never interpolate user input into a shell string.",
120
+ ),
121
+ ("bandit", "B608"): StandardsEntry(
122
+ canonical_cwe="CWE-89", owasp_top10="A03", asvs_section="V5.3.5",
123
+ nist_ssdf="PW.5.1", category=Category.CODE_VULNERABILITIES,
124
+ severity=Severity.HIGH, confidence=Confidence.MEDIUM,
125
+ short_desc="String-built SQL — possible injection.",
126
+ fix_hint="Use parameterized queries ($1, ?, :name). Never interpolate user input into SQL strings.",
127
+ ),
128
+
129
+ # ----- pip-audit -------------------------------------------------------
130
+ # Source: https://github.com/pypa/pip-audit
131
+ # pip-audit emits per-CVE findings — they all share CWE-1104 (Use of
132
+ # Unmaintained Third Party Components) plus a per-CVE rule id.
133
+ ("pip_audit", "*"): StandardsEntry(
134
+ canonical_cwe="CWE-1104", owasp_top10="A06", asvs_section="V14.2.1",
135
+ nist_ssdf="PW.4.4", category=Category.DEPENDENCIES,
136
+ severity=Severity.HIGH, confidence=Confidence.HIGH,
137
+ short_desc="Known-vulnerable dependency in pinned set.",
138
+ fix_hint="Bump to the fixed version per the advisory. If no fix exists, document the residual risk in `.scignore.yaml` with an expires date.",
139
+ ),
140
+
141
+ # ----- npm audit -------------------------------------------------------
142
+ ("npm_audit", "*"): StandardsEntry(
143
+ canonical_cwe="CWE-1104", owasp_top10="A06", asvs_section="V14.2.1",
144
+ nist_ssdf="PW.4.4", category=Category.DEPENDENCIES,
145
+ severity=Severity.HIGH, confidence=Confidence.HIGH,
146
+ short_desc="Known-vulnerable npm dependency.",
147
+ fix_hint="`npm audit fix` is often safe for patch-level bumps but can downgrade majors. Inspect the suggested fix before applying; for downgrades, track upstream.",
148
+ ),
149
+
150
+ # ----- Gitleaks --------------------------------------------------------
151
+ # Source: https://github.com/gitleaks/gitleaks
152
+ ("gitleaks", "*"): StandardsEntry(
153
+ canonical_cwe="CWE-798", owasp_top10="A07", asvs_section="V2.10.1",
154
+ nist_ssdf="PS.1.1", category=Category.SECRETS,
155
+ severity=Severity.CRITICAL, confidence=Confidence.HIGH,
156
+ short_desc="Hardcoded secret detected.",
157
+ fix_hint="Rotate the secret immediately. Move to an env var / secret manager. Run `git filter-repo` to scrub history if it's been pushed publicly.",
158
+ ),
159
+
160
+ # ----- TruffleHog ------------------------------------------------------
161
+ ("trufflehog", "*"): StandardsEntry(
162
+ canonical_cwe="CWE-798", owasp_top10="A07", asvs_section="V2.10.1",
163
+ nist_ssdf="PS.1.1", category=Category.SECRETS,
164
+ severity=Severity.CRITICAL, confidence=Confidence.HIGH,
165
+ short_desc="Verified secret detected.",
166
+ fix_hint="Same as Gitleaks: rotate, move to env / secret manager, scrub history if leaked publicly.",
167
+ ),
168
+
169
+ # ----- Built-in regex rules -------------------------------------------
170
+ ("builtin_rules", "sca.python.eval"): StandardsEntry(
171
+ canonical_cwe="CWE-95", owasp_top10="A03", asvs_section="V5.2.4",
172
+ nist_ssdf="PW.5.1", category=Category.CODE_VULNERABILITIES,
173
+ severity=Severity.HIGH, confidence=Confidence.MEDIUM,
174
+ short_desc="eval()/exec() on non-literal input.",
175
+ fix_hint="Eliminate eval. Use ast.literal_eval for safe data, or a typed registry for dispatch.",
176
+ ),
177
+ ("builtin_rules", "sca.python.yaml.unsafe_load"): StandardsEntry(
178
+ canonical_cwe="CWE-502", owasp_top10="A08", asvs_section="V5.5.2",
179
+ nist_ssdf="PW.5.1", category=Category.CODE_VULNERABILITIES,
180
+ severity=Severity.HIGH, confidence=Confidence.HIGH,
181
+ short_desc="yaml.load() without SafeLoader.",
182
+ fix_hint="Use yaml.safe_load() or yaml.load(stream, Loader=yaml.SafeLoader).",
183
+ ),
184
+ ("builtin_rules", "sca.python.requests.verify_false"): StandardsEntry(
185
+ canonical_cwe="CWE-295", owasp_top10="A07", asvs_section="V9.2.1",
186
+ nist_ssdf="PW.4.1", category=Category.CRYPTO,
187
+ severity=Severity.HIGH, confidence=Confidence.HIGH,
188
+ short_desc="requests.* called with verify=False.",
189
+ fix_hint="Remove verify=False. Pass the trusted CA bundle if the upstream cert is self-signed.",
190
+ ),
191
+ ("builtin_rules", "sca.python.fstring_sql"): StandardsEntry(
192
+ canonical_cwe="CWE-89", owasp_top10="A03", asvs_section="V5.3.5",
193
+ nist_ssdf="PW.5.1", category=Category.CODE_VULNERABILITIES,
194
+ severity=Severity.HIGH, confidence=Confidence.MEDIUM,
195
+ short_desc="f-string SQL — interpolated value in .execute()/.executemany().",
196
+ fix_hint="Parameterize: `await conn.execute('... WHERE id = $1', value)` instead of f-string.",
197
+ ),
198
+ ("builtin_rules", "sca.python.subprocess.shell_true"): StandardsEntry(
199
+ canonical_cwe="CWE-78", owasp_top10="A03", asvs_section="V5.3.8",
200
+ nist_ssdf="PW.5.1", category=Category.CODE_VULNERABILITIES,
201
+ severity=Severity.HIGH, confidence=Confidence.HIGH,
202
+ short_desc="subprocess.* with shell=True and non-literal command.",
203
+ fix_hint="Pass argv as a list (e.g. ['git', 'log', '-n', '5']) and use shell=False.",
204
+ ),
205
+ ("builtin_rules", "sca.python.hashlib.md5_sha1_security"): StandardsEntry(
206
+ canonical_cwe="CWE-327", owasp_top10="A02", asvs_section="V6.2.5",
207
+ nist_ssdf="PW.4.1", category=Category.CRYPTO,
208
+ severity=Severity.MEDIUM, confidence=Confidence.MEDIUM,
209
+ short_desc="MD5/SHA-1 in a non-test, non-checksum path.",
210
+ fix_hint="Use SHA-256 or BLAKE2. For password hashing, use Argon2id (via argon2-cffi or passlib).",
211
+ ),
212
+ ("builtin_rules", "sca.web.dangerously_set_inner_html"): StandardsEntry(
213
+ canonical_cwe="CWE-79", owasp_top10="A03", asvs_section="V5.3.3",
214
+ nist_ssdf="PW.5.1", category=Category.CODE_VULNERABILITIES,
215
+ severity=Severity.HIGH, confidence=Confidence.LOW,
216
+ short_desc="React dangerouslySetInnerHTML with non-literal input.",
217
+ fix_hint="Render via React text nodes. If raw HTML is required, sanitize with DOMPurify and document the trust source inline.",
218
+ ),
219
+ ("builtin_rules", "sca.web.cors_wildcard"): StandardsEntry(
220
+ canonical_cwe="CWE-942", owasp_top10="A05", asvs_section="V14.5.3",
221
+ nist_ssdf="PW.5.1", category=Category.CODE_VULNERABILITIES,
222
+ severity=Severity.HIGH, confidence=Confidence.HIGH,
223
+ short_desc="CORS Access-Control-Allow-Origin: * with credentials allowed.",
224
+ fix_hint="Scope Allow-Origin to a specific allowlist when credentials are in use. '*' + credentials is forbidden by spec.",
225
+ ),
226
+ ("builtin_rules", "sca.shell.curl_pipe_sh"): StandardsEntry(
227
+ canonical_cwe="CWE-78", owasp_top10="A03", asvs_section="V5.3.8",
228
+ nist_ssdf="PW.4.4", category=Category.SUPPLY_CHAIN,
229
+ severity=Severity.MEDIUM, confidence=Confidence.HIGH,
230
+ short_desc="curl | sh / wget | bash — opaque remote-script execution.",
231
+ fix_hint="Pin a checksum or use a package manager. If you must download a script, verify a SHA before executing.",
232
+ ),
233
+
234
+ # ----- Trivy ----------------------------------------------------------
235
+ # Trivy emits per-CVE rule ids (CVE-/GHSA-/AVD-). The per-rule CWE is
236
+ # carried inside the SARIF properties; this wildcard covers what the
237
+ # SARIF doesn't.
238
+ ("trivy", "*"): StandardsEntry(
239
+ canonical_cwe="CWE-1104", owasp_top10="A06", asvs_section="V14.2.1",
240
+ nist_ssdf="PW.4.4", category=Category.DEPENDENCIES,
241
+ severity=Severity.HIGH, confidence=Confidence.HIGH,
242
+ short_desc="Trivy finding (vuln / misconfig / secret).",
243
+ fix_hint="Trivy routes vuln/misconfig/secret into different categories — see the finding's category field for the specific guidance.",
244
+ ),
245
+
246
+ # ----- Checkov --------------------------------------------------------
247
+ # Checkov rule ids are like CKV_AWS_xxx, CKV_K8S_xxx, CKV_DOCKER_xxx.
248
+ # All map to config_iac with OWASP A05 (Security Misconfiguration).
249
+ ("checkov", "*"): StandardsEntry(
250
+ canonical_cwe="CWE-1188", owasp_top10="A05", asvs_section="V14.1.1",
251
+ nist_ssdf="PW.6.1", category=Category.CONFIG_IAC,
252
+ severity=Severity.MEDIUM, confidence=Confidence.HIGH,
253
+ short_desc="IaC misconfiguration detected by Checkov.",
254
+ fix_hint="Follow Checkov's documentation link in the finding message. Misconfigs are typically a one-property addition (encryption, public-access blockers, etc.).",
255
+ ),
256
+
257
+ # ----- Hadolint -------------------------------------------------------
258
+ # Most-flagged security-relevant rules. Style rules fall through to the
259
+ # wildcard.
260
+ ("hadolint", "hadolint.DL3002"): StandardsEntry(
261
+ canonical_cwe="CWE-250", owasp_top10="A05", asvs_section="V14.2.5",
262
+ nist_ssdf="PW.6.1", category=Category.CONFIG_IAC,
263
+ severity=Severity.HIGH, confidence=Confidence.HIGH,
264
+ short_desc="Dockerfile sets USER root — privileged container.",
265
+ fix_hint="Add `USER <non-root-uid>` near the end of the Dockerfile. Or run with `--user` at the container runtime.",
266
+ ),
267
+ ("hadolint", "hadolint.DL3025"): StandardsEntry(
268
+ canonical_cwe="CWE-78", owasp_top10="A03", asvs_section="V5.3.8",
269
+ nist_ssdf="PW.5.1", category=Category.CONFIG_IAC,
270
+ severity=Severity.MEDIUM, confidence=Confidence.HIGH,
271
+ short_desc="Dockerfile CMD/ENTRYPOINT in shell form — argv injection surface.",
272
+ fix_hint="Use JSON-array form: `CMD [\"node\", \"server.js\"]`. Avoids the shell wrapper that interprets metacharacters.",
273
+ ),
274
+ ("hadolint", "*"): StandardsEntry(
275
+ canonical_cwe="CWE-1188", owasp_top10="A05", asvs_section="V14.1.1",
276
+ nist_ssdf="PW.6.1", category=Category.CONFIG_IAC,
277
+ severity=Severity.LOW, confidence=Confidence.HIGH,
278
+ short_desc="Dockerfile lint finding.",
279
+ fix_hint="See https://github.com/hadolint/hadolint/wiki for the specific rule.",
280
+ ),
281
+
282
+ # ----- OSV-Scanner ----------------------------------------------------
283
+ ("osv_scanner", "*"): StandardsEntry(
284
+ canonical_cwe="CWE-1104", owasp_top10="A06", asvs_section="V14.2.1",
285
+ nist_ssdf="PW.4.4", category=Category.DEPENDENCIES,
286
+ severity=Severity.HIGH, confidence=Confidence.HIGH,
287
+ short_desc="Vulnerable dependency reported by osv.dev.",
288
+ fix_hint="Bump to the fixed version per the advisory. If no fix exists, document the residual risk in `.scignore.yaml`.",
289
+ ),
290
+
291
+ # ----- TruffleHog -----------------------------------------------------
292
+ ("trufflehog", "*"): StandardsEntry(
293
+ canonical_cwe="CWE-798", owasp_top10="A07", asvs_section="V2.10.1",
294
+ nist_ssdf="PS.1.1", category=Category.SECRETS,
295
+ severity=Severity.CRITICAL, confidence=Confidence.HIGH,
296
+ short_desc="Verified secret detected by TruffleHog.",
297
+ fix_hint="Rotate the secret immediately. Move to an env var / secret manager. Scrub history with `git filter-repo` if the leak reached a public branch.",
298
+ ),
299
+
300
+ # ----- OpenSSF Scorecard ----------------------------------------------
301
+ # Scorecard's check names are stable — map each to its standards refs.
302
+ ("scorecard", "scorecard.Branch-Protection"): StandardsEntry(
303
+ canonical_cwe="CWE-732", owasp_top10="A05", asvs_section="V14.1.4",
304
+ nist_ssdf="PO.5.1", category=Category.SUPPLY_CHAIN,
305
+ severity=Severity.HIGH, confidence=Confidence.HIGH,
306
+ short_desc="Branch protection insufficient on the default branch.",
307
+ fix_hint="Enable required PR reviews, required status checks, and prevent force-pushes on the default branch.",
308
+ ),
309
+ ("scorecard", "scorecard.Signed-Releases"): StandardsEntry(
310
+ canonical_cwe="CWE-345", owasp_top10="A08", asvs_section="V10.3.2",
311
+ nist_ssdf="PS.2.1", category=Category.SUPPLY_CHAIN,
312
+ severity=Severity.MEDIUM, confidence=Confidence.HIGH,
313
+ short_desc="Releases are not signed with Sigstore/cosign.",
314
+ fix_hint="Sign releases via Sigstore/cosign. Publish provenance with `slsa-github-generator` or equivalent.",
315
+ ),
316
+ ("scorecard", "scorecard.Pinned-Dependencies"): StandardsEntry(
317
+ canonical_cwe="CWE-829", owasp_top10="A08", asvs_section="V14.2.2",
318
+ nist_ssdf="PW.4.4", category=Category.SUPPLY_CHAIN,
319
+ severity=Severity.MEDIUM, confidence=Confidence.HIGH,
320
+ short_desc="Dependencies (esp. GitHub Actions) are not pinned by SHA.",
321
+ fix_hint="Pin third-party Actions to a commit SHA, not a tag. Pin Docker base images by digest.",
322
+ ),
323
+ ("scorecard", "scorecard.Token-Permissions"): StandardsEntry(
324
+ canonical_cwe="CWE-272", owasp_top10="A01", asvs_section="V4.1.5",
325
+ nist_ssdf="PO.5.2", category=Category.SUPPLY_CHAIN,
326
+ severity=Severity.HIGH, confidence=Confidence.HIGH,
327
+ short_desc="GitHub workflow tokens granted excess permissions.",
328
+ fix_hint="Add `permissions: contents: read` at workflow root; elevate per-job only as needed.",
329
+ ),
330
+ ("scorecard", "scorecard.Security-Policy"): StandardsEntry(
331
+ canonical_cwe="CWE-1059", owasp_top10="A09", asvs_section="V0.2.1",
332
+ nist_ssdf="PO.4.1", category=Category.POLICY_DOCS,
333
+ severity=Severity.LOW, confidence=Confidence.HIGH,
334
+ short_desc="Repository is missing SECURITY.md.",
335
+ fix_hint="Add SECURITY.md with a vulnerability disclosure path. Use `github.com/<repo>/security/advisories/new` for the form.",
336
+ ),
337
+ ("scorecard", "scorecard.Dangerous-Workflow"): StandardsEntry(
338
+ canonical_cwe="CWE-94", owasp_top10="A03", asvs_section="V5.2.4",
339
+ nist_ssdf="PW.5.1", category=Category.SUPPLY_CHAIN,
340
+ severity=Severity.HIGH, confidence=Confidence.HIGH,
341
+ short_desc="Workflow uses untrusted input in a dangerous context.",
342
+ fix_hint="Avoid `${{ github.event.pull_request.title }}` in `run:` blocks. Use env vars instead.",
343
+ ),
344
+ ("scorecard", "*"): StandardsEntry(
345
+ canonical_cwe=None, owasp_top10="A08", asvs_section=None,
346
+ nist_ssdf="PO.5.1", category=Category.SUPPLY_CHAIN,
347
+ severity=Severity.MEDIUM, confidence=Confidence.MEDIUM,
348
+ short_desc="OpenSSF Scorecard check failed.",
349
+ fix_hint="See the documentation link in the finding message.",
350
+ ),
351
+ }
352
+
353
+
354
+ def lookup(scanner: str, rule_id: str) -> Optional[StandardsEntry]:
355
+ """Resolve (scanner, rule_id) → StandardsEntry, with wildcard fallback.
356
+
357
+ Order: exact match → (scanner, "*") wildcard → None.
358
+ """
359
+ exact = _MAP.get((scanner.lower(), rule_id))
360
+ if exact is not None:
361
+ return exact
362
+ return _MAP.get((scanner.lower(), "*"))
363
+
364
+
365
+ def is_top25(canonical_cwe: Optional[str]) -> bool:
366
+ """Is this CWE on the MITRE Top 25 (2025) list? Used for scoring boost."""
367
+ return canonical_cwe is not None and canonical_cwe in CWE_TOP25_2025
368
+
369
+
370
+ def cwe_url(canonical_cwe: str) -> str:
371
+ """Cite a CWE id — e.g. 'CWE-89' → MITRE definition URL."""
372
+ num = canonical_cwe.split("-", 1)[1] if "-" in canonical_cwe else canonical_cwe
373
+ return f"https://cwe.mitre.org/data/definitions/{num}.html"
374
+
375
+
376
+ def owasp_url(owasp_id: str) -> str:
377
+ """OWASP Top 10 bucket id → deep-link URL. Falls back to the index
378
+ when the id doesn't map to a known bucket (e.g. legacy 2017 ids)."""
379
+ bucket = owasp_id.split(":", 1)[0] if ":" in owasp_id else owasp_id
380
+ label = OWASP_TOP10_2021.get(bucket)
381
+ if not label:
382
+ return OWASP_TOP10_2021_URL
383
+ # 'A03:2021-Injection' → 'A03_2021-Injection' for the URL slug.
384
+ slug = label.replace(":", "_").replace(" ", "_")
385
+ return f"{OWASP_TOP10_2021_URL}{slug}/"
386
+
387
+
388
+ def owasp_label(owasp_id: str) -> str:
389
+ """'A03' → 'A03:2021-Injection'. Returns the id unchanged if not mapped."""
390
+ bucket = owasp_id.split(":", 1)[0] if ":" in owasp_id else owasp_id
391
+ return OWASP_TOP10_2021.get(bucket, owasp_id)