secure-code-agent 0.10.0__tar.gz → 0.11.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 (52) hide show
  1. {secure_code_agent-0.10.0/src/secure_code_agent.egg-info → secure_code_agent-0.11.0}/PKG-INFO +31 -3
  2. {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/README.md +30 -2
  3. {secure_code_agent-0.10.0 → secure_code_agent-0.11.0/src/secure_code_agent.egg-info}/PKG-INFO +31 -3
  4. {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_agent.egg-info/SOURCES.txt +1 -0
  5. {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_audit/__init__.py +1 -1
  6. {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_audit/cli.py +126 -3
  7. {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_audit/config.py +73 -2
  8. secure_code_agent-0.11.0/src/secure_code_audit/demo.py +118 -0
  9. {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_audit/git_tools.py +49 -1
  10. secure_code_agent-0.11.0/src/secure_code_audit/remediation.py +332 -0
  11. {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_audit/scanners/base.py +28 -9
  12. secure_code_agent-0.10.0/src/secure_code_audit/remediation.py +0 -303
  13. {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/LICENSE +0 -0
  14. {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/pyproject.toml +0 -0
  15. {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/setup.cfg +0 -0
  16. {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_agent.egg-info/dependency_links.txt +0 -0
  17. {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_agent.egg-info/entry_points.txt +0 -0
  18. {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_agent.egg-info/requires.txt +0 -0
  19. {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_agent.egg-info/top_level.txt +0 -0
  20. {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_audit/baseline.py +0 -0
  21. {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_audit/data/semgrep-offline.yaml +0 -0
  22. {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_audit/findings.py +0 -0
  23. {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_audit/history.py +0 -0
  24. {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_audit/instructions.py +0 -0
  25. {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_audit/pillar.py +0 -0
  26. {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_audit/practice.py +0 -0
  27. {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_audit/renderers.py +0 -0
  28. {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_audit/ruleset.py +0 -0
  29. {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_audit/sarif.py +0 -0
  30. {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_audit/scanner_status.py +0 -0
  31. {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_audit/scanners/__init__.py +0 -0
  32. {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_audit/scanners/bandit_scanner.py +0 -0
  33. {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_audit/scanners/builtin_rules.py +0 -0
  34. {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_audit/scanners/checkov_scanner.py +0 -0
  35. {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_audit/scanners/floor.py +0 -0
  36. {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_audit/scanners/gitleaks_scanner.py +0 -0
  37. {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_audit/scanners/gosec_scanner.py +0 -0
  38. {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_audit/scanners/hadolint_scanner.py +0 -0
  39. {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_audit/scanners/njsscan_scanner.py +0 -0
  40. {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_audit/scanners/npm_audit_scanner.py +0 -0
  41. {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_audit/scanners/osv_scanner.py +0 -0
  42. {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_audit/scanners/pip_audit_scanner.py +0 -0
  43. {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_audit/scanners/rubocop_scanner.py +0 -0
  44. {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_audit/scanners/scorecard_scanner.py +0 -0
  45. {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_audit/scanners/semgrep_scanner.py +0 -0
  46. {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_audit/scanners/trivy_scanner.py +0 -0
  47. {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_audit/scanners/trufflehog_scanner.py +0 -0
  48. {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_audit/scoring.py +0 -0
  49. {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_audit/standards.py +0 -0
  50. {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_audit/suppressions.py +0 -0
  51. {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_audit/triage.py +0 -0
  52. {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/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.10.0
3
+ Version: 0.11.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
@@ -62,6 +62,34 @@ The sibling of [`maintainability-agent`](https://github.com/marshallguillory86/m
62
62
 
63
63
  ---
64
64
 
65
+ ## Try it in one command
66
+
67
+ ```bash
68
+ pip install 'secure-code-agent[python-scanners]'
69
+ secure-code-agent --demo
70
+ ```
71
+
72
+ `--demo` generates a small application carrying seven real defects — SQL
73
+ injection, command injection, unsafe deserialization, a weak hash, dynamic
74
+ evaluation — audits it, and writes the work order you would hand an agent. The
75
+ tree is generated at runtime into a temp directory, so installing this tool
76
+ never puts vulnerable source on your disk.
77
+
78
+ To audit something real:
79
+
80
+ ```bash
81
+ secure-code-agent /path/to/repo
82
+ ```
83
+
84
+ Any scanner that is missing is named, with the command to install it. This
85
+ tool never installs anything itself — naming a command is not running one —
86
+ and it will not grade what it could not examine. `--preflight` checks the
87
+ whole floor before a run.
88
+
89
+ A pure-Python repository requires six of the ten floor scanners; the Ruby,
90
+ JavaScript and container tools are *not applicable* rather than missing, so a
91
+ single-language project is never permanently incomplete.
92
+
65
93
  ## Why this exists
66
94
 
67
95
  AI coding agents ship code at human-review-saturating speed. Point them at a security finding and the documented anti-patterns are:
@@ -442,7 +470,7 @@ For agents that support invokable skills, this repo ships a portable skill under
442
470
  ## GitHub Action
443
471
 
444
472
  ```yaml
445
- - uses: marshallguillory86/secure-code-agent@v0.3.0
473
+ - uses: marshallguillory86/secure-code-agent@v0.11.0
446
474
  with:
447
475
  config: secure-code-agent.json
448
476
  fail-on-gate: true
@@ -508,7 +536,7 @@ Full design philosophy in [`docs/design.md`](docs/design.md).
508
536
  - [`docs/calibration.md`](docs/calibration.md) — The calibration study, its corpus, and what it found
509
537
  - [`docs/design.md`](docs/design.md) — Architecture + non-goals + scanner protocol
510
538
  - [`docs/architecture.md`](docs/architecture.md) — Audit of the system as built + remediation sequence
511
- - [`docs/release-blockers.md`](docs/release-blockers.md) — Open v0.3.0 release blockers (do not tag until closed)
539
+ - [`docs/release-blockers.md`](docs/release-blockers.md) — the v0.3.0 release blockers, all closed (historical)
512
540
  - [`docs/standards.md`](docs/standards.md) — NIST SSDF / OWASP / CWE / Scorecard / SARIF citations
513
541
  - [`docs/scoring.md`](docs/scoring.md) — Weighting model + worked examples
514
542
  - [`docs/scanners.md`](docs/scanners.md) — Per-scanner integrations + caveats
@@ -16,6 +16,34 @@ The sibling of [`maintainability-agent`](https://github.com/marshallguillory86/m
16
16
 
17
17
  ---
18
18
 
19
+ ## Try it in one command
20
+
21
+ ```bash
22
+ pip install 'secure-code-agent[python-scanners]'
23
+ secure-code-agent --demo
24
+ ```
25
+
26
+ `--demo` generates a small application carrying seven real defects — SQL
27
+ injection, command injection, unsafe deserialization, a weak hash, dynamic
28
+ evaluation — audits it, and writes the work order you would hand an agent. The
29
+ tree is generated at runtime into a temp directory, so installing this tool
30
+ never puts vulnerable source on your disk.
31
+
32
+ To audit something real:
33
+
34
+ ```bash
35
+ secure-code-agent /path/to/repo
36
+ ```
37
+
38
+ Any scanner that is missing is named, with the command to install it. This
39
+ tool never installs anything itself — naming a command is not running one —
40
+ and it will not grade what it could not examine. `--preflight` checks the
41
+ whole floor before a run.
42
+
43
+ A pure-Python repository requires six of the ten floor scanners; the Ruby,
44
+ JavaScript and container tools are *not applicable* rather than missing, so a
45
+ single-language project is never permanently incomplete.
46
+
19
47
  ## Why this exists
20
48
 
21
49
  AI coding agents ship code at human-review-saturating speed. Point them at a security finding and the documented anti-patterns are:
@@ -396,7 +424,7 @@ For agents that support invokable skills, this repo ships a portable skill under
396
424
  ## GitHub Action
397
425
 
398
426
  ```yaml
399
- - uses: marshallguillory86/secure-code-agent@v0.3.0
427
+ - uses: marshallguillory86/secure-code-agent@v0.11.0
400
428
  with:
401
429
  config: secure-code-agent.json
402
430
  fail-on-gate: true
@@ -462,7 +490,7 @@ Full design philosophy in [`docs/design.md`](docs/design.md).
462
490
  - [`docs/calibration.md`](docs/calibration.md) — The calibration study, its corpus, and what it found
463
491
  - [`docs/design.md`](docs/design.md) — Architecture + non-goals + scanner protocol
464
492
  - [`docs/architecture.md`](docs/architecture.md) — Audit of the system as built + remediation sequence
465
- - [`docs/release-blockers.md`](docs/release-blockers.md) — Open v0.3.0 release blockers (do not tag until closed)
493
+ - [`docs/release-blockers.md`](docs/release-blockers.md) — the v0.3.0 release blockers, all closed (historical)
466
494
  - [`docs/standards.md`](docs/standards.md) — NIST SSDF / OWASP / CWE / Scorecard / SARIF citations
467
495
  - [`docs/scoring.md`](docs/scoring.md) — Weighting model + worked examples
468
496
  - [`docs/scanners.md`](docs/scanners.md) — Per-scanner integrations + caveats
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: secure-code-agent
3
- Version: 0.10.0
3
+ Version: 0.11.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
@@ -62,6 +62,34 @@ The sibling of [`maintainability-agent`](https://github.com/marshallguillory86/m
62
62
 
63
63
  ---
64
64
 
65
+ ## Try it in one command
66
+
67
+ ```bash
68
+ pip install 'secure-code-agent[python-scanners]'
69
+ secure-code-agent --demo
70
+ ```
71
+
72
+ `--demo` generates a small application carrying seven real defects — SQL
73
+ injection, command injection, unsafe deserialization, a weak hash, dynamic
74
+ evaluation — audits it, and writes the work order you would hand an agent. The
75
+ tree is generated at runtime into a temp directory, so installing this tool
76
+ never puts vulnerable source on your disk.
77
+
78
+ To audit something real:
79
+
80
+ ```bash
81
+ secure-code-agent /path/to/repo
82
+ ```
83
+
84
+ Any scanner that is missing is named, with the command to install it. This
85
+ tool never installs anything itself — naming a command is not running one —
86
+ and it will not grade what it could not examine. `--preflight` checks the
87
+ whole floor before a run.
88
+
89
+ A pure-Python repository requires six of the ten floor scanners; the Ruby,
90
+ JavaScript and container tools are *not applicable* rather than missing, so a
91
+ single-language project is never permanently incomplete.
92
+
65
93
  ## Why this exists
66
94
 
67
95
  AI coding agents ship code at human-review-saturating speed. Point them at a security finding and the documented anti-patterns are:
@@ -442,7 +470,7 @@ For agents that support invokable skills, this repo ships a portable skill under
442
470
  ## GitHub Action
443
471
 
444
472
  ```yaml
445
- - uses: marshallguillory86/secure-code-agent@v0.3.0
473
+ - uses: marshallguillory86/secure-code-agent@v0.11.0
446
474
  with:
447
475
  config: secure-code-agent.json
448
476
  fail-on-gate: true
@@ -508,7 +536,7 @@ Full design philosophy in [`docs/design.md`](docs/design.md).
508
536
  - [`docs/calibration.md`](docs/calibration.md) — The calibration study, its corpus, and what it found
509
537
  - [`docs/design.md`](docs/design.md) — Architecture + non-goals + scanner protocol
510
538
  - [`docs/architecture.md`](docs/architecture.md) — Audit of the system as built + remediation sequence
511
- - [`docs/release-blockers.md`](docs/release-blockers.md) — Open v0.3.0 release blockers (do not tag until closed)
539
+ - [`docs/release-blockers.md`](docs/release-blockers.md) — the v0.3.0 release blockers, all closed (historical)
512
540
  - [`docs/standards.md`](docs/standards.md) — NIST SSDF / OWASP / CWE / Scorecard / SARIF citations
513
541
  - [`docs/scoring.md`](docs/scoring.md) — Weighting model + worked examples
514
542
  - [`docs/scanners.md`](docs/scanners.md) — Per-scanner integrations + caveats
@@ -11,6 +11,7 @@ src/secure_code_audit/__init__.py
11
11
  src/secure_code_audit/baseline.py
12
12
  src/secure_code_audit/cli.py
13
13
  src/secure_code_audit/config.py
14
+ src/secure_code_audit/demo.py
14
15
  src/secure_code_audit/findings.py
15
16
  src/secure_code_audit/git_tools.py
16
17
  src/secure_code_audit/history.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.10.0"
15
+ __version__ = "0.11.0"
@@ -15,6 +15,7 @@ from pathlib import Path
15
15
 
16
16
  from secure_code_audit import (
17
17
  __version__,
18
+ demo,
18
19
  instructions,
19
20
  remediation,
20
21
  renderers,
@@ -136,9 +137,22 @@ def _parser() -> argparse.ArgumentParser:
136
137
  "--trust-target-config",
137
138
  action="store_true",
138
139
  help=(
139
- "Allow a config inside the audited tree to name executables from "
140
- "that tree. Only for repositories you own. CLI-only by design: a "
141
- "config file cannot grant itself this."
140
+ "Treat a config inside the audited tree as if you wrote it. This "
141
+ "grants it TWO things: (1) it may name executables from that tree, "
142
+ "which this host will then run, and (2) it may direct this tool's "
143
+ "outputs, baseline and history to paths outside the tree. Only for "
144
+ "repositories you own. CLI-only by design: a config file cannot "
145
+ "grant itself this."
146
+ ),
147
+ )
148
+
149
+ p.add_argument(
150
+ "--demo",
151
+ action="store_true",
152
+ help=(
153
+ "Audit a generated example application instead of a repository, so "
154
+ "a first run produces a real work order without a scanner scavenger "
155
+ "hunt. The tree is written to a temp directory and is never shipped."
142
156
  ),
143
157
  )
144
158
 
@@ -187,6 +201,16 @@ def main(argv: list[str] | None = None) -> int:
187
201
  if args.init_agent_standards:
188
202
  return _do_init_standards(args)
189
203
 
204
+ if getattr(args, "demo", False):
205
+ # `paths` defaults to ["."], so "was a path given" is a comparison
206
+ # against the default rather than a truth test.
207
+ if args.paths != ["."]:
208
+ sys.stderr.write("ERROR: --demo audits its own generated tree; drop the path.\n")
209
+ return 2
210
+ root = demo.build()
211
+ print(demo.describe(root))
212
+ args.paths = [str(root)]
213
+
190
214
  if args.target:
191
215
  # `--target` names an *agent* for --init-agent-standards, and it reads
192
216
  # exactly like the flag for "the repository to audit". It was accepted
@@ -299,6 +323,8 @@ def _print_preflight(rows: list[dict], unselected: list[str], blocking: list[str
299
323
  def _do_audit(args: argparse.Namespace) -> int:
300
324
  cfg, target, root = _prepare_audit(args)
301
325
  _require_configured_gates(args, cfg)
326
+ # Before anything is resolved or written, and before any scanner runs.
327
+ _assert_config_writes_are_contained(cfg, root, target)
302
328
  # Resolved before the scan so the run can recognise its own artifacts.
303
329
  paths = _resolve_outputs(args, cfg, root)
304
330
 
@@ -922,6 +948,102 @@ def _under_root(root: Path, value: str) -> Path:
922
948
  return path.resolve() if path.is_absolute() else (root / path).resolve()
923
949
 
924
950
 
951
+ #: Config keys naming a file this tool writes.
952
+ _WRITE_KEYS = (
953
+ "markdown_path",
954
+ "json_path",
955
+ "sarif_path",
956
+ "comment_path",
957
+ "prompt_path",
958
+ "security_pillar_path",
959
+ "baseline_path",
960
+ "history_path",
961
+ )
962
+
963
+
964
+ def _assert_config_writes_are_contained(cfg: config_mod.Config, root: Path, target: Path) -> None:
965
+ """A tree may not choose where the host writes.
966
+
967
+ D1 draws the line at *executing* what the tree supplies, and that was the
968
+ only boundary implemented. The tree could still choose file *paths*: the
969
+ default configuration is loaded from the audit target, and its
970
+ `outputs.*_path`, baseline and history values were resolved against the
971
+ root with no containment check at all. A repository shipping
972
+
973
+ {"outputs": {"markdown_path": "../../../../.bashrc"}}
974
+
975
+ had an ordinary audit overwrite that file, with the report's own content.
976
+ Arbitrary write is not a lesser thing than arbitrary execute; it is
977
+ usually a slower route to the same place.
978
+
979
+ Three escapes, all closed here and all in the red contract: `..`
980
+ traversal, an absolute path, and a **symlink inside the tree** pointing
981
+ out of it. The third is why this resolves before comparing rather than
982
+ checking the string — `escaped/report.md`, where `escaped` is a symlink
983
+ to the parent, is textually innocent.
984
+
985
+ **Only for a config the tree supplied.** An operator whose config lives
986
+ outside the audited tree keeps full authority over where things go, and
987
+ so does anyone passing `--trust-target-config`. Explicit CLI output flags
988
+ are never touched by this: `--output /tmp/report.md` is the operator
989
+ speaking, and they are checked nowhere in this function.
990
+
991
+ Raises ValueError, so the CLI exits 2 before any scanner runs and before
992
+ a single byte is written.
993
+ """
994
+ if not config_mod.target_config_is_untrusted(cfg, target):
995
+ return
996
+ for key in _WRITE_KEYS:
997
+ configured = cfg.outputs.get(key)
998
+ if not configured:
999
+ continue
1000
+ destination = _under_root(root, configured)
1001
+ if config_mod.is_within(destination, root):
1002
+ continue
1003
+ raise ValueError(
1004
+ f"outputs.{key} in {cfg.source_path} resolves to {destination}, which is "
1005
+ f"outside the audit root {root}. Configuration supplied by the audited "
1006
+ f"tree may not choose where this tool writes. Pass the path on the "
1007
+ f"command line instead, move the configuration outside the tree, or "
1008
+ f"re-run with --trust-target-config if you wrote this file yourself."
1009
+ )
1010
+
1011
+
1012
+ def _print_install_guidance(unavailable: list[str]) -> None:
1013
+ """Say how to resolve a missing scanner, at the moment it is missing.
1014
+
1015
+ `--preflight` already produced exactly this — one install command per
1016
+ scanner — and the failure path printed only the names. So the first run
1017
+ of a fresh install reported four missing tools and left the operator to
1018
+ find `--preflight` on their own, which is a scavenger hunt wearing a
1019
+ coverage report.
1020
+
1021
+ The guidance is the adapter's own `install_hint`, so it cannot drift from
1022
+ what `--preflight` says. The tool still installs nothing itself; naming
1023
+ the command is not running it.
1024
+ """
1025
+ from secure_code_audit.scanners import SCANNERS
1026
+
1027
+ def hint_for(name: str) -> str:
1028
+ """Empty rather than raising. Advice must never break a run, and an
1029
+ imported SARIF can name a scanner this build does not ship."""
1030
+ cls = SCANNERS.get(name)
1031
+ if cls is None:
1032
+ return ""
1033
+ try:
1034
+ return cls().unavailable_fix_hint() or ""
1035
+ except Exception: # noqa: BLE001 — see above
1036
+ return ""
1037
+
1038
+ shown = [(name, hint) for name in unavailable if (hint := hint_for(name))]
1039
+ if not shown:
1040
+ return
1041
+ print(" to resolve:")
1042
+ for name, hint in shown:
1043
+ print(f" {name}: {hint}")
1044
+ print(" (or --preflight to check the whole floor before a run)")
1045
+
1046
+
925
1047
  def _print_summary(
926
1048
  verdict, score, gate, ran, unavailable, coverage, paths, axes=(), trend=None
927
1049
  ) -> None:
@@ -954,6 +1076,7 @@ def _print_summary(
954
1076
  print(coverage_line)
955
1077
  if unavailable:
956
1078
  print(f" unavailable: {', '.join(unavailable)}")
1079
+ _print_install_guidance(unavailable)
957
1080
  if not gate.passed:
958
1081
  for reason in gate.reasons:
959
1082
  print(f" ✗ {reason}")
@@ -141,6 +141,16 @@ DEFAULT_OUTPUTS: dict[str, str] = {
141
141
  "baseline_path": "secure-code-baseline.json",
142
142
  # Append-only trend. The score's one genuine use is movement over time.
143
143
  "history_path": ".secure-code/history.jsonl",
144
+ # The artifact maintainability-agent ingests (D3). Off unless declared,
145
+ # like sarif/json/comment — the default only supplies the name.
146
+ #
147
+ # It was missing here while `_resolve_outputs` read it, so
148
+ # `outputs.security_pillar_path` was a **dead key**: the validation loop
149
+ # below iterates this mapping, so a configured value never reached
150
+ # `cfg.outputs` and the resolver read `None` from it every time. That is
151
+ # the seventh instance of the defect `_resolve_outputs` already
152
+ # describes — "four of the six keys were dead the same way".
153
+ "security_pillar_path": "security-pillar.json",
144
154
  }
145
155
 
146
156
 
@@ -305,6 +315,51 @@ def is_within(path: Path, root: Path) -> bool:
305
315
  return resolved == root_resolved or root_resolved in resolved.parents
306
316
 
307
317
 
318
+ def containment_root(target: Path) -> Path:
319
+ """The directory every containment decision is made against.
320
+
321
+ **A file target's trust boundary is its parent, not the file.** Auditing
322
+ `app.py` meant `is_within(<anything>, app.py)` was false for everything,
323
+ because nothing lives beneath a regular file — so every containment test
324
+ inverted to "allowed" at once. A `secure-code-agent.json` beside the file
325
+ read as *outside* the tree, and `--only-scanners bandit` with an in-tree
326
+ `command` executed a script from the audited repository without
327
+ `--trust-target-config`. The single-file shape silently opted out of the
328
+ guard that D1 exists to hold.
329
+
330
+ Directories are returned unchanged, so the ordinary repository audit is
331
+ unaffected.
332
+ """
333
+ resolved = target.resolve()
334
+ return resolved if resolved.is_dir() else resolved.parent
335
+
336
+
337
+ def target_config_is_untrusted(config: Config, target: Path) -> bool:
338
+ """Was this configuration supplied by the audited tree?
339
+
340
+ Location, not provenance of the flag: a config *inside* the target is
341
+ repository content whether it was found by default discovery or named
342
+ explicitly with `--config`. An operator who means to trust it says so with
343
+ `--trust-target-config`.
344
+
345
+ **This is not the negation of `target_executables_allowed`, and writing it
346
+ as one was a live regression.** They disagree when there is no config file
347
+ at all. For execution, "no config" must still *deny* tree-local
348
+ executables — nothing has authorised them, and an absent file is not
349
+ permission. For writes, "no config" is simply nothing to distrust: the
350
+ built-in defaults are relative names that cannot escape. Collapsing the
351
+ two flipped the execution guard open for every run with no config, which
352
+ `test_defaults_never_allow_executables_from_the_tree` caught immediately.
353
+
354
+ Only `containment_root` is shared.
355
+ """
356
+ if config.trust_target_config:
357
+ return False
358
+ if config.source_path is None:
359
+ return False # built-in defaults are relative names; nothing can escape
360
+ return is_within(config.source_path, containment_root(target))
361
+
362
+
308
363
  def target_executables_allowed(config: Config, target: Path) -> bool:
309
364
  """May this configuration name an executable inside the audited tree?
310
365
 
@@ -318,12 +373,15 @@ def target_executables_allowed(config: Config, target: Path) -> bool:
318
373
  repositories wholesale, so a config the operator keeps outside the tree
319
374
  still gets the documented tree-local interpreter workflow. Inside the tree,
320
375
  it takes an explicit `--trust-target-config`.
376
+
377
+ See `target_config_is_untrusted` for why this is *not* expressed as its
378
+ negation: an absent config denies here and is harmless there.
321
379
  """
322
380
  if config.trust_target_config:
323
381
  return True
324
382
  if config.source_path is None:
325
- return False # built-in defaults name no commands anyway
326
- return not is_within(config.source_path, target)
383
+ return False # nothing authorised a tree-local command
384
+ return not is_within(config.source_path, containment_root(target))
327
385
 
328
386
 
329
387
  #: Top-level keys the loader understands. Mirrors `properties` in
@@ -432,6 +490,19 @@ def _from_dict(raw: dict[str, Any]) -> Config:
432
490
  outputs = raw.get("outputs", {})
433
491
  if not isinstance(outputs, dict):
434
492
  raise ValueError("outputs must be a JSON object")
493
+ # D2 applies inside `outputs`, and it did not. The loop below iterates
494
+ # the *known* keys, so anything else was read by nobody and reported by
495
+ # nobody: `outputs.markdwon_path` was accepted in silence while the
496
+ # report went to the default path. That is precisely the failure D2 cites
497
+ # for top-level keys — "a typo'd gate name disabled a gate with no
498
+ # diagnostic" — surviving one level down because the check was never
499
+ # applied recursively.
500
+ unknown = sorted(set(outputs) - set(DEFAULT_OUTPUTS))
501
+ if unknown:
502
+ raise ValueError(
503
+ f"unknown outputs key(s): {', '.join(unknown)}. "
504
+ f"Known keys: {', '.join(sorted(DEFAULT_OUTPUTS))}"
505
+ )
435
506
  for k, default_v in DEFAULT_OUTPUTS.items():
436
507
  value = outputs.get(k, default_v)
437
508
  # `null` turns an output off. The markdown report and the work order
@@ -0,0 +1,118 @@
1
+ """`secure-code-agent --demo` — a real work order in one command.
2
+
3
+ **The first five minutes were a scavenger hunt.** A fresh install pointed at
4
+ any repository reported `coverage: FAILED`, four missing scanners and an
5
+ unverified A+; pointed at *this* repository it reported 0 to fix, 0 to review
6
+ and 1,108 suppression candidates, because a security tool's tests are
7
+ deliberately vulnerable fixtures. Both are correct and neither shows anyone
8
+ what the tool is for.
9
+
10
+ The demo is a small application carrying seven real defects, audited with
11
+ whatever scanners are present. It answers the question a stranger actually
12
+ has — *what does this produce?* — without a scavenger hunt.
13
+
14
+ **The fixture is generated at runtime, never shipped.** Writing vulnerable
15
+ source into the package would put it in every user's `site-packages`, and
16
+ this project's own audit would flag its own demo. It is assembled from
17
+ fragments here and written to a temporary directory, so no vulnerable literal
18
+ exists in the installed files — the same technique the calibration controls
19
+ use, for the same reason.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import tempfile
25
+ from pathlib import Path
26
+
27
+ #: Assembled rather than written out, so no matchable literal ships. Each
28
+ #: entry is one defect a floor scanner is confident about.
29
+ _PARTS: tuple[tuple[str, tuple[str, ...]], ...] = (
30
+ (
31
+ "db.py",
32
+ (
33
+ "def lookup(cursor, name):",
34
+ " cursor.exec" + 'ute("SEL' + 'ECT * FROM users WHERE name = \'" + name + "\'")',
35
+ " return cursor.fetchall()",
36
+ ),
37
+ ),
38
+ (
39
+ "shell.py",
40
+ (
41
+ "import subprocess",
42
+ "",
43
+ "def archive(path):",
44
+ " return subprocess.call('tar -cf backup.tar ' + path, " + "shell=" + "True)",
45
+ ),
46
+ ),
47
+ (
48
+ "serialize.py",
49
+ (
50
+ "import pickle",
51
+ "",
52
+ "def restore(blob):",
53
+ " return pickle." + "loads(blob)",
54
+ ),
55
+ ),
56
+ (
57
+ "crypto.py",
58
+ (
59
+ "import hashlib",
60
+ "",
61
+ "def fingerprint(data):",
62
+ " return hashlib." + "md5(data).hexdigest()",
63
+ ),
64
+ ),
65
+ (
66
+ "calc.py",
67
+ (
68
+ "def evaluate(expression):",
69
+ " return " + "eval" + "(expression)",
70
+ ),
71
+ ),
72
+ (
73
+ "config.py",
74
+ (
75
+ "import os",
76
+ "",
77
+ "DEBUG = True",
78
+ "TIMEOUT = int(os.environ.get('TIMEOUT', '30'))",
79
+ ),
80
+ ),
81
+ )
82
+
83
+ README = """\
84
+ # secure-code-agent demo
85
+
86
+ A small application with real defects, generated for this run and thrown away
87
+ after. Point an agent at the work order printed alongside this audit.
88
+
89
+ Nothing here is shipped in the package; it is assembled at runtime so that
90
+ installing this tool never puts vulnerable source on your disk.
91
+ """
92
+
93
+
94
+ def build(destination: Path | None = None) -> Path:
95
+ """Write the demo tree and return its root."""
96
+ root = destination or Path(tempfile.mkdtemp(prefix="secure-code-demo-"))
97
+ source = root / "src"
98
+ source.mkdir(parents=True, exist_ok=True)
99
+ for name, lines in _PARTS:
100
+ (source / name).write_text("\n".join(lines) + "\n", encoding="utf-8")
101
+ (root / "README.md").write_text(README, encoding="utf-8")
102
+ # No dependency manifest. A pinned-vulnerable `requirements.txt` was here
103
+ # and it made the demo worse, not better: eighteen pip-audit CVEs drowned
104
+ # the seven code defects the demo exists to show. It also surfaced a
105
+ # separate defect worth its own fix — those CVEs were filed on the
106
+ # **documentation** axis, because the manifest is a `.txt` file and the
107
+ # axis split reads the extension. A dependency finding is not
108
+ # documentation whatever the manifest is called.
109
+ return root
110
+
111
+
112
+ def describe(root: Path) -> str:
113
+ return (
114
+ f"Demo tree: {root}\n"
115
+ f" {len(_PARTS)} files carrying SQL injection, command injection, unsafe\n"
116
+ f" deserialization, a weak hash, dynamic evaluation and a debug flag.\n"
117
+ f" Generated for this run; delete it when you are done.\n"
118
+ )
@@ -76,11 +76,59 @@ def _matches(rel: str, name: str, pat: str) -> bool:
76
76
  bare = pat[3:] if pat.startswith("**/") else pat
77
77
  if not bare: # a lone `**/` would otherwise exclude the entire tree
78
78
  return False
79
- return rel.startswith(bare) or f"/{bare}" in f"/{rel}/"
79
+ return _matches_directory(rel, bare.rstrip("/"))
80
80
  bare = pat[3:] if pat.startswith("**/") else pat
81
81
  return fnmatch.fnmatch(rel, pat) or fnmatch.fnmatch(rel, bare) or fnmatch.fnmatch(name, bare)
82
82
 
83
83
 
84
+ def _matches_directory(rel: str, name: str) -> bool:
85
+ """Does any directory component of `rel` match the glob `name`?
86
+
87
+ **The directory branch did no globbing at all.** It compared with
88
+ `startswith` and a substring test, so a pattern holding a glob was
89
+ *inert*: `*.egg-info/` matched nothing while `src/pkg.egg-info/` sat in
90
+ the tree being scanned, and `build-*/` and `test_*/` were the same. The
91
+ `**/` spelling was one instance of the class and fixing it left the rest
92
+ — an instance mistaken for a class, twice over, since
93
+ `maintainability-agent` found the same thing in its own matcher (its
94
+ D156) and reported the generalisation back.
95
+
96
+ **The final component is excluded from the comparison.** A trailing slash
97
+ says *directory*, so `*.egg-info/` must not match a **file** named
98
+ `notes.egg-info` — and it did, until this was narrowed. Every caller
99
+ filters to `path.is_file()` before asking, or asks about a finding's file
100
+ path, so nothing prunes directories and nothing needs the final component
101
+ to match. An earlier test asserted that it did; that was documenting an
102
+ accident as intent, and it is corrected alongside this.
103
+
104
+ **A pattern may name more than one segment.** `calibration/.corpus/` and
105
+ `src/generated/` are ordinary things to write, and the first version of
106
+ this globbed one component at a time — so no single component ever equalled
107
+ `calibration/.corpus` and the pattern went inert. That regression was
108
+ introduced *by* the fix for inert patterns and was caught one task later,
109
+ when the repository inventory started walking nineteen cloned corpus
110
+ repositories it was supposed to be excluding. Segments are matched as a
111
+ consecutive run.
112
+
113
+ An inert exclude is the worst kind of configuration defect: it reads as
114
+ intent, it never errors, and the only symptom is findings the operator
115
+ believed they had excluded.
116
+ """
117
+ wanted = [segment for segment in name.split("/") if segment]
118
+ if not wanted:
119
+ return False
120
+ # Directory components only: the last element of `rel` is the file.
121
+ components = rel.split("/")[:-1]
122
+ span = len(wanted)
123
+ for start in range(len(components) - span + 1):
124
+ if all(
125
+ fnmatch.fnmatch(component, pattern)
126
+ for component, pattern in zip(components[start : start + span], wanted, strict=True)
127
+ ):
128
+ return True
129
+ return False
130
+
131
+
84
132
  def in_scope(path: Path, include_exts: Iterable[str]) -> bool:
85
133
  """Does this path match the configured include_extensions?
86
134
  Dockerfile is matched by basename."""