secure-code-agent 0.11.0__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.0/src/secure_code_agent.egg-info → secure_code_agent-0.12.0}/PKG-INFO +41 -2
- {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/README.md +38 -1
- {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/pyproject.toml +8 -0
- {secure_code_agent-0.11.0 → secure_code_agent-0.12.0/src/secure_code_agent.egg-info}/PKG-INFO +41 -2
- {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_agent.egg-info/SOURCES.txt +1 -0
- {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_agent.egg-info/entry_points.txt +1 -0
- {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_agent.egg-info/requires.txt +3 -0
- {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_audit/__init__.py +1 -1
- {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_audit/cli.py +131 -12
- {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_audit/demo.py +5 -5
- {secure_code_agent-0.11.0 → 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.0 → secure_code_agent-0.12.0}/src/secure_code_audit/renderers.py +9 -1
- {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_audit/scoring.py +42 -4
- {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_audit/verify.py +116 -0
- {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/LICENSE +0 -0
- {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/setup.cfg +0 -0
- {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_agent.egg-info/dependency_links.txt +0 -0
- {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_agent.egg-info/top_level.txt +0 -0
- {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_audit/baseline.py +0 -0
- {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_audit/config.py +0 -0
- {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_audit/data/semgrep-offline.yaml +0 -0
- {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_audit/findings.py +0 -0
- {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_audit/history.py +0 -0
- {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_audit/instructions.py +0 -0
- {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_audit/pillar.py +0 -0
- {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_audit/practice.py +0 -0
- {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_audit/remediation.py +0 -0
- {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_audit/ruleset.py +0 -0
- {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_audit/sarif.py +0 -0
- {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_audit/scanner_status.py +0 -0
- {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_audit/scanners/__init__.py +0 -0
- {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_audit/scanners/bandit_scanner.py +0 -0
- {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_audit/scanners/base.py +0 -0
- {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_audit/scanners/builtin_rules.py +0 -0
- {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_audit/scanners/checkov_scanner.py +0 -0
- {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_audit/scanners/floor.py +0 -0
- {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_audit/scanners/gitleaks_scanner.py +0 -0
- {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_audit/scanners/gosec_scanner.py +0 -0
- {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_audit/scanners/hadolint_scanner.py +0 -0
- {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_audit/scanners/njsscan_scanner.py +0 -0
- {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_audit/scanners/npm_audit_scanner.py +0 -0
- {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_audit/scanners/osv_scanner.py +0 -0
- {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_audit/scanners/pip_audit_scanner.py +0 -0
- {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_audit/scanners/rubocop_scanner.py +0 -0
- {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_audit/scanners/scorecard_scanner.py +0 -0
- {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_audit/scanners/semgrep_scanner.py +0 -0
- {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_audit/scanners/trivy_scanner.py +0 -0
- {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_audit/scanners/trufflehog_scanner.py +0 -0
- {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_audit/standards.py +0 -0
- {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_audit/suppressions.py +0 -0
- {secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_audit/triage.py +0 -0
{secure_code_agent-0.11.0/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.0 → 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.0 → 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(
|
|
@@ -400,6 +410,19 @@ def _do_audit(args: argparse.Namespace) -> int:
|
|
|
400
410
|
root_for_tests = target if target.is_dir() else target.parent
|
|
401
411
|
|
|
402
412
|
def _classify(finding: Finding) -> str:
|
|
413
|
+
# A dependency advisory is about the dependency, not about the file
|
|
414
|
+
# that happened to declare it. Classified by category before path,
|
|
415
|
+
# because the path routing gets it wrong: `requirements.txt` matches
|
|
416
|
+
# the documentation pattern `**/*.txt`, so every CVE in a pip
|
|
417
|
+
# manifest was filed under **documentation** — eighteen of them on a
|
|
418
|
+
# six-file demo tree. A dependency finding is not documentation
|
|
419
|
+
# whatever the manifest is called.
|
|
420
|
+
#
|
|
421
|
+
# Returning "primary" hands it to `split_side_axes` below, which is
|
|
422
|
+
# what moves it onto the dependencies axis. Neither axis is scored,
|
|
423
|
+
# so this changes where a finding is *reported*, not the grade.
|
|
424
|
+
if finding.category is Category.DEPENDENCIES:
|
|
425
|
+
return "primary"
|
|
403
426
|
if is_test_path(finding.file_path, root_for_tests, cfg.test_patterns):
|
|
404
427
|
return "test tree"
|
|
405
428
|
if is_test_path(finding.file_path, root_for_tests, cfg.docs_patterns):
|
|
@@ -418,6 +441,11 @@ def _do_audit(args: argparse.Namespace) -> int:
|
|
|
418
441
|
# * the baseline. `baseline.write` recorded only the primary set, so
|
|
419
442
|
# every side-axis finding was absent from it and `fail_on_new`
|
|
420
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
|
+
|
|
421
449
|
primary_findings, path_axes = partition_by_path(all_findings, _classify)
|
|
422
450
|
test_findings = path_axes.get("test tree", [])
|
|
423
451
|
docs_findings = path_axes.get("documentation", [])
|
|
@@ -480,7 +508,27 @@ def _do_audit(args: argparse.Namespace) -> int:
|
|
|
480
508
|
gate = evaluate_gates(
|
|
481
509
|
gated, score, {**cfg.gates, "_baseline_state": baseline_state.value}, coverage
|
|
482
510
|
)
|
|
483
|
-
|
|
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
|
+
)
|
|
484
532
|
|
|
485
533
|
# ----- write outputs -----
|
|
486
534
|
# The pillar artifact is what maintainability-agent ingests (D3). Built
|
|
@@ -528,13 +576,14 @@ def _do_audit(args: argparse.Namespace) -> int:
|
|
|
528
576
|
|
|
529
577
|
# ----- did the work order actually improve anything? -----
|
|
530
578
|
if args.verify_against:
|
|
531
|
-
return _do_verify(args, all_findings, root, axes)
|
|
579
|
+
return _do_verify(args, all_findings, root, axes, own_artifacts)
|
|
532
580
|
|
|
533
581
|
# ----- terminal output -----
|
|
534
582
|
if args.json:
|
|
535
583
|
sys.stdout.write(
|
|
536
584
|
json.dumps(
|
|
537
|
-
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,
|
|
538
587
|
)
|
|
539
588
|
)
|
|
540
589
|
sys.stdout.write("\n")
|
|
@@ -548,10 +597,11 @@ def _prepare_audit(
|
|
|
548
597
|
args: argparse.Namespace,
|
|
549
598
|
) -> tuple[config_mod.Config, Path, Path]:
|
|
550
599
|
"""Load config and resolve the single scan root, or refuse."""
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
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**.
|
|
555
605
|
if len(args.paths) > 1:
|
|
556
606
|
raise ValueError("multiple scan roots are not supported; provide one repository root")
|
|
557
607
|
# The target is resolved first because the default config belongs to it.
|
|
@@ -856,7 +906,7 @@ def _write_outputs(
|
|
|
856
906
|
findings, score, gate, paths.markdown, ran, unavailable, coverage, verdict, axes
|
|
857
907
|
)
|
|
858
908
|
if paths.json_out is not None:
|
|
859
|
-
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)
|
|
860
910
|
if paths.sarif is not None:
|
|
861
911
|
sarif.write(findings, paths.sarif, coverage, lambda f: renderers.axis_of(f, axes))
|
|
862
912
|
if paths.comment is not None:
|
|
@@ -1056,7 +1106,7 @@ def _print_summary(
|
|
|
1056
1106
|
status = "PASS"
|
|
1057
1107
|
else:
|
|
1058
1108
|
status = "NOT CONFIGURED"
|
|
1059
|
-
print(f"secure-code-agent ·
|
|
1109
|
+
print(f"secure-code-agent · {verdict.headline()} · gate {status}")
|
|
1060
1110
|
for reason in verdict.reasons:
|
|
1061
1111
|
print(f" ! grade withheld: {reason}")
|
|
1062
1112
|
print(f" scanned LOC: {score.loc_scanned:,}")
|
|
@@ -1158,15 +1208,34 @@ def _findings_from_report(path: Path) -> list[Finding]:
|
|
|
1158
1208
|
return restored
|
|
1159
1209
|
|
|
1160
1210
|
|
|
1161
|
-
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:
|
|
1162
1218
|
"""Compare this run against the one that produced the work order.
|
|
1163
1219
|
|
|
1164
1220
|
Exits nonzero unless the run passes, because a verification step that
|
|
1165
1221
|
always passes verifies nothing. "Improved" is deliberately
|
|
1166
1222
|
strict: something fixed, nothing introduced, nothing merely silenced.
|
|
1167
1223
|
"""
|
|
1168
|
-
|
|
1224
|
+
report_path = _under_root(root, args.verify_against)
|
|
1225
|
+
before = _findings_from_report(report_path)
|
|
1169
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
|
+
)
|
|
1170
1239
|
|
|
1171
1240
|
if args.json:
|
|
1172
1241
|
sys.stdout.write(json.dumps(verify_mod.to_dict(result), indent=2) + "\n")
|
|
@@ -1194,11 +1263,61 @@ def _do_verify(args: argparse.Namespace, after: list[Finding], root: Path, axes=
|
|
|
1194
1263
|
f" ({len(result.deferred)} finding(s) in the test tree and documentation "
|
|
1195
1264
|
f"are reported but not required — see §ACCEPT)"
|
|
1196
1265
|
)
|
|
1266
|
+
print(f" {result.scope.headline()}")
|
|
1267
|
+
for path in sorted(result.scope.collateral)[:10]:
|
|
1268
|
+
print(f" collateral {path}")
|
|
1197
1269
|
for note in result.notes:
|
|
1198
1270
|
print(f" ! {note}")
|
|
1199
1271
|
|
|
1200
1272
|
return 0 if result.passed else 1
|
|
1201
1273
|
|
|
1202
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
|
+
|
|
1203
1322
|
if __name__ == "__main__":
|
|
1204
1323
|
sys.exit(main())
|
|
@@ -101,11 +101,11 @@ def build(destination: Path | None = None) -> Path:
|
|
|
101
101
|
(root / "README.md").write_text(README, encoding="utf-8")
|
|
102
102
|
# No dependency manifest. A pinned-vulnerable `requirements.txt` was here
|
|
103
103
|
# and it made the demo worse, not better: eighteen pip-audit CVEs drowned
|
|
104
|
-
# the seven code defects the demo exists to show.
|
|
105
|
-
#
|
|
106
|
-
#
|
|
107
|
-
# axis
|
|
108
|
-
#
|
|
104
|
+
# the seven code defects the demo exists to show.
|
|
105
|
+
#
|
|
106
|
+
# It also surfaced a real defect, since fixed: those CVEs were filed on
|
|
107
|
+
# the **documentation** axis, because the manifest is a `.txt` file and
|
|
108
|
+
# the axis split read the extension before the category.
|
|
109
109
|
return root
|
|
110
110
|
|
|
111
111
|
|
|
@@ -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.0 → 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
|
{secure_code_agent-0.11.0 → secure_code_agent-0.12.0}/src/secure_code_audit/scanner_status.py
RENAMED
|
File without changes
|
{secure_code_agent-0.11.0 → 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.0 → 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.0 → 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.0 → 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
|