secure-code-agent 0.12.10__tar.gz → 0.12.11__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 (60) hide show
  1. {secure_code_agent-0.12.10/src/secure_code_agent.egg-info → secure_code_agent-0.12.11}/PKG-INFO +44 -5
  2. {secure_code_agent-0.12.10 → secure_code_agent-0.12.11}/README.md +39 -1
  3. {secure_code_agent-0.12.10 → secure_code_agent-0.12.11}/pyproject.toml +37 -5
  4. {secure_code_agent-0.12.10 → secure_code_agent-0.12.11/src/secure_code_agent.egg-info}/PKG-INFO +44 -5
  5. {secure_code_agent-0.12.10 → secure_code_agent-0.12.11}/src/secure_code_agent.egg-info/SOURCES.txt +4 -0
  6. {secure_code_agent-0.12.10 → secure_code_agent-0.12.11}/src/secure_code_agent.egg-info/requires.txt +4 -3
  7. {secure_code_agent-0.12.10 → secure_code_agent-0.12.11}/src/secure_code_audit/__init__.py +1 -1
  8. {secure_code_agent-0.12.10 → secure_code_agent-0.12.11}/src/secure_code_audit/cli.py +226 -79
  9. {secure_code_agent-0.12.10 → secure_code_agent-0.12.11}/src/secure_code_audit/config.py +84 -27
  10. secure_code_agent-0.12.11/src/secure_code_audit/data/standards.yaml +424 -0
  11. {secure_code_agent-0.12.10 → secure_code_agent-0.12.11}/src/secure_code_audit/findings.py +11 -30
  12. {secure_code_agent-0.12.10 → secure_code_agent-0.12.11}/src/secure_code_audit/git_tools.py +34 -0
  13. {secure_code_agent-0.12.10 → secure_code_agent-0.12.11}/src/secure_code_audit/renderers.py +4 -2
  14. secure_code_agent-0.12.11/src/secure_code_audit/scanners/_execution.py +112 -0
  15. secure_code_agent-0.12.11/src/secure_code_audit/scanners/_finding_builder.py +233 -0
  16. secure_code_agent-0.12.11/src/secure_code_audit/scanners/_resolution.py +157 -0
  17. {secure_code_agent-0.12.10 → secure_code_agent-0.12.11}/src/secure_code_audit/scanners/bandit_scanner.py +15 -4
  18. secure_code_agent-0.12.11/src/secure_code_audit/scanners/base.py +140 -0
  19. {secure_code_agent-0.12.10 → secure_code_agent-0.12.11}/src/secure_code_audit/scanners/checkov_scanner.py +1 -3
  20. {secure_code_agent-0.12.10 → secure_code_agent-0.12.11}/src/secure_code_audit/scanners/gitleaks_scanner.py +1 -3
  21. {secure_code_agent-0.12.10 → secure_code_agent-0.12.11}/src/secure_code_audit/scanners/gosec_scanner.py +1 -3
  22. {secure_code_agent-0.12.10 → secure_code_agent-0.12.11}/src/secure_code_audit/scanners/hadolint_scanner.py +1 -1
  23. {secure_code_agent-0.12.10 → secure_code_agent-0.12.11}/src/secure_code_audit/scanners/njsscan_scanner.py +1 -3
  24. {secure_code_agent-0.12.10 → secure_code_agent-0.12.11}/src/secure_code_audit/scanners/npm_audit_scanner.py +1 -3
  25. {secure_code_agent-0.12.10 → secure_code_agent-0.12.11}/src/secure_code_audit/scanners/osv_scanner.py +1 -3
  26. {secure_code_agent-0.12.10 → secure_code_agent-0.12.11}/src/secure_code_audit/scanners/pip_audit_scanner.py +0 -1
  27. {secure_code_agent-0.12.10 → secure_code_agent-0.12.11}/src/secure_code_audit/scanners/rubocop_scanner.py +1 -3
  28. {secure_code_agent-0.12.10 → secure_code_agent-0.12.11}/src/secure_code_audit/scanners/scorecard_scanner.py +1 -3
  29. {secure_code_agent-0.12.10 → secure_code_agent-0.12.11}/src/secure_code_audit/scanners/semgrep_scanner.py +1 -3
  30. {secure_code_agent-0.12.10 → secure_code_agent-0.12.11}/src/secure_code_audit/scanners/trivy_scanner.py +1 -3
  31. {secure_code_agent-0.12.10 → secure_code_agent-0.12.11}/src/secure_code_audit/scanners/trufflehog_scanner.py +1 -1
  32. {secure_code_agent-0.12.10 → secure_code_agent-0.12.11}/src/secure_code_audit/scoring.py +13 -2
  33. secure_code_agent-0.12.11/src/secure_code_audit/standards.py +550 -0
  34. secure_code_agent-0.12.11/src/secure_code_audit/suppressions.py +442 -0
  35. secure_code_agent-0.12.10/src/secure_code_audit/scanners/base.py +0 -459
  36. secure_code_agent-0.12.10/src/secure_code_audit/standards.py +0 -705
  37. secure_code_agent-0.12.10/src/secure_code_audit/suppressions.py +0 -284
  38. {secure_code_agent-0.12.10 → secure_code_agent-0.12.11}/LICENSE +0 -0
  39. {secure_code_agent-0.12.10 → secure_code_agent-0.12.11}/setup.cfg +0 -0
  40. {secure_code_agent-0.12.10 → secure_code_agent-0.12.11}/src/secure_code_agent.egg-info/dependency_links.txt +0 -0
  41. {secure_code_agent-0.12.10 → secure_code_agent-0.12.11}/src/secure_code_agent.egg-info/entry_points.txt +0 -0
  42. {secure_code_agent-0.12.10 → secure_code_agent-0.12.11}/src/secure_code_agent.egg-info/top_level.txt +0 -0
  43. {secure_code_agent-0.12.10 → secure_code_agent-0.12.11}/src/secure_code_audit/baseline.py +0 -0
  44. {secure_code_agent-0.12.10 → secure_code_agent-0.12.11}/src/secure_code_audit/capabilities.py +0 -0
  45. {secure_code_agent-0.12.10 → secure_code_agent-0.12.11}/src/secure_code_audit/data/semgrep-offline.yaml +0 -0
  46. {secure_code_agent-0.12.10 → secure_code_agent-0.12.11}/src/secure_code_audit/demo.py +0 -0
  47. {secure_code_agent-0.12.10 → secure_code_agent-0.12.11}/src/secure_code_audit/history.py +0 -0
  48. {secure_code_agent-0.12.10 → secure_code_agent-0.12.11}/src/secure_code_audit/instructions.py +0 -0
  49. {secure_code_agent-0.12.10 → secure_code_agent-0.12.11}/src/secure_code_audit/mcp_server.py +0 -0
  50. {secure_code_agent-0.12.10 → secure_code_agent-0.12.11}/src/secure_code_audit/pillar.py +0 -0
  51. {secure_code_agent-0.12.10 → secure_code_agent-0.12.11}/src/secure_code_audit/practice.py +0 -0
  52. {secure_code_agent-0.12.10 → secure_code_agent-0.12.11}/src/secure_code_audit/remediation.py +0 -0
  53. {secure_code_agent-0.12.10 → secure_code_agent-0.12.11}/src/secure_code_audit/ruleset.py +0 -0
  54. {secure_code_agent-0.12.10 → secure_code_agent-0.12.11}/src/secure_code_audit/sarif.py +0 -0
  55. {secure_code_agent-0.12.10 → secure_code_agent-0.12.11}/src/secure_code_audit/scanner_status.py +0 -0
  56. {secure_code_agent-0.12.10 → secure_code_agent-0.12.11}/src/secure_code_audit/scanners/__init__.py +0 -0
  57. {secure_code_agent-0.12.10 → secure_code_agent-0.12.11}/src/secure_code_audit/scanners/builtin_rules.py +0 -0
  58. {secure_code_agent-0.12.10 → secure_code_agent-0.12.11}/src/secure_code_audit/scanners/floor.py +0 -0
  59. {secure_code_agent-0.12.10 → secure_code_agent-0.12.11}/src/secure_code_audit/triage.py +0 -0
  60. {secure_code_agent-0.12.10 → secure_code_agent-0.12.11}/src/secure_code_audit/verify.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: secure-code-agent
3
- Version: 0.12.10
3
+ Version: 0.12.11
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
@@ -30,11 +30,11 @@ Description-Content-Type: text/markdown
30
30
  License-File: LICENSE
31
31
  Requires-Dist: PyYAML<7,>=6.0.2
32
32
  Provides-Extra: mcp
33
- Requires-Dist: mcp<2,>=1.0; extra == "mcp"
33
+ Requires-Dist: mcp<3,>=1.0; extra == "mcp"
34
34
  Provides-Extra: required-scanners
35
35
  Requires-Dist: bandit==1.9.4; extra == "required-scanners"
36
36
  Requires-Dist: pip-audit==2.10.1; extra == "required-scanners"
37
- Requires-Dist: njsscan==1.0.0; extra == "required-scanners"
37
+ Requires-Dist: njsscan==1.0.1; extra == "required-scanners"
38
38
  Provides-Extra: python-scanners
39
39
  Requires-Dist: secure-code-agent[required-scanners]; extra == "python-scanners"
40
40
  Requires-Dist: semgrep<2,>=1.172; extra == "python-scanners"
@@ -43,7 +43,8 @@ Provides-Extra: dev
43
43
  Requires-Dist: secure-code-agent[required-scanners]; extra == "dev"
44
44
  Requires-Dist: pytest>=8.0; extra == "dev"
45
45
  Requires-Dist: pytest-cov>=4.0; extra == "dev"
46
- Requires-Dist: ruff==0.16.2; extra == "dev"
46
+ Requires-Dist: ruff==0.16.9; extra == "dev"
47
+ Requires-Dist: markdown>=3.5; extra == "dev"
47
48
  Dynamic: license-file
48
49
 
49
50
  # secure-code-agent
@@ -454,6 +455,44 @@ for repositories whose `pyproject.toml` is their authoritative audit input.
454
455
 
455
456
  Wildcard rule (`rule_id: "*"`) requires a `file` or `paths` scope — you cannot disable a rule globally.
456
457
 
458
+ ## Extending the standards mapping
459
+
460
+ The shipped mapping table lives in `data/standards.yaml` and covers the rules
461
+ this project curates. Semgrep alone publishes thousands, so point
462
+ `standards_overlay` at your own file to map or correct a rule without waiting
463
+ for a release:
464
+
465
+ ```json
466
+ { "standards_overlay": "security/standards-overlay.yaml" }
467
+ ```
468
+
469
+ ```yaml
470
+ version: 1
471
+ entries:
472
+ - scanner: semgrep
473
+ rule_id: python.lang.security.audit.my-rule
474
+ canonical_cwe: CWE-22
475
+ owasp_top10: A01
476
+ asvs_section: V12.3.1
477
+ nist_ssdf: PW.5.1
478
+ short_desc: Path traversal in our request handler.
479
+ fix_hint: Resolve the path and contain it under the upload root.
480
+ ```
481
+
482
+ An overlay entry wins over the shipped table for the fields it sets, and an
483
+ adapter asserting its own CWE still wins over both.
484
+
485
+ **An overlay cannot set `severity`, `confidence` or `category`**, and a file
486
+ that tries is refused with the reason rather than ignored. Those three are the
487
+ scoring inputs: an overlay that set them could move the grade, which is the
488
+ hole [D32](docs/decisions.md) closed on the configuration side. An overlay says
489
+ *what weakness a rule describes*; it does not say *how much it counts*. Set
490
+ severity policy with `severity_overrides` in a config kept outside the audited
491
+ tree.
492
+
493
+ A declared overlay that cannot be read fails the run rather than being skipped,
494
+ for the same reason a malformed `.scignore.yaml` does.
495
+
457
496
  ## Baseline + incremental adoption
458
497
 
459
498
  `secure-code-baseline.json` fingerprints every current finding. On the next run:
@@ -515,7 +554,7 @@ For agents that support invokable skills, this repo ships a portable skill under
515
554
  ## GitHub Action
516
555
 
517
556
  ```yaml
518
- - uses: marshallguillory86/secure-code-agent@v0.12.10
557
+ - uses: marshallguillory86/secure-code-agent@v0.12.11
519
558
  with:
520
559
  config: secure-code-agent.json
521
560
  fail-on-gate: true
@@ -406,6 +406,44 @@ for repositories whose `pyproject.toml` is their authoritative audit input.
406
406
 
407
407
  Wildcard rule (`rule_id: "*"`) requires a `file` or `paths` scope — you cannot disable a rule globally.
408
408
 
409
+ ## Extending the standards mapping
410
+
411
+ The shipped mapping table lives in `data/standards.yaml` and covers the rules
412
+ this project curates. Semgrep alone publishes thousands, so point
413
+ `standards_overlay` at your own file to map or correct a rule without waiting
414
+ for a release:
415
+
416
+ ```json
417
+ { "standards_overlay": "security/standards-overlay.yaml" }
418
+ ```
419
+
420
+ ```yaml
421
+ version: 1
422
+ entries:
423
+ - scanner: semgrep
424
+ rule_id: python.lang.security.audit.my-rule
425
+ canonical_cwe: CWE-22
426
+ owasp_top10: A01
427
+ asvs_section: V12.3.1
428
+ nist_ssdf: PW.5.1
429
+ short_desc: Path traversal in our request handler.
430
+ fix_hint: Resolve the path and contain it under the upload root.
431
+ ```
432
+
433
+ An overlay entry wins over the shipped table for the fields it sets, and an
434
+ adapter asserting its own CWE still wins over both.
435
+
436
+ **An overlay cannot set `severity`, `confidence` or `category`**, and a file
437
+ that tries is refused with the reason rather than ignored. Those three are the
438
+ scoring inputs: an overlay that set them could move the grade, which is the
439
+ hole [D32](docs/decisions.md) closed on the configuration side. An overlay says
440
+ *what weakness a rule describes*; it does not say *how much it counts*. Set
441
+ severity policy with `severity_overrides` in a config kept outside the audited
442
+ tree.
443
+
444
+ A declared overlay that cannot be read fails the run rather than being skipped,
445
+ for the same reason a malformed `.scignore.yaml` does.
446
+
409
447
  ## Baseline + incremental adoption
410
448
 
411
449
  `secure-code-baseline.json` fingerprints every current finding. On the next run:
@@ -467,7 +505,7 @@ For agents that support invokable skills, this repo ships a portable skill under
467
505
  ## GitHub Action
468
506
 
469
507
  ```yaml
470
- - uses: marshallguillory86/secure-code-agent@v0.12.10
508
+ - uses: marshallguillory86/secure-code-agent@v0.12.11
471
509
  with:
472
510
  config: secure-code-agent.json
473
511
  fail-on-gate: true
@@ -53,7 +53,7 @@ dependencies = [
53
53
  # server drives it by subprocess rather than reimplementing it — so nobody
54
54
  # who only wants the gate pays for an MCP dependency.
55
55
  mcp = [
56
- "mcp>=1.0,<2",
56
+ "mcp>=1.0,<3",
57
57
  ]
58
58
 
59
59
  # Scanners the default configuration lists in gates.require_scanners. Pinned
@@ -66,7 +66,7 @@ required-scanners = [
66
66
  # rules, so a JavaScript repository can be scanned on a host with no Node.
67
67
  # Pinned for the same reason as the rest: the floor is a claim about what
68
68
  # ran, and an unpinned ruleset makes that claim unreproducible.
69
- "njsscan==1.0.0",
69
+ "njsscan==1.0.1",
70
70
  ]
71
71
  # The remaining Python-packaged scanners. Bounded rather than pinned because
72
72
  # they are optional; the coverage gate does not require them by default.
@@ -87,7 +87,12 @@ dev = [
87
87
  "secure-code-agent[required-scanners]",
88
88
  "pytest>=8.0",
89
89
  "pytest-cov>=4.0",
90
- "ruff==0.16.2",
90
+ "ruff==0.16.9",
91
+ # Renders docs/html, the reading copy of every document in the repository.
92
+ # A dev dependency, not a runtime one: the audit never renders documentation,
93
+ # and tests/unit/test_rendered_docs.py is what keeps the committed copy
94
+ # current. See tools/render_docs.py.
95
+ "markdown>=3.5",
91
96
  ]
92
97
 
93
98
  [project.scripts]
@@ -133,14 +138,41 @@ ignore = ["E501"] # let line-length be a soft guide; long log strings are fine
133
138
 
134
139
  [tool.pytest.ini_options]
135
140
  testpaths = ["tests"]
136
- addopts = "-q --strict-markers"
141
+ # Coverage is on by default, so the declared test command — plain `pytest`,
142
+ # which is what maintainability-agent runs — reports it and the
143
+ # `fail_under` below actually applies. Without this the suite was
144
+ # measurable only by whoever remembered the flags, and MA scored
145
+ # test_effectiveness as "not measurable" on every run.
146
+ #
147
+ # `xml` is not decoration: a consumer reads the *artifact*, never stdout.
148
+ # MA looks for `coverage.xml`, `coverage/lcov.info` or `lcov.info` and
149
+ # requires it to be newer than the moment before it ran the suite, so a
150
+ # repository cannot hand it a committed report and set its own
151
+ # test_effectiveness. With `term` alone the number was printed for humans
152
+ # and invisible to every tool, which is the same defect one layer down.
153
+ # `coverage.xml` is gitignored.
154
+ #
155
+ # A step that is not the one measuring passes `--no-cov`;
156
+ # tests/unit/test_one_coverage_floor.py requires every CI invocation to say
157
+ # which it is.
158
+ addopts = "-q --strict-markers --cov=secure_code_audit --cov-branch --cov-report=term --cov-report=xml"
159
+ # The suite tests this checkout, never an installed copy. Without it, bare
160
+ # `pytest` imports whichever secure_code_audit the interpreter can find, and
161
+ # an environment holding a plain install grades code nobody is editing —
162
+ # which is how maintainability-agent came to report a bug fixed in #59.
163
+ pythonpath = ["src"]
137
164
 
138
165
  [tool.coverage.run]
139
166
  source = ["secure_code_audit"]
140
167
  branch = true
141
168
 
142
169
  [tool.coverage.report]
143
- fail_under = 85
170
+ # The house floor. Raised 85 -> 92 once the suite could actually clear it:
171
+ # measured 93.79% branch coverage after the test seat covered --verify-against
172
+ # (which had no test through main() at all), the four live-config keys, and the
173
+ # loader's fail-closed paths. Raising it while the suite sat at 90.42% would
174
+ # have bought a red build and no tests.
175
+ fail_under = 92
144
176
  exclude_lines = [
145
177
  "pragma: no cover",
146
178
  "if __name__ == .__main__.:",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: secure-code-agent
3
- Version: 0.12.10
3
+ Version: 0.12.11
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
@@ -30,11 +30,11 @@ Description-Content-Type: text/markdown
30
30
  License-File: LICENSE
31
31
  Requires-Dist: PyYAML<7,>=6.0.2
32
32
  Provides-Extra: mcp
33
- Requires-Dist: mcp<2,>=1.0; extra == "mcp"
33
+ Requires-Dist: mcp<3,>=1.0; extra == "mcp"
34
34
  Provides-Extra: required-scanners
35
35
  Requires-Dist: bandit==1.9.4; extra == "required-scanners"
36
36
  Requires-Dist: pip-audit==2.10.1; extra == "required-scanners"
37
- Requires-Dist: njsscan==1.0.0; extra == "required-scanners"
37
+ Requires-Dist: njsscan==1.0.1; extra == "required-scanners"
38
38
  Provides-Extra: python-scanners
39
39
  Requires-Dist: secure-code-agent[required-scanners]; extra == "python-scanners"
40
40
  Requires-Dist: semgrep<2,>=1.172; extra == "python-scanners"
@@ -43,7 +43,8 @@ Provides-Extra: dev
43
43
  Requires-Dist: secure-code-agent[required-scanners]; extra == "dev"
44
44
  Requires-Dist: pytest>=8.0; extra == "dev"
45
45
  Requires-Dist: pytest-cov>=4.0; extra == "dev"
46
- Requires-Dist: ruff==0.16.2; extra == "dev"
46
+ Requires-Dist: ruff==0.16.9; extra == "dev"
47
+ Requires-Dist: markdown>=3.5; extra == "dev"
47
48
  Dynamic: license-file
48
49
 
49
50
  # secure-code-agent
@@ -454,6 +455,44 @@ for repositories whose `pyproject.toml` is their authoritative audit input.
454
455
 
455
456
  Wildcard rule (`rule_id: "*"`) requires a `file` or `paths` scope — you cannot disable a rule globally.
456
457
 
458
+ ## Extending the standards mapping
459
+
460
+ The shipped mapping table lives in `data/standards.yaml` and covers the rules
461
+ this project curates. Semgrep alone publishes thousands, so point
462
+ `standards_overlay` at your own file to map or correct a rule without waiting
463
+ for a release:
464
+
465
+ ```json
466
+ { "standards_overlay": "security/standards-overlay.yaml" }
467
+ ```
468
+
469
+ ```yaml
470
+ version: 1
471
+ entries:
472
+ - scanner: semgrep
473
+ rule_id: python.lang.security.audit.my-rule
474
+ canonical_cwe: CWE-22
475
+ owasp_top10: A01
476
+ asvs_section: V12.3.1
477
+ nist_ssdf: PW.5.1
478
+ short_desc: Path traversal in our request handler.
479
+ fix_hint: Resolve the path and contain it under the upload root.
480
+ ```
481
+
482
+ An overlay entry wins over the shipped table for the fields it sets, and an
483
+ adapter asserting its own CWE still wins over both.
484
+
485
+ **An overlay cannot set `severity`, `confidence` or `category`**, and a file
486
+ that tries is refused with the reason rather than ignored. Those three are the
487
+ scoring inputs: an overlay that set them could move the grade, which is the
488
+ hole [D32](docs/decisions.md) closed on the configuration side. An overlay says
489
+ *what weakness a rule describes*; it does not say *how much it counts*. Set
490
+ severity policy with `severity_overrides` in a config kept outside the audited
491
+ tree.
492
+
493
+ A declared overlay that cannot be read fails the run rather than being skipped,
494
+ for the same reason a malformed `.scignore.yaml` does.
495
+
457
496
  ## Baseline + incremental adoption
458
497
 
459
498
  `secure-code-baseline.json` fingerprints every current finding. On the next run:
@@ -515,7 +554,7 @@ For agents that support invokable skills, this repo ships a portable skill under
515
554
  ## GitHub Action
516
555
 
517
556
  ```yaml
518
- - uses: marshallguillory86/secure-code-agent@v0.12.10
557
+ - uses: marshallguillory86/secure-code-agent@v0.12.11
519
558
  with:
520
559
  config: secure-code-agent.json
521
560
  fail-on-gate: true
@@ -31,7 +31,11 @@ src/secure_code_audit/suppressions.py
31
31
  src/secure_code_audit/triage.py
32
32
  src/secure_code_audit/verify.py
33
33
  src/secure_code_audit/data/semgrep-offline.yaml
34
+ src/secure_code_audit/data/standards.yaml
34
35
  src/secure_code_audit/scanners/__init__.py
36
+ src/secure_code_audit/scanners/_execution.py
37
+ src/secure_code_audit/scanners/_finding_builder.py
38
+ src/secure_code_audit/scanners/_resolution.py
35
39
  src/secure_code_audit/scanners/bandit_scanner.py
36
40
  src/secure_code_audit/scanners/base.py
37
41
  src/secure_code_audit/scanners/builtin_rules.py
@@ -4,10 +4,11 @@ PyYAML<7,>=6.0.2
4
4
  secure-code-agent[required-scanners]
5
5
  pytest>=8.0
6
6
  pytest-cov>=4.0
7
- ruff==0.16.2
7
+ ruff==0.16.9
8
+ markdown>=3.5
8
9
 
9
10
  [mcp]
10
- mcp<2,>=1.0
11
+ mcp<3,>=1.0
11
12
 
12
13
  [python-scanners]
13
14
  secure-code-agent[required-scanners]
@@ -17,4 +18,4 @@ checkov<4,>=3.3
17
18
  [required-scanners]
18
19
  bandit==1.9.4
19
20
  pip-audit==2.10.1
20
- njsscan==1.0.0
21
+ njsscan==1.0.1
@@ -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.10"
15
+ __version__ = "0.12.11"
@@ -21,6 +21,7 @@ from secure_code_audit import (
21
21
  renderers,
22
22
  sarif,
23
23
  scanners,
24
+ standards,
24
25
  suppressions,
25
26
  triage,
26
27
  )
@@ -361,87 +362,43 @@ def _print_preflight(rows: list[dict], unselected: list[str], blocking: list[str
361
362
  print(" all required scanners resolved")
362
363
 
363
364
 
364
- def _do_audit(args: argparse.Namespace) -> int:
365
- cfg, target, root = _prepare_audit(args)
366
- _require_configured_gates(args, cfg)
367
- # Before anything is resolved or written, and before any scanner runs.
368
- _assert_config_writes_are_contained(cfg, root, target)
369
- # Resolved before the scan so the run can recognise its own artifacts.
370
- paths = _resolve_outputs(args, cfg, root)
371
-
372
- # ----- scanners -----
373
- scan = _run_scanners(args, cfg, target)
374
- all_findings = scan.findings
375
- ran, unavailable, executions = scan.ran, scan.unavailable, scan.executions
376
-
377
- # ----- SARIF imports -----
378
- # An imported SARIF is coverage: a scanner someone else ran on our behalf.
379
- imported, imported_executions = _ingest_sarif_imports(args.sarif_import)
380
- all_findings.extend(imported)
381
- executions.extend(imported_executions)
382
-
383
- # ----- one path convention, before anything reads a path -----
384
- # Repository-relative from here on, whichever tool reported the finding.
385
- # Suppressions, the baseline and every written output compare or publish
386
- # this path, and none of them may depend on where the checkout lives.
387
- scanned = target if target.is_dir() else target.parent
388
- all_findings = anchor(all_findings, scanned=scanned, root=root)
389
-
390
- # ----- scan scope, enforced once -----
391
- own_artifacts = _own_artifacts(paths, cfg, root)
392
- all_findings = _drop_excluded(all_findings, target, cfg, own_artifacts, root)
393
-
394
- # ----- one weakness, one finding -----
395
- # Before overrides and suppressions, so an operator writing either one
396
- # sees the same finding the report will show. Bandit's B308 and B703 are
397
- # the same check under two ids and shared fifty lines in Django; a report
398
- # that lists a line twice is wrong about the code, and a work order built
399
- # from it would ask for the same fix twice.
400
- all_findings = merge_corroborating(all_findings)
401
-
402
- # ----- overrides from config -----
403
- all_findings = _apply_overrides(all_findings, cfg)
404
-
405
- # ----- suppressions -----
406
- suppression_path = _under_root(root, cfg.suppressions_file)
407
- sup_rules, sup_errors = suppressions.load(suppression_path)
408
- if sup_errors:
409
- # Fail closed. A suppression file that exists is an explicit
410
- # instruction; ignoring it silently changes which findings are
411
- # reported, and "my suppressions are working" then looks exactly
412
- # like "my suppressions were skipped". This bit in practice: a
413
- # missing PyYAML made an entire .scignore.yaml a no-op while the
414
- # run still exited 0 and reported the suppressed finding as live.
415
- #
416
- # Only reachable when the file is present, so repositories that
417
- # do not use suppressions are unaffected.
418
- for err in sup_errors:
419
- sys.stderr.write(f"ERROR: {err}\n")
420
- sys.stderr.write(
421
- f"ERROR: {suppression_path} exists but could not be applied; refusing to "
422
- "report results that silently ignore it.\n"
423
- )
424
- return 1
425
- all_findings = suppressions.apply(all_findings, sup_rules, root)
426
- all_findings.extend(
427
- anchor(
428
- suppressions.expired_findings(sup_rules, suppression_path),
429
- scanned=scanned,
430
- root=root,
431
- )
432
- )
433
-
434
- # ----- severity threshold filter -----
435
- threshold = Severity.from_string(args.severity_threshold)
436
- all_findings = [f for f in all_findings if f.severity.rank >= threshold.rank]
365
+ @dataclass(frozen=True)
366
+ class _Axes:
367
+ """What the axis split produced, and the LOC that goes with it.
437
368
 
438
- # ----- baseline -----
439
- baseline_path = _under_root(root, args.baseline or cfg.outputs["baseline_path"])
440
- baseline = baseline_mod.load(baseline_path)
441
- baseline_state = baseline_mod.state(baseline_path)
442
- all_findings = baseline_mod.mark_new(all_findings, baseline, root)
369
+ Seven values travelled together as seven locals inside a 346-line
370
+ function; a dataclass is what they were already.
371
+ """
443
372
 
444
- # ----- scoring -----
373
+ primary: list[Finding]
374
+ test: list[Finding]
375
+ docs: list[Finding]
376
+ #: Kept whole as well as split: `declared_axes` reads it by name for the
377
+ #: capability axes, which are not a fixed set.
378
+ path_axes: dict[str, list[Finding]]
379
+ all_findings: list[Finding]
380
+ loc: int
381
+ test_loc: int
382
+ docs_loc: int
383
+ changed_note: str | None
384
+
385
+
386
+ def _split_axes(
387
+ all_findings: list[Finding],
388
+ args: argparse.Namespace,
389
+ cfg: config_mod.Config,
390
+ target: Path,
391
+ root: Path,
392
+ own_artifacts: frozenset[Path],
393
+ ) -> _Axes:
394
+ """Partition findings by axis and measure each axis's LOC.
395
+
396
+ Lifted whole out of `_do_audit`, which was 346 lines at complexity 36
397
+ against configured limits of 80 and 15. The checks, their order and
398
+ their comments are unchanged: this is a move, not a rewrite, because a
399
+ rewrite of the axis split is how a repository gets graded on its test
400
+ fixtures and the corpus has already shown what that costs.
401
+ """
445
402
  # The test tree is reported, not scored. A project graded on its test
446
403
  # fixtures is graded on the wrong thing: across the calibration corpus,
447
404
  # including test directories moved the median normalized subtotal from
@@ -522,6 +479,124 @@ def _do_audit(args: argparse.Namespace) -> int:
522
479
  own_artifacts,
523
480
  cfg.docs_patterns,
524
481
  )
482
+
483
+ return _Axes(
484
+ primary=primary_findings,
485
+ test=test_findings,
486
+ docs=docs_findings,
487
+ path_axes=path_axes,
488
+ all_findings=all_findings,
489
+ loc=loc,
490
+ test_loc=test_loc,
491
+ docs_loc=docs_loc,
492
+ changed_note=changed_note,
493
+ )
494
+
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)
540
+
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
+ )
582
+
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]
586
+
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
+
593
+ # ----- scoring -----
594
+ _axes = _split_axes(all_findings, args, cfg, target, root, own_artifacts)
595
+ all_findings = _axes.all_findings
596
+ primary_findings, test_findings, docs_findings = _axes.primary, _axes.test, _axes.docs
597
+ path_axes = _axes.path_axes
598
+ loc, test_loc, docs_loc = _axes.loc, _axes.test_loc, _axes.docs_loc
599
+ changed_note = _axes.changed_note
525
600
  # Dependencies come off the code-condition score and onto their own axis.
526
601
  # A CVE in a pinned dependency is fixed with a version bump; an injection
527
602
  # flaw is fixed with a rewrite. Averaging them produced the largest
@@ -1070,6 +1145,78 @@ def _write_outputs(
1070
1145
  pillar_mod.write(pillar, paths.security_pillar)
1071
1146
 
1072
1147
 
1148
+ def _install_standards_overlay(cfg: config_mod.Config, root: Path) -> int | None:
1149
+ """Layer the operator's standards mapping over the shipped table.
1150
+
1151
+ Returns an exit code when the overlay cannot be applied, else None.
1152
+
1153
+ Fails closed, for the same reason a malformed suppressions file does: the
1154
+ operator named a mapping file, so running without it reports findings
1155
+ under the standards nobody chose — unmapped where they should be mapped,
1156
+ and no louder than a rule nobody has curated. Reachable only when the key
1157
+ is set, so repositories that do not use an overlay are unaffected.
1158
+ """
1159
+ if not cfg.standards_overlay:
1160
+ return None
1161
+ path = _under_root(root, cfg.standards_overlay)
1162
+ entries, errors = standards.load_overlay(path)
1163
+ if errors:
1164
+ for err in errors:
1165
+ sys.stderr.write(f"ERROR: {err}\n")
1166
+ sys.stderr.write(
1167
+ f"ERROR: standards_overlay {path} is declared but could not be applied; "
1168
+ "refusing to report findings mapped by a table the operator did not choose.\n"
1169
+ )
1170
+ return 1
1171
+ standards.install_overlay(entries)
1172
+ return None
1173
+
1174
+
1175
+ #: Config keys that move the grade, and so may not come from the audited tree.
1176
+ #: `severity_overrides` sets a finding's weight — `INFORMATIONAL` is 0.0 in
1177
+ #: `SEVERITY_WEIGHT`, so re-labelling removes a finding from the score while
1178
+ #: leaving it in the report. `category_overrides` moves it between the
1179
+ #: per-category rates, and the worst category drives the overall.
1180
+ _GRADING_KEYS = ("severity_overrides", "category_overrides")
1181
+
1182
+
1183
+ def _refuse_target_policy(cfg: config_mod.Config, target: Path) -> config_mod.Config:
1184
+ """Drop grading keys supplied by the audited tree. Returns the config to use.
1185
+
1186
+ D29 settled that a config the tree supplies does not choose a scanner's
1187
+ command. It said nothing about what that config may claim about the
1188
+ findings, and these two keys were applied from any config, including one
1189
+ discovered inside the tree, with no `--trust-target-config`.
1190
+
1191
+ Measured on one `subprocess` call made with `shell=True` — paraphrased
1192
+ rather than quoted, because this repository audits its own source and
1193
+ the verbatim expression trips `sca.python.subprocess.shell_true` here,
1194
+ in shipped code, where it is scored: 0.00/F honestly, and
1195
+ 5.00/A+ with an in-tree `severity_overrides` mapping B602 to
1196
+ informational. The audited party moved its own grade, which is §4
1197
+ criterion 1 falsifiable by the one actor with a motive, and promise P3
1198
+ from the other side — P3 refuses an input whose *removal* raises the
1199
+ graded field, and this is one whose *addition* raises it.
1200
+
1201
+ Not silent, and not fatal. A repository may carry this config for its
1202
+ own operator's use, and that operator says so with
1203
+ `--trust-target-config` or by keeping the config outside the tree —
1204
+ exactly the two routes that already work for a command.
1205
+ """
1206
+ if not config_mod.target_config_is_untrusted(cfg, target):
1207
+ return cfg
1208
+ ignored = [key for key in _GRADING_KEYS if getattr(cfg, key)]
1209
+ if not ignored:
1210
+ return cfg
1211
+ for key in ignored:
1212
+ sys.stderr.write(
1213
+ f"WARNING: {key} in {cfg.source_path} is inside the audited tree and "
1214
+ f"changes the grade, so it was ignored. Pass --trust-target-config, or "
1215
+ f"keep the configuration outside the tree, to apply it.\n"
1216
+ )
1217
+ return replace(cfg, severity_overrides={}, category_overrides={})
1218
+
1219
+
1073
1220
  def _apply_overrides(findings: list[Finding], cfg: config_mod.Config) -> list[Finding]:
1074
1221
  """Apply config-level severity_overrides + category_overrides."""
1075
1222
  if not cfg.severity_overrides and not cfg.category_overrides: