secure-code-agent 0.12.4__tar.gz → 0.12.6__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 (53) hide show
  1. {secure_code_agent-0.12.4/src/secure_code_agent.egg-info → secure_code_agent-0.12.6}/PKG-INFO +2 -2
  2. {secure_code_agent-0.12.4 → secure_code_agent-0.12.6}/README.md +1 -1
  3. {secure_code_agent-0.12.4 → secure_code_agent-0.12.6/src/secure_code_agent.egg-info}/PKG-INFO +2 -2
  4. {secure_code_agent-0.12.4 → secure_code_agent-0.12.6}/src/secure_code_agent.egg-info/SOURCES.txt +1 -0
  5. {secure_code_agent-0.12.4 → secure_code_agent-0.12.6}/src/secure_code_audit/__init__.py +1 -1
  6. secure_code_agent-0.12.6/src/secure_code_audit/capabilities.py +137 -0
  7. {secure_code_agent-0.12.4 → secure_code_agent-0.12.6}/src/secure_code_audit/cli.py +106 -3
  8. {secure_code_agent-0.12.4 → secure_code_agent-0.12.6}/src/secure_code_audit/config.py +38 -0
  9. {secure_code_agent-0.12.4 → secure_code_agent-0.12.6}/src/secure_code_audit/scanners/osv_scanner.py +14 -2
  10. {secure_code_agent-0.12.4 → secure_code_agent-0.12.6}/LICENSE +0 -0
  11. {secure_code_agent-0.12.4 → secure_code_agent-0.12.6}/pyproject.toml +0 -0
  12. {secure_code_agent-0.12.4 → secure_code_agent-0.12.6}/setup.cfg +0 -0
  13. {secure_code_agent-0.12.4 → secure_code_agent-0.12.6}/src/secure_code_agent.egg-info/dependency_links.txt +0 -0
  14. {secure_code_agent-0.12.4 → secure_code_agent-0.12.6}/src/secure_code_agent.egg-info/entry_points.txt +0 -0
  15. {secure_code_agent-0.12.4 → secure_code_agent-0.12.6}/src/secure_code_agent.egg-info/requires.txt +0 -0
  16. {secure_code_agent-0.12.4 → secure_code_agent-0.12.6}/src/secure_code_agent.egg-info/top_level.txt +0 -0
  17. {secure_code_agent-0.12.4 → secure_code_agent-0.12.6}/src/secure_code_audit/baseline.py +0 -0
  18. {secure_code_agent-0.12.4 → secure_code_agent-0.12.6}/src/secure_code_audit/data/semgrep-offline.yaml +0 -0
  19. {secure_code_agent-0.12.4 → secure_code_agent-0.12.6}/src/secure_code_audit/demo.py +0 -0
  20. {secure_code_agent-0.12.4 → secure_code_agent-0.12.6}/src/secure_code_audit/findings.py +0 -0
  21. {secure_code_agent-0.12.4 → secure_code_agent-0.12.6}/src/secure_code_audit/git_tools.py +0 -0
  22. {secure_code_agent-0.12.4 → secure_code_agent-0.12.6}/src/secure_code_audit/history.py +0 -0
  23. {secure_code_agent-0.12.4 → secure_code_agent-0.12.6}/src/secure_code_audit/instructions.py +0 -0
  24. {secure_code_agent-0.12.4 → secure_code_agent-0.12.6}/src/secure_code_audit/mcp_server.py +0 -0
  25. {secure_code_agent-0.12.4 → secure_code_agent-0.12.6}/src/secure_code_audit/pillar.py +0 -0
  26. {secure_code_agent-0.12.4 → secure_code_agent-0.12.6}/src/secure_code_audit/practice.py +0 -0
  27. {secure_code_agent-0.12.4 → secure_code_agent-0.12.6}/src/secure_code_audit/remediation.py +0 -0
  28. {secure_code_agent-0.12.4 → secure_code_agent-0.12.6}/src/secure_code_audit/renderers.py +0 -0
  29. {secure_code_agent-0.12.4 → secure_code_agent-0.12.6}/src/secure_code_audit/ruleset.py +0 -0
  30. {secure_code_agent-0.12.4 → secure_code_agent-0.12.6}/src/secure_code_audit/sarif.py +0 -0
  31. {secure_code_agent-0.12.4 → secure_code_agent-0.12.6}/src/secure_code_audit/scanner_status.py +0 -0
  32. {secure_code_agent-0.12.4 → secure_code_agent-0.12.6}/src/secure_code_audit/scanners/__init__.py +0 -0
  33. {secure_code_agent-0.12.4 → secure_code_agent-0.12.6}/src/secure_code_audit/scanners/bandit_scanner.py +0 -0
  34. {secure_code_agent-0.12.4 → secure_code_agent-0.12.6}/src/secure_code_audit/scanners/base.py +0 -0
  35. {secure_code_agent-0.12.4 → secure_code_agent-0.12.6}/src/secure_code_audit/scanners/builtin_rules.py +0 -0
  36. {secure_code_agent-0.12.4 → secure_code_agent-0.12.6}/src/secure_code_audit/scanners/checkov_scanner.py +0 -0
  37. {secure_code_agent-0.12.4 → secure_code_agent-0.12.6}/src/secure_code_audit/scanners/floor.py +0 -0
  38. {secure_code_agent-0.12.4 → secure_code_agent-0.12.6}/src/secure_code_audit/scanners/gitleaks_scanner.py +0 -0
  39. {secure_code_agent-0.12.4 → secure_code_agent-0.12.6}/src/secure_code_audit/scanners/gosec_scanner.py +0 -0
  40. {secure_code_agent-0.12.4 → secure_code_agent-0.12.6}/src/secure_code_audit/scanners/hadolint_scanner.py +0 -0
  41. {secure_code_agent-0.12.4 → secure_code_agent-0.12.6}/src/secure_code_audit/scanners/njsscan_scanner.py +0 -0
  42. {secure_code_agent-0.12.4 → secure_code_agent-0.12.6}/src/secure_code_audit/scanners/npm_audit_scanner.py +0 -0
  43. {secure_code_agent-0.12.4 → secure_code_agent-0.12.6}/src/secure_code_audit/scanners/pip_audit_scanner.py +0 -0
  44. {secure_code_agent-0.12.4 → secure_code_agent-0.12.6}/src/secure_code_audit/scanners/rubocop_scanner.py +0 -0
  45. {secure_code_agent-0.12.4 → secure_code_agent-0.12.6}/src/secure_code_audit/scanners/scorecard_scanner.py +0 -0
  46. {secure_code_agent-0.12.4 → secure_code_agent-0.12.6}/src/secure_code_audit/scanners/semgrep_scanner.py +0 -0
  47. {secure_code_agent-0.12.4 → secure_code_agent-0.12.6}/src/secure_code_audit/scanners/trivy_scanner.py +0 -0
  48. {secure_code_agent-0.12.4 → secure_code_agent-0.12.6}/src/secure_code_audit/scanners/trufflehog_scanner.py +0 -0
  49. {secure_code_agent-0.12.4 → secure_code_agent-0.12.6}/src/secure_code_audit/scoring.py +0 -0
  50. {secure_code_agent-0.12.4 → secure_code_agent-0.12.6}/src/secure_code_audit/standards.py +0 -0
  51. {secure_code_agent-0.12.4 → secure_code_agent-0.12.6}/src/secure_code_audit/suppressions.py +0 -0
  52. {secure_code_agent-0.12.4 → secure_code_agent-0.12.6}/src/secure_code_audit/triage.py +0 -0
  53. {secure_code_agent-0.12.4 → secure_code_agent-0.12.6}/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.12.4
3
+ Version: 0.12.6
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
@@ -513,7 +513,7 @@ For agents that support invokable skills, this repo ships a portable skill under
513
513
  ## GitHub Action
514
514
 
515
515
  ```yaml
516
- - uses: marshallguillory86/secure-code-agent@v0.12.4
516
+ - uses: marshallguillory86/secure-code-agent@v0.12.6
517
517
  with:
518
518
  config: secure-code-agent.json
519
519
  fail-on-gate: true
@@ -465,7 +465,7 @@ For agents that support invokable skills, this repo ships a portable skill under
465
465
  ## GitHub Action
466
466
 
467
467
  ```yaml
468
- - uses: marshallguillory86/secure-code-agent@v0.12.4
468
+ - uses: marshallguillory86/secure-code-agent@v0.12.6
469
469
  with:
470
470
  config: secure-code-agent.json
471
471
  fail-on-gate: true
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: secure-code-agent
3
- Version: 0.12.4
3
+ Version: 0.12.6
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
@@ -513,7 +513,7 @@ For agents that support invokable skills, this repo ships a portable skill under
513
513
  ## GitHub Action
514
514
 
515
515
  ```yaml
516
- - uses: marshallguillory86/secure-code-agent@v0.12.4
516
+ - uses: marshallguillory86/secure-code-agent@v0.12.6
517
517
  with:
518
518
  config: secure-code-agent.json
519
519
  fail-on-gate: true
@@ -9,6 +9,7 @@ src/secure_code_agent.egg-info/requires.txt
9
9
  src/secure_code_agent.egg-info/top_level.txt
10
10
  src/secure_code_audit/__init__.py
11
11
  src/secure_code_audit/baseline.py
12
+ src/secure_code_audit/capabilities.py
12
13
  src/secure_code_audit/cli.py
13
14
  src/secure_code_audit/config.py
14
15
  src/secure_code_audit/demo.py
@@ -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.12.4"
15
+ __version__ = "0.12.6"
@@ -0,0 +1,137 @@
1
+ """What a project declares it does, and the findings that follow from it.
2
+
3
+ Some findings are not defects and never will be. A tool that runs external
4
+ analyzers imports `subprocess` and spawns them; a tool that reads analyzer
5
+ output parses XML it did not write. Bandit reports both, correctly — the
6
+ observation is true. What was missing is any way for the project to say "yes,
7
+ that is what this is", so the same findings were reported as defects on every
8
+ run forever.
9
+
10
+ The instruments that existed could only hide them. `exclude_patterns` stops
11
+ the scan. `extra_args: --skip` turns the check off. Both leave a report that
12
+ says nothing, which is the silence this tool exists to refuse — the same shape
13
+ `_conformance` counts as a defect when it finds `NOSONAR` in someone else's
14
+ tree. `.scignore.yaml` states a reason, but a suppression expires, and an
15
+ architectural fact does not stop being true in a year; renewing it annually is
16
+ a ritual that teaches people to renew rituals.
17
+
18
+ A declaration is different from all three. The operator states, in the config,
19
+ what the project does. Findings consistent with that declaration are routed to
20
+ their own axis: **counted, listed in the report, and not scored**. Nothing is
21
+ hidden, nothing expires, and a subprocess call written next year is already
22
+ covered rather than generating a new exception.
23
+
24
+ **Declared, never inferred.** The tool does not decide that a project looks
25
+ like it spawns processes. It is told, in a file a reviewer reads, and the
26
+ report names the declaration next to the findings it accounts for. Inference
27
+ here would make the grade depend on a guess about the codebase, which is the
28
+ property this rubric does not have anywhere else.
29
+
30
+ **A declaration is falsifiable.** Declaring a capability the project does not
31
+ exercise is itself reported (`unexercised`), so the config cannot be padded
32
+ with pre-emptive declarations against findings that might arrive later.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ from collections.abc import Iterable
38
+ from dataclasses import dataclass
39
+
40
+ from secure_code_audit.findings import Finding
41
+
42
+ #: Capability name -> the rule ids whose finding *is* that capability being
43
+ #: exercised. Deterministic and enumerated rather than pattern-matched: a
44
+ #: reader can see exactly which observations a declaration accounts for, and
45
+ #: a rule absent from this table is never routed by it.
46
+ #:
47
+ #: Scoped narrowly on purpose. `spawns_processes` covers the reports that say
48
+ #: "this code starts a process", not every report that happens to occur in a
49
+ #: file that also starts one. B602 (`shell=True`) is deliberately **not**
50
+ #: here: spawning a process is architecture, handing a string to a shell is a
51
+ #: decision, and a project that declares the former has not excused the latter.
52
+ CAPABILITY_RULES: dict[str, frozenset[str]] = {
53
+ "spawns_processes": frozenset(
54
+ {
55
+ "B404", # import subprocess
56
+ "B603", # subprocess call without shell
57
+ "B606", # start process with no shell
58
+ "B607", # start process with a partial executable path
59
+ }
60
+ ),
61
+ "parses_untrusted_xml": frozenset(
62
+ {
63
+ "B313", # xml.etree.cElementTree
64
+ "B314", # xml.etree.ElementTree
65
+ "B405", # import xml.etree
66
+ "B406", # import xml.sax
67
+ "B408", # import xml.minidom
68
+ "B409", # import xml.pulldom
69
+ }
70
+ ),
71
+ "fetches_remote_urls": frozenset(
72
+ {
73
+ "B310", # urllib.request.urlopen
74
+ }
75
+ ),
76
+ "uses_nondeterministic_randomness": frozenset(
77
+ {
78
+ "B311", # random, not suitable for cryptography
79
+ }
80
+ ),
81
+ }
82
+
83
+ #: The axis a declared finding is reported on, prefixed so a reader of the
84
+ #: report can tell a declaration apart from a path-derived side axis.
85
+ AXIS_PREFIX = "declared: "
86
+
87
+
88
+ @dataclass(frozen=True)
89
+ class DeclarationReport:
90
+ """What the declarations accounted for, for the report to state."""
91
+
92
+ #: Capability -> the operator's stated reason, as written in the config.
93
+ declared: dict[str, str]
94
+ #: Capability -> how many findings it accounted for.
95
+ accounted: dict[str, int]
96
+ #: Declared, and nothing matched it. A declaration that describes nothing
97
+ #: this project does is reported rather than ignored, because a config can
98
+ #: otherwise be padded against findings that have not arrived yet.
99
+ unexercised: tuple[str, ...]
100
+
101
+ @property
102
+ def total_accounted(self) -> int:
103
+ return sum(self.accounted.values())
104
+
105
+
106
+ def unknown_capabilities(declared: Iterable[str]) -> tuple[str, ...]:
107
+ """Declared names this build has no rules for.
108
+
109
+ A typo in a capability name would otherwise declare nothing and route
110
+ nothing, silently, and the operator would read a report that still scored
111
+ the findings they thought they had accounted for.
112
+ """
113
+ return tuple(sorted(name for name in declared if name not in CAPABILITY_RULES))
114
+
115
+
116
+ def capability_for(finding: Finding, declared: Iterable[str]) -> str | None:
117
+ """The declared capability this finding is an instance of, or None."""
118
+ for name in declared:
119
+ if finding.rule_id in CAPABILITY_RULES.get(name, frozenset()):
120
+ return name
121
+ return None
122
+
123
+
124
+ def summarize_declarations(
125
+ declared: dict[str, str], findings: Iterable[Finding]
126
+ ) -> DeclarationReport:
127
+ """Count what each declaration accounted for, and what accounted for nothing."""
128
+ accounted = dict.fromkeys(declared, 0)
129
+ for finding in findings:
130
+ name = capability_for(finding, declared)
131
+ if name is not None:
132
+ accounted[name] += 1
133
+ return DeclarationReport(
134
+ declared=dict(declared),
135
+ accounted=accounted,
136
+ unexercised=tuple(sorted(n for n, count in accounted.items() if count == 0)),
137
+ )
@@ -30,6 +30,11 @@ from secure_code_audit import history as history_mod
30
30
  from secure_code_audit import pillar as pillar_mod
31
31
  from secure_code_audit import practice as practice_mod
32
32
  from secure_code_audit import verify as verify_mod
33
+ from secure_code_audit.capabilities import (
34
+ AXIS_PREFIX,
35
+ capability_for,
36
+ summarize_declarations,
37
+ )
33
38
  from secure_code_audit.findings import (
34
39
  Category,
35
40
  Confidence,
@@ -454,6 +459,16 @@ def _do_audit(args: argparse.Namespace) -> int:
454
459
  return "test tree"
455
460
  if is_test_path(located, root_for_tests, cfg.docs_patterns):
456
461
  return "documentation"
462
+ # A finding that *is* a declared capability being exercised (D24),
463
+ # checked **after** the path axes. A subprocess call in the test tree
464
+ # is test tree: it is already unscored, and routing it to the
465
+ # declaration instead moved 492 findings off the tree's own axis and
466
+ # made the declaration look like it accounted for work it did not.
467
+ # The declaration is for the shipped source, which is the only place
468
+ # the path axes leave on the primary axis.
469
+ declared_as = capability_for(finding, cfg.capabilities)
470
+ if declared_as is not None:
471
+ return AXIS_PREFIX + declared_as
457
472
  return "primary"
458
473
 
459
474
  # `all_findings` keeps meaning *all* of them. Rebinding it to the primary
@@ -504,13 +519,49 @@ def _do_audit(args: argparse.Namespace) -> int:
504
519
  # named — a secret matters wherever it lives, a test fixture's HIGH code
505
520
  # smell does not, and feeding a deliberately-vulnerable fixture tree to
506
521
  # `fail_on_severity` would fail every build in the corpus.
507
- gated = gate_set(primary_findings, (test_findings, docs_findings), cfg.gates)
522
+ # Every axis whose name carries the declaration prefix, in the order the
523
+ # operator declared them so the report is stable across runs.
524
+ declared_axes = {
525
+ AXIS_PREFIX + name: path_axes.get(AXIS_PREFIX + name, []) for name in cfg.capabilities
526
+ }
527
+ # Declared axes gate on the same terms as the path axes: a secret is a
528
+ # secret wherever it is found, and declaring that this project spawns
529
+ # processes does not excuse a credential sitting next to one.
530
+ gated = gate_set(
531
+ primary_findings,
532
+ (test_findings, docs_findings, *declared_axes.values()),
533
+ cfg.gates,
534
+ )
508
535
  measurable = _measurable_categories(executions, scored_findings)
536
+ # Counted over what was actually *routed*, not over every finding whose
537
+ # rule id matches. A subprocess call in the test tree is test tree, and
538
+ # counting it here made the disclosure claim 554 findings while the axis
539
+ # beside it showed 62 — two numbers about the same thing, in the same
540
+ # report, disagreeing by an order of magnitude.
541
+ declarations = summarize_declarations(
542
+ cfg.capabilities,
543
+ [f for found in declared_axes.values() for f in found],
544
+ )
509
545
  score = score_findings(scored_findings, loc, measurable)
546
+ # What the declarations bought, stated as a number rather than left for a
547
+ # reader to reconstruct (D24). A declaration moves the grade — that is its
548
+ # purpose, and `exclude_patterns` has always been able to move it further
549
+ # while leaving no trace at all. The difference is disclosure: this reports
550
+ # the score the same tree earns when the declarations are disregarded, so a
551
+ # project that declares its way up a band has to show that on its own
552
+ # report. Computed only when something was actually accounted for, so an
553
+ # undeclared repository's output is unchanged.
554
+ undeclared_score = None
555
+ if declarations.total_accounted:
556
+ undeclared_findings = list(scored_findings) + [
557
+ f for found in declared_axes.values() for f in found if not f.suppressed
558
+ ]
559
+ undeclared_score = score_findings(undeclared_findings, loc, measurable)
510
560
  axes = (
511
561
  summarize_axis("test tree", test_findings, test_loc),
512
562
  summarize_axis("documentation", docs_findings, docs_loc or None),
513
563
  summarize_axis("dependencies", dependency_findings),
564
+ *(summarize_axis(name, found) for name, found in declared_axes.items()),
514
565
  )
515
566
  # Naming an import on the command line asserts that it contributes coverage,
516
567
  # so a broken one fails the gate even if no config requires that scanner.
@@ -615,7 +666,18 @@ def _do_audit(args: argparse.Namespace) -> int:
615
666
  )
616
667
  sys.stdout.write("\n")
617
668
  else:
618
- _print_summary(verdict, score, gate, ran, unavailable, coverage, paths, axes, trend_line)
669
+ _print_summary(
670
+ verdict,
671
+ score,
672
+ gate,
673
+ ran,
674
+ unavailable,
675
+ coverage,
676
+ paths,
677
+ axes,
678
+ trend_line,
679
+ _declaration_delta(score, undeclared_score, declarations),
680
+ )
619
681
 
620
682
  return _exit_code(args, gate, all_findings)
621
683
 
@@ -1139,8 +1201,42 @@ def _print_install_guidance(unavailable: list[str]) -> None:
1139
1201
  print(" (or --preflight to check the whole floor before a run)")
1140
1202
 
1141
1203
 
1204
+ def _declaration_delta(score, undeclared_score, declarations) -> str | None:
1205
+ """One line naming what the declarations accounted for, and what it cost.
1206
+
1207
+ `exclude_patterns` can move a grade further than this and says nothing at
1208
+ all. The difference this mechanism is meant to hold is disclosure, so the
1209
+ number a reader would otherwise have to reconstruct is printed for them.
1210
+ """
1211
+ if undeclared_score is None or not declarations.total_accounted:
1212
+ return None
1213
+ named = ", ".join(
1214
+ f"{name} ({count})" for name, count in sorted(declarations.accounted.items()) if count
1215
+ )
1216
+ line = (
1217
+ f"declared: {declarations.total_accounted} finding(s) accounted for by "
1218
+ f"{named}; without declarations "
1219
+ f"{undeclared_score.overall:.2f} ({undeclared_score.letter})"
1220
+ )
1221
+ if declarations.unexercised:
1222
+ # A declaration that matched nothing describes something this project
1223
+ # does not do. Named rather than dropped, so a config cannot be padded
1224
+ # against findings that have not arrived yet.
1225
+ line += f"; unexercised: {', '.join(declarations.unexercised)}"
1226
+ return line
1227
+
1228
+
1142
1229
  def _print_summary(
1143
- verdict, score, gate, ran, unavailable, coverage, paths, axes=(), trend=None
1230
+ verdict,
1231
+ score,
1232
+ gate,
1233
+ ran,
1234
+ unavailable,
1235
+ coverage,
1236
+ paths,
1237
+ axes=(),
1238
+ trend=None,
1239
+ declaration_delta=None,
1144
1240
  ) -> None:
1145
1241
  # "PASS" is a claim that something was checked. With no gate configured
1146
1242
  # nothing was, and saying so is the difference between a report and a
@@ -1162,6 +1258,13 @@ def _print_summary(
1162
1258
  for axis in axes or ():
1163
1259
  if axis.count or axis.loc:
1164
1260
  print(f" {axis.headline()}")
1261
+ # What the declarations bought, next to the axes that show what they
1262
+ # accounted for. Printed only when they accounted for something, and
1263
+ # printed even when the two scores land in the same band — "no change"
1264
+ # is the reassuring case and is exactly what a reader should be able to
1265
+ # see without recomputing it.
1266
+ if declaration_delta:
1267
+ print(f" {declaration_delta}")
1165
1268
  print(f" scanners run: {', '.join(ran) if ran else '(none)'}")
1166
1269
  coverage_line = f" coverage: {coverage.status.value.upper()}"
1167
1270
  if coverage.unverified:
@@ -11,6 +11,11 @@ from dataclasses import dataclass, field
11
11
  from pathlib import Path, PurePosixPath
12
12
  from typing import Any
13
13
 
14
+ from secure_code_audit.capabilities import (
15
+ CAPABILITY_RULES,
16
+ unknown_capabilities,
17
+ )
18
+
14
19
  DEFAULT_CONFIG_PATH = Path("secure-code-agent.json")
15
20
 
16
21
  DEFAULT_EXCLUDES: tuple[str, ...] = (
@@ -245,6 +250,13 @@ class Config:
245
250
  #: carries example JWTs that scored as critical secrets and drove it to F.
246
251
  #: Reported and gated like the test tree, never scored as code condition.
247
252
  docs_patterns: tuple[str, ...] = DEFAULT_DOCS_PATTERNS
253
+ #: What this project declares it does, as ``name -> reason`` (D24). A
254
+ #: finding that *is* a declared capability being exercised is reported on
255
+ #: its own axis rather than scored: a tool that runs analyzers spawns
256
+ #: processes, and reporting that as a defect on every run forever is the
257
+ #: observation being true and useless. Declared, never inferred, and
258
+ #: falsifiable — a declaration nothing matches is reported as unexercised.
259
+ capabilities: dict[str, str] = field(default_factory=dict)
248
260
  scanners: dict[str, ScannerConfig] = field(default_factory=dict)
249
261
  severity_overrides: dict[str, str] = field(default_factory=dict)
250
262
  category_overrides: dict[str, str] = field(default_factory=dict)
@@ -398,6 +410,7 @@ _KNOWN_KEYS = frozenset(
398
410
  "$schema",
399
411
  "version",
400
412
  "paths",
413
+ "capabilities",
401
414
  "scanners",
402
415
  "severity_overrides",
403
416
  "category_overrides",
@@ -442,6 +455,31 @@ def _from_dict(raw: dict[str, Any]) -> Config:
442
455
  if "docs_patterns" in paths:
443
456
  cfg.docs_patterns = tuple(_string_list(paths["docs_patterns"], "paths.docs_patterns"))
444
457
 
458
+ capabilities_raw = raw.get("capabilities", {})
459
+ if not isinstance(capabilities_raw, dict):
460
+ raise ValueError("capabilities must be a JSON object of name -> reason")
461
+ for name, reason in capabilities_raw.items():
462
+ if not isinstance(name, str) or not name:
463
+ raise ValueError("capability names must be non-empty strings")
464
+ if not isinstance(reason, str) or not reason.strip():
465
+ # A declaration without a reason is the thing this is meant to
466
+ # replace. The reason is what a reviewer reads to decide whether
467
+ # the declaration is still true, so an empty one is refused here
468
+ # rather than rendered as a blank line in the report.
469
+ raise ValueError(f"capabilities.{name} must state a non-empty reason")
470
+ unknown = unknown_capabilities(capabilities_raw)
471
+ if unknown:
472
+ # A typo would declare nothing, route nothing, and score the findings
473
+ # the operator believed they had accounted for -- silently, which is
474
+ # the failure mode this whole mechanism exists to remove.
475
+ raise ValueError(
476
+ "unknown capabilities: "
477
+ + ", ".join(unknown)
478
+ + "; known names are "
479
+ + ", ".join(sorted(CAPABILITY_RULES))
480
+ )
481
+ cfg.capabilities = dict(capabilities_raw)
482
+
445
483
  scanners_raw = raw.get("scanners", {})
446
484
  if not isinstance(scanners_raw, dict):
447
485
  raise ValueError("scanners must be a JSON object")
@@ -20,6 +20,13 @@ from secure_code_audit.findings import Category, Confidence, Finding, Severity
20
20
  from secure_code_audit.scanner_status import ScanResult
21
21
  from secure_code_audit.scanners.base import Scanner
22
22
 
23
+ #: osv-scanner's exit code for "No package sources found" — no lockfile,
24
+ #: manifest or SBOM anywhere under the target. Nothing to read is not a
25
+ #: failure to read, so it becomes NOT_APPLICABLE (D23). The tool's own
26
+ #: `--allow-no-lockfiles` would turn this into exit 0 instead, which would
27
+ #: report a completed run over nothing; an outcome that says so is better.
28
+ _NO_PACKAGE_SOURCES = 128
29
+
23
30
  _OSV_SEVERITY: dict[str, Severity] = {
24
31
  "CRITICAL": Severity.CRITICAL,
25
32
  "HIGH": Severity.HIGH,
@@ -53,13 +60,18 @@ class OsvScanner(Scanner):
53
60
  ]
54
61
  args.extend(sc_cfg.extra_args)
55
62
 
56
- # osv-scanner exits 1 on findings, 0 on clean, 127 on bad usage,
57
- # 128 on internal error.
63
+ # osv-scanner exits 0 on clean, 1 on findings, 127 on general failure,
64
+ # and 128 when it found nothing to read. 128 was documented here as an
65
+ # internal error and mapped to FAILED — see D23.
58
66
  r = self._exec(
59
67
  args, cwd=target, timeout_seconds=sc_cfg.timeout_seconds, allowed_exits=(0, 1)
60
68
  )
61
69
  if r.returncode == 124:
62
70
  return self.timed_out(target, f"osv-scanner timed out: {r.stderr[:200]}")
71
+ if r.returncode == _NO_PACKAGE_SOURCES:
72
+ return self.not_applicable(
73
+ target, "osv-scanner found no package sources (lockfile, manifest or SBOM) to scan"
74
+ )
63
75
  if r.returncode not in (0, 1):
64
76
  return self.failed(target, f"osv-scanner failed: {r.stderr[:300]}")
65
77
  if not r.stdout.strip():