secure-code-agent 0.11.1__tar.gz → 0.12.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.11.1/src/secure_code_agent.egg-info → secure_code_agent-0.12.0}/PKG-INFO +41 -2
- {secure_code_agent-0.11.1 → secure_code_agent-0.12.0}/README.md +38 -1
- {secure_code_agent-0.11.1 → secure_code_agent-0.12.0}/pyproject.toml +8 -0
- {secure_code_agent-0.11.1 → secure_code_agent-0.12.0/src/secure_code_agent.egg-info}/PKG-INFO +41 -2
- {secure_code_agent-0.11.1 → secure_code_agent-0.12.0}/src/secure_code_agent.egg-info/SOURCES.txt +1 -0
- {secure_code_agent-0.11.1 → secure_code_agent-0.12.0}/src/secure_code_agent.egg-info/entry_points.txt +1 -0
- {secure_code_agent-0.11.1 → secure_code_agent-0.12.0}/src/secure_code_agent.egg-info/requires.txt +3 -0
- {secure_code_agent-0.11.1 → secure_code_agent-0.12.0}/src/secure_code_audit/__init__.py +1 -1
- {secure_code_agent-0.11.1 → secure_code_agent-0.12.0}/src/secure_code_audit/cli.py +118 -12
- {secure_code_agent-0.11.1 → secure_code_agent-0.12.0}/src/secure_code_audit/git_tools.py +89 -0
- secure_code_agent-0.12.0/src/secure_code_audit/mcp_server.py +186 -0
- {secure_code_agent-0.11.1 → secure_code_agent-0.12.0}/src/secure_code_audit/renderers.py +9 -1
- {secure_code_agent-0.11.1 → secure_code_agent-0.12.0}/src/secure_code_audit/scoring.py +42 -4
- {secure_code_agent-0.11.1 → secure_code_agent-0.12.0}/src/secure_code_audit/verify.py +116 -0
- {secure_code_agent-0.11.1 → secure_code_agent-0.12.0}/LICENSE +0 -0
- {secure_code_agent-0.11.1 → secure_code_agent-0.12.0}/setup.cfg +0 -0
- {secure_code_agent-0.11.1 → secure_code_agent-0.12.0}/src/secure_code_agent.egg-info/dependency_links.txt +0 -0
- {secure_code_agent-0.11.1 → secure_code_agent-0.12.0}/src/secure_code_agent.egg-info/top_level.txt +0 -0
- {secure_code_agent-0.11.1 → secure_code_agent-0.12.0}/src/secure_code_audit/baseline.py +0 -0
- {secure_code_agent-0.11.1 → secure_code_agent-0.12.0}/src/secure_code_audit/config.py +0 -0
- {secure_code_agent-0.11.1 → secure_code_agent-0.12.0}/src/secure_code_audit/data/semgrep-offline.yaml +0 -0
- {secure_code_agent-0.11.1 → secure_code_agent-0.12.0}/src/secure_code_audit/demo.py +0 -0
- {secure_code_agent-0.11.1 → secure_code_agent-0.12.0}/src/secure_code_audit/findings.py +0 -0
- {secure_code_agent-0.11.1 → secure_code_agent-0.12.0}/src/secure_code_audit/history.py +0 -0
- {secure_code_agent-0.11.1 → secure_code_agent-0.12.0}/src/secure_code_audit/instructions.py +0 -0
- {secure_code_agent-0.11.1 → secure_code_agent-0.12.0}/src/secure_code_audit/pillar.py +0 -0
- {secure_code_agent-0.11.1 → secure_code_agent-0.12.0}/src/secure_code_audit/practice.py +0 -0
- {secure_code_agent-0.11.1 → secure_code_agent-0.12.0}/src/secure_code_audit/remediation.py +0 -0
- {secure_code_agent-0.11.1 → secure_code_agent-0.12.0}/src/secure_code_audit/ruleset.py +0 -0
- {secure_code_agent-0.11.1 → secure_code_agent-0.12.0}/src/secure_code_audit/sarif.py +0 -0
- {secure_code_agent-0.11.1 → secure_code_agent-0.12.0}/src/secure_code_audit/scanner_status.py +0 -0
- {secure_code_agent-0.11.1 → secure_code_agent-0.12.0}/src/secure_code_audit/scanners/__init__.py +0 -0
- {secure_code_agent-0.11.1 → secure_code_agent-0.12.0}/src/secure_code_audit/scanners/bandit_scanner.py +0 -0
- {secure_code_agent-0.11.1 → secure_code_agent-0.12.0}/src/secure_code_audit/scanners/base.py +0 -0
- {secure_code_agent-0.11.1 → secure_code_agent-0.12.0}/src/secure_code_audit/scanners/builtin_rules.py +0 -0
- {secure_code_agent-0.11.1 → secure_code_agent-0.12.0}/src/secure_code_audit/scanners/checkov_scanner.py +0 -0
- {secure_code_agent-0.11.1 → secure_code_agent-0.12.0}/src/secure_code_audit/scanners/floor.py +0 -0
- {secure_code_agent-0.11.1 → secure_code_agent-0.12.0}/src/secure_code_audit/scanners/gitleaks_scanner.py +0 -0
- {secure_code_agent-0.11.1 → secure_code_agent-0.12.0}/src/secure_code_audit/scanners/gosec_scanner.py +0 -0
- {secure_code_agent-0.11.1 → secure_code_agent-0.12.0}/src/secure_code_audit/scanners/hadolint_scanner.py +0 -0
- {secure_code_agent-0.11.1 → secure_code_agent-0.12.0}/src/secure_code_audit/scanners/njsscan_scanner.py +0 -0
- {secure_code_agent-0.11.1 → secure_code_agent-0.12.0}/src/secure_code_audit/scanners/npm_audit_scanner.py +0 -0
- {secure_code_agent-0.11.1 → secure_code_agent-0.12.0}/src/secure_code_audit/scanners/osv_scanner.py +0 -0
- {secure_code_agent-0.11.1 → secure_code_agent-0.12.0}/src/secure_code_audit/scanners/pip_audit_scanner.py +0 -0
- {secure_code_agent-0.11.1 → secure_code_agent-0.12.0}/src/secure_code_audit/scanners/rubocop_scanner.py +0 -0
- {secure_code_agent-0.11.1 → secure_code_agent-0.12.0}/src/secure_code_audit/scanners/scorecard_scanner.py +0 -0
- {secure_code_agent-0.11.1 → secure_code_agent-0.12.0}/src/secure_code_audit/scanners/semgrep_scanner.py +0 -0
- {secure_code_agent-0.11.1 → secure_code_agent-0.12.0}/src/secure_code_audit/scanners/trivy_scanner.py +0 -0
- {secure_code_agent-0.11.1 → secure_code_agent-0.12.0}/src/secure_code_audit/scanners/trufflehog_scanner.py +0 -0
- {secure_code_agent-0.11.1 → secure_code_agent-0.12.0}/src/secure_code_audit/standards.py +0 -0
- {secure_code_agent-0.11.1 → secure_code_agent-0.12.0}/src/secure_code_audit/suppressions.py +0 -0
- {secure_code_agent-0.11.1 → secure_code_agent-0.12.0}/src/secure_code_audit/triage.py +0 -0
{secure_code_agent-0.11.1/src/secure_code_agent.egg-info → secure_code_agent-0.12.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.12.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
|
|
@@ -29,6 +29,8 @@ Requires-Python: >=3.10
|
|
|
29
29
|
Description-Content-Type: text/markdown
|
|
30
30
|
License-File: LICENSE
|
|
31
31
|
Requires-Dist: PyYAML<7,>=6.0.2
|
|
32
|
+
Provides-Extra: mcp
|
|
33
|
+
Requires-Dist: mcp<2,>=1.0; extra == "mcp"
|
|
32
34
|
Provides-Extra: required-scanners
|
|
33
35
|
Requires-Dist: bandit==1.9.4; extra == "required-scanners"
|
|
34
36
|
Requires-Dist: pip-audit==2.10.1; extra == "required-scanners"
|
|
@@ -90,6 +92,43 @@ A pure-Python repository requires six of the ten floor scanners; the Ruby,
|
|
|
90
92
|
JavaScript and container tools are *not applicable* rather than missing, so a
|
|
91
93
|
single-language project is never permanently incomplete.
|
|
92
94
|
|
|
95
|
+
## In your editor or an agent, not just a terminal
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
pip install 'secure-code-agent[mcp]'
|
|
99
|
+
secure-code-agent-mcp
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Exposes `audit_repository`, `preflight` and `agent_info`. It never audits
|
|
103
|
+
unasked — `audit_repository` without `action="run"` returns the question — and
|
|
104
|
+
it hands back the **work order first**, because the score is second class.
|
|
105
|
+
|
|
106
|
+
## Auditing a pull request
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
secure-code-agent . --changed-only origin/main
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
The whole tree is still scanned; the *report* is scoped to what changed. **No
|
|
113
|
+
grade is issued** for a scoped run, because a run that looks at less must not
|
|
114
|
+
score better.
|
|
115
|
+
|
|
116
|
+
## Proving the agent stayed inside the order
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
secure-code-agent . --verify-against before.json
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Reports what was fixed, what is still open, what was **silenced rather than
|
|
123
|
+
fixed** — and the blast radius: which changed files the work order never
|
|
124
|
+
cited.
|
|
125
|
+
|
|
126
|
+
```
|
|
127
|
+
work order verification · improved: 1 fixed
|
|
128
|
+
fixed sca.python.subprocess.shell_true bad.py:3
|
|
129
|
+
scope: EXCEEDED — 1 of 2 changed file(s) were never cited: src/other.py
|
|
130
|
+
```
|
|
131
|
+
|
|
93
132
|
## Why this exists
|
|
94
133
|
|
|
95
134
|
AI coding agents ship code at human-review-saturating speed. Point them at a security finding and the documented anti-patterns are:
|
|
@@ -470,7 +509,7 @@ For agents that support invokable skills, this repo ships a portable skill under
|
|
|
470
509
|
## GitHub Action
|
|
471
510
|
|
|
472
511
|
```yaml
|
|
473
|
-
- uses: marshallguillory86/secure-code-agent@v0.
|
|
512
|
+
- uses: marshallguillory86/secure-code-agent@v0.12.0
|
|
474
513
|
with:
|
|
475
514
|
config: secure-code-agent.json
|
|
476
515
|
fail-on-gate: true
|
|
@@ -44,6 +44,43 @@ A pure-Python repository requires six of the ten floor scanners; the Ruby,
|
|
|
44
44
|
JavaScript and container tools are *not applicable* rather than missing, so a
|
|
45
45
|
single-language project is never permanently incomplete.
|
|
46
46
|
|
|
47
|
+
## In your editor or an agent, not just a terminal
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
pip install 'secure-code-agent[mcp]'
|
|
51
|
+
secure-code-agent-mcp
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Exposes `audit_repository`, `preflight` and `agent_info`. It never audits
|
|
55
|
+
unasked — `audit_repository` without `action="run"` returns the question — and
|
|
56
|
+
it hands back the **work order first**, because the score is second class.
|
|
57
|
+
|
|
58
|
+
## Auditing a pull request
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
secure-code-agent . --changed-only origin/main
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The whole tree is still scanned; the *report* is scoped to what changed. **No
|
|
65
|
+
grade is issued** for a scoped run, because a run that looks at less must not
|
|
66
|
+
score better.
|
|
67
|
+
|
|
68
|
+
## Proving the agent stayed inside the order
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
secure-code-agent . --verify-against before.json
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Reports what was fixed, what is still open, what was **silenced rather than
|
|
75
|
+
fixed** — and the blast radius: which changed files the work order never
|
|
76
|
+
cited.
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
work order verification · improved: 1 fixed
|
|
80
|
+
fixed sca.python.subprocess.shell_true bad.py:3
|
|
81
|
+
scope: EXCEEDED — 1 of 2 changed file(s) were never cited: src/other.py
|
|
82
|
+
```
|
|
83
|
+
|
|
47
84
|
## Why this exists
|
|
48
85
|
|
|
49
86
|
AI coding agents ship code at human-review-saturating speed. Point them at a security finding and the documented anti-patterns are:
|
|
@@ -424,7 +461,7 @@ For agents that support invokable skills, this repo ships a portable skill under
|
|
|
424
461
|
## GitHub Action
|
|
425
462
|
|
|
426
463
|
```yaml
|
|
427
|
-
- uses: marshallguillory86/secure-code-agent@v0.
|
|
464
|
+
- uses: marshallguillory86/secure-code-agent@v0.12.0
|
|
428
465
|
with:
|
|
429
466
|
config: secure-code-agent.json
|
|
430
467
|
fail-on-gate: true
|
|
@@ -49,6 +49,13 @@ dependencies = [
|
|
|
49
49
|
]
|
|
50
50
|
|
|
51
51
|
[project.optional-dependencies]
|
|
52
|
+
# The chat door. Optional because the CLI is the tested surface and the MCP
|
|
53
|
+
# server drives it by subprocess rather than reimplementing it — so nobody
|
|
54
|
+
# who only wants the gate pays for an MCP dependency.
|
|
55
|
+
mcp = [
|
|
56
|
+
"mcp>=1.0,<2",
|
|
57
|
+
]
|
|
58
|
+
|
|
52
59
|
# Scanners the default configuration lists in gates.require_scanners. Pinned
|
|
53
60
|
# exactly: a gate asserting "bandit completed" should mean a known Bandit
|
|
54
61
|
# completed, not whatever Bandit resolved that day.
|
|
@@ -86,6 +93,7 @@ dev = [
|
|
|
86
93
|
[project.scripts]
|
|
87
94
|
secure-code-agent = "secure_code_audit.cli:main"
|
|
88
95
|
secure-code-audit = "secure_code_audit.cli:main"
|
|
96
|
+
secure-code-agent-mcp = "secure_code_audit.mcp_server:main"
|
|
89
97
|
|
|
90
98
|
[project.urls]
|
|
91
99
|
Homepage = "https://github.com/marshallguillory86/secure-code-agent"
|
{secure_code_agent-0.11.1 → secure_code_agent-0.12.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.12.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
|
|
@@ -29,6 +29,8 @@ Requires-Python: >=3.10
|
|
|
29
29
|
Description-Content-Type: text/markdown
|
|
30
30
|
License-File: LICENSE
|
|
31
31
|
Requires-Dist: PyYAML<7,>=6.0.2
|
|
32
|
+
Provides-Extra: mcp
|
|
33
|
+
Requires-Dist: mcp<2,>=1.0; extra == "mcp"
|
|
32
34
|
Provides-Extra: required-scanners
|
|
33
35
|
Requires-Dist: bandit==1.9.4; extra == "required-scanners"
|
|
34
36
|
Requires-Dist: pip-audit==2.10.1; extra == "required-scanners"
|
|
@@ -90,6 +92,43 @@ A pure-Python repository requires six of the ten floor scanners; the Ruby,
|
|
|
90
92
|
JavaScript and container tools are *not applicable* rather than missing, so a
|
|
91
93
|
single-language project is never permanently incomplete.
|
|
92
94
|
|
|
95
|
+
## In your editor or an agent, not just a terminal
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
pip install 'secure-code-agent[mcp]'
|
|
99
|
+
secure-code-agent-mcp
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Exposes `audit_repository`, `preflight` and `agent_info`. It never audits
|
|
103
|
+
unasked — `audit_repository` without `action="run"` returns the question — and
|
|
104
|
+
it hands back the **work order first**, because the score is second class.
|
|
105
|
+
|
|
106
|
+
## Auditing a pull request
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
secure-code-agent . --changed-only origin/main
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
The whole tree is still scanned; the *report* is scoped to what changed. **No
|
|
113
|
+
grade is issued** for a scoped run, because a run that looks at less must not
|
|
114
|
+
score better.
|
|
115
|
+
|
|
116
|
+
## Proving the agent stayed inside the order
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
secure-code-agent . --verify-against before.json
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Reports what was fixed, what is still open, what was **silenced rather than
|
|
123
|
+
fixed** — and the blast radius: which changed files the work order never
|
|
124
|
+
cited.
|
|
125
|
+
|
|
126
|
+
```
|
|
127
|
+
work order verification · improved: 1 fixed
|
|
128
|
+
fixed sca.python.subprocess.shell_true bad.py:3
|
|
129
|
+
scope: EXCEEDED — 1 of 2 changed file(s) were never cited: src/other.py
|
|
130
|
+
```
|
|
131
|
+
|
|
93
132
|
## Why this exists
|
|
94
133
|
|
|
95
134
|
AI coding agents ship code at human-review-saturating speed. Point them at a security finding and the documented anti-patterns are:
|
|
@@ -470,7 +509,7 @@ For agents that support invokable skills, this repo ships a portable skill under
|
|
|
470
509
|
## GitHub Action
|
|
471
510
|
|
|
472
511
|
```yaml
|
|
473
|
-
- uses: marshallguillory86/secure-code-agent@v0.
|
|
512
|
+
- uses: marshallguillory86/secure-code-agent@v0.12.0
|
|
474
513
|
with:
|
|
475
514
|
config: secure-code-agent.json
|
|
476
515
|
fail-on-gate: true
|
{secure_code_agent-0.11.1 → secure_code_agent-0.12.0}/src/secure_code_agent.egg-info/SOURCES.txt
RENAMED
|
@@ -16,6 +16,7 @@ src/secure_code_audit/findings.py
|
|
|
16
16
|
src/secure_code_audit/git_tools.py
|
|
17
17
|
src/secure_code_audit/history.py
|
|
18
18
|
src/secure_code_audit/instructions.py
|
|
19
|
+
src/secure_code_audit/mcp_server.py
|
|
19
20
|
src/secure_code_audit/pillar.py
|
|
20
21
|
src/secure_code_audit/practice.py
|
|
21
22
|
src/secure_code_audit/remediation.py
|
|
@@ -22,6 +22,7 @@ from secure_code_audit import (
|
|
|
22
22
|
sarif,
|
|
23
23
|
scanners,
|
|
24
24
|
suppressions,
|
|
25
|
+
triage,
|
|
25
26
|
)
|
|
26
27
|
from secure_code_audit import baseline as baseline_mod
|
|
27
28
|
from secure_code_audit import config as config_mod
|
|
@@ -111,7 +112,16 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
111
112
|
"--fail-on-new", action="store_true", help="Exit nonzero on findings not in baseline."
|
|
112
113
|
)
|
|
113
114
|
|
|
114
|
-
p.add_argument(
|
|
115
|
+
p.add_argument(
|
|
116
|
+
"--changed-only",
|
|
117
|
+
metavar="REF",
|
|
118
|
+
help=(
|
|
119
|
+
"Report only findings in files changed since REF (e.g. main...HEAD). "
|
|
120
|
+
"The whole tree is still scanned — scanners read trees, not diffs — "
|
|
121
|
+
"and the grade is WITHHELD, because a run that looks at less must "
|
|
122
|
+
"not score better."
|
|
123
|
+
),
|
|
124
|
+
)
|
|
115
125
|
p.add_argument("--skip-scanners", help="Comma-separated scanner names to skip.")
|
|
116
126
|
p.add_argument("--only-scanners", help="Comma-separated scanner names — only these run.")
|
|
117
127
|
p.add_argument(
|
|
@@ -431,6 +441,11 @@ def _do_audit(args: argparse.Namespace) -> int:
|
|
|
431
441
|
# * the baseline. `baseline.write` recorded only the primary set, so
|
|
432
442
|
# every side-axis finding was absent from it and `fail_on_new`
|
|
433
443
|
# re-flagged the same test-tree secret as new on every run, forever.
|
|
444
|
+
if args.changed_only:
|
|
445
|
+
all_findings, changed_note = _restrict_to_changed(all_findings, root, args.changed_only)
|
|
446
|
+
else:
|
|
447
|
+
changed_note = None
|
|
448
|
+
|
|
434
449
|
primary_findings, path_axes = partition_by_path(all_findings, _classify)
|
|
435
450
|
test_findings = path_axes.get("test tree", [])
|
|
436
451
|
docs_findings = path_axes.get("documentation", [])
|
|
@@ -493,7 +508,27 @@ def _do_audit(args: argparse.Namespace) -> int:
|
|
|
493
508
|
gate = evaluate_gates(
|
|
494
509
|
gated, score, {**cfg.gates, "_baseline_state": baseline_state.value}, coverage
|
|
495
510
|
)
|
|
496
|
-
|
|
511
|
+
# A first run has triaged nothing: no baseline exists, so no finding has
|
|
512
|
+
# been accepted, deferred or dismissed by anyone. Counted from the
|
|
513
|
+
# actionable set (§FIX and §REVIEW), not the raw total — §ACCEPT is the
|
|
514
|
+
# test tree and documentation, which the work order explicitly says not
|
|
515
|
+
# to patch, and counting those would make every repository look untriaged
|
|
516
|
+
# forever.
|
|
517
|
+
untriaged = 0
|
|
518
|
+
if baseline_state is baseline_mod.State.ABSENT:
|
|
519
|
+
tiers = triage.partition(all_findings, lambda f: renderers.axis_of(f, axes))
|
|
520
|
+
untriaged = len(tiers[triage.Tier.FIX]) + len(tiers[triage.Tier.REVIEW])
|
|
521
|
+
verdict = build_verdict(score, cfg.gates, coverage, untriaged)
|
|
522
|
+
if changed_note is not None:
|
|
523
|
+
# A scoped run has no denominator it can defend: the LOC is the whole
|
|
524
|
+
# tree and the findings are a slice of it, so any number would be
|
|
525
|
+
# flattering by construction. Withhold the grade and say why. The
|
|
526
|
+
# findings, the work order and the gate are all still real.
|
|
527
|
+
verdict = replace(
|
|
528
|
+
verdict,
|
|
529
|
+
verified_grade=None,
|
|
530
|
+
reasons=(*verdict.reasons, changed_note),
|
|
531
|
+
)
|
|
497
532
|
|
|
498
533
|
# ----- write outputs -----
|
|
499
534
|
# The pillar artifact is what maintainability-agent ingests (D3). Built
|
|
@@ -541,13 +576,14 @@ def _do_audit(args: argparse.Namespace) -> int:
|
|
|
541
576
|
|
|
542
577
|
# ----- did the work order actually improve anything? -----
|
|
543
578
|
if args.verify_against:
|
|
544
|
-
return _do_verify(args, all_findings, root, axes)
|
|
579
|
+
return _do_verify(args, all_findings, root, axes, own_artifacts)
|
|
545
580
|
|
|
546
581
|
# ----- terminal output -----
|
|
547
582
|
if args.json:
|
|
548
583
|
sys.stdout.write(
|
|
549
584
|
json.dumps(
|
|
550
|
-
renderers.to_json(all_findings, score, gate, coverage, verdict, axes),
|
|
585
|
+
renderers.to_json(all_findings, score, gate, coverage, verdict, axes, root),
|
|
586
|
+
indent=2,
|
|
551
587
|
)
|
|
552
588
|
)
|
|
553
589
|
sys.stdout.write("\n")
|
|
@@ -561,10 +597,11 @@ def _prepare_audit(
|
|
|
561
597
|
args: argparse.Namespace,
|
|
562
598
|
) -> tuple[config_mod.Config, Path, Path]:
|
|
563
599
|
"""Load config and resolve the single scan root, or refuse."""
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
600
|
+
# `--changed-only` used to refuse outright, because a scoped audit that
|
|
601
|
+
# produced a score would let looking at less score better — the
|
|
602
|
+
# absence-as-value defect, arriving through a convenience flag. It is
|
|
603
|
+
# implemented now, and the refusal is replaced by the property that made
|
|
604
|
+
# it unsafe: a scoped run reports findings and **never a grade**.
|
|
568
605
|
if len(args.paths) > 1:
|
|
569
606
|
raise ValueError("multiple scan roots are not supported; provide one repository root")
|
|
570
607
|
# The target is resolved first because the default config belongs to it.
|
|
@@ -869,7 +906,7 @@ def _write_outputs(
|
|
|
869
906
|
findings, score, gate, paths.markdown, ran, unavailable, coverage, verdict, axes
|
|
870
907
|
)
|
|
871
908
|
if paths.json_out is not None:
|
|
872
|
-
renderers.write_json(findings, score, gate, paths.json_out, coverage, verdict, axes)
|
|
909
|
+
renderers.write_json(findings, score, gate, paths.json_out, coverage, verdict, axes, root)
|
|
873
910
|
if paths.sarif is not None:
|
|
874
911
|
sarif.write(findings, paths.sarif, coverage, lambda f: renderers.axis_of(f, axes))
|
|
875
912
|
if paths.comment is not None:
|
|
@@ -1069,7 +1106,7 @@ def _print_summary(
|
|
|
1069
1106
|
status = "PASS"
|
|
1070
1107
|
else:
|
|
1071
1108
|
status = "NOT CONFIGURED"
|
|
1072
|
-
print(f"secure-code-agent ·
|
|
1109
|
+
print(f"secure-code-agent · {verdict.headline()} · gate {status}")
|
|
1073
1110
|
for reason in verdict.reasons:
|
|
1074
1111
|
print(f" ! grade withheld: {reason}")
|
|
1075
1112
|
print(f" scanned LOC: {score.loc_scanned:,}")
|
|
@@ -1171,15 +1208,34 @@ def _findings_from_report(path: Path) -> list[Finding]:
|
|
|
1171
1208
|
return restored
|
|
1172
1209
|
|
|
1173
1210
|
|
|
1174
|
-
def _do_verify(
|
|
1211
|
+
def _do_verify(
|
|
1212
|
+
args: argparse.Namespace,
|
|
1213
|
+
after: list[Finding],
|
|
1214
|
+
root: Path,
|
|
1215
|
+
axes=(),
|
|
1216
|
+
own_artifacts: frozenset[Path] = frozenset(),
|
|
1217
|
+
) -> int:
|
|
1175
1218
|
"""Compare this run against the one that produced the work order.
|
|
1176
1219
|
|
|
1177
1220
|
Exits nonzero unless the run passes, because a verification step that
|
|
1178
1221
|
always passes verifies nothing. "Improved" is deliberately
|
|
1179
1222
|
strict: something fixed, nothing introduced, nothing merely silenced.
|
|
1180
1223
|
"""
|
|
1181
|
-
|
|
1224
|
+
report_path = _under_root(root, args.verify_against)
|
|
1225
|
+
before = _findings_from_report(report_path)
|
|
1182
1226
|
result = verify_mod.compare(before, after, root, lambda f: renderers.axis_of(f, axes))
|
|
1227
|
+
# Blast radius: what changed that the order never cited. Reads the commit
|
|
1228
|
+
# the before-report was taken at; unknown rather than conformant when the
|
|
1229
|
+
# report predates that field or the tree is not a git repository.
|
|
1230
|
+
result = replace(
|
|
1231
|
+
result,
|
|
1232
|
+
scope=verify_mod.measure_scope(
|
|
1233
|
+
before,
|
|
1234
|
+
root,
|
|
1235
|
+
_commit_of_report(report_path),
|
|
1236
|
+
ours={*own_artifacts, report_path},
|
|
1237
|
+
),
|
|
1238
|
+
)
|
|
1183
1239
|
|
|
1184
1240
|
if args.json:
|
|
1185
1241
|
sys.stdout.write(json.dumps(verify_mod.to_dict(result), indent=2) + "\n")
|
|
@@ -1207,11 +1263,61 @@ def _do_verify(args: argparse.Namespace, after: list[Finding], root: Path, axes=
|
|
|
1207
1263
|
f" ({len(result.deferred)} finding(s) in the test tree and documentation "
|
|
1208
1264
|
f"are reported but not required — see §ACCEPT)"
|
|
1209
1265
|
)
|
|
1266
|
+
print(f" {result.scope.headline()}")
|
|
1267
|
+
for path in sorted(result.scope.collateral)[:10]:
|
|
1268
|
+
print(f" collateral {path}")
|
|
1210
1269
|
for note in result.notes:
|
|
1211
1270
|
print(f" ! {note}")
|
|
1212
1271
|
|
|
1213
1272
|
return 0 if result.passed else 1
|
|
1214
1273
|
|
|
1215
1274
|
|
|
1275
|
+
def _restrict_to_changed(
|
|
1276
|
+
findings: list[Finding], root: Path, ref: str
|
|
1277
|
+
) -> tuple[list[Finding], str]:
|
|
1278
|
+
"""Keep only findings in files changed since `ref`.
|
|
1279
|
+
|
|
1280
|
+
The whole tree is still scanned. Scanners read trees rather than diffs,
|
|
1281
|
+
and asking one to look at a subset changes what it can see — Semgrep's
|
|
1282
|
+
cross-file dataflow being the obvious case. So the *scan* is complete and
|
|
1283
|
+
the *report* is scoped, which is the only ordering that does not quietly
|
|
1284
|
+
trade coverage for speed.
|
|
1285
|
+
|
|
1286
|
+
A ref git cannot resolve is an error rather than an empty diff. "Nothing
|
|
1287
|
+
changed" and "your ref is wrong" produce the same finding count and only
|
|
1288
|
+
one of them should exit 0.
|
|
1289
|
+
"""
|
|
1290
|
+
from secure_code_audit.git_tools import changed_files
|
|
1291
|
+
|
|
1292
|
+
paths, reason = changed_files(root, ref)
|
|
1293
|
+
if paths is None:
|
|
1294
|
+
raise ValueError(f"--changed-only {ref}: {reason}")
|
|
1295
|
+
|
|
1296
|
+
absolute = {(root / path).resolve() for path in paths}
|
|
1297
|
+
|
|
1298
|
+
def touched(finding: Finding) -> bool:
|
|
1299
|
+
try:
|
|
1300
|
+
return finding.file_path.resolve() in absolute
|
|
1301
|
+
except OSError:
|
|
1302
|
+
return False
|
|
1303
|
+
|
|
1304
|
+
kept = [f for f in findings if f.severity is Severity.INFORMATIONAL or touched(f)]
|
|
1305
|
+
note = (
|
|
1306
|
+
f"scoped to files changed since {ref} — a grade needs the whole tree, "
|
|
1307
|
+
f"and this run reports a slice of it"
|
|
1308
|
+
)
|
|
1309
|
+
return kept, note
|
|
1310
|
+
|
|
1311
|
+
|
|
1312
|
+
def _commit_of_report(path: Path) -> str | None:
|
|
1313
|
+
"""The commit a saved report was taken at, if it recorded one."""
|
|
1314
|
+
try:
|
|
1315
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
1316
|
+
except (OSError, json.JSONDecodeError):
|
|
1317
|
+
return None
|
|
1318
|
+
commit = payload.get("commit")
|
|
1319
|
+
return commit if isinstance(commit, str) and commit else None
|
|
1320
|
+
|
|
1321
|
+
|
|
1216
1322
|
if __name__ == "__main__":
|
|
1217
1323
|
sys.exit(main())
|
|
@@ -216,3 +216,92 @@ def loc_under(
|
|
|
216
216
|
else:
|
|
217
217
|
primary += lines
|
|
218
218
|
return primary, test, docs
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _git() -> str | None:
|
|
222
|
+
"""Resolve `git` to an absolute path, once.
|
|
223
|
+
|
|
224
|
+
Invoking a bare `git` leaves the choice of binary to `PATH`, which is the
|
|
225
|
+
same class of exposure this project refuses for scanner commands — and
|
|
226
|
+
Bandit says so (`B607`, partial executable path). Resolving it is cheaper
|
|
227
|
+
than suppressing it, and consistent with how every scanner adapter here
|
|
228
|
+
already resolves its tool.
|
|
229
|
+
"""
|
|
230
|
+
import shutil # noqa: PLC0415
|
|
231
|
+
|
|
232
|
+
return shutil.which("git")
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def head_commit(root: Path) -> str | None:
|
|
236
|
+
"""The commit an audit was taken at, or None outside a git repository.
|
|
237
|
+
|
|
238
|
+
Recorded in the JSON report so a later `--verify-against` can ask *what
|
|
239
|
+
actually changed* rather than inferring it from two finding sets. Two
|
|
240
|
+
reports tell you which findings moved; they cannot tell you that an agent
|
|
241
|
+
also rewrote three unrelated modules, and that is the question the work
|
|
242
|
+
order's constraints exist to answer.
|
|
243
|
+
"""
|
|
244
|
+
import subprocess # noqa: PLC0415 — only needed on this path
|
|
245
|
+
|
|
246
|
+
git = _git()
|
|
247
|
+
if git is None:
|
|
248
|
+
return None
|
|
249
|
+
try:
|
|
250
|
+
completed = subprocess.run(
|
|
251
|
+
[git, "-C", str(root), "rev-parse", "HEAD"],
|
|
252
|
+
capture_output=True,
|
|
253
|
+
text=True,
|
|
254
|
+
timeout=10,
|
|
255
|
+
check=False,
|
|
256
|
+
)
|
|
257
|
+
except (OSError, subprocess.SubprocessError):
|
|
258
|
+
return None
|
|
259
|
+
if completed.returncode != 0:
|
|
260
|
+
return None
|
|
261
|
+
sha = completed.stdout.strip()
|
|
262
|
+
return sha or None
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def changed_files(root: Path, since: str) -> tuple[frozenset[str], str | None] | tuple[None, str]:
|
|
266
|
+
"""Repository-relative paths changed since `since`, or a reason it is unknown.
|
|
267
|
+
|
|
268
|
+
Includes uncommitted work, because an agent handed a work order usually
|
|
269
|
+
has not committed. `git diff --name-only <sha>` covers tracked
|
|
270
|
+
modifications against the working tree; untracked files are asked for
|
|
271
|
+
separately, since a newly added module is exactly the kind of collateral
|
|
272
|
+
worth seeing.
|
|
273
|
+
|
|
274
|
+
Returns `(paths, None)` on success and `(None, reason)` when the answer is
|
|
275
|
+
unavailable — never an empty set standing in for "could not tell", which
|
|
276
|
+
would read as "nothing changed" and turn a failed measurement into a
|
|
277
|
+
clean bill of health.
|
|
278
|
+
"""
|
|
279
|
+
import subprocess # noqa: PLC0415
|
|
280
|
+
|
|
281
|
+
git = _git()
|
|
282
|
+
if git is None:
|
|
283
|
+
return None, "git is not on PATH"
|
|
284
|
+
|
|
285
|
+
def _run(args: list[str]) -> tuple[str, str | None]:
|
|
286
|
+
try:
|
|
287
|
+
completed = subprocess.run(
|
|
288
|
+
[git, "-C", str(root), *args],
|
|
289
|
+
capture_output=True,
|
|
290
|
+
text=True,
|
|
291
|
+
timeout=30,
|
|
292
|
+
check=False,
|
|
293
|
+
)
|
|
294
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
295
|
+
return "", f"git failed: {type(exc).__name__}"
|
|
296
|
+
if completed.returncode != 0:
|
|
297
|
+
return "", (completed.stderr.strip().splitlines() or ["git failed"])[0]
|
|
298
|
+
return completed.stdout, None
|
|
299
|
+
|
|
300
|
+
tracked, reason = _run(["diff", "--name-only", since])
|
|
301
|
+
if reason is not None:
|
|
302
|
+
return None, reason
|
|
303
|
+
untracked, reason = _run(["ls-files", "--others", "--exclude-standard"])
|
|
304
|
+
if reason is not None:
|
|
305
|
+
return None, reason
|
|
306
|
+
paths = {line.strip() for line in (tracked + untracked).splitlines() if line.strip()}
|
|
307
|
+
return frozenset(paths), None
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""An MCP door, because almost nobody lives in the CLI.
|
|
2
|
+
|
|
3
|
+
A reviewer's objection: *"It is named agent and there is no chat door. No
|
|
4
|
+
MCP. Skills are files you copy. MA already learned almost nobody lives in the
|
|
5
|
+
CLI. This still does."* That is fair, and `maintainability-agent` learned it
|
|
6
|
+
first — its server is the reference for the shape used here.
|
|
7
|
+
|
|
8
|
+
**The server never audits unasked.** `audit_repository` without an explicit
|
|
9
|
+
`action` returns a question, not a result. Scanning someone's repository is
|
|
10
|
+
not a thing to do because a model inferred it might be useful, and a tool
|
|
11
|
+
that audits on mention trains people to stop reading what it did.
|
|
12
|
+
|
|
13
|
+
**It returns the work order, not only the score.** The score is second class
|
|
14
|
+
here by design; the work order is the product, and a chat surface that hands
|
|
15
|
+
back a letter grade would invert that.
|
|
16
|
+
|
|
17
|
+
Optional: `pip install 'secure-code-agent[mcp]'`. Nothing in the package
|
|
18
|
+
imports this module unless the server is started, so the dependency stays
|
|
19
|
+
off the default install.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import subprocess
|
|
26
|
+
import sys
|
|
27
|
+
import tempfile
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
from secure_code_audit import __version__
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _run_cli(args: list[str]) -> tuple[int, str, str]:
|
|
35
|
+
"""Drive the CLI rather than reimport its internals.
|
|
36
|
+
|
|
37
|
+
The CLI is the tested surface: gates, containment, the axis split and
|
|
38
|
+
coverage integrity all live behind it. A second entry point that
|
|
39
|
+
assembled the same pieces differently would be a second set of bugs, and
|
|
40
|
+
the one thing this project cannot afford is two answers to "what did you
|
|
41
|
+
find".
|
|
42
|
+
"""
|
|
43
|
+
completed = subprocess.run(
|
|
44
|
+
[sys.executable, "-m", "secure_code_audit.cli", *args],
|
|
45
|
+
capture_output=True,
|
|
46
|
+
text=True,
|
|
47
|
+
timeout=1800,
|
|
48
|
+
check=False,
|
|
49
|
+
)
|
|
50
|
+
return completed.returncode, completed.stdout, completed.stderr
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _audit(path: str, extra: list[str] | None = None) -> dict[str, Any]:
|
|
54
|
+
target = Path(path).expanduser().resolve()
|
|
55
|
+
if not target.exists():
|
|
56
|
+
return {"error": f"path does not exist: {target}", "audit_ran": False}
|
|
57
|
+
|
|
58
|
+
with tempfile.TemporaryDirectory(prefix="sca-mcp-") as tmp:
|
|
59
|
+
report = Path(tmp) / "report.json"
|
|
60
|
+
prompt = Path(tmp) / "work-order.md"
|
|
61
|
+
code, out, err = _run_cli(
|
|
62
|
+
[
|
|
63
|
+
str(target),
|
|
64
|
+
"--json-output",
|
|
65
|
+
str(report),
|
|
66
|
+
"--prompt-output",
|
|
67
|
+
str(prompt),
|
|
68
|
+
# No report, work order, SARIF or baseline is written into
|
|
69
|
+
# the operator's tree from a chat surface — they land in a
|
|
70
|
+
# temp directory and are read back.
|
|
71
|
+
#
|
|
72
|
+
# One thing IS written: `.secure-code/history.jsonl`, this
|
|
73
|
+
# tool's own state directory, because that is how `trend`
|
|
74
|
+
# works across runs and a trend is the score's one genuine
|
|
75
|
+
# use. Saying "writes nothing" would have been the tidier
|
|
76
|
+
# sentence and a false one; a test asserts the tree is
|
|
77
|
+
# otherwise untouched.
|
|
78
|
+
"--output",
|
|
79
|
+
str(Path(tmp) / "report.md"),
|
|
80
|
+
*(extra or []),
|
|
81
|
+
]
|
|
82
|
+
)
|
|
83
|
+
if not report.is_file():
|
|
84
|
+
return {
|
|
85
|
+
"error": "the audit did not produce a report",
|
|
86
|
+
"exit_code": code,
|
|
87
|
+
"stderr": err[-2000:],
|
|
88
|
+
"audit_ran": False,
|
|
89
|
+
}
|
|
90
|
+
payload = json.loads(report.read_text(encoding="utf-8"))
|
|
91
|
+
work_order = prompt.read_text(encoding="utf-8") if prompt.is_file() else ""
|
|
92
|
+
|
|
93
|
+
score = payload.get("score", {})
|
|
94
|
+
coverage = payload.get("coverage", {})
|
|
95
|
+
return {
|
|
96
|
+
"audit_ran": True,
|
|
97
|
+
"exit_code": code,
|
|
98
|
+
"summary": out.strip().splitlines()[:1],
|
|
99
|
+
# The work order first, deliberately. It is the output that changes
|
|
100
|
+
# the code; the score is the one that describes it.
|
|
101
|
+
"work_order": work_order,
|
|
102
|
+
"verified_grade": score.get("verified_grade"),
|
|
103
|
+
"score": score.get("overall"),
|
|
104
|
+
"evidence_status": score.get("evidence_status"),
|
|
105
|
+
"evidence_reasons": score.get("evidence_reasons", []),
|
|
106
|
+
"coverage_status": coverage.get("status"),
|
|
107
|
+
"scanners_missing": [
|
|
108
|
+
s.get("name") for s in coverage.get("scanners", []) if s.get("outcome") != "completed"
|
|
109
|
+
],
|
|
110
|
+
"gate": payload.get("gate", {}),
|
|
111
|
+
"producer": {"tool": "secure-code-agent", "version": __version__},
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def build_server(): # pragma: no cover - exercised by the smoke test below
|
|
116
|
+
from mcp.server.fastmcp import FastMCP
|
|
117
|
+
|
|
118
|
+
server = FastMCP("secure-code-agent")
|
|
119
|
+
|
|
120
|
+
@server.tool()
|
|
121
|
+
def audit_repository(path: str, action: str | None = None) -> dict[str, Any]:
|
|
122
|
+
"""Audit a repository and return its bounded work order.
|
|
123
|
+
|
|
124
|
+
`action` must be `"run"` before anything is scanned. Called without
|
|
125
|
+
it, this returns the question instead — auditing someone's code
|
|
126
|
+
because it came up in conversation is not a thing to do quietly.
|
|
127
|
+
"""
|
|
128
|
+
if action != "run":
|
|
129
|
+
return {
|
|
130
|
+
"audit_ran": False,
|
|
131
|
+
"choice_needed": (
|
|
132
|
+
f"Audit {path}? This runs the scanner floor over the tree. "
|
|
133
|
+
f"No report is written into it; only this tool's own "
|
|
134
|
+
f"`.secure-code/history.jsonl` trend log is appended. "
|
|
135
|
+
f"Call again with action='run' to proceed."
|
|
136
|
+
),
|
|
137
|
+
"options": ["run", "preflight"],
|
|
138
|
+
}
|
|
139
|
+
return _audit(path)
|
|
140
|
+
|
|
141
|
+
@server.tool()
|
|
142
|
+
def preflight(path: str) -> dict[str, Any]:
|
|
143
|
+
"""Which scanners resolve here, and how to install the ones that do not.
|
|
144
|
+
|
|
145
|
+
Read-only and safe to call unasked: it resolves tool paths and runs
|
|
146
|
+
nothing against the code.
|
|
147
|
+
"""
|
|
148
|
+
code, out, err = _run_cli([path, "--preflight"])
|
|
149
|
+
return {"audit_ran": False, "exit_code": code, "report": out.strip() or err.strip()}
|
|
150
|
+
|
|
151
|
+
@server.tool()
|
|
152
|
+
def agent_info() -> dict[str, Any]:
|
|
153
|
+
"""What this tool is for, so a model does not have to guess."""
|
|
154
|
+
return {
|
|
155
|
+
"tool": "secure-code-agent",
|
|
156
|
+
"version": __version__,
|
|
157
|
+
"produces": [
|
|
158
|
+
"a bounded work order an agent can act on (the product)",
|
|
159
|
+
"a coverage report stating what was examined (never inferred)",
|
|
160
|
+
"a score, which is second class to both",
|
|
161
|
+
],
|
|
162
|
+
"never": [
|
|
163
|
+
"installs a scanner",
|
|
164
|
+
"audits without an explicit action='run'",
|
|
165
|
+
"grades what it could not examine",
|
|
166
|
+
],
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return server
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def main() -> int:
|
|
173
|
+
try:
|
|
174
|
+
server = build_server()
|
|
175
|
+
except ImportError:
|
|
176
|
+
sys.stderr.write(
|
|
177
|
+
"ERROR: the MCP server needs the `mcp` package.\n"
|
|
178
|
+
" pip install 'secure-code-agent[mcp]'\n"
|
|
179
|
+
)
|
|
180
|
+
return 2
|
|
181
|
+
server.run()
|
|
182
|
+
return 0
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
if __name__ == "__main__": # pragma: no cover
|
|
186
|
+
raise SystemExit(main())
|
|
@@ -10,6 +10,7 @@ from pathlib import Path
|
|
|
10
10
|
|
|
11
11
|
from secure_code_audit import __version__
|
|
12
12
|
from secure_code_audit.findings import Finding, Severity
|
|
13
|
+
from secure_code_audit.git_tools import head_commit
|
|
13
14
|
from secure_code_audit.scanner_status import CoverageReport
|
|
14
15
|
from secure_code_audit.scanners import floor
|
|
15
16
|
from secure_code_audit.scoring import AxisReport, GateResult, ScoreReport, Verdict
|
|
@@ -27,11 +28,17 @@ def to_json(
|
|
|
27
28
|
coverage: CoverageReport | None = None,
|
|
28
29
|
verdict: Verdict | None = None,
|
|
29
30
|
axes: Iterable[AxisReport] = (),
|
|
31
|
+
root: Path | None = None,
|
|
30
32
|
) -> dict:
|
|
31
33
|
findings = list(findings)
|
|
32
34
|
return {
|
|
33
35
|
"version": __version__,
|
|
34
36
|
"generated": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
37
|
+
# The commit this was taken at, so a later `--verify-against` can ask
|
|
38
|
+
# what actually changed rather than inferring it from two finding
|
|
39
|
+
# sets. Null outside a git repository, which is honest: the scope
|
|
40
|
+
# measurement then reports itself unknown rather than conformant.
|
|
41
|
+
"commit": head_commit(root) if root is not None else None,
|
|
35
42
|
"score": {
|
|
36
43
|
"overall": score.overall,
|
|
37
44
|
"letter": score.letter,
|
|
@@ -207,9 +214,10 @@ def write_json(
|
|
|
207
214
|
coverage: CoverageReport | None = None,
|
|
208
215
|
verdict: Verdict | None = None,
|
|
209
216
|
axes: Iterable[AxisReport] = (),
|
|
217
|
+
root: Path | None = None,
|
|
210
218
|
) -> None:
|
|
211
219
|
path.write_text(
|
|
212
|
-
json.dumps(to_json(findings, score, gate, coverage, verdict, axes), indent=2),
|
|
220
|
+
json.dumps(to_json(findings, score, gate, coverage, verdict, axes, root), indent=2),
|
|
213
221
|
encoding="utf-8",
|
|
214
222
|
)
|
|
215
223
|
|
|
@@ -151,6 +151,9 @@ class Verdict:
|
|
|
151
151
|
estimated_letter: str | None
|
|
152
152
|
verified_grade: str | None
|
|
153
153
|
reasons: tuple[str, ...]
|
|
154
|
+
#: Findings exist and none has been triaged — no baseline, nothing
|
|
155
|
+
#: accepted, nobody has looked. See `headline`.
|
|
156
|
+
untriaged: int = 0
|
|
154
157
|
|
|
155
158
|
@property
|
|
156
159
|
def is_verified(self) -> bool:
|
|
@@ -161,17 +164,51 @@ class Verdict:
|
|
|
161
164
|
return "complete" if self.is_verified else "incomplete"
|
|
162
165
|
|
|
163
166
|
def headline(self) -> str:
|
|
164
|
-
"""One line, used by every renderer that shows a score.
|
|
167
|
+
"""One line, used by every renderer that shows a score.
|
|
168
|
+
|
|
169
|
+
**An untriaged run leads with the work, not a letter.** A reviewer put
|
|
170
|
+
the failure precisely: *"a staff engineer who sees Django at F turns
|
|
171
|
+
the gate off."* They are right to, and the F is not wrong — it is a
|
|
172
|
+
density of findings nobody has looked at yet, which is a starting
|
|
173
|
+
position rather than a verdict on the code. Leading with the letter
|
|
174
|
+
invites the reader to treat it as one, and the letter they see on
|
|
175
|
+
first contact decides whether the tool survives the afternoon.
|
|
176
|
+
|
|
177
|
+
So on a first run with findings and no baseline, the headline states
|
|
178
|
+
the work. The number is still in the report, in the JSON, and in the
|
|
179
|
+
trend; it is simply not the first thing said about a repository
|
|
180
|
+
nobody has triaged.
|
|
181
|
+
"""
|
|
165
182
|
if self.estimate is None:
|
|
166
183
|
return "no score — nothing measurable was scanned"
|
|
184
|
+
if self.untriaged:
|
|
185
|
+
return f"{self.untriaged} finding(s), none triaged — a starting position, not a grade"
|
|
167
186
|
if self.is_verified:
|
|
168
|
-
return f"{self.estimate:.2f} ({self.verified_grade})"
|
|
169
|
-
return f"{self.estimate:.2f} — grade withheld ({self.estimated_letter} unverified)"
|
|
187
|
+
return f"score {self.estimate:.2f} ({self.verified_grade})"
|
|
188
|
+
return f"score {self.estimate:.2f} — grade withheld ({self.estimated_letter} unverified)"
|
|
170
189
|
|
|
171
190
|
|
|
172
|
-
def verdict(
|
|
191
|
+
def verdict(
|
|
192
|
+
report: ScoreReport,
|
|
193
|
+
gate_config: dict,
|
|
194
|
+
coverage: CoverageReport | None,
|
|
195
|
+
untriaged: int = 0,
|
|
196
|
+
) -> Verdict:
|
|
173
197
|
"""Decide the letter, or withhold it, once for the whole run."""
|
|
174
198
|
reasons = list(evidence_reasons(gate_config, coverage))
|
|
199
|
+
# `untriaged` deliberately does NOT join `reasons`.
|
|
200
|
+
#
|
|
201
|
+
# It did, briefly, and that conflated two different axes. `reasons`
|
|
202
|
+
# answers *did we look* — coverage, required scanners, evidence — and
|
|
203
|
+
# withholding the grade is its consequence. Triage answers *did you
|
|
204
|
+
# review what we found*, which is the operator's work rather than the
|
|
205
|
+
# scanners'. Folding it in made a verified grade unreachable on first
|
|
206
|
+
# contact for any repository with a single finding, and
|
|
207
|
+
# `test_a_grade_is_issued_only_when_a_declared_scanner_set_actually_ran`
|
|
208
|
+
# caught it: the positive half of P7 stopped being reachable.
|
|
209
|
+
#
|
|
210
|
+
# So an untriaged run changes what the headline *leads with* and nothing
|
|
211
|
+
# else. The grade is still computed, still reported, still in the trend.
|
|
175
212
|
if report.overall is None:
|
|
176
213
|
# Nothing measurable ran. There is no estimate to qualify, so the
|
|
177
214
|
# reason is stated rather than a letter being caveated — a caveated
|
|
@@ -182,6 +219,7 @@ def verdict(report: ScoreReport, gate_config: dict, coverage: CoverageReport | N
|
|
|
182
219
|
estimated_letter=report.letter,
|
|
183
220
|
verified_grade=None if reasons else report.letter,
|
|
184
221
|
reasons=tuple(reasons),
|
|
222
|
+
untriaged=untriaged,
|
|
185
223
|
)
|
|
186
224
|
|
|
187
225
|
|
|
@@ -44,6 +44,108 @@ class Outcome(enum.Enum):
|
|
|
44
44
|
INTRODUCED = "introduced"
|
|
45
45
|
|
|
46
46
|
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class Scope:
|
|
49
|
+
"""Did the work stay inside the order?
|
|
50
|
+
|
|
51
|
+
The product's claim is that a bounded work order keeps an agent from
|
|
52
|
+
doing crypto roulette, auth rewrites and 600-line "while I was in there"
|
|
53
|
+
patches. Until now only *silencing* and *regressions* were mechanical —
|
|
54
|
+
a reviewer put it exactly: "ten hard constraints are a leash I can still
|
|
55
|
+
ignore ... not for 'did you rewrite the session model'."
|
|
56
|
+
|
|
57
|
+
This makes that question mechanical. The work order cites files and
|
|
58
|
+
lines; the repository knows what actually changed. Everything changed
|
|
59
|
+
outside the cited files is collateral, and collateral is the measurable
|
|
60
|
+
shadow of the constraint the prompt cannot enforce.
|
|
61
|
+
|
|
62
|
+
**`known` is False when the answer is unavailable** — no commit in the
|
|
63
|
+
before-report, or not a git repository. An unknown scope is reported as
|
|
64
|
+
unknown and never as conformant: a measurement that failed must not read
|
|
65
|
+
as a clean result, which is the same rule the coverage axis applies to
|
|
66
|
+
scanners.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
known: bool = False
|
|
70
|
+
reason: str | None = None
|
|
71
|
+
#: Files the work order named.
|
|
72
|
+
cited: tuple[str, ...] = ()
|
|
73
|
+
#: Files that actually changed, including untracked additions.
|
|
74
|
+
changed: tuple[str, ...] = ()
|
|
75
|
+
#: Changed, and never cited. The blast radius.
|
|
76
|
+
collateral: tuple[str, ...] = ()
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def conformant(self) -> bool:
|
|
80
|
+
"""Only ever True when the question was actually answered."""
|
|
81
|
+
return self.known and not self.collateral
|
|
82
|
+
|
|
83
|
+
def headline(self) -> str:
|
|
84
|
+
if not self.known:
|
|
85
|
+
return f"scope: unknown ({self.reason or 'no baseline commit recorded'})"
|
|
86
|
+
if not self.changed:
|
|
87
|
+
return "scope: nothing changed"
|
|
88
|
+
if not self.collateral:
|
|
89
|
+
return f"scope: conformant — {len(self.changed)} file(s), all cited in the order"
|
|
90
|
+
return (
|
|
91
|
+
f"scope: EXCEEDED — {len(self.collateral)} of {len(self.changed)} changed "
|
|
92
|
+
f"file(s) were never cited: {', '.join(sorted(self.collateral)[:5])}"
|
|
93
|
+
+ (" …" if len(self.collateral) > 5 else "")
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def measure_scope(
|
|
98
|
+
before: Iterable[Finding],
|
|
99
|
+
root: Path | None,
|
|
100
|
+
since: str | None,
|
|
101
|
+
ours: Iterable[Path] = (),
|
|
102
|
+
) -> Scope:
|
|
103
|
+
"""Compare what the order cited against what the repository changed."""
|
|
104
|
+
if root is None:
|
|
105
|
+
return Scope(reason="no repository root")
|
|
106
|
+
if not since:
|
|
107
|
+
return Scope(reason="the before-report records no commit")
|
|
108
|
+
|
|
109
|
+
from secure_code_audit.git_tools import changed_files
|
|
110
|
+
|
|
111
|
+
paths, reason = changed_files(root, since)
|
|
112
|
+
if paths is None:
|
|
113
|
+
return Scope(reason=reason)
|
|
114
|
+
|
|
115
|
+
cited: set[str] = set()
|
|
116
|
+
for finding in before:
|
|
117
|
+
try:
|
|
118
|
+
cited.add(finding.file_path.resolve().relative_to(root.resolve()).as_posix())
|
|
119
|
+
except (ValueError, OSError):
|
|
120
|
+
cited.add(finding.file_path.as_posix())
|
|
121
|
+
|
|
122
|
+
# This tool's own artifacts are written *by* the run doing the verifying.
|
|
123
|
+
# An agent that fixed something did not "also change
|
|
124
|
+
# secure-code-report.md"; we did, a second ago. Counting them as
|
|
125
|
+
# collateral would make every verification exceed its scope.
|
|
126
|
+
#
|
|
127
|
+
# The set comes from the caller — `cli._own_artifacts`, the same function
|
|
128
|
+
# that keeps a run from scanning its own report — rather than a list of
|
|
129
|
+
# default basenames here. A report written to `--json-output
|
|
130
|
+
# before.json` is ours too, and a hard-coded list of defaults said it was
|
|
131
|
+
# the agent's.
|
|
132
|
+
ignored: set[str] = set()
|
|
133
|
+
for artifact in ours:
|
|
134
|
+
try:
|
|
135
|
+
ignored.add(artifact.resolve().relative_to(root.resolve()).as_posix())
|
|
136
|
+
except (ValueError, OSError):
|
|
137
|
+
continue
|
|
138
|
+
changed = {
|
|
139
|
+
path for path in paths if path not in ignored and not path.startswith(".secure-code/")
|
|
140
|
+
}
|
|
141
|
+
return Scope(
|
|
142
|
+
known=True,
|
|
143
|
+
cited=tuple(sorted(cited)),
|
|
144
|
+
changed=tuple(sorted(changed)),
|
|
145
|
+
collateral=tuple(sorted(changed - cited)),
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
47
149
|
@dataclass(frozen=True)
|
|
48
150
|
class Verification:
|
|
49
151
|
"""What changed between two audits of the same repository."""
|
|
@@ -60,6 +162,8 @@ class Verification:
|
|
|
60
162
|
before_count: int = 0
|
|
61
163
|
after_count: int = 0
|
|
62
164
|
notes: tuple[str, ...] = field(default_factory=tuple)
|
|
165
|
+
#: Blast radius, when it could be measured.
|
|
166
|
+
scope: Scope = field(default_factory=Scope)
|
|
63
167
|
|
|
64
168
|
@property
|
|
65
169
|
def regressed(self) -> bool:
|
|
@@ -262,6 +366,17 @@ def compare(
|
|
|
262
366
|
)
|
|
263
367
|
|
|
264
368
|
|
|
369
|
+
def _scope_to_dict(scope: Scope) -> dict:
|
|
370
|
+
return {
|
|
371
|
+
"known": scope.known,
|
|
372
|
+
"reason": scope.reason,
|
|
373
|
+
"conformant": scope.conformant,
|
|
374
|
+
"cited": list(scope.cited),
|
|
375
|
+
"changed": list(scope.changed),
|
|
376
|
+
"collateral": list(scope.collateral),
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
|
|
265
380
|
def to_dict(result: Verification) -> dict:
|
|
266
381
|
"""The machine-readable form, for CI and for maintainability-agent."""
|
|
267
382
|
|
|
@@ -291,5 +406,6 @@ def to_dict(result: Verification) -> dict:
|
|
|
291
406
|
"deferred": _rows(result.deferred),
|
|
292
407
|
"suppressed": _rows(result.suppressed),
|
|
293
408
|
"introduced": _rows(result.introduced),
|
|
409
|
+
"scope": _scope_to_dict(result.scope),
|
|
294
410
|
"notes": list(result.notes),
|
|
295
411
|
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{secure_code_agent-0.11.1 → secure_code_agent-0.12.0}/src/secure_code_agent.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{secure_code_agent-0.11.1 → secure_code_agent-0.12.0}/src/secure_code_audit/scanner_status.py
RENAMED
|
File without changes
|
{secure_code_agent-0.11.1 → secure_code_agent-0.12.0}/src/secure_code_audit/scanners/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{secure_code_agent-0.11.1 → secure_code_agent-0.12.0}/src/secure_code_audit/scanners/base.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{secure_code_agent-0.11.1 → secure_code_agent-0.12.0}/src/secure_code_audit/scanners/floor.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{secure_code_agent-0.11.1 → secure_code_agent-0.12.0}/src/secure_code_audit/scanners/osv_scanner.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|