secure-code-agent 0.4.0__tar.gz → 0.5.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 (48) hide show
  1. {secure_code_agent-0.4.0/src/secure_code_agent.egg-info → secure_code_agent-0.5.0}/PKG-INFO +28 -1
  2. {secure_code_agent-0.4.0 → secure_code_agent-0.5.0}/README.md +27 -0
  3. {secure_code_agent-0.4.0 → secure_code_agent-0.5.0}/pyproject.toml +15 -1
  4. {secure_code_agent-0.4.0 → secure_code_agent-0.5.0/src/secure_code_agent.egg-info}/PKG-INFO +28 -1
  5. {secure_code_agent-0.4.0 → secure_code_agent-0.5.0}/src/secure_code_agent.egg-info/SOURCES.txt +2 -0
  6. secure_code_agent-0.5.0/src/secure_code_audit/__init__.py +15 -0
  7. {secure_code_agent-0.4.0 → secure_code_agent-0.5.0}/src/secure_code_audit/cli.py +91 -13
  8. {secure_code_agent-0.4.0 → secure_code_agent-0.5.0}/src/secure_code_audit/config.py +36 -0
  9. {secure_code_agent-0.4.0 → secure_code_agent-0.5.0}/src/secure_code_audit/git_tools.py +36 -6
  10. secure_code_agent-0.5.0/src/secure_code_audit/pillar.py +194 -0
  11. secure_code_agent-0.5.0/src/secure_code_audit/practice.py +300 -0
  12. {secure_code_agent-0.4.0 → secure_code_agent-0.5.0}/src/secure_code_audit/renderers.py +93 -3
  13. {secure_code_agent-0.4.0 → secure_code_agent-0.5.0}/src/secure_code_audit/scoring.py +121 -0
  14. secure_code_agent-0.4.0/src/secure_code_audit/__init__.py +0 -3
  15. {secure_code_agent-0.4.0 → secure_code_agent-0.5.0}/LICENSE +0 -0
  16. {secure_code_agent-0.4.0 → secure_code_agent-0.5.0}/setup.cfg +0 -0
  17. {secure_code_agent-0.4.0 → secure_code_agent-0.5.0}/src/secure_code_agent.egg-info/dependency_links.txt +0 -0
  18. {secure_code_agent-0.4.0 → secure_code_agent-0.5.0}/src/secure_code_agent.egg-info/entry_points.txt +0 -0
  19. {secure_code_agent-0.4.0 → secure_code_agent-0.5.0}/src/secure_code_agent.egg-info/requires.txt +0 -0
  20. {secure_code_agent-0.4.0 → secure_code_agent-0.5.0}/src/secure_code_agent.egg-info/top_level.txt +0 -0
  21. {secure_code_agent-0.4.0 → secure_code_agent-0.5.0}/src/secure_code_audit/baseline.py +0 -0
  22. {secure_code_agent-0.4.0 → secure_code_agent-0.5.0}/src/secure_code_audit/data/semgrep-offline.yaml +0 -0
  23. {secure_code_agent-0.4.0 → secure_code_agent-0.5.0}/src/secure_code_audit/findings.py +0 -0
  24. {secure_code_agent-0.4.0 → secure_code_agent-0.5.0}/src/secure_code_audit/instructions.py +0 -0
  25. {secure_code_agent-0.4.0 → secure_code_agent-0.5.0}/src/secure_code_audit/remediation.py +0 -0
  26. {secure_code_agent-0.4.0 → secure_code_agent-0.5.0}/src/secure_code_audit/ruleset.py +0 -0
  27. {secure_code_agent-0.4.0 → secure_code_agent-0.5.0}/src/secure_code_audit/sarif.py +0 -0
  28. {secure_code_agent-0.4.0 → secure_code_agent-0.5.0}/src/secure_code_audit/scanner_status.py +0 -0
  29. {secure_code_agent-0.4.0 → secure_code_agent-0.5.0}/src/secure_code_audit/scanners/__init__.py +0 -0
  30. {secure_code_agent-0.4.0 → secure_code_agent-0.5.0}/src/secure_code_audit/scanners/bandit_scanner.py +0 -0
  31. {secure_code_agent-0.4.0 → secure_code_agent-0.5.0}/src/secure_code_audit/scanners/base.py +0 -0
  32. {secure_code_agent-0.4.0 → secure_code_agent-0.5.0}/src/secure_code_audit/scanners/builtin_rules.py +0 -0
  33. {secure_code_agent-0.4.0 → secure_code_agent-0.5.0}/src/secure_code_audit/scanners/checkov_scanner.py +0 -0
  34. {secure_code_agent-0.4.0 → secure_code_agent-0.5.0}/src/secure_code_audit/scanners/floor.py +0 -0
  35. {secure_code_agent-0.4.0 → secure_code_agent-0.5.0}/src/secure_code_audit/scanners/gitleaks_scanner.py +0 -0
  36. {secure_code_agent-0.4.0 → secure_code_agent-0.5.0}/src/secure_code_audit/scanners/gosec_scanner.py +0 -0
  37. {secure_code_agent-0.4.0 → secure_code_agent-0.5.0}/src/secure_code_audit/scanners/hadolint_scanner.py +0 -0
  38. {secure_code_agent-0.4.0 → secure_code_agent-0.5.0}/src/secure_code_audit/scanners/njsscan_scanner.py +0 -0
  39. {secure_code_agent-0.4.0 → secure_code_agent-0.5.0}/src/secure_code_audit/scanners/npm_audit_scanner.py +0 -0
  40. {secure_code_agent-0.4.0 → secure_code_agent-0.5.0}/src/secure_code_audit/scanners/osv_scanner.py +0 -0
  41. {secure_code_agent-0.4.0 → secure_code_agent-0.5.0}/src/secure_code_audit/scanners/pip_audit_scanner.py +0 -0
  42. {secure_code_agent-0.4.0 → secure_code_agent-0.5.0}/src/secure_code_audit/scanners/rubocop_scanner.py +0 -0
  43. {secure_code_agent-0.4.0 → secure_code_agent-0.5.0}/src/secure_code_audit/scanners/scorecard_scanner.py +0 -0
  44. {secure_code_agent-0.4.0 → secure_code_agent-0.5.0}/src/secure_code_audit/scanners/semgrep_scanner.py +0 -0
  45. {secure_code_agent-0.4.0 → secure_code_agent-0.5.0}/src/secure_code_audit/scanners/trivy_scanner.py +0 -0
  46. {secure_code_agent-0.4.0 → secure_code_agent-0.5.0}/src/secure_code_audit/scanners/trufflehog_scanner.py +0 -0
  47. {secure_code_agent-0.4.0 → secure_code_agent-0.5.0}/src/secure_code_audit/standards.py +0 -0
  48. {secure_code_agent-0.4.0 → secure_code_agent-0.5.0}/src/secure_code_audit/suppressions.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: secure-code-agent
3
- Version: 0.4.0
3
+ Version: 0.5.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
@@ -375,6 +375,31 @@ SARIF upload. Pin production usage to a full commit SHA; the version tag above
375
375
  is shown for readability. See [`action.yml`](action.yml) and
376
376
  [`examples/github-actions/`](examples/github-actions/) for full workflows.
377
377
 
378
+ ## maintainability-agent integration
379
+
380
+ `maintainability-agent` declares Security a **delegated** pillar naming this
381
+ tool, and reports it as `NotApplicable` so silence is never read as safety.
382
+ This tool emits the artifact that completes the picture:
383
+
384
+ ```bash
385
+ secure-code-agent . --security-pillar security-pillar.json
386
+ maintainability-agent . --security-pillar security-pillar.json
387
+ ```
388
+
389
+ Standalone use is unaffected — without the flag nothing is written and every
390
+ other output is identical. MA never executes this tool, and this tool never
391
+ imports MA; two independently releasable packages exchanging one document.
392
+
393
+ The artifact carries **two values that are never averaged**: a practice level
394
+ read from configuration and CI (*is anything preventing the next
395
+ vulnerability?*) and a code condition read from the scanners (*what did they
396
+ find?*). `condition` is `null` whenever scanner coverage is incomplete — this
397
+ tool's score is a rate over findings, so removing scanners makes the raw number
398
+ go **up**, and an unscanned repository must not arrive at MA looking measured.
399
+
400
+ Full contract, invariants and the practice rubric:
401
+ [`docs/ma-integration.md`](docs/ma-integration.md).
402
+
378
403
  ## What this is NOT
379
404
 
380
405
  - ❌ **Not a SAST engine.** We delegate to Semgrep / Bandit / CodeQL / etc. — we don't write yet another AST analyzer.
@@ -398,6 +423,8 @@ Full design philosophy in [`docs/design.md`](docs/design.md).
398
423
 
399
424
  - [`docs/product-intent.md`](docs/product-intent.md) — Why this exists, who it serves, what it refuses to become
400
425
  - [`docs/decisions.md`](docs/decisions.md) — Decision register: rulings the code alone cannot answer
426
+ - [`docs/ma-integration.md`](docs/ma-integration.md) — The `security-pillar.json` contract maintainability-agent reads
427
+ - [`docs/calibration.md`](docs/calibration.md) — The calibration study, its corpus, and what it found
401
428
  - [`docs/design.md`](docs/design.md) — Architecture + non-goals + scanner protocol
402
429
  - [`docs/architecture.md`](docs/architecture.md) — Audit of the system as built + remediation sequence
403
430
  - [`docs/release-blockers.md`](docs/release-blockers.md) — Open v0.3.0 release blockers (do not tag until closed)
@@ -329,6 +329,31 @@ SARIF upload. Pin production usage to a full commit SHA; the version tag above
329
329
  is shown for readability. See [`action.yml`](action.yml) and
330
330
  [`examples/github-actions/`](examples/github-actions/) for full workflows.
331
331
 
332
+ ## maintainability-agent integration
333
+
334
+ `maintainability-agent` declares Security a **delegated** pillar naming this
335
+ tool, and reports it as `NotApplicable` so silence is never read as safety.
336
+ This tool emits the artifact that completes the picture:
337
+
338
+ ```bash
339
+ secure-code-agent . --security-pillar security-pillar.json
340
+ maintainability-agent . --security-pillar security-pillar.json
341
+ ```
342
+
343
+ Standalone use is unaffected — without the flag nothing is written and every
344
+ other output is identical. MA never executes this tool, and this tool never
345
+ imports MA; two independently releasable packages exchanging one document.
346
+
347
+ The artifact carries **two values that are never averaged**: a practice level
348
+ read from configuration and CI (*is anything preventing the next
349
+ vulnerability?*) and a code condition read from the scanners (*what did they
350
+ find?*). `condition` is `null` whenever scanner coverage is incomplete — this
351
+ tool's score is a rate over findings, so removing scanners makes the raw number
352
+ go **up**, and an unscanned repository must not arrive at MA looking measured.
353
+
354
+ Full contract, invariants and the practice rubric:
355
+ [`docs/ma-integration.md`](docs/ma-integration.md).
356
+
332
357
  ## What this is NOT
333
358
 
334
359
  - ❌ **Not a SAST engine.** We delegate to Semgrep / Bandit / CodeQL / etc. — we don't write yet another AST analyzer.
@@ -352,6 +377,8 @@ Full design philosophy in [`docs/design.md`](docs/design.md).
352
377
 
353
378
  - [`docs/product-intent.md`](docs/product-intent.md) — Why this exists, who it serves, what it refuses to become
354
379
  - [`docs/decisions.md`](docs/decisions.md) — Decision register: rulings the code alone cannot answer
380
+ - [`docs/ma-integration.md`](docs/ma-integration.md) — The `security-pillar.json` contract maintainability-agent reads
381
+ - [`docs/calibration.md`](docs/calibration.md) — The calibration study, its corpus, and what it found
355
382
  - [`docs/design.md`](docs/design.md) — Architecture + non-goals + scanner protocol
356
383
  - [`docs/architecture.md`](docs/architecture.md) — Audit of the system as built + remediation sequence
357
384
  - [`docs/release-blockers.md`](docs/release-blockers.md) — Open v0.3.0 release blockers (do not tag until closed)
@@ -4,7 +4,15 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "secure-code-agent"
7
- version = "0.4.0"
7
+ # Derived from `secure_code_audit.__version__`, which is the only place the
8
+ # number is written. It used to be duplicated here, and the two drifted: this
9
+ # file said 0.4.0 while the package said 0.3.0, so the released v0.4.0 wheel
10
+ # stamped 0.3.0 into every SARIF document, JSON report, Markdown header,
11
+ # `--version` and the pillar artifact handed to maintainability-agent. The
12
+ # release workflow compared the tag against *this* line and never looked at the
13
+ # package, so nothing caught it. A test asserting the two agree would only
14
+ # police the duplication; removing it is the fix.
15
+ dynamic = ["version"]
8
16
  description = "Deterministic security gate + bounded AI remediation prompt generator. NIST SSDF / OWASP ASVS / CWE Top 25 anchored."
9
17
  readme = "README.md"
10
18
  requires-python = ">=3.10"
@@ -86,6 +94,12 @@ Documentation = "https://github.com/marshallguillory86/secure-code-agent/tree/ma
86
94
  Issues = "https://github.com/marshallguillory86/secure-code-agent/issues"
87
95
  Changelog = "https://github.com/marshallguillory86/secure-code-agent/blob/main/CHANGELOG.md"
88
96
 
97
+ [tool.setuptools.dynamic]
98
+ # The single source of truth for the version. `src/secure_code_audit/__init__.py`
99
+ # holds the literal; this reads it. The two cannot drift because there is only
100
+ # one of them.
101
+ version = { attr = "secure_code_audit.__version__" }
102
+
89
103
  [tool.setuptools.packages.find]
90
104
  where = ["src"]
91
105
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: secure-code-agent
3
- Version: 0.4.0
3
+ Version: 0.5.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
@@ -375,6 +375,31 @@ SARIF upload. Pin production usage to a full commit SHA; the version tag above
375
375
  is shown for readability. See [`action.yml`](action.yml) and
376
376
  [`examples/github-actions/`](examples/github-actions/) for full workflows.
377
377
 
378
+ ## maintainability-agent integration
379
+
380
+ `maintainability-agent` declares Security a **delegated** pillar naming this
381
+ tool, and reports it as `NotApplicable` so silence is never read as safety.
382
+ This tool emits the artifact that completes the picture:
383
+
384
+ ```bash
385
+ secure-code-agent . --security-pillar security-pillar.json
386
+ maintainability-agent . --security-pillar security-pillar.json
387
+ ```
388
+
389
+ Standalone use is unaffected — without the flag nothing is written and every
390
+ other output is identical. MA never executes this tool, and this tool never
391
+ imports MA; two independently releasable packages exchanging one document.
392
+
393
+ The artifact carries **two values that are never averaged**: a practice level
394
+ read from configuration and CI (*is anything preventing the next
395
+ vulnerability?*) and a code condition read from the scanners (*what did they
396
+ find?*). `condition` is `null` whenever scanner coverage is incomplete — this
397
+ tool's score is a rate over findings, so removing scanners makes the raw number
398
+ go **up**, and an unscanned repository must not arrive at MA looking measured.
399
+
400
+ Full contract, invariants and the practice rubric:
401
+ [`docs/ma-integration.md`](docs/ma-integration.md).
402
+
378
403
  ## What this is NOT
379
404
 
380
405
  - ❌ **Not a SAST engine.** We delegate to Semgrep / Bandit / CodeQL / etc. — we don't write yet another AST analyzer.
@@ -398,6 +423,8 @@ Full design philosophy in [`docs/design.md`](docs/design.md).
398
423
 
399
424
  - [`docs/product-intent.md`](docs/product-intent.md) — Why this exists, who it serves, what it refuses to become
400
425
  - [`docs/decisions.md`](docs/decisions.md) — Decision register: rulings the code alone cannot answer
426
+ - [`docs/ma-integration.md`](docs/ma-integration.md) — The `security-pillar.json` contract maintainability-agent reads
427
+ - [`docs/calibration.md`](docs/calibration.md) — The calibration study, its corpus, and what it found
401
428
  - [`docs/design.md`](docs/design.md) — Architecture + non-goals + scanner protocol
402
429
  - [`docs/architecture.md`](docs/architecture.md) — Audit of the system as built + remediation sequence
403
430
  - [`docs/release-blockers.md`](docs/release-blockers.md) — Open v0.3.0 release blockers (do not tag until closed)
@@ -14,6 +14,8 @@ src/secure_code_audit/config.py
14
14
  src/secure_code_audit/findings.py
15
15
  src/secure_code_audit/git_tools.py
16
16
  src/secure_code_audit/instructions.py
17
+ src/secure_code_audit/pillar.py
18
+ src/secure_code_audit/practice.py
17
19
  src/secure_code_audit/remediation.py
18
20
  src/secure_code_audit/renderers.py
19
21
  src/secure_code_audit/ruleset.py
@@ -0,0 +1,15 @@
1
+ """secure-code-agent — deterministic security gate + bounded AI remediation prompts."""
2
+
3
+ #: The only place the version is written. `pyproject.toml` derives it via
4
+ #: `[tool.setuptools.dynamic]`, so the two cannot drift.
5
+ #:
6
+ #: They did drift once, and it shipped: pyproject said 0.4.0 while this said
7
+ #: 0.3.0, so the released v0.4.0 wheel stamped 0.3.0 into every SARIF document,
8
+ #: every JSON report, the Markdown header, `--version`, and the
9
+ #: `security-pillar.json` handed to maintainability-agent. The release workflow
10
+ #: compared the tag against pyproject's line and never looked here — it
11
+ #: verified the half that was right and shipped the half that was wrong.
12
+ #:
13
+ #: PyPI is immutable, so 0.4.0 stays wrong. 0.5.0 is the first build whose
14
+ #: artifacts name their own producer correctly.
15
+ __version__ = "0.5.0"
@@ -24,8 +24,10 @@ from secure_code_audit import (
24
24
  )
25
25
  from secure_code_audit import baseline as baseline_mod
26
26
  from secure_code_audit import config as config_mod
27
+ from secure_code_audit import pillar as pillar_mod
28
+ from secure_code_audit import practice as practice_mod
27
29
  from secure_code_audit.findings import Category, Finding, Severity
28
- from secure_code_audit.git_tools import find_repo_root, is_excluded, loc_under
30
+ from secure_code_audit.git_tools import find_repo_root, is_excluded, is_test_path, loc_under
29
31
  from secure_code_audit.scanner_status import (
30
32
  ScannerExecution,
31
33
  ScannerOutcome,
@@ -33,7 +35,13 @@ from secure_code_audit.scanner_status import (
33
35
  execution_from_result,
34
36
  )
35
37
  from secure_code_audit.scanners import floor
36
- from secure_code_audit.scoring import active_gates, evaluate_gates
38
+ from secure_code_audit.scoring import (
39
+ active_gates,
40
+ evaluate_gates,
41
+ partition_by_tree,
42
+ split_side_axes,
43
+ summarize_axis,
44
+ )
37
45
  from secure_code_audit.scoring import score as score_findings
38
46
  from secure_code_audit.scoring import verdict as build_verdict
39
47
 
@@ -60,6 +68,14 @@ def _parser() -> argparse.ArgumentParser:
60
68
  p.add_argument("--sarif-output", help="SARIF 2.1.0 output path.")
61
69
  p.add_argument("--comment-output", help="PR-comment markdown output path.")
62
70
  p.add_argument("--prompt-output", help="Remediation prompt output path.")
71
+ p.add_argument(
72
+ "--security-pillar",
73
+ help=(
74
+ "Write security-pillar.json for maintainability-agent to ingest "
75
+ "via --security-pillar (D3). Carries practice level and code "
76
+ "condition as two values that are never averaged."
77
+ ),
78
+ )
63
79
  p.add_argument("--baseline", help="Baseline file path (read).")
64
80
  p.add_argument(
65
81
  "--bump-baseline", action="store_true", help="Rewrite the baseline from current findings."
@@ -290,11 +306,37 @@ def _do_audit(args: argparse.Namespace) -> int:
290
306
  all_findings = baseline_mod.mark_new(all_findings, baseline)
291
307
 
292
308
  # ----- scoring -----
309
+ # The test tree is reported, not scored. A project graded on its test
310
+ # fixtures is graded on the wrong thing: across the calibration corpus,
311
+ # including test directories moved the median normalized subtotal from
312
+ # 4.36 to 50.15 and put ten of fourteen well-maintained projects at F.
313
+ # Secrets are exempt and stay in the score — see ALWAYS_SCORED_CATEGORIES.
314
+ root_for_tests = target if target.is_dir() else target.parent
315
+ all_findings, test_findings = partition_by_tree(
316
+ all_findings,
317
+ lambda f: is_test_path(f.file_path, root_for_tests, cfg.test_patterns),
318
+ )
293
319
  if cfg.loc_for_scoring:
294
320
  loc = int(cfg.loc_for_scoring.get("value", 0))
321
+ test_loc = 0
295
322
  else:
296
- loc = loc_under(target, cfg.include_extensions, cfg.exclude_patterns)
297
- score = score_findings(all_findings, loc)
323
+ loc, test_loc = loc_under(
324
+ target, cfg.include_extensions, cfg.exclude_patterns, cfg.test_patterns
325
+ )
326
+ # Dependencies come off the code-condition score and onto their own axis.
327
+ # A CVE in a pinned dependency is fixed with a version bump; an injection
328
+ # flaw is fixed with a rewrite. Averaging them produced the largest
329
+ # remaining distortion in the corpus — median 15.36 against 7.32.
330
+ #
331
+ # The split is between *scoring* and *gating*, not between reported and
332
+ # hidden: `gated_findings` keeps the dependency advisories, so a critical
333
+ # runtime CVE still fails a build exactly as before.
334
+ gated_findings = all_findings
335
+ scored_findings, dependency_findings = split_side_axes(all_findings)
336
+ score = score_findings(scored_findings, loc)
337
+ test_tree = summarize_axis("test tree", test_findings, test_loc)
338
+ dependencies = summarize_axis("dependencies", dependency_findings)
339
+ axes = (test_tree, dependencies)
298
340
  # Naming an import on the command line asserts that it contributes coverage,
299
341
  # so a broken one fails the gate even if no config requires that scanner.
300
342
  # Requiring a tool that has nothing to look at would make every
@@ -311,23 +353,41 @@ def _do_audit(args: argparse.Namespace) -> int:
311
353
  *(execution.name for execution in imported_executions),
312
354
  ]
313
355
  coverage = evaluate_coverage(executions, required)
314
- gate = evaluate_gates(all_findings, score, cfg.gates, coverage)
356
+ # Gates see the dependency advisories; the score does not.
357
+ gate = evaluate_gates(gated_findings, score, cfg.gates, coverage)
315
358
  verdict = build_verdict(score, cfg.gates, coverage)
316
359
 
317
360
  # ----- write outputs -----
318
361
  paths = _resolve_outputs(args, cfg, root)
319
- _write_outputs(paths, all_findings, score, gate, coverage, ran, unavailable, verdict)
362
+ # The pillar artifact is what maintainability-agent ingests (D3). Built
363
+ # here rather than inside a renderer because it needs the practice level,
364
+ # which is read from configuration and CI rather than from findings.
365
+ security_pillar = pillar_mod.build(score, verdict, coverage, practice_mod.assess(target), axes)
366
+ _write_outputs(
367
+ paths,
368
+ all_findings,
369
+ score,
370
+ gate,
371
+ coverage,
372
+ ran,
373
+ unavailable,
374
+ verdict,
375
+ axes,
376
+ security_pillar,
377
+ )
320
378
  if args.bump_baseline:
321
379
  baseline_mod.write(baseline_path, all_findings, baseline)
322
380
 
323
381
  # ----- terminal output -----
324
382
  if args.json:
325
383
  sys.stdout.write(
326
- json.dumps(renderers.to_json(all_findings, score, gate, coverage, verdict), indent=2)
384
+ json.dumps(
385
+ renderers.to_json(all_findings, score, gate, coverage, verdict, axes), indent=2
386
+ )
327
387
  )
328
388
  sys.stdout.write("\n")
329
389
  else:
330
- _print_summary(verdict, score, gate, ran, unavailable, coverage, paths)
390
+ _print_summary(verdict, score, gate, ran, unavailable, coverage, paths, axes)
331
391
 
332
392
  return _exit_code(args, gate, all_findings)
333
393
 
@@ -523,19 +583,25 @@ def _ingest_sarif_imports(specs: list[str]) -> tuple[list[Finding], list[Scanner
523
583
  return findings, executions
524
584
 
525
585
 
526
- def _write_outputs(paths, findings, score, gate, coverage, ran, unavailable, verdict) -> None:
586
+ def _write_outputs(
587
+ paths, findings, score, gate, coverage, ran, unavailable, verdict, axes=(), pillar=None
588
+ ) -> None:
527
589
  if paths.markdown is not None:
528
590
  renderers.write_markdown(
529
- findings, score, gate, paths.markdown, ran, unavailable, coverage, verdict
591
+ findings, score, gate, paths.markdown, ran, unavailable, coverage, verdict, axes
530
592
  )
531
593
  if paths.json_out is not None:
532
- renderers.write_json(findings, score, gate, paths.json_out, coverage, verdict)
594
+ renderers.write_json(findings, score, gate, paths.json_out, coverage, verdict, axes)
533
595
  if paths.sarif is not None:
534
596
  sarif.write(findings, paths.sarif, coverage)
535
597
  if paths.comment is not None:
536
598
  renderers.write_pr_comment(findings, score, gate, paths.comment, coverage, verdict)
537
599
  if paths.prompt is not None:
538
600
  remediation.write(findings, paths.prompt)
601
+ # The artifact maintainability-agent ingests (D3). Written last because it
602
+ # is the only output that carries both axes plus the practice level.
603
+ if paths.security_pillar is not None and pillar is not None:
604
+ pillar_mod.write(pillar, paths.security_pillar)
539
605
 
540
606
 
541
607
  def _apply_overrides(findings: list[Finding], cfg: config_mod.Config) -> list[Finding]:
@@ -562,6 +628,7 @@ class _OutputPaths:
562
628
  sarif: Path | None
563
629
  comment: Path | None
564
630
  prompt: Path | None
631
+ security_pillar: Path | None
565
632
 
566
633
 
567
634
  def _resolve_outputs(args: argparse.Namespace, cfg: config_mod.Config, root: Path) -> _OutputPaths:
@@ -580,6 +647,7 @@ def _resolve_outputs(args: argparse.Namespace, cfg: config_mod.Config, root: Pat
580
647
  sarif=_p(args.sarif_output),
581
648
  comment=_p(args.comment_output),
582
649
  prompt=_p(args.prompt_output),
650
+ security_pillar=_p(args.security_pillar),
583
651
  )
584
652
 
585
653
 
@@ -588,12 +656,15 @@ def _under_root(root: Path, value: str) -> Path:
588
656
  return path.resolve() if path.is_absolute() else (root / path).resolve()
589
657
 
590
658
 
591
- def _print_summary(verdict, score, gate, ran, unavailable, coverage, paths) -> None:
659
+ def _print_summary(verdict, score, gate, ran, unavailable, coverage, paths, axes=()) -> None:
592
660
  status = "PASS" if gate.passed else "FAIL"
593
661
  print(f"secure-code-agent · score {verdict.headline()} · gate {status}")
594
662
  for reason in verdict.reasons:
595
663
  print(f" ! grade withheld: {reason}")
596
664
  print(f" scanned LOC: {score.loc_scanned:,}")
665
+ for axis in axes or ():
666
+ if axis.count or axis.loc:
667
+ print(f" {axis.headline()}")
597
668
  print(f" scanners run: {', '.join(ran) if ran else '(none)'}")
598
669
  coverage_line = f" coverage: {coverage.status.value.upper()}"
599
670
  if coverage.unverified:
@@ -608,7 +679,14 @@ def _print_summary(verdict, score, gate, ran, unavailable, coverage, paths) -> N
608
679
  print(f" ✗ {reason}")
609
680
  written = [
610
681
  str(p)
611
- for p in (paths.markdown, paths.json_out, paths.sarif, paths.comment, paths.prompt)
682
+ for p in (
683
+ paths.markdown,
684
+ paths.json_out,
685
+ paths.sarif,
686
+ paths.comment,
687
+ paths.prompt,
688
+ paths.security_pillar,
689
+ )
612
690
  if p is not None
613
691
  ]
614
692
  if written:
@@ -28,6 +28,33 @@ DEFAULT_EXCLUDES: tuple[str, ...] = (
28
28
  "**/*.lock",
29
29
  )
30
30
 
31
+ #: Conventional test-tree locations across the languages the floor reads.
32
+ #: Deliberately conservative: a false positive here moves real findings out of
33
+ #: the score, so `src/` layouts and single `*_test.go` files are matched by
34
+ #: name rather than by guessing at directory intent.
35
+ DEFAULT_TEST_PATTERNS: tuple[str, ...] = (
36
+ "test/",
37
+ "tests/",
38
+ "spec/",
39
+ "specs/",
40
+ "testing/",
41
+ "__tests__/",
42
+ "**/test/",
43
+ "**/tests/",
44
+ "**/spec/",
45
+ "**/__tests__/",
46
+ "**/test_*.py",
47
+ "**/*_test.py",
48
+ "**/*_test.go",
49
+ "**/*_test.rb",
50
+ "**/*.test.js",
51
+ "**/*.spec.js",
52
+ "**/*.test.ts",
53
+ "**/*.spec.ts",
54
+ "**/*Test.java",
55
+ "**/*Tests.java",
56
+ )
57
+
31
58
  DEFAULT_INCLUDE_EXTS: tuple[str, ...] = (
32
59
  ".py",
33
60
  ".js",
@@ -89,6 +116,13 @@ class Config:
89
116
  version: int = 1
90
117
  include_extensions: tuple[str, ...] = DEFAULT_INCLUDE_EXTS
91
118
  exclude_patterns: tuple[str, ...] = DEFAULT_EXCLUDES
119
+ #: Paths that are the repository's *own* test tree. Findings there are
120
+ #: reported separately rather than scored, because a project graded on its
121
+ #: test fixtures is graded on the wrong thing — measured at 50.15 vs 4.36
122
+ #: median normalized subtotal, see docs/calibration.md. Not an exclusion:
123
+ #: the findings are still collected, still reported, and secrets among them
124
+ #: still score and still gate.
125
+ test_patterns: tuple[str, ...] = DEFAULT_TEST_PATTERNS
92
126
  scanners: dict[str, ScannerConfig] = field(default_factory=dict)
93
127
  severity_overrides: dict[str, str] = field(default_factory=dict)
94
128
  category_overrides: dict[str, str] = field(default_factory=dict)
@@ -216,6 +250,8 @@ def _from_dict(raw: dict[str, Any]) -> Config:
216
250
  cfg.exclude_patterns = tuple(
217
251
  _string_list(paths["exclude_patterns"], "paths.exclude_patterns")
218
252
  )
253
+ if "test_patterns" in paths:
254
+ cfg.test_patterns = tuple(_string_list(paths["test_patterns"], "paths.test_patterns"))
219
255
 
220
256
  scanners_raw = raw.get("scanners", {})
221
257
  if not isinstance(scanners_raw, dict):
@@ -46,10 +46,36 @@ def in_scope(path: Path, include_exts: Iterable[str]) -> bool:
46
46
  return False
47
47
 
48
48
 
49
- def loc_under(root: Path, include_exts: Iterable[str], excludes: Iterable[str]) -> int:
50
- """Best-effort LOC count for the scoring normalizer. Counts non-blank
51
- lines across in-scope files; skips binary content."""
52
- total = 0
49
+ def is_test_path(path: Path, root: Path, patterns: Iterable[str]) -> bool:
50
+ """Does this path belong to the repository's own test tree?
51
+
52
+ Same matching as `is_excluded`, and deliberately so — an operator who can
53
+ write an exclude pattern already knows how to write one of these.
54
+
55
+ A path outside `root` is not a test path. An imported SARIF can name
56
+ absolute paths from another machine, and guessing that someone else's
57
+ `/build/tests/` is our test tree would move real findings out of the score.
58
+ """
59
+ return is_excluded(path, root, patterns)
60
+
61
+
62
+ def loc_under(
63
+ root: Path,
64
+ include_exts: Iterable[str],
65
+ excludes: Iterable[str],
66
+ test_patterns: Iterable[str] = (),
67
+ ) -> tuple[int, int]:
68
+ """Non-blank in-scope lines, split into (primary, test).
69
+
70
+ The split exists because the score's denominator has to move with its
71
+ numerator. Scoring primary-tree findings over a LOC count that included the
72
+ test tree would understate every repository in proportion to how well it is
73
+ tested — the same numerator/denominator mismatch that `exclude_patterns`
74
+ already caused once, arriving by a different door.
75
+ """
76
+ test_patterns = tuple(test_patterns)
77
+ primary = 0
78
+ test = 0
53
79
  for path in root.rglob("*"):
54
80
  if not path.is_file():
55
81
  continue
@@ -61,5 +87,9 @@ def loc_under(root: Path, include_exts: Iterable[str], excludes: Iterable[str])
61
87
  text = path.read_text(encoding="utf-8", errors="ignore")
62
88
  except OSError:
63
89
  continue
64
- total += sum(1 for line in text.splitlines() if line.strip())
65
- return total
90
+ lines = sum(1 for line in text.splitlines() if line.strip())
91
+ if test_patterns and is_test_path(path, root, test_patterns):
92
+ test += lines
93
+ else:
94
+ primary += lines
95
+ return primary, test