secure-code-agent 0.12.11__tar.gz → 0.13.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 (58) hide show
  1. {secure_code_agent-0.12.11/src/secure_code_agent.egg-info → secure_code_agent-0.13.0}/PKG-INFO +3 -3
  2. {secure_code_agent-0.12.11 → secure_code_agent-0.13.0}/README.md +2 -2
  3. {secure_code_agent-0.12.11 → secure_code_agent-0.13.0/src/secure_code_agent.egg-info}/PKG-INFO +3 -3
  4. {secure_code_agent-0.12.11 → secure_code_agent-0.13.0}/src/secure_code_agent.egg-info/SOURCES.txt +1 -0
  5. {secure_code_agent-0.12.11 → secure_code_agent-0.13.0}/src/secure_code_audit/__init__.py +1 -1
  6. {secure_code_agent-0.12.11 → secure_code_agent-0.13.0}/src/secure_code_audit/cli.py +301 -125
  7. {secure_code_agent-0.12.11 → secure_code_agent-0.13.0}/src/secure_code_audit/config.py +88 -49
  8. {secure_code_agent-0.12.11 → secure_code_agent-0.13.0}/src/secure_code_audit/findings.py +22 -0
  9. {secure_code_agent-0.12.11 → secure_code_agent-0.13.0}/src/secure_code_audit/git_tools.py +41 -0
  10. {secure_code_agent-0.12.11 → secure_code_agent-0.13.0}/src/secure_code_audit/practice.py +81 -42
  11. {secure_code_agent-0.12.11 → secure_code_agent-0.13.0}/src/secure_code_audit/remediation.py +1 -1
  12. {secure_code_agent-0.12.11 → secure_code_agent-0.13.0}/src/secure_code_audit/renderers.py +20 -1
  13. {secure_code_agent-0.12.11 → secure_code_agent-0.13.0}/src/secure_code_audit/scanner_status.py +134 -0
  14. secure_code_agent-0.13.0/src/secure_code_audit/scanners/_exclusions.py +94 -0
  15. {secure_code_agent-0.12.11 → secure_code_agent-0.13.0}/src/secure_code_audit/scanners/_finding_builder.py +32 -0
  16. {secure_code_agent-0.12.11 → secure_code_agent-0.13.0}/src/secure_code_audit/scanners/bandit_scanner.py +27 -19
  17. {secure_code_agent-0.12.11 → secure_code_agent-0.13.0}/src/secure_code_audit/scanners/base.py +26 -0
  18. {secure_code_agent-0.12.11 → secure_code_agent-0.13.0}/src/secure_code_audit/scanners/builtin_rules.py +3 -0
  19. {secure_code_agent-0.12.11 → secure_code_agent-0.13.0}/src/secure_code_audit/scanners/checkov_scanner.py +19 -0
  20. {secure_code_agent-0.12.11 → secure_code_agent-0.13.0}/src/secure_code_audit/scanners/gitleaks_scanner.py +7 -0
  21. {secure_code_agent-0.12.11 → secure_code_agent-0.13.0}/src/secure_code_audit/scanners/gosec_scanner.py +7 -0
  22. {secure_code_agent-0.12.11 → secure_code_agent-0.13.0}/src/secure_code_audit/scanners/hadolint_scanner.py +3 -0
  23. {secure_code_agent-0.12.11 → secure_code_agent-0.13.0}/src/secure_code_audit/scanners/njsscan_scanner.py +6 -0
  24. {secure_code_agent-0.12.11 → secure_code_agent-0.13.0}/src/secure_code_audit/scanners/npm_audit_scanner.py +14 -21
  25. {secure_code_agent-0.12.11 → secure_code_agent-0.13.0}/src/secure_code_audit/scanners/osv_scanner.py +13 -11
  26. {secure_code_agent-0.12.11 → secure_code_agent-0.13.0}/src/secure_code_audit/scanners/pip_audit_scanner.py +23 -18
  27. {secure_code_agent-0.12.11 → secure_code_agent-0.13.0}/src/secure_code_audit/scanners/rubocop_scanner.py +6 -0
  28. {secure_code_agent-0.12.11 → secure_code_agent-0.13.0}/src/secure_code_audit/scanners/scorecard_scanner.py +6 -0
  29. {secure_code_agent-0.12.11 → secure_code_agent-0.13.0}/src/secure_code_audit/scanners/semgrep_scanner.py +87 -51
  30. {secure_code_agent-0.12.11 → secure_code_agent-0.13.0}/src/secure_code_audit/scanners/trivy_scanner.py +45 -1
  31. {secure_code_agent-0.12.11 → secure_code_agent-0.13.0}/src/secure_code_audit/scanners/trufflehog_scanner.py +6 -0
  32. {secure_code_agent-0.12.11 → secure_code_agent-0.13.0}/src/secure_code_audit/scoring.py +115 -26
  33. {secure_code_agent-0.12.11 → secure_code_agent-0.13.0}/src/secure_code_audit/suppressions.py +63 -39
  34. {secure_code_agent-0.12.11 → secure_code_agent-0.13.0}/src/secure_code_audit/verify.py +20 -1
  35. {secure_code_agent-0.12.11 → secure_code_agent-0.13.0}/LICENSE +0 -0
  36. {secure_code_agent-0.12.11 → secure_code_agent-0.13.0}/pyproject.toml +0 -0
  37. {secure_code_agent-0.12.11 → secure_code_agent-0.13.0}/setup.cfg +0 -0
  38. {secure_code_agent-0.12.11 → secure_code_agent-0.13.0}/src/secure_code_agent.egg-info/dependency_links.txt +0 -0
  39. {secure_code_agent-0.12.11 → secure_code_agent-0.13.0}/src/secure_code_agent.egg-info/entry_points.txt +0 -0
  40. {secure_code_agent-0.12.11 → secure_code_agent-0.13.0}/src/secure_code_agent.egg-info/requires.txt +0 -0
  41. {secure_code_agent-0.12.11 → secure_code_agent-0.13.0}/src/secure_code_agent.egg-info/top_level.txt +0 -0
  42. {secure_code_agent-0.12.11 → secure_code_agent-0.13.0}/src/secure_code_audit/baseline.py +0 -0
  43. {secure_code_agent-0.12.11 → secure_code_agent-0.13.0}/src/secure_code_audit/capabilities.py +0 -0
  44. {secure_code_agent-0.12.11 → secure_code_agent-0.13.0}/src/secure_code_audit/data/semgrep-offline.yaml +0 -0
  45. {secure_code_agent-0.12.11 → secure_code_agent-0.13.0}/src/secure_code_audit/data/standards.yaml +0 -0
  46. {secure_code_agent-0.12.11 → secure_code_agent-0.13.0}/src/secure_code_audit/demo.py +0 -0
  47. {secure_code_agent-0.12.11 → secure_code_agent-0.13.0}/src/secure_code_audit/history.py +0 -0
  48. {secure_code_agent-0.12.11 → secure_code_agent-0.13.0}/src/secure_code_audit/instructions.py +0 -0
  49. {secure_code_agent-0.12.11 → secure_code_agent-0.13.0}/src/secure_code_audit/mcp_server.py +0 -0
  50. {secure_code_agent-0.12.11 → secure_code_agent-0.13.0}/src/secure_code_audit/pillar.py +0 -0
  51. {secure_code_agent-0.12.11 → secure_code_agent-0.13.0}/src/secure_code_audit/ruleset.py +0 -0
  52. {secure_code_agent-0.12.11 → secure_code_agent-0.13.0}/src/secure_code_audit/sarif.py +0 -0
  53. {secure_code_agent-0.12.11 → secure_code_agent-0.13.0}/src/secure_code_audit/scanners/__init__.py +0 -0
  54. {secure_code_agent-0.12.11 → secure_code_agent-0.13.0}/src/secure_code_audit/scanners/_execution.py +0 -0
  55. {secure_code_agent-0.12.11 → secure_code_agent-0.13.0}/src/secure_code_audit/scanners/_resolution.py +0 -0
  56. {secure_code_agent-0.12.11 → secure_code_agent-0.13.0}/src/secure_code_audit/scanners/floor.py +0 -0
  57. {secure_code_agent-0.12.11 → secure_code_agent-0.13.0}/src/secure_code_audit/standards.py +0 -0
  58. {secure_code_agent-0.12.11 → secure_code_agent-0.13.0}/src/secure_code_audit/triage.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: secure-code-agent
3
- Version: 0.12.11
3
+ Version: 0.13.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
@@ -185,7 +185,7 @@ This is a constrained task, not a refactor.
185
185
  unless a finding explicitly proves the current behavior is unsafe.
186
186
  9. Add a focused test that exercises the specific security boundary
187
187
  you fixed. The test must FAIL on the pre-fix code and PASS on
188
- the post-fix code. No "TODO: add test later".
188
+ the post-fix code. Do not defer the test to a later change.
189
189
  10. Keep the patch small. If you find yourself rewriting a function
190
190
  rather than patching it, stop and report the structural issue.
191
191
 
@@ -554,7 +554,7 @@ For agents that support invokable skills, this repo ships a portable skill under
554
554
  ## GitHub Action
555
555
 
556
556
  ```yaml
557
- - uses: marshallguillory86/secure-code-agent@v0.12.11
557
+ - uses: marshallguillory86/secure-code-agent@v0.13.0
558
558
  with:
559
559
  config: secure-code-agent.json
560
560
  fail-on-gate: true
@@ -136,7 +136,7 @@ This is a constrained task, not a refactor.
136
136
  unless a finding explicitly proves the current behavior is unsafe.
137
137
  9. Add a focused test that exercises the specific security boundary
138
138
  you fixed. The test must FAIL on the pre-fix code and PASS on
139
- the post-fix code. No "TODO: add test later".
139
+ the post-fix code. Do not defer the test to a later change.
140
140
  10. Keep the patch small. If you find yourself rewriting a function
141
141
  rather than patching it, stop and report the structural issue.
142
142
 
@@ -505,7 +505,7 @@ For agents that support invokable skills, this repo ships a portable skill under
505
505
  ## GitHub Action
506
506
 
507
507
  ```yaml
508
- - uses: marshallguillory86/secure-code-agent@v0.12.11
508
+ - uses: marshallguillory86/secure-code-agent@v0.13.0
509
509
  with:
510
510
  config: secure-code-agent.json
511
511
  fail-on-gate: true
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: secure-code-agent
3
- Version: 0.12.11
3
+ Version: 0.13.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
@@ -185,7 +185,7 @@ This is a constrained task, not a refactor.
185
185
  unless a finding explicitly proves the current behavior is unsafe.
186
186
  9. Add a focused test that exercises the specific security boundary
187
187
  you fixed. The test must FAIL on the pre-fix code and PASS on
188
- the post-fix code. No "TODO: add test later".
188
+ the post-fix code. Do not defer the test to a later change.
189
189
  10. Keep the patch small. If you find yourself rewriting a function
190
190
  rather than patching it, stop and report the structural issue.
191
191
 
@@ -554,7 +554,7 @@ For agents that support invokable skills, this repo ships a portable skill under
554
554
  ## GitHub Action
555
555
 
556
556
  ```yaml
557
- - uses: marshallguillory86/secure-code-agent@v0.12.11
557
+ - uses: marshallguillory86/secure-code-agent@v0.13.0
558
558
  with:
559
559
  config: secure-code-agent.json
560
560
  fail-on-gate: true
@@ -33,6 +33,7 @@ src/secure_code_audit/verify.py
33
33
  src/secure_code_audit/data/semgrep-offline.yaml
34
34
  src/secure_code_audit/data/standards.yaml
35
35
  src/secure_code_audit/scanners/__init__.py
36
+ src/secure_code_audit/scanners/_exclusions.py
36
37
  src/secure_code_audit/scanners/_execution.py
37
38
  src/secure_code_audit/scanners/_finding_builder.py
38
39
  src/secure_code_audit/scanners/_resolution.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.11"
15
+ __version__ = "0.13.0"
@@ -10,7 +10,7 @@ import argparse
10
10
  import contextlib
11
11
  import json
12
12
  import sys
13
- from dataclasses import dataclass, replace
13
+ from dataclasses import MISSING, dataclass, fields, replace
14
14
  from pathlib import Path
15
15
 
16
16
  from secure_code_audit import (
@@ -44,13 +44,20 @@ from secure_code_audit.findings import (
44
44
  anchor,
45
45
  merge_corroborating,
46
46
  )
47
- from secure_code_audit.git_tools import find_repo_root, is_excluded, is_test_path, loc_under
47
+ from secure_code_audit.git_tools import (
48
+ extensions_under,
49
+ find_repo_root,
50
+ is_excluded,
51
+ is_test_path,
52
+ loc_under,
53
+ )
48
54
  from secure_code_audit.scanner_status import (
49
55
  COVERING_OUTCOMES,
50
56
  ScannerExecution,
51
57
  ScannerOutcome,
52
58
  evaluate_coverage,
53
59
  execution_from_result,
60
+ languages_for,
54
61
  )
55
62
  from secure_code_audit.scanners import floor
56
63
  from secure_code_audit.scoring import (
@@ -493,104 +500,46 @@ def _split_axes(
493
500
  )
494
501
 
495
502
 
496
- def _do_audit(args: argparse.Namespace) -> int:
497
- cfg, target, root = _prepare_audit(args)
498
- # Before any consumer reads them: the audited tree does not grade itself.
499
- cfg = _refuse_target_policy(cfg, target)
500
- _require_configured_gates(args, cfg)
501
- if (failed := _install_standards_overlay(cfg, root)) is not None:
502
- return failed
503
- # Before anything is resolved or written, and before any scanner runs.
504
- _assert_config_writes_are_contained(cfg, root, target)
505
- # Resolved before the scan so the run can recognise its own artifacts.
506
- paths = _resolve_outputs(args, cfg, root)
507
-
508
- # ----- scanners -----
509
- scan = _run_scanners(args, cfg, target)
510
- all_findings = scan.findings
511
- ran, unavailable, executions = scan.ran, scan.unavailable, scan.executions
512
-
513
- # ----- SARIF imports -----
514
- # An imported SARIF is coverage: a scanner someone else ran on our behalf.
515
- imported, imported_executions = _ingest_sarif_imports(args.sarif_import)
516
- all_findings.extend(imported)
517
- executions.extend(imported_executions)
518
-
519
- # ----- one path convention, before anything reads a path -----
520
- # Repository-relative from here on, whichever tool reported the finding.
521
- # Suppressions, the baseline and every written output compare or publish
522
- # this path, and none of them may depend on where the checkout lives.
523
- scanned = target if target.is_dir() else target.parent
524
- all_findings = anchor(all_findings, scanned=scanned, root=root)
525
-
526
- # ----- scan scope, enforced once -----
527
- own_artifacts = _own_artifacts(paths, cfg, root)
528
- all_findings = _drop_excluded(all_findings, target, cfg, own_artifacts, root)
529
-
530
- # ----- one weakness, one finding -----
531
- # Before overrides and suppressions, so an operator writing either one
532
- # sees the same finding the report will show. Bandit's B308 and B703 are
533
- # the same check under two ids and shared fifty lines in Django; a report
534
- # that lists a line twice is wrong about the code, and a work order built
535
- # from it would ask for the same fix twice.
536
- all_findings = merge_corroborating(all_findings)
537
-
538
- # ----- overrides from config -----
539
- all_findings = _apply_overrides(all_findings, cfg)
503
+ @dataclass(frozen=True)
504
+ class _Scored:
505
+ """What the scoring phase produces, and nothing else.
540
506
 
541
- # ----- suppressions -----
542
- suppression_path = _under_root(root, cfg.suppressions_file)
543
- sup_rules, sup_errors = suppressions.load(suppression_path)
544
- if sup_errors:
545
- # Fail closed. A suppression file that exists is an explicit
546
- # instruction; ignoring it silently changes which findings are
547
- # reported, and "my suppressions are working" then looks exactly
548
- # like "my suppressions were skipped". This bit in practice: a
549
- # missing PyYAML made an entire .scignore.yaml a no-op while the
550
- # run still exited 0 and reported the suppressed finding as live.
551
- #
552
- # Only reachable when the file is present, so repositories that
553
- # do not use suppressions are unaffected.
554
- for err in sup_errors:
555
- sys.stderr.write(f"ERROR: {err}\n")
556
- sys.stderr.write(
557
- f"ERROR: {suppression_path} exists but could not be applied; refusing to "
558
- "report results that silently ignore it.\n"
559
- )
560
- return 1
561
- all_findings = suppressions.apply(all_findings, sup_rules, root)
562
- # Snapshot the scanner findings before the synthetic ones are appended.
563
- # `unused_findings` asks whether each rule still has a subject, and a
564
- # wildcard rule with `paths` would happily match the expired-suppression
565
- # finding about `.scignore.yaml` itself — reporting itself as used
566
- # because it matched a finding about suppressions.
567
- scanner_findings = list(all_findings)
568
- all_findings.extend(
569
- anchor(
570
- suppressions.expired_findings(sup_rules, suppression_path),
571
- scanned=scanned,
572
- root=root,
573
- )
574
- )
575
- all_findings.extend(
576
- anchor(
577
- suppressions.unused_findings(sup_rules, scanner_findings, suppression_path, root),
578
- scanned=scanned,
579
- root=root,
580
- )
581
- )
507
+ The phase assigns 31 names and only these 8 are read afterwards —
508
+ measured, not guessed. The other 23 were locals in a 120-line stretch of
509
+ `_do_audit`, which is what `architecture.md` §6 means by "a straight-line
510
+ function threading a dozen locals". Same shape as `_Axes`.
511
+ """
582
512
 
583
- # ----- severity threshold filter -----
584
- threshold = Severity.from_string(args.severity_threshold)
585
- all_findings = [f for f in all_findings if f.severity.rank >= threshold.rank]
513
+ all_findings: list[Finding]
514
+ axes: object
515
+ coverage: object
516
+ declarations: object
517
+ gate: object
518
+ score: object
519
+ undeclared_score: object
520
+ verdict: object
586
521
 
587
- # ----- baseline -----
588
- baseline_path = _under_root(root, args.baseline or cfg.outputs["baseline_path"])
589
- baseline = baseline_mod.load(baseline_path)
590
- baseline_state = baseline_mod.state(baseline_path)
591
- all_findings = baseline_mod.mark_new(all_findings, baseline, root)
592
522
 
593
- # ----- scoring -----
523
+ def _score_everything(
524
+ all_findings: list[Finding],
525
+ args: argparse.Namespace,
526
+ cfg: config_mod.Config,
527
+ target: Path,
528
+ root: Path,
529
+ own_artifacts: object,
530
+ executions: list,
531
+ imported_executions: list,
532
+ baseline_state: object,
533
+ ) -> _Scored:
534
+ """Split the axes, score, gate, and build the verdict.
535
+
536
+ A pure move out of `_do_audit`: the same calls in the same order, with
537
+ the comments that record *why* each split exists kept where the code is.
538
+ Ordering is load-bearing throughout — classification happens before
539
+ suppression upstream, and the axis split happens before scoring here,
540
+ because a finding routed to the test tree must not be scored as shipped
541
+ code.
542
+ """
594
543
  _axes = _split_axes(all_findings, args, cfg, target, root, own_artifacts)
595
544
  all_findings = _axes.all_findings
596
545
  primary_findings, test_findings, docs_findings = _axes.primary, _axes.test, _axes.docs
@@ -680,7 +629,27 @@ def _do_audit(args: argparse.Namespace) -> int:
680
629
  ),
681
630
  *(execution.name for execution in imported_executions),
682
631
  ]
683
- coverage = evaluate_coverage(executions, required)
632
+ # The languages the *scored* tree actually contains, so COMPLETE can mean
633
+ # "this tree was covered" rather than "every configured scanner ran".
634
+ # `gin` — 7,146 lines of Go — reported COMPLETE and `code_vulnerabilities:
635
+ # 5.0` while bandit, njsscan and rubocop found nothing in Go source and
636
+ # gosec was not in the run. P7's own falsification condition.
637
+ #
638
+ # Scoped exactly as the scoring denominator is, which is why it is
639
+ # `extensions_under` and not `_repository_inventory`: the broad inventory
640
+ # answers "is there any Go here at all" for applicability, and a Go fixture
641
+ # under `tests/` must not oblige a Python project to install gosec.
642
+ primary_languages = languages_for(
643
+ extensions_under(
644
+ target,
645
+ cfg.include_extensions,
646
+ cfg.exclude_patterns,
647
+ cfg.test_patterns,
648
+ own_artifacts,
649
+ cfg.docs_patterns,
650
+ )
651
+ )
652
+ coverage = evaluate_coverage(executions, required, primary_languages)
684
653
  # Gates see the dependency advisories; the score does not.
685
654
  # The gate needs to know *why* the baseline is empty to describe a first
686
655
  # run truthfully. Passed in the config dict rather than as a parameter so
@@ -710,6 +679,99 @@ def _do_audit(args: argparse.Namespace) -> int:
710
679
  reasons=(*verdict.reasons, changed_note),
711
680
  )
712
681
 
682
+ return _Scored(
683
+ all_findings=all_findings,
684
+ axes=axes,
685
+ coverage=coverage,
686
+ declarations=declarations,
687
+ gate=gate,
688
+ score=score,
689
+ undeclared_score=undeclared_score,
690
+ verdict=verdict,
691
+ )
692
+
693
+
694
+ def _do_audit(args: argparse.Namespace) -> int:
695
+ cfg, target, root = _prepare_audit(args)
696
+ # Before any consumer reads them: the audited tree does not grade itself.
697
+ cfg = _refuse_target_policy(cfg, target)
698
+ _require_configured_gates(args, cfg)
699
+ if (failed := _install_standards_overlay(cfg, root)) is not None:
700
+ return failed
701
+ # Before anything is resolved or written, and before any scanner runs.
702
+ _assert_config_writes_are_contained(cfg, root, target)
703
+ # Resolved before the scan so the run can recognise its own artifacts.
704
+ paths = _resolve_outputs(args, cfg, root)
705
+
706
+ # ----- scanners -----
707
+ scan = _run_scanners(args, cfg, target)
708
+ all_findings = scan.findings
709
+ ran, unavailable, executions = scan.ran, scan.unavailable, scan.executions
710
+
711
+ # ----- SARIF imports -----
712
+ # An imported SARIF is coverage: a scanner someone else ran on our behalf.
713
+ imported, imported_executions = _ingest_sarif_imports(args.sarif_import)
714
+ all_findings.extend(imported)
715
+ executions.extend(imported_executions)
716
+
717
+ # ----- one path convention, before anything reads a path -----
718
+ # Repository-relative from here on, whichever tool reported the finding.
719
+ # Suppressions, the baseline and every written output compare or publish
720
+ # this path, and none of them may depend on where the checkout lives.
721
+ scanned = target if target.is_dir() else target.parent
722
+ all_findings = anchor(all_findings, scanned=scanned, root=root)
723
+
724
+ # ----- scan scope, enforced once -----
725
+ own_artifacts = _own_artifacts(paths, cfg, root)
726
+ all_findings = _drop_excluded(all_findings, target, cfg, own_artifacts, root)
727
+
728
+ # ----- one weakness, one finding -----
729
+ # Before overrides and suppressions, so an operator writing either one
730
+ # sees the same finding the report will show. Bandit's B308 and B703 are
731
+ # the same check under two ids and shared fifty lines in Django; a report
732
+ # that lists a line twice is wrong about the code, and a work order built
733
+ # from it would ask for the same fix twice.
734
+ all_findings = merge_corroborating(all_findings)
735
+
736
+ # ----- overrides from config -----
737
+ all_findings = _apply_overrides(all_findings, cfg)
738
+
739
+ # ----- suppressions -----
740
+ suppressed = _apply_suppressions(all_findings, cfg, root, scanned)
741
+ if suppressed is None:
742
+ return 1
743
+ all_findings = suppressed
744
+
745
+ # ----- severity threshold filter -----
746
+ threshold = Severity.from_string(args.severity_threshold)
747
+ all_findings = [f for f in all_findings if f.severity.rank >= threshold.rank]
748
+
749
+ # ----- baseline -----
750
+ baseline_path = _under_root(root, args.baseline or cfg.outputs["baseline_path"])
751
+ baseline = baseline_mod.load(baseline_path)
752
+ baseline_state = baseline_mod.state(baseline_path)
753
+ all_findings = baseline_mod.mark_new(all_findings, baseline, root)
754
+
755
+ # ----- scoring -----
756
+ _scored = _score_everything(
757
+ all_findings,
758
+ args,
759
+ cfg,
760
+ target,
761
+ root,
762
+ own_artifacts,
763
+ executions,
764
+ imported_executions,
765
+ baseline_state,
766
+ )
767
+ all_findings = _scored.all_findings
768
+ axes = _scored.axes
769
+ coverage = _scored.coverage
770
+ declarations = _scored.declarations
771
+ gate = _scored.gate
772
+ score = _scored.score
773
+ undeclared_score = _scored.undeclared_score
774
+ verdict = _scored.verdict
713
775
  # ----- write outputs -----
714
776
  # The pillar artifact is what maintainability-agent ingests (D3). Built
715
777
  # here rather than inside a renderer because it needs the practice level,
@@ -732,26 +794,8 @@ def _do_audit(args: argparse.Namespace) -> int:
732
794
  baseline_mod.write(baseline_path, all_findings, baseline, root)
733
795
 
734
796
  # ----- trend -----
735
- # Appended before the verification branch returns, so a verify run is
736
- # recorded too: repair is the movement most worth seeing in a trend.
737
- history_path = _under_root(root, cfg.outputs.get("history_path") or "")
738
- if cfg.outputs.get("history_path"):
739
- history_mod.append(
740
- history_path,
741
- history_mod.entry_from(
742
- version=__version__,
743
- score=score,
744
- gate=gate,
745
- coverage=coverage,
746
- finding_count=len([f for f in all_findings if not f.suppressed]),
747
- scanners=tuple(ran),
748
- ),
749
- )
750
-
751
- trend_line = (
752
- history_mod.trend(history_mod.read(history_path))
753
- if cfg.outputs.get("history_path")
754
- else None
797
+ trend_line = _record_trend(
798
+ cfg, root, all_findings, score=score, gate=gate, coverage=coverage, ran=ran
755
799
  )
756
800
 
757
801
  # ----- did the work order actually improve anything? -----
@@ -1036,7 +1080,25 @@ def _drop_excluded(
1036
1080
  continue
1037
1081
  if path.resolve() in own_artifacts:
1038
1082
  continue
1039
- if is_excluded(path, root, cfg.exclude_patterns):
1083
+ # A dependency advisory outlives the exclusion of the file it is filed
1084
+ # against, for the reason `_classify` already gives one layer up: the
1085
+ # finding is about the pinned package, and the manifest or lockfile is
1086
+ # only the one file there is to point at. A pattern matching that path
1087
+ # was never a statement about the package.
1088
+ #
1089
+ # `**/*.lock` is the ordinary case, and it is in this repository's own
1090
+ # config — a lockfile is generated, enormous, and full of hashes that
1091
+ # read like secrets. The effect was that trivy's 19 advisories against
1092
+ # `uv.lock`, one CRITICAL, never reached the report, and the
1093
+ # `dependencies` category graded 5.0: a perfect score for a category
1094
+ # whose only evidence source was excluded.
1095
+ #
1096
+ # Everything else in that file stays excluded — a secret or a code
1097
+ # finding inside it is about the file, which is what the operator
1098
+ # asked to be rid of. D34's scope note, and the question §8 asked.
1099
+ if finding.category is not Category.DEPENDENCIES and is_excluded(
1100
+ path, root, cfg.exclude_patterns
1101
+ ):
1040
1102
  continue
1041
1103
  kept.append(finding)
1042
1104
  return kept
@@ -1177,7 +1239,26 @@ def _install_standards_overlay(cfg: config_mod.Config, root: Path) -> int | None
1177
1239
  #: `SEVERITY_WEIGHT`, so re-labelling removes a finding from the score while
1178
1240
  #: leaving it in the report. `category_overrides` moves it between the
1179
1241
  #: per-category rates, and the worst category drives the overall.
1180
- _GRADING_KEYS = ("severity_overrides", "category_overrides")
1242
+ #: `loc_for_scoring` is the denominator. Every category but `secrets` is a
1243
+ #: density — weighted findings per thousand scanned lines — so a tree that
1244
+ #: declares its own line count declares its own grade, and declaring ten
1245
+ #: million lines took the same one-finding fixture from 0.00/F to 4.999/A+
1246
+ #: with no flag and no warning. It was missed the first time because this
1247
+ #: tuple was written as a list of the two keys the attack had been
1248
+ #: demonstrated through, rather than derived from what the attack *is*.
1249
+ _GRADING_KEYS = ("severity_overrides", "category_overrides", "loc_for_scoring")
1250
+
1251
+
1252
+ def _unset_value(key: str) -> object:
1253
+ """What this config field holds when no config set it.
1254
+
1255
+ Read from `Config`'s own dataclass fields, so "ignore this key" means
1256
+ exactly "behave as though the file had not mentioned it" — including for
1257
+ a key whose empty state is `None` rather than `{}`, which is the kind of
1258
+ difference a hand-written clearing expression gets wrong.
1259
+ """
1260
+ spec = next(f for f in fields(config_mod.Config) if f.name == key)
1261
+ return spec.default_factory() if spec.default_factory is not MISSING else spec.default
1181
1262
 
1182
1263
 
1183
1264
  def _refuse_target_policy(cfg: config_mod.Config, target: Path) -> config_mod.Config:
@@ -1214,7 +1295,93 @@ def _refuse_target_policy(cfg: config_mod.Config, target: Path) -> config_mod.Co
1214
1295
  f"changes the grade, so it was ignored. Pass --trust-target-config, or "
1215
1296
  f"keep the configuration outside the tree, to apply it.\n"
1216
1297
  )
1217
- return replace(cfg, severity_overrides={}, category_overrides={})
1298
+ # Cleared to each field's own default, derived from `_GRADING_KEYS` rather
1299
+ # than restated. The two lists used to be written out separately — the
1300
+ # warning loop read the tuple and the `replace` named two keys by hand —
1301
+ # so adding a third key told the operator it had been ignored and then
1302
+ # applied it anyway. A warning that says the opposite of what happened is
1303
+ # worse than no warning, and `test_every_grading_key_is_actually_cleared`
1304
+ # is what holds the two together now.
1305
+ return replace(cfg, **{key: _unset_value(key) for key in ignored})
1306
+
1307
+
1308
+ def _record_trend(
1309
+ cfg: config_mod.Config,
1310
+ root: Path,
1311
+ findings: list[Finding],
1312
+ *,
1313
+ score: object,
1314
+ gate: object,
1315
+ coverage: object,
1316
+ ran: list[str],
1317
+ ) -> str | None:
1318
+ """Append this run to the history, and return the trend line to print.
1319
+
1320
+ Called before the verification branch returns, so a `--verify-against`
1321
+ run is recorded too: repair is the movement most worth seeing in a
1322
+ trend.
1323
+
1324
+ None when no `history_path` is configured — a repository that keeps no
1325
+ history gets no trend line rather than an empty one, because "no
1326
+ movement" and "nothing recorded" are different statements.
1327
+ """
1328
+ configured = cfg.outputs.get("history_path")
1329
+ if not configured:
1330
+ return None
1331
+ history_path = _under_root(root, configured)
1332
+ history_mod.append(
1333
+ history_path,
1334
+ history_mod.entry_from(
1335
+ version=__version__,
1336
+ score=score,
1337
+ gate=gate,
1338
+ coverage=coverage,
1339
+ finding_count=len([f for f in findings if not f.suppressed]),
1340
+ scanners=tuple(ran),
1341
+ ),
1342
+ )
1343
+ return history_mod.trend(history_mod.read(history_path))
1344
+
1345
+
1346
+ def _apply_suppressions(
1347
+ findings: list[Finding], cfg: config_mod.Config, root: Path, scanned: Path
1348
+ ) -> list[Finding] | None:
1349
+ """Findings with suppressions applied, or None when the file is unusable.
1350
+
1351
+ None means the caller exits 1. **Fail closed**: a suppression file that
1352
+ exists is an explicit instruction, and ignoring it silently changes which
1353
+ findings are reported — "my suppressions are working" then looks exactly
1354
+ like "my suppressions were skipped". That bit in practice, when a missing
1355
+ PyYAML made an entire `.scignore.yaml` a no-op while the run still exited
1356
+ 0 and reported the suppressed finding as live.
1357
+
1358
+ Only reachable when the file is present, so repositories that do not use
1359
+ suppressions are unaffected.
1360
+ """
1361
+ suppression_path = _under_root(root, cfg.suppressions_file)
1362
+ rules, errors = suppressions.load(suppression_path)
1363
+ if errors:
1364
+ for err in errors:
1365
+ sys.stderr.write(f"ERROR: {err}\n")
1366
+ sys.stderr.write(
1367
+ f"ERROR: {suppression_path} exists but could not be applied; refusing to "
1368
+ "report results that silently ignore it.\n"
1369
+ )
1370
+ return None
1371
+
1372
+ applied = suppressions.apply(findings, rules, root)
1373
+ # The scanner findings, snapshotted before the synthetic ones are
1374
+ # appended. `unused_findings` asks whether each rule still has a subject,
1375
+ # and a wildcard rule with `paths` would happily match the
1376
+ # expired-suppression finding about `.scignore.yaml` itself — reporting
1377
+ # itself as used because it matched a finding about suppressions.
1378
+ scanner_findings = list(applied)
1379
+ for synthetic in (
1380
+ suppressions.expired_findings(rules, suppression_path),
1381
+ suppressions.unused_findings(rules, scanner_findings, suppression_path, root),
1382
+ ):
1383
+ applied.extend(anchor(synthetic, scanned=scanned, root=root))
1384
+ return applied
1218
1385
 
1219
1386
 
1220
1387
  def _apply_overrides(findings: list[Finding], cfg: config_mod.Config) -> list[Finding]:
@@ -1408,11 +1575,20 @@ def _declaration_delta(score, undeclared_score, declarations) -> str | None:
1408
1575
  named = ", ".join(
1409
1576
  f"{name} ({count})" for name, count in sorted(declarations.accounted.items()) if count
1410
1577
  )
1411
- line = (
1412
- f"declared: {declarations.total_accounted} finding(s) accounted for by "
1413
- f"{named}; without declarations "
1414
- f"{undeclared_score.overall:.2f} ({undeclared_score.letter})"
1415
- )
1578
+ line = f"declared: {declarations.total_accounted} finding(s) accounted for by {named}"
1579
+ if undeclared_score.overall is None:
1580
+ # No grade either way, so there is no cost to state. Reachable since
1581
+ # D33: nothing counted as scanned, every finding suppressed so no
1582
+ # category has a count — and `summarize_declarations` counts matched
1583
+ # findings without skipping suppressed ones, so the accounting is
1584
+ # non-empty while the grade is absent. `:.2f` on that raised, in
1585
+ # shipped code, on the summary path.
1586
+ #
1587
+ # The accounting is still printed. It is true, it is the disclosure
1588
+ # this mechanism exists for, and dropping the line would hide it to
1589
+ # avoid admitting there is no number.
1590
+ return f"{line}; no grade either way, so no without-declarations comparison"
1591
+ line += f"; without declarations {undeclared_score.overall:.2f} ({undeclared_score.letter})"
1416
1592
  if declarations.unexercised:
1417
1593
  # A declaration that matched nothing describes something this project
1418
1594
  # does not do. Named rather than dropped, so a config cannot be padded