secure-code-agent 0.3.0__tar.gz → 0.4.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 (46) hide show
  1. {secure_code_agent-0.3.0/src/secure_code_agent.egg-info → secure_code_agent-0.4.0}/PKG-INFO +10 -2
  2. {secure_code_agent-0.3.0 → secure_code_agent-0.4.0}/README.md +8 -1
  3. {secure_code_agent-0.3.0 → secure_code_agent-0.4.0}/pyproject.toml +16 -5
  4. {secure_code_agent-0.3.0 → secure_code_agent-0.4.0/src/secure_code_agent.egg-info}/PKG-INFO +10 -2
  5. {secure_code_agent-0.3.0 → secure_code_agent-0.4.0}/src/secure_code_agent.egg-info/SOURCES.txt +6 -0
  6. {secure_code_agent-0.3.0 → secure_code_agent-0.4.0}/src/secure_code_agent.egg-info/requires.txt +1 -0
  7. {secure_code_agent-0.3.0 → secure_code_agent-0.4.0}/src/secure_code_audit/cli.py +162 -45
  8. {secure_code_agent-0.3.0 → secure_code_agent-0.4.0}/src/secure_code_audit/config.py +92 -8
  9. secure_code_agent-0.4.0/src/secure_code_audit/data/semgrep-offline.yaml +396 -0
  10. {secure_code_agent-0.3.0 → secure_code_agent-0.4.0}/src/secure_code_audit/renderers.py +55 -16
  11. secure_code_agent-0.4.0/src/secure_code_audit/ruleset.py +97 -0
  12. {secure_code_agent-0.3.0 → secure_code_agent-0.4.0}/src/secure_code_audit/sarif.py +53 -8
  13. secure_code_agent-0.4.0/src/secure_code_audit/scanner_status.py +197 -0
  14. {secure_code_agent-0.3.0 → secure_code_agent-0.4.0}/src/secure_code_audit/scanners/__init__.py +6 -0
  15. {secure_code_agent-0.3.0 → secure_code_agent-0.4.0}/src/secure_code_audit/scanners/bandit_scanner.py +7 -30
  16. {secure_code_agent-0.3.0 → secure_code_agent-0.4.0}/src/secure_code_audit/scanners/base.py +137 -8
  17. {secure_code_agent-0.3.0 → secure_code_agent-0.4.0}/src/secure_code_audit/scanners/builtin_rules.py +3 -2
  18. {secure_code_agent-0.3.0 → secure_code_agent-0.4.0}/src/secure_code_audit/scanners/checkov_scanner.py +10 -40
  19. secure_code_agent-0.4.0/src/secure_code_audit/scanners/floor.py +282 -0
  20. {secure_code_agent-0.3.0 → secure_code_agent-0.4.0}/src/secure_code_audit/scanners/gitleaks_scanner.py +21 -30
  21. secure_code_agent-0.4.0/src/secure_code_audit/scanners/gosec_scanner.py +132 -0
  22. {secure_code_agent-0.3.0 → secure_code_agent-0.4.0}/src/secure_code_audit/scanners/hadolint_scanner.py +11 -66
  23. secure_code_agent-0.4.0/src/secure_code_audit/scanners/njsscan_scanner.py +108 -0
  24. {secure_code_agent-0.3.0 → secure_code_agent-0.4.0}/src/secure_code_audit/scanners/npm_audit_scanner.py +21 -49
  25. {secure_code_agent-0.3.0 → secure_code_agent-0.4.0}/src/secure_code_audit/scanners/osv_scanner.py +9 -52
  26. {secure_code_agent-0.3.0 → secure_code_agent-0.4.0}/src/secure_code_audit/scanners/pip_audit_scanner.py +55 -72
  27. secure_code_agent-0.4.0/src/secure_code_audit/scanners/rubocop_scanner.py +118 -0
  28. {secure_code_agent-0.3.0 → secure_code_agent-0.4.0}/src/secure_code_audit/scanners/scorecard_scanner.py +18 -65
  29. {secure_code_agent-0.3.0 → secure_code_agent-0.4.0}/src/secure_code_audit/scanners/semgrep_scanner.py +79 -43
  30. {secure_code_agent-0.3.0 → secure_code_agent-0.4.0}/src/secure_code_audit/scanners/trivy_scanner.py +8 -40
  31. {secure_code_agent-0.3.0 → secure_code_agent-0.4.0}/src/secure_code_audit/scanners/trufflehog_scanner.py +23 -39
  32. {secure_code_agent-0.3.0 → secure_code_agent-0.4.0}/src/secure_code_audit/scoring.py +128 -2
  33. {secure_code_agent-0.3.0 → secure_code_agent-0.4.0}/src/secure_code_audit/suppressions.py +68 -0
  34. secure_code_agent-0.3.0/src/secure_code_audit/scanner_status.py +0 -169
  35. {secure_code_agent-0.3.0 → secure_code_agent-0.4.0}/LICENSE +0 -0
  36. {secure_code_agent-0.3.0 → secure_code_agent-0.4.0}/setup.cfg +0 -0
  37. {secure_code_agent-0.3.0 → secure_code_agent-0.4.0}/src/secure_code_agent.egg-info/dependency_links.txt +0 -0
  38. {secure_code_agent-0.3.0 → secure_code_agent-0.4.0}/src/secure_code_agent.egg-info/entry_points.txt +0 -0
  39. {secure_code_agent-0.3.0 → secure_code_agent-0.4.0}/src/secure_code_agent.egg-info/top_level.txt +0 -0
  40. {secure_code_agent-0.3.0 → secure_code_agent-0.4.0}/src/secure_code_audit/__init__.py +0 -0
  41. {secure_code_agent-0.3.0 → secure_code_agent-0.4.0}/src/secure_code_audit/baseline.py +0 -0
  42. {secure_code_agent-0.3.0 → secure_code_agent-0.4.0}/src/secure_code_audit/findings.py +0 -0
  43. {secure_code_agent-0.3.0 → secure_code_agent-0.4.0}/src/secure_code_audit/git_tools.py +0 -0
  44. {secure_code_agent-0.3.0 → secure_code_agent-0.4.0}/src/secure_code_audit/instructions.py +0 -0
  45. {secure_code_agent-0.3.0 → secure_code_agent-0.4.0}/src/secure_code_audit/remediation.py +0 -0
  46. {secure_code_agent-0.3.0 → secure_code_agent-0.4.0}/src/secure_code_audit/standards.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: secure-code-agent
3
- Version: 0.3.0
3
+ Version: 0.4.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
@@ -32,6 +32,7 @@ Requires-Dist: PyYAML<7,>=6.0.2
32
32
  Provides-Extra: required-scanners
33
33
  Requires-Dist: bandit==1.9.4; extra == "required-scanners"
34
34
  Requires-Dist: pip-audit==2.10.1; extra == "required-scanners"
35
+ Requires-Dist: njsscan==1.0.0; extra == "required-scanners"
35
36
  Provides-Extra: python-scanners
36
37
  Requires-Dist: secure-code-agent[required-scanners]; extra == "python-scanners"
37
38
  Requires-Dist: semgrep<2,>=1.172; extra == "python-scanners"
@@ -173,7 +174,9 @@ collapsed before scoring; reports preserve the original scanner evidence.
173
174
  ├── Hadolint (Dockerfile lint)
174
175
  ├── OSV-Scanner (multi-ecosystem SCA via osv.dev)
175
176
  ├── OpenSSF Scorecard (repo hygiene + supply chain)
176
- ├── eslint-plugin-security (JS/TS SAST) [v0.3]
177
+ ├── njsscan (JS/TS SAST, offline, no Node needed)
178
+ ├── RuboCop (Ruby security cops, --only Security)
179
+ ├── gosec (Go SAST — opt-in; needs the Go toolchain)
177
180
  ├── CodeQL SARIF (ingest GitHub-hosted analysis)
178
181
  └── Built-in regex rules (high-confidence, low-FP)
179
182
  ```
@@ -287,6 +290,10 @@ for repositories whose `pyproject.toml` is their authoritative audit input.
287
290
  reason: "Slated for rewrite Q3 2026 — gated by initiative INV-44."
288
291
  expires: "2026-09-30"
289
292
 
293
+ fingerprint: 0aaa689f8a967d8c # optional: pin to ONE finding (16 hex, from the report)
294
+
295
+ line: 18 # optional: with fingerprint, pins the exact location
296
+
290
297
  - rule_id: "B101"
291
298
  paths: ["tests/"]
292
299
  reason: "assert statements are legitimate in test code."
@@ -390,6 +397,7 @@ Full design philosophy in [`docs/design.md`](docs/design.md).
390
397
  ## Documentation
391
398
 
392
399
  - [`docs/product-intent.md`](docs/product-intent.md) — Why this exists, who it serves, what it refuses to become
400
+ - [`docs/decisions.md`](docs/decisions.md) — Decision register: rulings the code alone cannot answer
393
401
  - [`docs/design.md`](docs/design.md) — Architecture + non-goals + scanner protocol
394
402
  - [`docs/architecture.md`](docs/architecture.md) — Audit of the system as built + remediation sequence
395
403
  - [`docs/release-blockers.md`](docs/release-blockers.md) — Open v0.3.0 release blockers (do not tag until closed)
@@ -128,7 +128,9 @@ collapsed before scoring; reports preserve the original scanner evidence.
128
128
  ├── Hadolint (Dockerfile lint)
129
129
  ├── OSV-Scanner (multi-ecosystem SCA via osv.dev)
130
130
  ├── OpenSSF Scorecard (repo hygiene + supply chain)
131
- ├── eslint-plugin-security (JS/TS SAST) [v0.3]
131
+ ├── njsscan (JS/TS SAST, offline, no Node needed)
132
+ ├── RuboCop (Ruby security cops, --only Security)
133
+ ├── gosec (Go SAST — opt-in; needs the Go toolchain)
132
134
  ├── CodeQL SARIF (ingest GitHub-hosted analysis)
133
135
  └── Built-in regex rules (high-confidence, low-FP)
134
136
  ```
@@ -242,6 +244,10 @@ for repositories whose `pyproject.toml` is their authoritative audit input.
242
244
  reason: "Slated for rewrite Q3 2026 — gated by initiative INV-44."
243
245
  expires: "2026-09-30"
244
246
 
247
+ fingerprint: 0aaa689f8a967d8c # optional: pin to ONE finding (16 hex, from the report)
248
+
249
+ line: 18 # optional: with fingerprint, pins the exact location
250
+
245
251
  - rule_id: "B101"
246
252
  paths: ["tests/"]
247
253
  reason: "assert statements are legitimate in test code."
@@ -345,6 +351,7 @@ Full design philosophy in [`docs/design.md`](docs/design.md).
345
351
  ## Documentation
346
352
 
347
353
  - [`docs/product-intent.md`](docs/product-intent.md) — Why this exists, who it serves, what it refuses to become
354
+ - [`docs/decisions.md`](docs/decisions.md) — Decision register: rulings the code alone cannot answer
348
355
  - [`docs/design.md`](docs/design.md) — Architecture + non-goals + scanner protocol
349
356
  - [`docs/architecture.md`](docs/architecture.md) — Audit of the system as built + remediation sequence
350
357
  - [`docs/release-blockers.md`](docs/release-blockers.md) — Open v0.3.0 release blockers (do not tag until closed)
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "secure-code-agent"
7
- version = "0.3.0"
7
+ version = "0.4.0"
8
8
  description = "Deterministic security gate + bounded AI remediation prompt generator. NIST SSDF / OWASP ASVS / CWE Top 25 anchored."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -47,12 +47,18 @@ dependencies = [
47
47
  required-scanners = [
48
48
  "bandit==1.9.4",
49
49
  "pip-audit==2.10.1",
50
+ # njsscan is in the floor (D12) and is a Python package carrying its own
51
+ # rules, so a JavaScript repository can be scanned on a host with no Node.
52
+ # Pinned for the same reason as the rest: the floor is a claim about what
53
+ # ran, and an unpinned ruleset makes that claim unreproducible.
54
+ "njsscan==1.0.0",
50
55
  ]
51
56
  # The remaining Python-packaged scanners. Bounded rather than pinned because
52
57
  # they are optional; the coverage gate does not require them by default.
53
- # Scanners distributed as standalone binaries (gitleaks, hadolint, npm,
54
- # osv-scanner, scorecard, trivy, trufflehog) cannot be installed from PyPI —
55
- # see docs/scanners.md for their install commands and the SARIF-import route.
58
+ # Scanners distributed as standalone binaries or through another language's
59
+ # package manager (gitleaks, gosec, hadolint, npm, osv-scanner, rubocop,
60
+ # scorecard, trivy, trufflehog) cannot be installed from PyPI see
61
+ # docs/scanners.md for their install commands and the SARIF-import route.
56
62
  python-scanners = [
57
63
  "secure-code-agent[required-scanners]",
58
64
  "semgrep>=1.172,<2",
@@ -92,7 +98,12 @@ target-version = "py310"
92
98
  # Ruff 0.16 formats Python blocks embedded in Markdown. Our docs use those
93
99
  # blocks illustratively — excerpted statements, aligned-comment schemas — and
94
100
  # reformatting them damages the documentation without improving any code.
95
- extend-exclude = ["**/*.md"]
101
+ extend-exclude = [
102
+ "**/*.md",
103
+ # Scanner fixtures are deliberately vulnerable sample code. Linting them
104
+ # would demand we fix the very patterns the rules exist to detect.
105
+ "tests/fixtures/**",
106
+ ]
96
107
 
97
108
  [tool.ruff.lint]
98
109
  select = ["E", "F", "I", "B", "UP", "SIM", "C4"]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: secure-code-agent
3
- Version: 0.3.0
3
+ Version: 0.4.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
@@ -32,6 +32,7 @@ Requires-Dist: PyYAML<7,>=6.0.2
32
32
  Provides-Extra: required-scanners
33
33
  Requires-Dist: bandit==1.9.4; extra == "required-scanners"
34
34
  Requires-Dist: pip-audit==2.10.1; extra == "required-scanners"
35
+ Requires-Dist: njsscan==1.0.0; extra == "required-scanners"
35
36
  Provides-Extra: python-scanners
36
37
  Requires-Dist: secure-code-agent[required-scanners]; extra == "python-scanners"
37
38
  Requires-Dist: semgrep<2,>=1.172; extra == "python-scanners"
@@ -173,7 +174,9 @@ collapsed before scoring; reports preserve the original scanner evidence.
173
174
  ├── Hadolint (Dockerfile lint)
174
175
  ├── OSV-Scanner (multi-ecosystem SCA via osv.dev)
175
176
  ├── OpenSSF Scorecard (repo hygiene + supply chain)
176
- ├── eslint-plugin-security (JS/TS SAST) [v0.3]
177
+ ├── njsscan (JS/TS SAST, offline, no Node needed)
178
+ ├── RuboCop (Ruby security cops, --only Security)
179
+ ├── gosec (Go SAST — opt-in; needs the Go toolchain)
177
180
  ├── CodeQL SARIF (ingest GitHub-hosted analysis)
178
181
  └── Built-in regex rules (high-confidence, low-FP)
179
182
  ```
@@ -287,6 +290,10 @@ for repositories whose `pyproject.toml` is their authoritative audit input.
287
290
  reason: "Slated for rewrite Q3 2026 — gated by initiative INV-44."
288
291
  expires: "2026-09-30"
289
292
 
293
+ fingerprint: 0aaa689f8a967d8c # optional: pin to ONE finding (16 hex, from the report)
294
+
295
+ line: 18 # optional: with fingerprint, pins the exact location
296
+
290
297
  - rule_id: "B101"
291
298
  paths: ["tests/"]
292
299
  reason: "assert statements are legitimate in test code."
@@ -390,6 +397,7 @@ Full design philosophy in [`docs/design.md`](docs/design.md).
390
397
  ## Documentation
391
398
 
392
399
  - [`docs/product-intent.md`](docs/product-intent.md) — Why this exists, who it serves, what it refuses to become
400
+ - [`docs/decisions.md`](docs/decisions.md) — Decision register: rulings the code alone cannot answer
393
401
  - [`docs/design.md`](docs/design.md) — Architecture + non-goals + scanner protocol
394
402
  - [`docs/architecture.md`](docs/architecture.md) — Audit of the system as built + remediation sequence
395
403
  - [`docs/release-blockers.md`](docs/release-blockers.md) — Open v0.3.0 release blockers (do not tag until closed)
@@ -16,21 +16,27 @@ src/secure_code_audit/git_tools.py
16
16
  src/secure_code_audit/instructions.py
17
17
  src/secure_code_audit/remediation.py
18
18
  src/secure_code_audit/renderers.py
19
+ src/secure_code_audit/ruleset.py
19
20
  src/secure_code_audit/sarif.py
20
21
  src/secure_code_audit/scanner_status.py
21
22
  src/secure_code_audit/scoring.py
22
23
  src/secure_code_audit/standards.py
23
24
  src/secure_code_audit/suppressions.py
25
+ src/secure_code_audit/data/semgrep-offline.yaml
24
26
  src/secure_code_audit/scanners/__init__.py
25
27
  src/secure_code_audit/scanners/bandit_scanner.py
26
28
  src/secure_code_audit/scanners/base.py
27
29
  src/secure_code_audit/scanners/builtin_rules.py
28
30
  src/secure_code_audit/scanners/checkov_scanner.py
31
+ src/secure_code_audit/scanners/floor.py
29
32
  src/secure_code_audit/scanners/gitleaks_scanner.py
33
+ src/secure_code_audit/scanners/gosec_scanner.py
30
34
  src/secure_code_audit/scanners/hadolint_scanner.py
35
+ src/secure_code_audit/scanners/njsscan_scanner.py
31
36
  src/secure_code_audit/scanners/npm_audit_scanner.py
32
37
  src/secure_code_audit/scanners/osv_scanner.py
33
38
  src/secure_code_audit/scanners/pip_audit_scanner.py
39
+ src/secure_code_audit/scanners/rubocop_scanner.py
34
40
  src/secure_code_audit/scanners/scorecard_scanner.py
35
41
  src/secure_code_audit/scanners/semgrep_scanner.py
36
42
  src/secure_code_audit/scanners/trivy_scanner.py
@@ -14,3 +14,4 @@ checkov<4,>=3.3
14
14
  [required-scanners]
15
15
  bandit==1.9.4
16
16
  pip-audit==2.10.1
17
+ njsscan==1.0.0
@@ -25,15 +25,17 @@ from secure_code_audit import (
25
25
  from secure_code_audit import baseline as baseline_mod
26
26
  from secure_code_audit import config as config_mod
27
27
  from secure_code_audit.findings import Category, Finding, Severity
28
- from secure_code_audit.git_tools import find_repo_root, loc_under
28
+ from secure_code_audit.git_tools import find_repo_root, is_excluded, loc_under
29
29
  from secure_code_audit.scanner_status import (
30
30
  ScannerExecution,
31
31
  ScannerOutcome,
32
- classify_execution,
33
32
  evaluate_coverage,
33
+ execution_from_result,
34
34
  )
35
- from secure_code_audit.scoring import evaluate_gates
35
+ from secure_code_audit.scanners import floor
36
+ from secure_code_audit.scoring import active_gates, evaluate_gates
36
37
  from secure_code_audit.scoring import score as score_findings
38
+ from secure_code_audit.scoring import verdict as build_verdict
37
39
 
38
40
 
39
41
  def _parser() -> argparse.ArgumentParser:
@@ -90,6 +92,16 @@ def _parser() -> argparse.ArgumentParser:
90
92
  ),
91
93
  )
92
94
 
95
+ p.add_argument(
96
+ "--trust-target-config",
97
+ action="store_true",
98
+ help=(
99
+ "Allow a config inside the audited tree to name executables from "
100
+ "that tree. Only for repositories you own. CLI-only by design: a "
101
+ "config file cannot grant itself this."
102
+ ),
103
+ )
104
+
93
105
  p.add_argument(
94
106
  "--preflight",
95
107
  action="store_true",
@@ -156,7 +168,12 @@ def _do_preflight(args: argparse.Namespace) -> int:
156
168
  cached from this command.
157
169
  """
158
170
  cfg, target, _ = _prepare_audit(args)
159
- required = set(cfg.gates.get("require_scanners", []))
171
+ inventory = _repository_inventory(target, cfg)
172
+ required = {
173
+ name
174
+ for name in floor.expand_required(cfg.gates.get("require_scanners", []))
175
+ if floor.applies_to_repository(name, *inventory)
176
+ }
160
177
  selected = _selected_scanners(args, cfg)
161
178
 
162
179
  rows: list[dict] = []
@@ -209,6 +226,10 @@ def _print_preflight(rows: list[dict], unselected: list[str], blocking: list[str
209
226
  print(f" → {row['remedy']}")
210
227
  for name in unselected:
211
228
  print(f" ✗ {name:<14} required not enabled in this configuration")
229
+ # Named, not omitted: a floor tool this run does not evaluate is a stated
230
+ # scope decision, and silence would read as a pass.
231
+ for name in floor.REPOSITORY_CADENCE_NAMES:
232
+ print(f" · {name:<14} deferred repository cadence; supplied by SARIF import (D3)")
212
233
  if blocking:
213
234
  print(f" required scanners unavailable: {', '.join(blocking)}")
214
235
  else:
@@ -217,6 +238,7 @@ def _print_preflight(rows: list[dict], unselected: list[str], blocking: list[str
217
238
 
218
239
  def _do_audit(args: argparse.Namespace) -> int:
219
240
  cfg, target, root = _prepare_audit(args)
241
+ _require_configured_gates(args, cfg)
220
242
 
221
243
  # ----- scanners -----
222
244
  scan = _run_scanners(args, cfg, target)
@@ -229,6 +251,9 @@ def _do_audit(args: argparse.Namespace) -> int:
229
251
  all_findings.extend(imported)
230
252
  executions.extend(imported_executions)
231
253
 
254
+ # ----- scan scope, enforced once -----
255
+ all_findings = _drop_excluded(all_findings, target, cfg)
256
+
232
257
  # ----- overrides from config -----
233
258
  all_findings = _apply_overrides(all_findings, cfg)
234
259
 
@@ -272,27 +297,37 @@ def _do_audit(args: argparse.Namespace) -> int:
272
297
  score = score_findings(all_findings, loc)
273
298
  # Naming an import on the command line asserts that it contributes coverage,
274
299
  # so a broken one fails the gate even if no config requires that scanner.
300
+ # Requiring a tool that has nothing to look at would make every
301
+ # single-language repository permanently incomplete, and an alarm that is
302
+ # always on is not an alarm. Applicability is decided from what the tree
303
+ # actually contains, before the requirement is asserted.
304
+ inventory = _repository_inventory(target, cfg)
275
305
  required = [
276
- *cfg.gates.get("require_scanners", []),
306
+ *(
307
+ name
308
+ for name in floor.expand_required(cfg.gates.get("require_scanners", []))
309
+ if floor.applies_to_repository(name, *inventory)
310
+ ),
277
311
  *(execution.name for execution in imported_executions),
278
312
  ]
279
313
  coverage = evaluate_coverage(executions, required)
280
314
  gate = evaluate_gates(all_findings, score, cfg.gates, coverage)
315
+ verdict = build_verdict(score, cfg.gates, coverage)
281
316
 
282
317
  # ----- write outputs -----
283
318
  paths = _resolve_outputs(args, cfg, root)
284
- _write_outputs(paths, all_findings, score, gate, coverage, ran, unavailable)
319
+ _write_outputs(paths, all_findings, score, gate, coverage, ran, unavailable, verdict)
285
320
  if args.bump_baseline:
286
321
  baseline_mod.write(baseline_path, all_findings, baseline)
287
322
 
288
323
  # ----- terminal output -----
289
324
  if args.json:
290
325
  sys.stdout.write(
291
- json.dumps(renderers.to_json(all_findings, score, gate, coverage), indent=2)
326
+ json.dumps(renderers.to_json(all_findings, score, gate, coverage, verdict), indent=2)
292
327
  )
293
328
  sys.stdout.write("\n")
294
329
  else:
295
- _print_summary(score, gate, ran, unavailable, coverage, paths)
330
+ _print_summary(verdict, score, gate, ran, unavailable, coverage, paths)
296
331
 
297
332
  return _exit_code(args, gate, all_findings)
298
333
 
@@ -301,18 +336,42 @@ def _prepare_audit(
301
336
  args: argparse.Namespace,
302
337
  ) -> tuple[config_mod.Config, Path, Path]:
303
338
  """Load config and resolve the single scan root, or refuse."""
304
- cfg = config_mod.load(args.config)
305
- _validate_scanner_config(cfg)
306
339
  if args.changed_only:
307
340
  raise ValueError(
308
341
  "--changed-only is not implemented safely; refusing to claim a scoped audit"
309
342
  )
310
343
  if len(args.paths) > 1:
311
344
  raise ValueError("multiple scan roots are not supported; provide one repository root")
345
+ # The target is resolved first because the default config belongs to it.
312
346
  target = Path(args.paths[0]).resolve()
347
+ cfg = config_mod.load(args.config, default_root=target)
348
+ # Set from the command line only. Threading it through the loaded config
349
+ # would let a repository-supplied file assert its own trustworthiness.
350
+ cfg.trust_target_config = bool(getattr(args, "trust_target_config", False))
351
+ _validate_scanner_config(cfg)
313
352
  return cfg, target, find_repo_root(target)
314
353
 
315
354
 
355
+ def _require_configured_gates(args: argparse.Namespace, cfg: config_mod.Config) -> None:
356
+ """Refuse --fail-on-gate when no gate could ever fail.
357
+
358
+ Without this, an audit that detects a HIGH finding, scores it 0.00/F, and
359
+ reports it in full still exits 0, because every gate is absent and an
360
+ absent gate does not trip. That is a green build with no security floor,
361
+ and it is the shape a default Action adoption takes.
362
+ """
363
+ if not args.fail_on_gate or active_gates(cfg.gates):
364
+ return
365
+ raise ValueError(
366
+ "--fail-on-gate was requested but no gate is configured, so no finding "
367
+ "could ever fail the build. Configure at least one of "
368
+ "gates.fail_on_severity, gates.fail_on_category, gates.fail_on_new, "
369
+ "gates.min_score (above 0), gates.require_scanners, or "
370
+ "gates.max_unsuppressed — see secure-code-agent.example.json. Drop "
371
+ "--fail-on-gate to run an ungated, report-only audit."
372
+ )
373
+
374
+
316
375
  def _exit_code(args: argparse.Namespace, gate, findings: list[Finding]) -> int:
317
376
  if args.fail_on_gate and not gate.passed:
318
377
  return 1
@@ -336,6 +395,38 @@ class _ScanResult:
336
395
  executions: list[ScannerExecution]
337
396
 
338
397
 
398
+ def _repository_inventory(target: Path, cfg: config_mod.Config) -> tuple[set[str], set[str]]:
399
+ """Extensions and notable filenames present in the tree, for applicability.
400
+
401
+ Bounded by the same exclusions the scan uses, so a vendored manifest does
402
+ not make a whole ecosystem look present.
403
+ """
404
+ from secure_code_audit.git_tools import is_excluded
405
+
406
+ extensions: set[str] = set()
407
+ filenames: set[str] = set()
408
+ for path in target.rglob("*"):
409
+ if not path.is_file() or is_excluded(path, target, cfg.exclude_patterns):
410
+ continue
411
+ extensions.add(path.suffix)
412
+ filenames.add(path.name)
413
+ return extensions, filenames
414
+
415
+
416
+ def _scanner_enabled(cfg: config_mod.Config, name: str) -> bool:
417
+ """Whether this scanner runs, with the floor supplying the default.
418
+
419
+ A configuration that says nothing about a scanner gets the project's
420
+ declared opinion: floor tools run, opt-in tools do not. Previously every
421
+ registered scanner defaulted to enabled, which quietly turned overlapping
422
+ and copyleft tools on for operators who never chose them.
423
+ """
424
+ configured = cfg.scanners.get(name)
425
+ # `cfg.scanners` only holds scanners the configuration actually named, so
426
+ # presence here is the operator having an opinion.
427
+ return configured.enabled if configured is not None else floor.default_enabled(name)
428
+
429
+
339
430
  def _selected_scanners(args: argparse.Namespace, cfg: config_mod.Config) -> list[str]:
340
431
  """Names the operator actually asked for, in registry order."""
341
432
  skip = set(filter(None, (args.skip_scanners or "").split(",")))
@@ -343,30 +434,63 @@ def _selected_scanners(args: argparse.Namespace, cfg: config_mod.Config) -> list
343
434
  return [
344
435
  name
345
436
  for name in scanners.SCANNERS
346
- if not (only and name not in only)
347
- and name not in skip
348
- and (cfg.scanners.get(name) or config_mod.ScannerConfig()).enabled
437
+ if not (only and name not in only) and name not in skip and _scanner_enabled(cfg, name)
349
438
  ]
350
439
 
351
440
 
441
+ def _drop_excluded(findings: list[Finding], target: Path, cfg: config_mod.Config) -> list[Finding]:
442
+ """Enforce `paths.exclude_patterns` on findings, not just on file discovery.
443
+
444
+ Five of fifteen adapters push the exclusion down to their tool; the rest
445
+ have no flag for it, or read it from a config file in the audited tree that
446
+ D1 forbids us honouring. So the setting was true for Bandit and a polite
447
+ fiction for Checkov, RuboCop, Trivy, Semgrep and the others.
448
+
449
+ That is worse than cosmetic, because `exclude_patterns` is *also* the
450
+ denominator: `loc_under()` counts only non-excluded files while the
451
+ findings counted against them came from everywhere. A repository excluding
452
+ its vendored tree was scored on vendored findings over first-party lines —
453
+ the numerator and denominator measuring different repositories.
454
+
455
+ Pushing the exclusion into each adapter is still worth doing for speed and
456
+ for smaller tool output. Correctness is enforced here, once, where every
457
+ finding passes regardless of which adapter or SARIF import produced it.
458
+
459
+ Control findings are exempt: they carry the scan root as their path, and
460
+ dropping "bandit could not run" because the root matched a pattern would
461
+ turn a failed scanner back into a silent one — the defect this whole
462
+ project exists to prevent.
463
+ """
464
+ if not cfg.exclude_patterns:
465
+ return findings
466
+
467
+ root = target if target.is_dir() else target.parent
468
+ kept: list[Finding] = []
469
+ for finding in findings:
470
+ path = finding.file_path
471
+ is_control = path in (target, root)
472
+ if not is_control and is_excluded(path, root, cfg.exclude_patterns):
473
+ continue
474
+ kept.append(finding)
475
+ return kept
476
+
477
+
352
478
  def _run_scanners(args: argparse.Namespace, cfg: config_mod.Config, target: Path) -> _ScanResult:
353
479
  result = _ScanResult(findings=[], ran=[], unavailable=[], executions=[])
354
480
  for name in _selected_scanners(args, cfg):
355
- sc_cfg = cfg.scanners.get(name) or config_mod.ScannerConfig()
356
481
  scanner = scanners.SCANNERS[name]()
357
482
  scanner.configure(target, cfg)
358
483
  available = scanner.is_available()
359
484
  if not available:
360
485
  result.unavailable.append(name)
361
486
  version = scanner.binary_version() if available else None
362
- findings = scanner.run(target, cfg)
363
- result.findings.extend(findings)
364
- execution = classify_execution(
487
+ scan = scanner.scan(target, cfg)
488
+ result.findings.extend(scan.findings)
489
+ execution = execution_from_result(
365
490
  name,
366
- findings,
491
+ scan,
367
492
  command=scanner.command if available else (),
368
493
  version=version,
369
- scope=_scanner_scope(name, sc_cfg),
370
494
  )
371
495
  result.executions.append(execution)
372
496
  if execution.outcome is ScannerOutcome.COMPLETED:
@@ -399,15 +523,17 @@ def _ingest_sarif_imports(specs: list[str]) -> tuple[list[Finding], list[Scanner
399
523
  return findings, executions
400
524
 
401
525
 
402
- def _write_outputs(paths, findings, score, gate, coverage, ran, unavailable) -> None:
526
+ def _write_outputs(paths, findings, score, gate, coverage, ran, unavailable, verdict) -> None:
403
527
  if paths.markdown is not None:
404
- renderers.write_markdown(findings, score, gate, paths.markdown, ran, unavailable, coverage)
528
+ renderers.write_markdown(
529
+ findings, score, gate, paths.markdown, ran, unavailable, coverage, verdict
530
+ )
405
531
  if paths.json_out is not None:
406
- renderers.write_json(findings, score, gate, paths.json_out, coverage)
532
+ renderers.write_json(findings, score, gate, paths.json_out, coverage, verdict)
407
533
  if paths.sarif is not None:
408
534
  sarif.write(findings, paths.sarif, coverage)
409
535
  if paths.comment is not None:
410
- renderers.write_pr_comment(findings, score, gate, paths.comment, coverage)
536
+ renderers.write_pr_comment(findings, score, gate, paths.comment, coverage, verdict)
411
537
  if paths.prompt is not None:
412
538
  remediation.write(findings, paths.prompt)
413
539
 
@@ -462,18 +588,19 @@ def _under_root(root: Path, value: str) -> Path:
462
588
  return path.resolve() if path.is_absolute() else (root / path).resolve()
463
589
 
464
590
 
465
- def _print_summary(score, gate, ran, unavailable, coverage, paths) -> None:
591
+ def _print_summary(verdict, score, gate, ran, unavailable, coverage, paths) -> None:
466
592
  status = "PASS" if gate.passed else "FAIL"
467
- score_label = "score"
468
- if coverage.status.value != "complete":
469
- score_label = "finding score (coverage incomplete)"
470
- print(
471
- f"secure-code-agent · {score_label} {score.overall:.2f} ({score.letter}) "
472
- f"· gate {status}"
473
- )
593
+ print(f"secure-code-agent · score {verdict.headline()} · gate {status}")
594
+ for reason in verdict.reasons:
595
+ print(f" ! grade withheld: {reason}")
474
596
  print(f" scanned LOC: {score.loc_scanned:,}")
475
597
  print(f" scanners run: {', '.join(ran) if ran else '(none)'}")
476
- print(f" coverage: {coverage.status.value.upper()}")
598
+ coverage_line = f" coverage: {coverage.status.value.upper()}"
599
+ if coverage.unverified:
600
+ coverage_line += (
601
+ f" ({len(coverage.unverified)} unverified: {', '.join(coverage.unverified)})"
602
+ )
603
+ print(coverage_line)
477
604
  if unavailable:
478
605
  print(f" unavailable: {', '.join(unavailable)}")
479
606
  if not gate.passed:
@@ -493,23 +620,13 @@ def _validate_scanner_config(cfg: config_mod.Config) -> None:
493
620
  unknown = sorted(set(cfg.scanners) - known)
494
621
  if unknown:
495
622
  raise ValueError(f"unknown scanner configuration: {', '.join(unknown)}")
496
- required = set(cfg.gates.get("require_scanners", []))
623
+ # Expand the `floor` token before validating, so requiring the declared
624
+ # minimum does not read as a typo'd scanner name.
625
+ required = set(floor.expand_required(cfg.gates.get("require_scanners", [])))
497
626
  unknown_required = sorted(required - known)
498
627
  if unknown_required:
499
628
  raise ValueError(f"unknown required scanner: {', '.join(unknown_required)}")
500
629
 
501
630
 
502
- def _scanner_scope(name: str, cfg: config_mod.ScannerConfig) -> str | None:
503
- """Describe configured audit scope without changing scanner semantics."""
504
- if name != "pip_audit":
505
- return None
506
- parts = [f"mode={cfg.mode}"]
507
- if cfg.inputs:
508
- parts.append(f"inputs={','.join(cfg.inputs)}")
509
- if cfg.extra_args:
510
- parts.append(f"extra_args={' '.join(cfg.extra_args)}")
511
- return "; ".join(parts)
512
-
513
-
514
631
  if __name__ == "__main__":
515
632
  sys.exit(main())