secure-code-agent 0.12.11__tar.gz → 0.12.12__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.12.12}/PKG-INFO +3 -3
  2. {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/README.md +2 -2
  3. {secure_code_agent-0.12.11 → secure_code_agent-0.12.12/src/secure_code_agent.egg-info}/PKG-INFO +3 -3
  4. {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_agent.egg-info/SOURCES.txt +1 -0
  5. {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/__init__.py +1 -1
  6. {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/cli.py +272 -123
  7. {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/config.py +88 -49
  8. {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/findings.py +22 -0
  9. {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/practice.py +81 -42
  10. {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/remediation.py +1 -1
  11. {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/renderers.py +7 -1
  12. {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/scanner_status.py +10 -0
  13. secure_code_agent-0.12.12/src/secure_code_audit/scanners/_exclusions.py +94 -0
  14. {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/scanners/_finding_builder.py +32 -0
  15. {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/scanners/bandit_scanner.py +27 -19
  16. {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/scanners/base.py +26 -0
  17. {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/scanners/builtin_rules.py +3 -0
  18. {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/scanners/checkov_scanner.py +19 -0
  19. {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/scanners/gitleaks_scanner.py +7 -0
  20. {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/scanners/gosec_scanner.py +7 -0
  21. {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/scanners/hadolint_scanner.py +3 -0
  22. {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/scanners/njsscan_scanner.py +6 -0
  23. {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/scanners/npm_audit_scanner.py +14 -21
  24. {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/scanners/osv_scanner.py +13 -11
  25. {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/scanners/pip_audit_scanner.py +23 -18
  26. {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/scanners/rubocop_scanner.py +6 -0
  27. {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/scanners/scorecard_scanner.py +6 -0
  28. {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/scanners/semgrep_scanner.py +87 -51
  29. {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/scanners/trivy_scanner.py +45 -1
  30. {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/scanners/trufflehog_scanner.py +6 -0
  31. {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/scoring.py +92 -24
  32. {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/suppressions.py +63 -39
  33. {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/verify.py +20 -1
  34. {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/LICENSE +0 -0
  35. {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/pyproject.toml +0 -0
  36. {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/setup.cfg +0 -0
  37. {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_agent.egg-info/dependency_links.txt +0 -0
  38. {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_agent.egg-info/entry_points.txt +0 -0
  39. {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_agent.egg-info/requires.txt +0 -0
  40. {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_agent.egg-info/top_level.txt +0 -0
  41. {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/baseline.py +0 -0
  42. {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/capabilities.py +0 -0
  43. {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/data/semgrep-offline.yaml +0 -0
  44. {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/data/standards.yaml +0 -0
  45. {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/demo.py +0 -0
  46. {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/git_tools.py +0 -0
  47. {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/history.py +0 -0
  48. {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/instructions.py +0 -0
  49. {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/mcp_server.py +0 -0
  50. {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/pillar.py +0 -0
  51. {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/ruleset.py +0 -0
  52. {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/sarif.py +0 -0
  53. {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/scanners/__init__.py +0 -0
  54. {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/scanners/_execution.py +0 -0
  55. {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/scanners/_resolution.py +0 -0
  56. {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/scanners/floor.py +0 -0
  57. {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/standards.py +0 -0
  58. {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/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.12.12
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.12.12
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.12.12
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.12.12
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.12.12
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.12.12"
@@ -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 (
@@ -493,104 +493,46 @@ def _split_axes(
493
493
  )
494
494
 
495
495
 
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)
496
+ @dataclass(frozen=True)
497
+ class _Scored:
498
+ """What the scoring phase produces, and nothing else.
540
499
 
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
- )
500
+ The phase assigns 31 names and only these 8 are read afterwards —
501
+ measured, not guessed. The other 23 were locals in a 120-line stretch of
502
+ `_do_audit`, which is what `architecture.md` §6 means by "a straight-line
503
+ function threading a dozen locals". Same shape as `_Axes`.
504
+ """
582
505
 
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]
506
+ all_findings: list[Finding]
507
+ axes: object
508
+ coverage: object
509
+ declarations: object
510
+ gate: object
511
+ score: object
512
+ undeclared_score: object
513
+ verdict: object
586
514
 
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
515
 
593
- # ----- scoring -----
516
+ def _score_everything(
517
+ all_findings: list[Finding],
518
+ args: argparse.Namespace,
519
+ cfg: config_mod.Config,
520
+ target: Path,
521
+ root: Path,
522
+ own_artifacts: object,
523
+ executions: list,
524
+ imported_executions: list,
525
+ baseline_state: object,
526
+ ) -> _Scored:
527
+ """Split the axes, score, gate, and build the verdict.
528
+
529
+ A pure move out of `_do_audit`: the same calls in the same order, with
530
+ the comments that record *why* each split exists kept where the code is.
531
+ Ordering is load-bearing throughout — classification happens before
532
+ suppression upstream, and the axis split happens before scoring here,
533
+ because a finding routed to the test tree must not be scored as shipped
534
+ code.
535
+ """
594
536
  _axes = _split_axes(all_findings, args, cfg, target, root, own_artifacts)
595
537
  all_findings = _axes.all_findings
596
538
  primary_findings, test_findings, docs_findings = _axes.primary, _axes.test, _axes.docs
@@ -710,6 +652,99 @@ def _do_audit(args: argparse.Namespace) -> int:
710
652
  reasons=(*verdict.reasons, changed_note),
711
653
  )
712
654
 
655
+ return _Scored(
656
+ all_findings=all_findings,
657
+ axes=axes,
658
+ coverage=coverage,
659
+ declarations=declarations,
660
+ gate=gate,
661
+ score=score,
662
+ undeclared_score=undeclared_score,
663
+ verdict=verdict,
664
+ )
665
+
666
+
667
+ def _do_audit(args: argparse.Namespace) -> int:
668
+ cfg, target, root = _prepare_audit(args)
669
+ # Before any consumer reads them: the audited tree does not grade itself.
670
+ cfg = _refuse_target_policy(cfg, target)
671
+ _require_configured_gates(args, cfg)
672
+ if (failed := _install_standards_overlay(cfg, root)) is not None:
673
+ return failed
674
+ # Before anything is resolved or written, and before any scanner runs.
675
+ _assert_config_writes_are_contained(cfg, root, target)
676
+ # Resolved before the scan so the run can recognise its own artifacts.
677
+ paths = _resolve_outputs(args, cfg, root)
678
+
679
+ # ----- scanners -----
680
+ scan = _run_scanners(args, cfg, target)
681
+ all_findings = scan.findings
682
+ ran, unavailable, executions = scan.ran, scan.unavailable, scan.executions
683
+
684
+ # ----- SARIF imports -----
685
+ # An imported SARIF is coverage: a scanner someone else ran on our behalf.
686
+ imported, imported_executions = _ingest_sarif_imports(args.sarif_import)
687
+ all_findings.extend(imported)
688
+ executions.extend(imported_executions)
689
+
690
+ # ----- one path convention, before anything reads a path -----
691
+ # Repository-relative from here on, whichever tool reported the finding.
692
+ # Suppressions, the baseline and every written output compare or publish
693
+ # this path, and none of them may depend on where the checkout lives.
694
+ scanned = target if target.is_dir() else target.parent
695
+ all_findings = anchor(all_findings, scanned=scanned, root=root)
696
+
697
+ # ----- scan scope, enforced once -----
698
+ own_artifacts = _own_artifacts(paths, cfg, root)
699
+ all_findings = _drop_excluded(all_findings, target, cfg, own_artifacts, root)
700
+
701
+ # ----- one weakness, one finding -----
702
+ # Before overrides and suppressions, so an operator writing either one
703
+ # sees the same finding the report will show. Bandit's B308 and B703 are
704
+ # the same check under two ids and shared fifty lines in Django; a report
705
+ # that lists a line twice is wrong about the code, and a work order built
706
+ # from it would ask for the same fix twice.
707
+ all_findings = merge_corroborating(all_findings)
708
+
709
+ # ----- overrides from config -----
710
+ all_findings = _apply_overrides(all_findings, cfg)
711
+
712
+ # ----- suppressions -----
713
+ suppressed = _apply_suppressions(all_findings, cfg, root, scanned)
714
+ if suppressed is None:
715
+ return 1
716
+ all_findings = suppressed
717
+
718
+ # ----- severity threshold filter -----
719
+ threshold = Severity.from_string(args.severity_threshold)
720
+ all_findings = [f for f in all_findings if f.severity.rank >= threshold.rank]
721
+
722
+ # ----- baseline -----
723
+ baseline_path = _under_root(root, args.baseline or cfg.outputs["baseline_path"])
724
+ baseline = baseline_mod.load(baseline_path)
725
+ baseline_state = baseline_mod.state(baseline_path)
726
+ all_findings = baseline_mod.mark_new(all_findings, baseline, root)
727
+
728
+ # ----- scoring -----
729
+ _scored = _score_everything(
730
+ all_findings,
731
+ args,
732
+ cfg,
733
+ target,
734
+ root,
735
+ own_artifacts,
736
+ executions,
737
+ imported_executions,
738
+ baseline_state,
739
+ )
740
+ all_findings = _scored.all_findings
741
+ axes = _scored.axes
742
+ coverage = _scored.coverage
743
+ declarations = _scored.declarations
744
+ gate = _scored.gate
745
+ score = _scored.score
746
+ undeclared_score = _scored.undeclared_score
747
+ verdict = _scored.verdict
713
748
  # ----- write outputs -----
714
749
  # The pillar artifact is what maintainability-agent ingests (D3). Built
715
750
  # here rather than inside a renderer because it needs the practice level,
@@ -732,26 +767,8 @@ def _do_audit(args: argparse.Namespace) -> int:
732
767
  baseline_mod.write(baseline_path, all_findings, baseline, root)
733
768
 
734
769
  # ----- 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
770
+ trend_line = _record_trend(
771
+ cfg, root, all_findings, score=score, gate=gate, coverage=coverage, ran=ran
755
772
  )
756
773
 
757
774
  # ----- did the work order actually improve anything? -----
@@ -1036,7 +1053,25 @@ def _drop_excluded(
1036
1053
  continue
1037
1054
  if path.resolve() in own_artifacts:
1038
1055
  continue
1039
- if is_excluded(path, root, cfg.exclude_patterns):
1056
+ # A dependency advisory outlives the exclusion of the file it is filed
1057
+ # against, for the reason `_classify` already gives one layer up: the
1058
+ # finding is about the pinned package, and the manifest or lockfile is
1059
+ # only the one file there is to point at. A pattern matching that path
1060
+ # was never a statement about the package.
1061
+ #
1062
+ # `**/*.lock` is the ordinary case, and it is in this repository's own
1063
+ # config — a lockfile is generated, enormous, and full of hashes that
1064
+ # read like secrets. The effect was that trivy's 19 advisories against
1065
+ # `uv.lock`, one CRITICAL, never reached the report, and the
1066
+ # `dependencies` category graded 5.0: a perfect score for a category
1067
+ # whose only evidence source was excluded.
1068
+ #
1069
+ # Everything else in that file stays excluded — a secret or a code
1070
+ # finding inside it is about the file, which is what the operator
1071
+ # asked to be rid of. D34's scope note, and the question §8 asked.
1072
+ if finding.category is not Category.DEPENDENCIES and is_excluded(
1073
+ path, root, cfg.exclude_patterns
1074
+ ):
1040
1075
  continue
1041
1076
  kept.append(finding)
1042
1077
  return kept
@@ -1177,7 +1212,26 @@ def _install_standards_overlay(cfg: config_mod.Config, root: Path) -> int | None
1177
1212
  #: `SEVERITY_WEIGHT`, so re-labelling removes a finding from the score while
1178
1213
  #: leaving it in the report. `category_overrides` moves it between the
1179
1214
  #: per-category rates, and the worst category drives the overall.
1180
- _GRADING_KEYS = ("severity_overrides", "category_overrides")
1215
+ #: `loc_for_scoring` is the denominator. Every category but `secrets` is a
1216
+ #: density — weighted findings per thousand scanned lines — so a tree that
1217
+ #: declares its own line count declares its own grade, and declaring ten
1218
+ #: million lines took the same one-finding fixture from 0.00/F to 4.999/A+
1219
+ #: with no flag and no warning. It was missed the first time because this
1220
+ #: tuple was written as a list of the two keys the attack had been
1221
+ #: demonstrated through, rather than derived from what the attack *is*.
1222
+ _GRADING_KEYS = ("severity_overrides", "category_overrides", "loc_for_scoring")
1223
+
1224
+
1225
+ def _unset_value(key: str) -> object:
1226
+ """What this config field holds when no config set it.
1227
+
1228
+ Read from `Config`'s own dataclass fields, so "ignore this key" means
1229
+ exactly "behave as though the file had not mentioned it" — including for
1230
+ a key whose empty state is `None` rather than `{}`, which is the kind of
1231
+ difference a hand-written clearing expression gets wrong.
1232
+ """
1233
+ spec = next(f for f in fields(config_mod.Config) if f.name == key)
1234
+ return spec.default_factory() if spec.default_factory is not MISSING else spec.default
1181
1235
 
1182
1236
 
1183
1237
  def _refuse_target_policy(cfg: config_mod.Config, target: Path) -> config_mod.Config:
@@ -1214,7 +1268,93 @@ def _refuse_target_policy(cfg: config_mod.Config, target: Path) -> config_mod.Co
1214
1268
  f"changes the grade, so it was ignored. Pass --trust-target-config, or "
1215
1269
  f"keep the configuration outside the tree, to apply it.\n"
1216
1270
  )
1217
- return replace(cfg, severity_overrides={}, category_overrides={})
1271
+ # Cleared to each field's own default, derived from `_GRADING_KEYS` rather
1272
+ # than restated. The two lists used to be written out separately — the
1273
+ # warning loop read the tuple and the `replace` named two keys by hand —
1274
+ # so adding a third key told the operator it had been ignored and then
1275
+ # applied it anyway. A warning that says the opposite of what happened is
1276
+ # worse than no warning, and `test_every_grading_key_is_actually_cleared`
1277
+ # is what holds the two together now.
1278
+ return replace(cfg, **{key: _unset_value(key) for key in ignored})
1279
+
1280
+
1281
+ def _record_trend(
1282
+ cfg: config_mod.Config,
1283
+ root: Path,
1284
+ findings: list[Finding],
1285
+ *,
1286
+ score: object,
1287
+ gate: object,
1288
+ coverage: object,
1289
+ ran: list[str],
1290
+ ) -> str | None:
1291
+ """Append this run to the history, and return the trend line to print.
1292
+
1293
+ Called before the verification branch returns, so a `--verify-against`
1294
+ run is recorded too: repair is the movement most worth seeing in a
1295
+ trend.
1296
+
1297
+ None when no `history_path` is configured — a repository that keeps no
1298
+ history gets no trend line rather than an empty one, because "no
1299
+ movement" and "nothing recorded" are different statements.
1300
+ """
1301
+ configured = cfg.outputs.get("history_path")
1302
+ if not configured:
1303
+ return None
1304
+ history_path = _under_root(root, configured)
1305
+ history_mod.append(
1306
+ history_path,
1307
+ history_mod.entry_from(
1308
+ version=__version__,
1309
+ score=score,
1310
+ gate=gate,
1311
+ coverage=coverage,
1312
+ finding_count=len([f for f in findings if not f.suppressed]),
1313
+ scanners=tuple(ran),
1314
+ ),
1315
+ )
1316
+ return history_mod.trend(history_mod.read(history_path))
1317
+
1318
+
1319
+ def _apply_suppressions(
1320
+ findings: list[Finding], cfg: config_mod.Config, root: Path, scanned: Path
1321
+ ) -> list[Finding] | None:
1322
+ """Findings with suppressions applied, or None when the file is unusable.
1323
+
1324
+ None means the caller exits 1. **Fail closed**: a suppression file that
1325
+ exists is an explicit instruction, and ignoring it silently changes which
1326
+ findings are reported — "my suppressions are working" then looks exactly
1327
+ like "my suppressions were skipped". That bit in practice, when a missing
1328
+ PyYAML made an entire `.scignore.yaml` a no-op while the run still exited
1329
+ 0 and reported the suppressed finding as live.
1330
+
1331
+ Only reachable when the file is present, so repositories that do not use
1332
+ suppressions are unaffected.
1333
+ """
1334
+ suppression_path = _under_root(root, cfg.suppressions_file)
1335
+ rules, errors = suppressions.load(suppression_path)
1336
+ if errors:
1337
+ for err in errors:
1338
+ sys.stderr.write(f"ERROR: {err}\n")
1339
+ sys.stderr.write(
1340
+ f"ERROR: {suppression_path} exists but could not be applied; refusing to "
1341
+ "report results that silently ignore it.\n"
1342
+ )
1343
+ return None
1344
+
1345
+ applied = suppressions.apply(findings, rules, root)
1346
+ # The scanner findings, snapshotted before the synthetic ones are
1347
+ # appended. `unused_findings` asks whether each rule still has a subject,
1348
+ # and a wildcard rule with `paths` would happily match the
1349
+ # expired-suppression finding about `.scignore.yaml` itself — reporting
1350
+ # itself as used because it matched a finding about suppressions.
1351
+ scanner_findings = list(applied)
1352
+ for synthetic in (
1353
+ suppressions.expired_findings(rules, suppression_path),
1354
+ suppressions.unused_findings(rules, scanner_findings, suppression_path, root),
1355
+ ):
1356
+ applied.extend(anchor(synthetic, scanned=scanned, root=root))
1357
+ return applied
1218
1358
 
1219
1359
 
1220
1360
  def _apply_overrides(findings: list[Finding], cfg: config_mod.Config) -> list[Finding]:
@@ -1408,11 +1548,20 @@ def _declaration_delta(score, undeclared_score, declarations) -> str | None:
1408
1548
  named = ", ".join(
1409
1549
  f"{name} ({count})" for name, count in sorted(declarations.accounted.items()) if count
1410
1550
  )
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
- )
1551
+ line = f"declared: {declarations.total_accounted} finding(s) accounted for by {named}"
1552
+ if undeclared_score.overall is None:
1553
+ # No grade either way, so there is no cost to state. Reachable since
1554
+ # D33: nothing counted as scanned, every finding suppressed so no
1555
+ # category has a count — and `summarize_declarations` counts matched
1556
+ # findings without skipping suppressed ones, so the accounting is
1557
+ # non-empty while the grade is absent. `:.2f` on that raised, in
1558
+ # shipped code, on the summary path.
1559
+ #
1560
+ # The accounting is still printed. It is true, it is the disclosure
1561
+ # this mechanism exists for, and dropping the line would hide it to
1562
+ # avoid admitting there is no number.
1563
+ return f"{line}; no grade either way, so no without-declarations comparison"
1564
+ line += f"; without declarations {undeclared_score.overall:.2f} ({undeclared_score.letter})"
1416
1565
  if declarations.unexercised:
1417
1566
  # A declaration that matched nothing describes something this project
1418
1567
  # does not do. Named rather than dropped, so a config cannot be padded