secure-code-agent 0.6.0__tar.gz → 0.8.0__tar.gz

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 (50) hide show
  1. {secure_code_agent-0.6.0/src/secure_code_agent.egg-info → secure_code_agent-0.8.0}/PKG-INFO +1 -1
  2. {secure_code_agent-0.6.0 → secure_code_agent-0.8.0/src/secure_code_agent.egg-info}/PKG-INFO +1 -1
  3. {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/__init__.py +1 -1
  4. {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/cli.py +44 -10
  5. {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/config.py +7 -0
  6. {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/findings.py +64 -14
  7. {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/git_tools.py +18 -3
  8. {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/renderers.py +6 -0
  9. {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/sarif.py +67 -6
  10. {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/scanners/base.py +12 -1
  11. {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/scanners/checkov_scanner.py +9 -1
  12. {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/scanners/semgrep_scanner.py +14 -1
  13. {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/scoring.py +21 -0
  14. {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/standards.py +16 -0
  15. {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/LICENSE +0 -0
  16. {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/README.md +0 -0
  17. {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/pyproject.toml +0 -0
  18. {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/setup.cfg +0 -0
  19. {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_agent.egg-info/SOURCES.txt +0 -0
  20. {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_agent.egg-info/dependency_links.txt +0 -0
  21. {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_agent.egg-info/entry_points.txt +0 -0
  22. {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_agent.egg-info/requires.txt +0 -0
  23. {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_agent.egg-info/top_level.txt +0 -0
  24. {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/baseline.py +0 -0
  25. {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/data/semgrep-offline.yaml +0 -0
  26. {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/history.py +0 -0
  27. {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/instructions.py +0 -0
  28. {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/pillar.py +0 -0
  29. {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/practice.py +0 -0
  30. {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/remediation.py +0 -0
  31. {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/ruleset.py +0 -0
  32. {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/scanner_status.py +0 -0
  33. {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/scanners/__init__.py +0 -0
  34. {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/scanners/bandit_scanner.py +0 -0
  35. {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/scanners/builtin_rules.py +0 -0
  36. {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/scanners/floor.py +0 -0
  37. {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/scanners/gitleaks_scanner.py +0 -0
  38. {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/scanners/gosec_scanner.py +0 -0
  39. {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/scanners/hadolint_scanner.py +0 -0
  40. {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/scanners/njsscan_scanner.py +0 -0
  41. {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/scanners/npm_audit_scanner.py +0 -0
  42. {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/scanners/osv_scanner.py +0 -0
  43. {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/scanners/pip_audit_scanner.py +0 -0
  44. {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/scanners/rubocop_scanner.py +0 -0
  45. {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/scanners/scorecard_scanner.py +0 -0
  46. {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/scanners/trivy_scanner.py +0 -0
  47. {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/scanners/trufflehog_scanner.py +0 -0
  48. {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/suppressions.py +0 -0
  49. {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/triage.py +0 -0
  50. {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/verify.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: secure-code-agent
3
- Version: 0.6.0
3
+ Version: 0.8.0
4
4
  Summary: Deterministic security gate + bounded AI remediation prompt generator. NIST SSDF / OWASP ASVS / CWE Top 25 anchored.
5
5
  Author: Marshall Guillory
6
6
  License: MIT
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: secure-code-agent
3
- Version: 0.6.0
3
+ Version: 0.8.0
4
4
  Summary: Deterministic security gate + bounded AI remediation prompt generator. NIST SSDF / OWASP ASVS / CWE Top 25 anchored.
5
5
  Author: Marshall Guillory
6
6
  License: MIT
@@ -12,4 +12,4 @@
12
12
  #:
13
13
  #: PyPI is immutable, so 0.4.0 stays wrong. 0.5.0 is the first build whose
14
14
  #: artifacts name their own producer correctly.
15
- __version__ = "0.6.0"
15
+ __version__ = "0.8.0"
@@ -56,6 +56,7 @@ from secure_code_audit.scoring import (
56
56
  )
57
57
  from secure_code_audit.scoring import score as score_findings
58
58
  from secure_code_audit.scoring import verdict as build_verdict
59
+ from secure_code_audit.standards import categories_for_scanner
59
60
 
60
61
 
61
62
  def _parser() -> argparse.ArgumentParser:
@@ -501,6 +502,12 @@ def _prepare_audit(
501
502
  raise ValueError("multiple scan roots are not supported; provide one repository root")
502
503
  # The target is resolved first because the default config belongs to it.
503
504
  target = Path(args.paths[0]).resolve()
505
+ # A typo in the path used to reach the scanners, where the first adapter
506
+ # passed the missing directory as a subprocess `cwd` and the run died
507
+ # with a raw `FileNotFoundError` traceback. An operator who mistypes a
508
+ # path should be told so, not handed a stack trace from `subprocess.py`.
509
+ if not target.exists():
510
+ raise ValueError(f"scan root does not exist: {target}")
504
511
  cfg = config_mod.load(args.config, default_root=target)
505
512
  # Set from the command line only. Threading it through the loaded config
506
513
  # would let a repository-supplied file assert its own trustworthiness.
@@ -623,14 +630,24 @@ def _measurable_categories(
623
630
  domains.add(policy.domain)
624
631
 
625
632
  measurable = {category for category in Category if category.value in domains}
626
- if "multiple" in domains:
627
- # builtin_rules reads several categories and declares none of them.
628
- measurable |= {
629
- Category.SECRETS,
630
- Category.CODE_VULNERABILITIES,
631
- Category.CRYPTO,
632
- Category.CONFIG_IAC,
633
- }
633
+ for execution in executions:
634
+ # A scanner declaring `multiple` reads several categories and names
635
+ # none of them, so ask the standards map which rules it actually has.
636
+ #
637
+ # This list used to be written out by hand and was wrong in both
638
+ # directions: it claimed `builtin_rules` covered `secrets` and
639
+ # `config_iac` — its rules are Python and shell language primitives,
640
+ # it has never had one of either — and omitted `supply_chain`, which
641
+ # it does cover. An IaC repository carrying a `public-read` S3
642
+ # bucket, a 0.0.0.0/0 ingress rule, `USER root` and `chmod 777`
643
+ # scored `config_iac` **5.0** with neither checkov nor hadolint
644
+ # installed. A category graded perfectly because nothing could read
645
+ # it is the absence-as-value defect this function exists to prevent.
646
+ if execution.outcome not in COVERING_OUTCOMES:
647
+ continue
648
+ policy = floor.policy(execution.name)
649
+ if policy is not None and policy.domain == "multiple":
650
+ measurable |= categories_for_scanner(execution.name)
634
651
  measurable |= {finding.category for finding in findings if not finding.suppressed}
635
652
  return measurable
636
653
 
@@ -787,7 +804,7 @@ def _write_outputs(
787
804
  if paths.json_out is not None:
788
805
  renderers.write_json(findings, score, gate, paths.json_out, coverage, verdict, axes)
789
806
  if paths.sarif is not None:
790
- sarif.write(findings, paths.sarif, coverage)
807
+ sarif.write(findings, paths.sarif, coverage, lambda f: renderers.axis_of(f, axes))
791
808
  if paths.comment is not None:
792
809
  renderers.write_pr_comment(findings, score, gate, paths.comment, coverage, verdict)
793
810
  if paths.prompt is not None:
@@ -880,7 +897,15 @@ def _under_root(root: Path, value: str) -> Path:
880
897
  def _print_summary(
881
898
  verdict, score, gate, ran, unavailable, coverage, paths, axes=(), trend=None
882
899
  ) -> None:
883
- status = "PASS" if gate.passed else "FAIL"
900
+ # "PASS" is a claim that something was checked. With no gate configured
901
+ # nothing was, and saying so is the difference between a report and a
902
+ # false assurance.
903
+ if not gate.passed:
904
+ status = "FAIL"
905
+ elif gate.enforced:
906
+ status = "PASS"
907
+ else:
908
+ status = "NOT CONFIGURED"
884
909
  print(f"secure-code-agent · score {verdict.headline()} · gate {status}")
885
910
  for reason in verdict.reasons:
886
911
  print(f" ! grade withheld: {reason}")
@@ -904,6 +929,15 @@ def _print_summary(
904
929
  if not gate.passed:
905
930
  for reason in gate.reasons:
906
931
  print(f" ✗ {reason}")
932
+ elif not gate.enforced:
933
+ print(
934
+ " ! no gate is configured, so nothing here could have failed. "
935
+ "This audit is a report, not a check."
936
+ )
937
+ print(
938
+ " Set gates.fail_on_severity in secure-code-agent.json to make "
939
+ "findings block a build; read the work order either way."
940
+ )
907
941
  written = [
908
942
  str(p)
909
943
  for p in (
@@ -75,6 +75,13 @@ DEFAULT_INCLUDE_EXTS: tuple[str, ...] = (
75
75
  ".yaml",
76
76
  ".yml",
77
77
  ".json",
78
+ # Terraform. checkov is in the floor and reads `.tf`, so its findings
79
+ # were being scored against a line count that excluded every file it
80
+ # had read — an IaC repository measured six lines for a Dockerfile and
81
+ # counted none of its ten lines of Terraform. Numerator and denominator
82
+ # have to describe the same tree.
83
+ ".tf",
84
+ ".tfvars",
78
85
  "Dockerfile",
79
86
  )
80
87
 
@@ -207,6 +207,46 @@ RULE_ALIASES: dict[str, dict[str, str]] = {
207
207
  }
208
208
 
209
209
 
210
+ def _canonical_rule(finding: Finding) -> str:
211
+ """A rule id with aliases resolved, scoped to its scanner."""
212
+ alias = RULE_ALIASES.get(finding.scanner, {})
213
+ return f"{finding.scanner}:{alias.get(finding.rule_id, finding.rule_id)}"
214
+
215
+
216
+ def _same_weakness(a: Finding, b: Finding) -> bool:
217
+ """Are these two reports of one weakness, or two weaknesses at one line?
218
+
219
+ Two conditions, and a CWE match alone is not enough.
220
+
221
+ **Two different rules from the same scanner are two different checks.**
222
+ Bandit files `B602` (shell=True), `B603` (subprocess call) and `B607`
223
+ (partial executable path) all under CWE-78, and they are not the same
224
+ finding: one is fixed with an argument list, one with an absolute path.
225
+ Keying on the CWE merged them and the work order lost a finding: a
226
+ one-line shell-enabled subprocess call reported `B607` alone, with the
227
+ more serious `B602` hidden inside it as a footnote.
228
+
229
+ (The example is described rather than quoted. Writing the offending
230
+ call out verbatim put a real `B602` on the primary axis of this file
231
+ and failed the repository's own gate — the second time in two days that
232
+ documenting a vulnerable pattern created one. Prose is scanned too.)
233
+
234
+ An earlier test asserted exactly this must not happen and passed anyway,
235
+ because its fixtures carried no CWE. Reading Bandit's CWEs gave them one
236
+ and turned a passing test into a false assurance.
237
+
238
+ So: the same scanner merges only through the hand-checked alias table.
239
+ Different scanners merge on a shared CWE, which is the corroboration
240
+ this function exists for — bandit and our own rule catching one
241
+ `shell=True` is one finding with two witnesses.
242
+ """
243
+ if a.file_path != b.file_path or a.line_start != b.line_start:
244
+ return False
245
+ if a.scanner == b.scanner:
246
+ return _canonical_rule(a) == _canonical_rule(b)
247
+ return bool(a.canonical_cwe) and a.canonical_cwe == b.canonical_cwe
248
+
249
+
210
250
  def _merge_key(finding: Finding) -> tuple:
211
251
  """What makes two reports the same report.
212
252
 
@@ -250,25 +290,35 @@ def merge_corroborating(findings: Iterable[Finding]) -> list[Finding]:
250
290
  because a report that lists one line twice is wrong about the code, and
251
291
  a work order derived from it would ask for the same fix twice.
252
292
  """
253
- merged: dict[tuple, Finding] = {}
254
- extra: dict[tuple, list[str]] = {}
293
+ # Keyed by line so the pairwise test only runs against plausible
294
+ # neighbours; `_same_weakness` decides the rest.
295
+ by_line: dict[tuple, list[int]] = {}
296
+ kept: list[Finding] = []
297
+ witnesses: list[list[str]] = []
255
298
 
256
299
  for finding in findings:
257
- key = _merge_key(finding)
258
- kept = merged.get(key)
259
- if kept is None:
260
- merged[key] = finding
261
- extra[key] = []
300
+ locus = (finding.file_path.as_posix(), finding.line_start)
301
+ slot = None
302
+ for index in by_line.get(locus, []):
303
+ if _same_weakness(kept[index], finding):
304
+ slot = index
305
+ break
306
+ if slot is None:
307
+ by_line.setdefault(locus, []).append(len(kept))
308
+ kept.append(finding)
309
+ witnesses.append([])
262
310
  continue
263
311
  label = f"{finding.scanner}:{finding.rule_id}"
264
- if label not in extra[key] and label != f"{kept.scanner}:{kept.rule_id}":
265
- extra[key].append(label)
266
- if finding.severity.rank > kept.severity.rank or (
267
- finding.severity.rank == kept.severity.rank
268
- and CONFIDENCE_RANK[finding.confidence] > CONFIDENCE_RANK[kept.confidence]
312
+ existing = kept[slot]
313
+ if label not in witnesses[slot] and label != f"{existing.scanner}:{existing.rule_id}":
314
+ witnesses[slot].append(label)
315
+ if finding.severity.rank > existing.severity.rank or (
316
+ finding.severity.rank == existing.severity.rank
317
+ and CONFIDENCE_RANK[finding.confidence] > CONFIDENCE_RANK[existing.confidence]
269
318
  ):
270
- merged[key] = replace(finding, corroborated_by=kept.corroborated_by)
319
+ kept[slot] = finding
271
320
 
272
321
  return [
273
- replace(f, corroborated_by=tuple(extra[k])) if extra[k] else f for k, f in merged.items()
322
+ replace(f, corroborated_by=tuple(seen)) if seen else f
323
+ for f, seen in zip(kept, witnesses, strict=True)
274
324
  ]
@@ -8,9 +8,19 @@ from pathlib import Path
8
8
 
9
9
 
10
10
  def find_repo_root(start: Path) -> Path:
11
- """Walk up from `start` until a .git directory is found. Returns `start`
12
- if no git repo is present (a tarball / archive run is still supported)."""
11
+ """Walk up from `start` until a .git directory is found.
12
+
13
+ Returns the nearest directory when no git repo is present, so a tarball
14
+ or archive run is still supported.
15
+
16
+ **A root is always a directory.** Auditing a single file outside a git
17
+ repository used to return the file itself, and every path built under it
18
+ became nonsense — `secure-code-agent one.py` died writing its report to
19
+ `one.py/secure-code-report.md`.
20
+ """
13
21
  cur = start.resolve()
22
+ if not cur.is_dir():
23
+ cur = cur.parent
14
24
  for parent in (cur, *cur.parents):
15
25
  if (parent / ".git").exists():
16
26
  return parent
@@ -107,7 +117,12 @@ def loc_under(
107
117
  skip = {p.resolve() for p in skip}
108
118
  primary = 0
109
119
  test = 0
110
- for path in root.rglob("*"):
120
+ # `rglob` on a file yields nothing, so a single-file audit reported zero
121
+ # lines — and a zero denominator is not normalised at all, so the grade
122
+ # became the raw subtotal. Auditing one file is supported; it should be
123
+ # measured against that file.
124
+ candidates = root.rglob("*") if root.is_dir() else [root]
125
+ for path in candidates:
111
126
  if not path.is_file():
112
127
  continue
113
128
  if path.resolve() in skip:
@@ -51,6 +51,12 @@ def to_json(
51
51
  },
52
52
  "gate": {
53
53
  "passed": gate.passed,
54
+ # Which gates could actually have failed. Empty means no policy
55
+ # was configured, and `passed: true` then says only that nothing
56
+ # tripped — not that anything was checked. A consumer treating
57
+ # `passed` as a security signal needs to see this.
58
+ "configured": list(gate.configured),
59
+ "enforced": gate.enforced,
54
60
  "reasons": list(gate.reasons),
55
61
  "tripped": list(gate.tripped),
56
62
  },
@@ -29,7 +29,11 @@ _SARIF_LEVEL = {
29
29
  }
30
30
 
31
31
 
32
- def emit(findings: Iterable[Finding], coverage: CoverageReport | None = None) -> dict:
32
+ def emit(
33
+ findings: Iterable[Finding],
34
+ coverage: CoverageReport | None = None,
35
+ axis_of=lambda _f: "primary",
36
+ ) -> dict:
33
37
  """Build a SARIF 2.1.0 document from canonical findings."""
34
38
  findings = list(findings)
35
39
 
@@ -41,7 +45,7 @@ def emit(findings: Iterable[Finding], coverage: CoverageReport | None = None) ->
41
45
  rid = f.rule_id
42
46
  if rid not in rule_meta:
43
47
  rule_meta[rid] = _rule(f)
44
- results.append(_result(f))
48
+ results.append(_result(f, axis_of(f)))
45
49
 
46
50
  run = {
47
51
  "tool": {
@@ -114,14 +118,63 @@ def _rule(f: Finding) -> dict:
114
118
  return rule
115
119
 
116
120
 
117
- def _result(f: Finding) -> dict:
121
+ #: Axes whose findings are reported but are not defects to raise an alert
122
+ #: for. Same set the work order files under §ACCEPT.
123
+ _SIDE_AXES = frozenset({"test tree", "documentation"})
124
+
125
+
126
+ def _suppressions(f: Finding, axis: str) -> list[dict] | None:
127
+ """The SARIF-standard suppression array, or None to raise an alert.
128
+
129
+ SARIF is consumed by code-scanning platforms that turn each result into
130
+ an alert, and `suppressions` is the field they honour. We were writing
131
+ `properties.suppressed` instead — a field of our own invention that no
132
+ consumer reads — so two kinds of finding raised alerts they should not:
133
+
134
+ * findings the operator had explicitly suppressed in `.scignore.yaml`,
135
+ with a reason and an expiry, which is as clear a "do not alert me
136
+ about this" as exists;
137
+ * and every test-tree and documentation finding, which the product
138
+ itself reports as "not scored" and the work order files under §ACCEPT
139
+ with "do not patch these".
140
+
141
+ Auditing `maintainability-agent` produced 4,929 SARIF results of which
142
+ **4,847 were test fixtures**. Uploaded to code scanning that is 4,847
143
+ alerts for deliberately-vulnerable test data, burying the 82 findings in
144
+ the shipped source.
145
+
146
+ Suppressed, not omitted. The finding stays in the file with its
147
+ location and justification, so nothing is hidden from a reader — it
148
+ simply does not become someone's ticket.
149
+ """
150
+ if f.suppressed:
151
+ return [
152
+ {
153
+ "kind": "external",
154
+ "justification": f.suppression_note or "suppressed by operator configuration",
155
+ }
156
+ ]
157
+ if axis in _SIDE_AXES:
158
+ return [
159
+ {
160
+ "kind": "external",
161
+ "justification": (
162
+ f"reported on the {axis} axis: outside the shipped source, "
163
+ f"not scored as code condition, and not a patch target"
164
+ ),
165
+ }
166
+ ]
167
+ return None
168
+
169
+
170
+ def _result(f: Finding, axis: str = "primary") -> dict:
118
171
  region: dict = {"startLine": max(1, f.line_start)}
119
172
  if f.line_end and f.line_end != f.line_start:
120
173
  region["endLine"] = f.line_end
121
174
  if f.code_snippet:
122
175
  region["snippet"] = {"text": f.code_snippet}
123
176
 
124
- return {
177
+ result = {
125
178
  "ruleId": f.rule_id,
126
179
  "level": _SARIF_LEVEL[f.severity],
127
180
  "message": {"text": f.message},
@@ -131,6 +184,7 @@ def _result(f: Finding) -> dict:
131
184
  "category": f.category.value,
132
185
  "is_new": f.is_new,
133
186
  "suppressed": f.suppressed,
187
+ "axis": axis,
134
188
  },
135
189
  "locations": [
136
190
  {
@@ -141,12 +195,19 @@ def _result(f: Finding) -> dict:
141
195
  }
142
196
  ],
143
197
  }
198
+ suppressions = _suppressions(f, axis)
199
+ if suppressions:
200
+ result["suppressions"] = suppressions
201
+ return result
144
202
 
145
203
 
146
204
  def write(
147
- findings: Iterable[Finding], output: Path, coverage: CoverageReport | None = None
205
+ findings: Iterable[Finding],
206
+ output: Path,
207
+ coverage: CoverageReport | None = None,
208
+ axis_of=lambda _f: "primary",
148
209
  ) -> None:
149
- output.write_text(json.dumps(emit(findings, coverage), indent=2), encoding="utf-8")
210
+ output.write_text(json.dumps(emit(findings, coverage, axis_of), indent=2), encoding="utf-8")
150
211
 
151
212
 
152
213
  # ----- ingest -------------------------------------------------------------
@@ -156,7 +156,18 @@ class Scanner(ABC):
156
156
  """Run a scanner subprocess with sanitized env, no shell.
157
157
 
158
158
  Some scanners (npm audit, pip-audit) exit nonzero on findings; pass
159
- `allowed_exits` to mark those as success."""
159
+ `allowed_exits` to mark those as success.
160
+
161
+ `cwd` is coerced to a directory. Auditing a single file is supported
162
+ — the CLI and several adapters carry `target if target.is_dir() else
163
+ target.parent` for exactly that — but every adapter passed the raw
164
+ target here, so `secure-code-agent path/to/one.py` died with
165
+ `NotADirectoryError` out of `subprocess.py` before any scanner ran.
166
+ Fixing it once here is better than asking fifteen adapters to
167
+ remember.
168
+ """
169
+ if cwd is not None and not cwd.is_dir():
170
+ cwd = cwd.parent
160
171
  env = self._sanitized_env()
161
172
  try:
162
173
  r = subprocess.run(
@@ -24,7 +24,15 @@ from secure_code_audit.scanners.base import Scanner
24
24
  class CheckovScanner(Scanner):
25
25
  name = "checkov"
26
26
  binary = "checkov"
27
- python_module = "checkov"
27
+ #: **No `python_module` fallback.** checkov ships no `__main__`, so
28
+ #: `python -m checkov` fails with "No module named checkov.__main__".
29
+ #: Declaring the fallback meant that with the package installed but the
30
+ #: console script off PATH, checkov resolved to a command that could
31
+ #: never run — reported as FAILED rather than the honest UNAVAILABLE.
32
+ #:
33
+ #: Caught by the test added alongside the identical semgrep defect,
34
+ #: running in CI where the full floor is installed. It passed locally
35
+ #: only because checkov was not installed on this machine.
28
36
  default_category = Category.CONFIG_IAC
29
37
  install_hint = "pip install 'secure-code-agent[python-scanners]'"
30
38
 
@@ -54,7 +54,20 @@ def _cwe_from_rule(rule: dict) -> str | None:
54
54
  class SemgrepScanner(Scanner):
55
55
  name = "semgrep"
56
56
  binary = "semgrep"
57
- python_module = "semgrep"
57
+ #: **No `python_module` fallback.** Semgrep deprecated `python -m
58
+ #: semgrep` in 1.38.0: it now prints a notice, exits 0, and analyses
59
+ #: nothing. The fallback fires whenever the module is importable but the
60
+ #: binary is off PATH — which is the normal state after installing
61
+ #: njsscan, since that pulls semgrep in as a dependency.
62
+ #:
63
+ #: The outcome was FAILED rather than a silent COMPLETED, so coverage
64
+ #: caught it and no grade was claimed on an unrun scanner. But "semgrep
65
+ #: failed" is the wrong thing to tell an operator whose actual situation
66
+ #: is "semgrep is not on PATH", and it sent five of this project's own
67
+ #: tests from skipped to failing the moment njsscan was installed.
68
+ #:
69
+ #: bandit, njsscan, checkov and pip-audit all still run correctly under
70
+ #: `python -m`, so the mechanism stays; semgrep simply opts out.
58
71
  default_category = Category.CODE_VULNERABILITIES
59
72
  install_hint = "pip install 'secure-code-agent[python-scanners]'"
60
73
 
@@ -551,6 +551,26 @@ class GateResult:
551
551
  passed: bool
552
552
  reasons: tuple[str, ...] # human-readable trip reasons
553
553
  tripped: tuple[str, ...] = field(default_factory=tuple)
554
+ #: Gates that could actually have failed this run. Empty means no
555
+ #: policy was configured, which is **not** the same as passing one.
556
+ #:
557
+ #: `passed` stays True in that case — no gate tripped, which is
558
+ #: accurate — but reporting it as PASS is a claim nothing checked. A
559
+ #: 200,000-line repository carrying SQL injection, `shell=True`,
560
+ #: `pickle.loads` and `eval` printed "gate PASS" out of the box, with
561
+ #: no configuration, because an absent gate cannot trip. The work
562
+ #: order listed all seven findings correctly at the same time.
563
+ #:
564
+ #: `_require_configured_gates` already refuses `--fail-on-gate` in this
565
+ #: situation, calling it "a green build with no security floor". This
566
+ #: is the same fact, carried far enough to reach the summary line a
567
+ #: person actually reads.
568
+ configured: tuple[str, ...] = field(default_factory=tuple)
569
+
570
+ @property
571
+ def enforced(self) -> bool:
572
+ """Did any gate actually stand between these findings and a pass?"""
573
+ return bool(self.configured)
554
574
 
555
575
 
556
576
  # Every gate, with the predicate that decides whether it can actually trip.
@@ -611,6 +631,7 @@ def evaluate_gates(
611
631
  passed=not tripped,
612
632
  reasons=tuple(reasons),
613
633
  tripped=tuple(tripped),
634
+ configured=active_gates(gate_config),
614
635
  )
615
636
 
616
637
 
@@ -630,3 +630,19 @@ def owasp_for_cwe(canonical_cwe: str | None) -> str | None:
630
630
  if canonical_cwe is None:
631
631
  return None
632
632
  return _CWE_TO_OWASP.get(canonical_cwe)
633
+
634
+
635
+ def categories_for_scanner(scanner: str) -> frozenset[Category]:
636
+ """Which categories this scanner has mapped rules for.
637
+
638
+ Used to work out what a run could have measured. Derived from the map
639
+ rather than written down a second time: the hand-maintained copy claimed
640
+ `builtin_rules` covered `secrets` and `config_iac`, which it never has —
641
+ its rules are Python and shell language primitives — and omitted
642
+ `supply_chain`, which it does cover. An IaC repository with a
643
+ `public-read` S3 bucket and an open security group therefore scored
644
+ `config_iac` 5.0 with no IaC scanner installed.
645
+ """
646
+ return frozenset(
647
+ entry.category for (name, _rule), entry in _MAP.items() if name == scanner.lower()
648
+ )