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.
- {secure_code_agent-0.10.0/src/secure_code_agent.egg-info → secure_code_agent-0.11.0}/PKG-INFO +31 -3
- {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/README.md +30 -2
- {secure_code_agent-0.10.0 → secure_code_agent-0.11.0/src/secure_code_agent.egg-info}/PKG-INFO +31 -3
- {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_agent.egg-info/SOURCES.txt +1 -0
- {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_audit/__init__.py +1 -1
- {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_audit/cli.py +126 -3
- {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_audit/config.py +73 -2
- secure_code_agent-0.11.0/src/secure_code_audit/demo.py +118 -0
- {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_audit/git_tools.py +49 -1
- secure_code_agent-0.11.0/src/secure_code_audit/remediation.py +332 -0
- {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_audit/scanners/base.py +28 -9
- secure_code_agent-0.10.0/src/secure_code_audit/remediation.py +0 -303
- {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/LICENSE +0 -0
- {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/pyproject.toml +0 -0
- {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/setup.cfg +0 -0
- {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_agent.egg-info/dependency_links.txt +0 -0
- {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_agent.egg-info/entry_points.txt +0 -0
- {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_agent.egg-info/requires.txt +0 -0
- {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_agent.egg-info/top_level.txt +0 -0
- {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_audit/baseline.py +0 -0
- {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_audit/data/semgrep-offline.yaml +0 -0
- {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_audit/findings.py +0 -0
- {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_audit/history.py +0 -0
- {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_audit/instructions.py +0 -0
- {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_audit/pillar.py +0 -0
- {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_audit/practice.py +0 -0
- {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_audit/renderers.py +0 -0
- {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_audit/ruleset.py +0 -0
- {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_audit/sarif.py +0 -0
- {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_audit/scanner_status.py +0 -0
- {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_audit/scanners/__init__.py +0 -0
- {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_audit/scanners/bandit_scanner.py +0 -0
- {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_audit/scanners/builtin_rules.py +0 -0
- {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_audit/scanners/checkov_scanner.py +0 -0
- {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_audit/scanners/floor.py +0 -0
- {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_audit/scanners/gitleaks_scanner.py +0 -0
- {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_audit/scanners/gosec_scanner.py +0 -0
- {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_audit/scanners/hadolint_scanner.py +0 -0
- {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_audit/scanners/njsscan_scanner.py +0 -0
- {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_audit/scanners/npm_audit_scanner.py +0 -0
- {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_audit/scanners/osv_scanner.py +0 -0
- {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_audit/scanners/pip_audit_scanner.py +0 -0
- {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_audit/scanners/rubocop_scanner.py +0 -0
- {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_audit/scanners/scorecard_scanner.py +0 -0
- {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_audit/scanners/semgrep_scanner.py +0 -0
- {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_audit/scanners/trivy_scanner.py +0 -0
- {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_audit/scanners/trufflehog_scanner.py +0 -0
- {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_audit/scoring.py +0 -0
- {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_audit/standards.py +0 -0
- {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_audit/suppressions.py +0 -0
- {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_audit/triage.py +0 -0
- {secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_audit/verify.py +0 -0
{secure_code_agent-0.10.0/src/secure_code_agent.egg-info → secure_code_agent-0.11.0}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: secure-code-agent
|
|
3
|
-
Version: 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.
|
|
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) —
|
|
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.
|
|
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) —
|
|
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
|
{secure_code_agent-0.10.0 → secure_code_agent-0.11.0/src/secure_code_agent.egg-info}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: secure-code-agent
|
|
3
|
-
Version: 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.
|
|
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) —
|
|
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
|
{secure_code_agent-0.10.0 → secure_code_agent-0.11.0}/src/secure_code_agent.egg-info/SOURCES.txt
RENAMED
|
@@ -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
|
|
@@ -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
|
-
"
|
|
140
|
-
"
|
|
141
|
-
"
|
|
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 #
|
|
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.
|
|
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."""
|