secure-code-agent 0.3.0__tar.gz → 0.4.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.3.0/src/secure_code_agent.egg-info → secure_code_agent-0.4.0}/PKG-INFO +10 -2
- {secure_code_agent-0.3.0 → secure_code_agent-0.4.0}/README.md +8 -1
- {secure_code_agent-0.3.0 → secure_code_agent-0.4.0}/pyproject.toml +16 -5
- {secure_code_agent-0.3.0 → secure_code_agent-0.4.0/src/secure_code_agent.egg-info}/PKG-INFO +10 -2
- {secure_code_agent-0.3.0 → secure_code_agent-0.4.0}/src/secure_code_agent.egg-info/SOURCES.txt +6 -0
- {secure_code_agent-0.3.0 → secure_code_agent-0.4.0}/src/secure_code_agent.egg-info/requires.txt +1 -0
- {secure_code_agent-0.3.0 → secure_code_agent-0.4.0}/src/secure_code_audit/cli.py +162 -45
- {secure_code_agent-0.3.0 → secure_code_agent-0.4.0}/src/secure_code_audit/config.py +92 -8
- secure_code_agent-0.4.0/src/secure_code_audit/data/semgrep-offline.yaml +396 -0
- {secure_code_agent-0.3.0 → secure_code_agent-0.4.0}/src/secure_code_audit/renderers.py +55 -16
- secure_code_agent-0.4.0/src/secure_code_audit/ruleset.py +97 -0
- {secure_code_agent-0.3.0 → secure_code_agent-0.4.0}/src/secure_code_audit/sarif.py +53 -8
- secure_code_agent-0.4.0/src/secure_code_audit/scanner_status.py +197 -0
- {secure_code_agent-0.3.0 → secure_code_agent-0.4.0}/src/secure_code_audit/scanners/__init__.py +6 -0
- {secure_code_agent-0.3.0 → secure_code_agent-0.4.0}/src/secure_code_audit/scanners/bandit_scanner.py +7 -30
- {secure_code_agent-0.3.0 → secure_code_agent-0.4.0}/src/secure_code_audit/scanners/base.py +137 -8
- {secure_code_agent-0.3.0 → secure_code_agent-0.4.0}/src/secure_code_audit/scanners/builtin_rules.py +3 -2
- {secure_code_agent-0.3.0 → secure_code_agent-0.4.0}/src/secure_code_audit/scanners/checkov_scanner.py +10 -40
- secure_code_agent-0.4.0/src/secure_code_audit/scanners/floor.py +282 -0
- {secure_code_agent-0.3.0 → secure_code_agent-0.4.0}/src/secure_code_audit/scanners/gitleaks_scanner.py +21 -30
- secure_code_agent-0.4.0/src/secure_code_audit/scanners/gosec_scanner.py +132 -0
- {secure_code_agent-0.3.0 → secure_code_agent-0.4.0}/src/secure_code_audit/scanners/hadolint_scanner.py +11 -66
- secure_code_agent-0.4.0/src/secure_code_audit/scanners/njsscan_scanner.py +108 -0
- {secure_code_agent-0.3.0 → secure_code_agent-0.4.0}/src/secure_code_audit/scanners/npm_audit_scanner.py +21 -49
- {secure_code_agent-0.3.0 → secure_code_agent-0.4.0}/src/secure_code_audit/scanners/osv_scanner.py +9 -52
- {secure_code_agent-0.3.0 → secure_code_agent-0.4.0}/src/secure_code_audit/scanners/pip_audit_scanner.py +55 -72
- secure_code_agent-0.4.0/src/secure_code_audit/scanners/rubocop_scanner.py +118 -0
- {secure_code_agent-0.3.0 → secure_code_agent-0.4.0}/src/secure_code_audit/scanners/scorecard_scanner.py +18 -65
- {secure_code_agent-0.3.0 → secure_code_agent-0.4.0}/src/secure_code_audit/scanners/semgrep_scanner.py +79 -43
- {secure_code_agent-0.3.0 → secure_code_agent-0.4.0}/src/secure_code_audit/scanners/trivy_scanner.py +8 -40
- {secure_code_agent-0.3.0 → secure_code_agent-0.4.0}/src/secure_code_audit/scanners/trufflehog_scanner.py +23 -39
- {secure_code_agent-0.3.0 → secure_code_agent-0.4.0}/src/secure_code_audit/scoring.py +128 -2
- {secure_code_agent-0.3.0 → secure_code_agent-0.4.0}/src/secure_code_audit/suppressions.py +68 -0
- secure_code_agent-0.3.0/src/secure_code_audit/scanner_status.py +0 -169
- {secure_code_agent-0.3.0 → secure_code_agent-0.4.0}/LICENSE +0 -0
- {secure_code_agent-0.3.0 → secure_code_agent-0.4.0}/setup.cfg +0 -0
- {secure_code_agent-0.3.0 → secure_code_agent-0.4.0}/src/secure_code_agent.egg-info/dependency_links.txt +0 -0
- {secure_code_agent-0.3.0 → secure_code_agent-0.4.0}/src/secure_code_agent.egg-info/entry_points.txt +0 -0
- {secure_code_agent-0.3.0 → secure_code_agent-0.4.0}/src/secure_code_agent.egg-info/top_level.txt +0 -0
- {secure_code_agent-0.3.0 → secure_code_agent-0.4.0}/src/secure_code_audit/__init__.py +0 -0
- {secure_code_agent-0.3.0 → secure_code_agent-0.4.0}/src/secure_code_audit/baseline.py +0 -0
- {secure_code_agent-0.3.0 → secure_code_agent-0.4.0}/src/secure_code_audit/findings.py +0 -0
- {secure_code_agent-0.3.0 → secure_code_agent-0.4.0}/src/secure_code_audit/git_tools.py +0 -0
- {secure_code_agent-0.3.0 → secure_code_agent-0.4.0}/src/secure_code_audit/instructions.py +0 -0
- {secure_code_agent-0.3.0 → secure_code_agent-0.4.0}/src/secure_code_audit/remediation.py +0 -0
- {secure_code_agent-0.3.0 → secure_code_agent-0.4.0}/src/secure_code_audit/standards.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: secure-code-agent
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.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
|
|
@@ -32,6 +32,7 @@ Requires-Dist: PyYAML<7,>=6.0.2
|
|
|
32
32
|
Provides-Extra: required-scanners
|
|
33
33
|
Requires-Dist: bandit==1.9.4; extra == "required-scanners"
|
|
34
34
|
Requires-Dist: pip-audit==2.10.1; extra == "required-scanners"
|
|
35
|
+
Requires-Dist: njsscan==1.0.0; extra == "required-scanners"
|
|
35
36
|
Provides-Extra: python-scanners
|
|
36
37
|
Requires-Dist: secure-code-agent[required-scanners]; extra == "python-scanners"
|
|
37
38
|
Requires-Dist: semgrep<2,>=1.172; extra == "python-scanners"
|
|
@@ -173,7 +174,9 @@ collapsed before scoring; reports preserve the original scanner evidence.
|
|
|
173
174
|
├── Hadolint (Dockerfile lint)
|
|
174
175
|
├── OSV-Scanner (multi-ecosystem SCA via osv.dev)
|
|
175
176
|
├── OpenSSF Scorecard (repo hygiene + supply chain)
|
|
176
|
-
├──
|
|
177
|
+
├── njsscan (JS/TS SAST, offline, no Node needed)
|
|
178
|
+
├── RuboCop (Ruby security cops, --only Security)
|
|
179
|
+
├── gosec (Go SAST — opt-in; needs the Go toolchain)
|
|
177
180
|
├── CodeQL SARIF (ingest GitHub-hosted analysis)
|
|
178
181
|
└── Built-in regex rules (high-confidence, low-FP)
|
|
179
182
|
```
|
|
@@ -287,6 +290,10 @@ for repositories whose `pyproject.toml` is their authoritative audit input.
|
|
|
287
290
|
reason: "Slated for rewrite Q3 2026 — gated by initiative INV-44."
|
|
288
291
|
expires: "2026-09-30"
|
|
289
292
|
|
|
293
|
+
fingerprint: 0aaa689f8a967d8c # optional: pin to ONE finding (16 hex, from the report)
|
|
294
|
+
|
|
295
|
+
line: 18 # optional: with fingerprint, pins the exact location
|
|
296
|
+
|
|
290
297
|
- rule_id: "B101"
|
|
291
298
|
paths: ["tests/"]
|
|
292
299
|
reason: "assert statements are legitimate in test code."
|
|
@@ -390,6 +397,7 @@ Full design philosophy in [`docs/design.md`](docs/design.md).
|
|
|
390
397
|
## Documentation
|
|
391
398
|
|
|
392
399
|
- [`docs/product-intent.md`](docs/product-intent.md) — Why this exists, who it serves, what it refuses to become
|
|
400
|
+
- [`docs/decisions.md`](docs/decisions.md) — Decision register: rulings the code alone cannot answer
|
|
393
401
|
- [`docs/design.md`](docs/design.md) — Architecture + non-goals + scanner protocol
|
|
394
402
|
- [`docs/architecture.md`](docs/architecture.md) — Audit of the system as built + remediation sequence
|
|
395
403
|
- [`docs/release-blockers.md`](docs/release-blockers.md) — Open v0.3.0 release blockers (do not tag until closed)
|
|
@@ -128,7 +128,9 @@ collapsed before scoring; reports preserve the original scanner evidence.
|
|
|
128
128
|
├── Hadolint (Dockerfile lint)
|
|
129
129
|
├── OSV-Scanner (multi-ecosystem SCA via osv.dev)
|
|
130
130
|
├── OpenSSF Scorecard (repo hygiene + supply chain)
|
|
131
|
-
├──
|
|
131
|
+
├── njsscan (JS/TS SAST, offline, no Node needed)
|
|
132
|
+
├── RuboCop (Ruby security cops, --only Security)
|
|
133
|
+
├── gosec (Go SAST — opt-in; needs the Go toolchain)
|
|
132
134
|
├── CodeQL SARIF (ingest GitHub-hosted analysis)
|
|
133
135
|
└── Built-in regex rules (high-confidence, low-FP)
|
|
134
136
|
```
|
|
@@ -242,6 +244,10 @@ for repositories whose `pyproject.toml` is their authoritative audit input.
|
|
|
242
244
|
reason: "Slated for rewrite Q3 2026 — gated by initiative INV-44."
|
|
243
245
|
expires: "2026-09-30"
|
|
244
246
|
|
|
247
|
+
fingerprint: 0aaa689f8a967d8c # optional: pin to ONE finding (16 hex, from the report)
|
|
248
|
+
|
|
249
|
+
line: 18 # optional: with fingerprint, pins the exact location
|
|
250
|
+
|
|
245
251
|
- rule_id: "B101"
|
|
246
252
|
paths: ["tests/"]
|
|
247
253
|
reason: "assert statements are legitimate in test code."
|
|
@@ -345,6 +351,7 @@ Full design philosophy in [`docs/design.md`](docs/design.md).
|
|
|
345
351
|
## Documentation
|
|
346
352
|
|
|
347
353
|
- [`docs/product-intent.md`](docs/product-intent.md) — Why this exists, who it serves, what it refuses to become
|
|
354
|
+
- [`docs/decisions.md`](docs/decisions.md) — Decision register: rulings the code alone cannot answer
|
|
348
355
|
- [`docs/design.md`](docs/design.md) — Architecture + non-goals + scanner protocol
|
|
349
356
|
- [`docs/architecture.md`](docs/architecture.md) — Audit of the system as built + remediation sequence
|
|
350
357
|
- [`docs/release-blockers.md`](docs/release-blockers.md) — Open v0.3.0 release blockers (do not tag until closed)
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "secure-code-agent"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.4.0"
|
|
8
8
|
description = "Deterministic security gate + bounded AI remediation prompt generator. NIST SSDF / OWASP ASVS / CWE Top 25 anchored."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -47,12 +47,18 @@ dependencies = [
|
|
|
47
47
|
required-scanners = [
|
|
48
48
|
"bandit==1.9.4",
|
|
49
49
|
"pip-audit==2.10.1",
|
|
50
|
+
# njsscan is in the floor (D12) and is a Python package carrying its own
|
|
51
|
+
# rules, so a JavaScript repository can be scanned on a host with no Node.
|
|
52
|
+
# Pinned for the same reason as the rest: the floor is a claim about what
|
|
53
|
+
# ran, and an unpinned ruleset makes that claim unreproducible.
|
|
54
|
+
"njsscan==1.0.0",
|
|
50
55
|
]
|
|
51
56
|
# The remaining Python-packaged scanners. Bounded rather than pinned because
|
|
52
57
|
# they are optional; the coverage gate does not require them by default.
|
|
53
|
-
# Scanners distributed as standalone binaries
|
|
54
|
-
#
|
|
55
|
-
#
|
|
58
|
+
# Scanners distributed as standalone binaries or through another language's
|
|
59
|
+
# package manager (gitleaks, gosec, hadolint, npm, osv-scanner, rubocop,
|
|
60
|
+
# scorecard, trivy, trufflehog) cannot be installed from PyPI — see
|
|
61
|
+
# docs/scanners.md for their install commands and the SARIF-import route.
|
|
56
62
|
python-scanners = [
|
|
57
63
|
"secure-code-agent[required-scanners]",
|
|
58
64
|
"semgrep>=1.172,<2",
|
|
@@ -92,7 +98,12 @@ target-version = "py310"
|
|
|
92
98
|
# Ruff 0.16 formats Python blocks embedded in Markdown. Our docs use those
|
|
93
99
|
# blocks illustratively — excerpted statements, aligned-comment schemas — and
|
|
94
100
|
# reformatting them damages the documentation without improving any code.
|
|
95
|
-
extend-exclude = [
|
|
101
|
+
extend-exclude = [
|
|
102
|
+
"**/*.md",
|
|
103
|
+
# Scanner fixtures are deliberately vulnerable sample code. Linting them
|
|
104
|
+
# would demand we fix the very patterns the rules exist to detect.
|
|
105
|
+
"tests/fixtures/**",
|
|
106
|
+
]
|
|
96
107
|
|
|
97
108
|
[tool.ruff.lint]
|
|
98
109
|
select = ["E", "F", "I", "B", "UP", "SIM", "C4"]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: secure-code-agent
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.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
|
|
@@ -32,6 +32,7 @@ Requires-Dist: PyYAML<7,>=6.0.2
|
|
|
32
32
|
Provides-Extra: required-scanners
|
|
33
33
|
Requires-Dist: bandit==1.9.4; extra == "required-scanners"
|
|
34
34
|
Requires-Dist: pip-audit==2.10.1; extra == "required-scanners"
|
|
35
|
+
Requires-Dist: njsscan==1.0.0; extra == "required-scanners"
|
|
35
36
|
Provides-Extra: python-scanners
|
|
36
37
|
Requires-Dist: secure-code-agent[required-scanners]; extra == "python-scanners"
|
|
37
38
|
Requires-Dist: semgrep<2,>=1.172; extra == "python-scanners"
|
|
@@ -173,7 +174,9 @@ collapsed before scoring; reports preserve the original scanner evidence.
|
|
|
173
174
|
├── Hadolint (Dockerfile lint)
|
|
174
175
|
├── OSV-Scanner (multi-ecosystem SCA via osv.dev)
|
|
175
176
|
├── OpenSSF Scorecard (repo hygiene + supply chain)
|
|
176
|
-
├──
|
|
177
|
+
├── njsscan (JS/TS SAST, offline, no Node needed)
|
|
178
|
+
├── RuboCop (Ruby security cops, --only Security)
|
|
179
|
+
├── gosec (Go SAST — opt-in; needs the Go toolchain)
|
|
177
180
|
├── CodeQL SARIF (ingest GitHub-hosted analysis)
|
|
178
181
|
└── Built-in regex rules (high-confidence, low-FP)
|
|
179
182
|
```
|
|
@@ -287,6 +290,10 @@ for repositories whose `pyproject.toml` is their authoritative audit input.
|
|
|
287
290
|
reason: "Slated for rewrite Q3 2026 — gated by initiative INV-44."
|
|
288
291
|
expires: "2026-09-30"
|
|
289
292
|
|
|
293
|
+
fingerprint: 0aaa689f8a967d8c # optional: pin to ONE finding (16 hex, from the report)
|
|
294
|
+
|
|
295
|
+
line: 18 # optional: with fingerprint, pins the exact location
|
|
296
|
+
|
|
290
297
|
- rule_id: "B101"
|
|
291
298
|
paths: ["tests/"]
|
|
292
299
|
reason: "assert statements are legitimate in test code."
|
|
@@ -390,6 +397,7 @@ Full design philosophy in [`docs/design.md`](docs/design.md).
|
|
|
390
397
|
## Documentation
|
|
391
398
|
|
|
392
399
|
- [`docs/product-intent.md`](docs/product-intent.md) — Why this exists, who it serves, what it refuses to become
|
|
400
|
+
- [`docs/decisions.md`](docs/decisions.md) — Decision register: rulings the code alone cannot answer
|
|
393
401
|
- [`docs/design.md`](docs/design.md) — Architecture + non-goals + scanner protocol
|
|
394
402
|
- [`docs/architecture.md`](docs/architecture.md) — Audit of the system as built + remediation sequence
|
|
395
403
|
- [`docs/release-blockers.md`](docs/release-blockers.md) — Open v0.3.0 release blockers (do not tag until closed)
|
{secure_code_agent-0.3.0 → secure_code_agent-0.4.0}/src/secure_code_agent.egg-info/SOURCES.txt
RENAMED
|
@@ -16,21 +16,27 @@ src/secure_code_audit/git_tools.py
|
|
|
16
16
|
src/secure_code_audit/instructions.py
|
|
17
17
|
src/secure_code_audit/remediation.py
|
|
18
18
|
src/secure_code_audit/renderers.py
|
|
19
|
+
src/secure_code_audit/ruleset.py
|
|
19
20
|
src/secure_code_audit/sarif.py
|
|
20
21
|
src/secure_code_audit/scanner_status.py
|
|
21
22
|
src/secure_code_audit/scoring.py
|
|
22
23
|
src/secure_code_audit/standards.py
|
|
23
24
|
src/secure_code_audit/suppressions.py
|
|
25
|
+
src/secure_code_audit/data/semgrep-offline.yaml
|
|
24
26
|
src/secure_code_audit/scanners/__init__.py
|
|
25
27
|
src/secure_code_audit/scanners/bandit_scanner.py
|
|
26
28
|
src/secure_code_audit/scanners/base.py
|
|
27
29
|
src/secure_code_audit/scanners/builtin_rules.py
|
|
28
30
|
src/secure_code_audit/scanners/checkov_scanner.py
|
|
31
|
+
src/secure_code_audit/scanners/floor.py
|
|
29
32
|
src/secure_code_audit/scanners/gitleaks_scanner.py
|
|
33
|
+
src/secure_code_audit/scanners/gosec_scanner.py
|
|
30
34
|
src/secure_code_audit/scanners/hadolint_scanner.py
|
|
35
|
+
src/secure_code_audit/scanners/njsscan_scanner.py
|
|
31
36
|
src/secure_code_audit/scanners/npm_audit_scanner.py
|
|
32
37
|
src/secure_code_audit/scanners/osv_scanner.py
|
|
33
38
|
src/secure_code_audit/scanners/pip_audit_scanner.py
|
|
39
|
+
src/secure_code_audit/scanners/rubocop_scanner.py
|
|
34
40
|
src/secure_code_audit/scanners/scorecard_scanner.py
|
|
35
41
|
src/secure_code_audit/scanners/semgrep_scanner.py
|
|
36
42
|
src/secure_code_audit/scanners/trivy_scanner.py
|
|
@@ -25,15 +25,17 @@ from secure_code_audit import (
|
|
|
25
25
|
from secure_code_audit import baseline as baseline_mod
|
|
26
26
|
from secure_code_audit import config as config_mod
|
|
27
27
|
from secure_code_audit.findings import Category, Finding, Severity
|
|
28
|
-
from secure_code_audit.git_tools import find_repo_root, loc_under
|
|
28
|
+
from secure_code_audit.git_tools import find_repo_root, is_excluded, loc_under
|
|
29
29
|
from secure_code_audit.scanner_status import (
|
|
30
30
|
ScannerExecution,
|
|
31
31
|
ScannerOutcome,
|
|
32
|
-
classify_execution,
|
|
33
32
|
evaluate_coverage,
|
|
33
|
+
execution_from_result,
|
|
34
34
|
)
|
|
35
|
-
from secure_code_audit.
|
|
35
|
+
from secure_code_audit.scanners import floor
|
|
36
|
+
from secure_code_audit.scoring import active_gates, evaluate_gates
|
|
36
37
|
from secure_code_audit.scoring import score as score_findings
|
|
38
|
+
from secure_code_audit.scoring import verdict as build_verdict
|
|
37
39
|
|
|
38
40
|
|
|
39
41
|
def _parser() -> argparse.ArgumentParser:
|
|
@@ -90,6 +92,16 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
90
92
|
),
|
|
91
93
|
)
|
|
92
94
|
|
|
95
|
+
p.add_argument(
|
|
96
|
+
"--trust-target-config",
|
|
97
|
+
action="store_true",
|
|
98
|
+
help=(
|
|
99
|
+
"Allow a config inside the audited tree to name executables from "
|
|
100
|
+
"that tree. Only for repositories you own. CLI-only by design: a "
|
|
101
|
+
"config file cannot grant itself this."
|
|
102
|
+
),
|
|
103
|
+
)
|
|
104
|
+
|
|
93
105
|
p.add_argument(
|
|
94
106
|
"--preflight",
|
|
95
107
|
action="store_true",
|
|
@@ -156,7 +168,12 @@ def _do_preflight(args: argparse.Namespace) -> int:
|
|
|
156
168
|
cached from this command.
|
|
157
169
|
"""
|
|
158
170
|
cfg, target, _ = _prepare_audit(args)
|
|
159
|
-
|
|
171
|
+
inventory = _repository_inventory(target, cfg)
|
|
172
|
+
required = {
|
|
173
|
+
name
|
|
174
|
+
for name in floor.expand_required(cfg.gates.get("require_scanners", []))
|
|
175
|
+
if floor.applies_to_repository(name, *inventory)
|
|
176
|
+
}
|
|
160
177
|
selected = _selected_scanners(args, cfg)
|
|
161
178
|
|
|
162
179
|
rows: list[dict] = []
|
|
@@ -209,6 +226,10 @@ def _print_preflight(rows: list[dict], unselected: list[str], blocking: list[str
|
|
|
209
226
|
print(f" → {row['remedy']}")
|
|
210
227
|
for name in unselected:
|
|
211
228
|
print(f" ✗ {name:<14} required not enabled in this configuration")
|
|
229
|
+
# Named, not omitted: a floor tool this run does not evaluate is a stated
|
|
230
|
+
# scope decision, and silence would read as a pass.
|
|
231
|
+
for name in floor.REPOSITORY_CADENCE_NAMES:
|
|
232
|
+
print(f" · {name:<14} deferred repository cadence; supplied by SARIF import (D3)")
|
|
212
233
|
if blocking:
|
|
213
234
|
print(f" required scanners unavailable: {', '.join(blocking)}")
|
|
214
235
|
else:
|
|
@@ -217,6 +238,7 @@ def _print_preflight(rows: list[dict], unselected: list[str], blocking: list[str
|
|
|
217
238
|
|
|
218
239
|
def _do_audit(args: argparse.Namespace) -> int:
|
|
219
240
|
cfg, target, root = _prepare_audit(args)
|
|
241
|
+
_require_configured_gates(args, cfg)
|
|
220
242
|
|
|
221
243
|
# ----- scanners -----
|
|
222
244
|
scan = _run_scanners(args, cfg, target)
|
|
@@ -229,6 +251,9 @@ def _do_audit(args: argparse.Namespace) -> int:
|
|
|
229
251
|
all_findings.extend(imported)
|
|
230
252
|
executions.extend(imported_executions)
|
|
231
253
|
|
|
254
|
+
# ----- scan scope, enforced once -----
|
|
255
|
+
all_findings = _drop_excluded(all_findings, target, cfg)
|
|
256
|
+
|
|
232
257
|
# ----- overrides from config -----
|
|
233
258
|
all_findings = _apply_overrides(all_findings, cfg)
|
|
234
259
|
|
|
@@ -272,27 +297,37 @@ def _do_audit(args: argparse.Namespace) -> int:
|
|
|
272
297
|
score = score_findings(all_findings, loc)
|
|
273
298
|
# Naming an import on the command line asserts that it contributes coverage,
|
|
274
299
|
# so a broken one fails the gate even if no config requires that scanner.
|
|
300
|
+
# Requiring a tool that has nothing to look at would make every
|
|
301
|
+
# single-language repository permanently incomplete, and an alarm that is
|
|
302
|
+
# always on is not an alarm. Applicability is decided from what the tree
|
|
303
|
+
# actually contains, before the requirement is asserted.
|
|
304
|
+
inventory = _repository_inventory(target, cfg)
|
|
275
305
|
required = [
|
|
276
|
-
*
|
|
306
|
+
*(
|
|
307
|
+
name
|
|
308
|
+
for name in floor.expand_required(cfg.gates.get("require_scanners", []))
|
|
309
|
+
if floor.applies_to_repository(name, *inventory)
|
|
310
|
+
),
|
|
277
311
|
*(execution.name for execution in imported_executions),
|
|
278
312
|
]
|
|
279
313
|
coverage = evaluate_coverage(executions, required)
|
|
280
314
|
gate = evaluate_gates(all_findings, score, cfg.gates, coverage)
|
|
315
|
+
verdict = build_verdict(score, cfg.gates, coverage)
|
|
281
316
|
|
|
282
317
|
# ----- write outputs -----
|
|
283
318
|
paths = _resolve_outputs(args, cfg, root)
|
|
284
|
-
_write_outputs(paths, all_findings, score, gate, coverage, ran, unavailable)
|
|
319
|
+
_write_outputs(paths, all_findings, score, gate, coverage, ran, unavailable, verdict)
|
|
285
320
|
if args.bump_baseline:
|
|
286
321
|
baseline_mod.write(baseline_path, all_findings, baseline)
|
|
287
322
|
|
|
288
323
|
# ----- terminal output -----
|
|
289
324
|
if args.json:
|
|
290
325
|
sys.stdout.write(
|
|
291
|
-
json.dumps(renderers.to_json(all_findings, score, gate, coverage), indent=2)
|
|
326
|
+
json.dumps(renderers.to_json(all_findings, score, gate, coverage, verdict), indent=2)
|
|
292
327
|
)
|
|
293
328
|
sys.stdout.write("\n")
|
|
294
329
|
else:
|
|
295
|
-
_print_summary(score, gate, ran, unavailable, coverage, paths)
|
|
330
|
+
_print_summary(verdict, score, gate, ran, unavailable, coverage, paths)
|
|
296
331
|
|
|
297
332
|
return _exit_code(args, gate, all_findings)
|
|
298
333
|
|
|
@@ -301,18 +336,42 @@ def _prepare_audit(
|
|
|
301
336
|
args: argparse.Namespace,
|
|
302
337
|
) -> tuple[config_mod.Config, Path, Path]:
|
|
303
338
|
"""Load config and resolve the single scan root, or refuse."""
|
|
304
|
-
cfg = config_mod.load(args.config)
|
|
305
|
-
_validate_scanner_config(cfg)
|
|
306
339
|
if args.changed_only:
|
|
307
340
|
raise ValueError(
|
|
308
341
|
"--changed-only is not implemented safely; refusing to claim a scoped audit"
|
|
309
342
|
)
|
|
310
343
|
if len(args.paths) > 1:
|
|
311
344
|
raise ValueError("multiple scan roots are not supported; provide one repository root")
|
|
345
|
+
# The target is resolved first because the default config belongs to it.
|
|
312
346
|
target = Path(args.paths[0]).resolve()
|
|
347
|
+
cfg = config_mod.load(args.config, default_root=target)
|
|
348
|
+
# Set from the command line only. Threading it through the loaded config
|
|
349
|
+
# would let a repository-supplied file assert its own trustworthiness.
|
|
350
|
+
cfg.trust_target_config = bool(getattr(args, "trust_target_config", False))
|
|
351
|
+
_validate_scanner_config(cfg)
|
|
313
352
|
return cfg, target, find_repo_root(target)
|
|
314
353
|
|
|
315
354
|
|
|
355
|
+
def _require_configured_gates(args: argparse.Namespace, cfg: config_mod.Config) -> None:
|
|
356
|
+
"""Refuse --fail-on-gate when no gate could ever fail.
|
|
357
|
+
|
|
358
|
+
Without this, an audit that detects a HIGH finding, scores it 0.00/F, and
|
|
359
|
+
reports it in full still exits 0, because every gate is absent and an
|
|
360
|
+
absent gate does not trip. That is a green build with no security floor,
|
|
361
|
+
and it is the shape a default Action adoption takes.
|
|
362
|
+
"""
|
|
363
|
+
if not args.fail_on_gate or active_gates(cfg.gates):
|
|
364
|
+
return
|
|
365
|
+
raise ValueError(
|
|
366
|
+
"--fail-on-gate was requested but no gate is configured, so no finding "
|
|
367
|
+
"could ever fail the build. Configure at least one of "
|
|
368
|
+
"gates.fail_on_severity, gates.fail_on_category, gates.fail_on_new, "
|
|
369
|
+
"gates.min_score (above 0), gates.require_scanners, or "
|
|
370
|
+
"gates.max_unsuppressed — see secure-code-agent.example.json. Drop "
|
|
371
|
+
"--fail-on-gate to run an ungated, report-only audit."
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
|
|
316
375
|
def _exit_code(args: argparse.Namespace, gate, findings: list[Finding]) -> int:
|
|
317
376
|
if args.fail_on_gate and not gate.passed:
|
|
318
377
|
return 1
|
|
@@ -336,6 +395,38 @@ class _ScanResult:
|
|
|
336
395
|
executions: list[ScannerExecution]
|
|
337
396
|
|
|
338
397
|
|
|
398
|
+
def _repository_inventory(target: Path, cfg: config_mod.Config) -> tuple[set[str], set[str]]:
|
|
399
|
+
"""Extensions and notable filenames present in the tree, for applicability.
|
|
400
|
+
|
|
401
|
+
Bounded by the same exclusions the scan uses, so a vendored manifest does
|
|
402
|
+
not make a whole ecosystem look present.
|
|
403
|
+
"""
|
|
404
|
+
from secure_code_audit.git_tools import is_excluded
|
|
405
|
+
|
|
406
|
+
extensions: set[str] = set()
|
|
407
|
+
filenames: set[str] = set()
|
|
408
|
+
for path in target.rglob("*"):
|
|
409
|
+
if not path.is_file() or is_excluded(path, target, cfg.exclude_patterns):
|
|
410
|
+
continue
|
|
411
|
+
extensions.add(path.suffix)
|
|
412
|
+
filenames.add(path.name)
|
|
413
|
+
return extensions, filenames
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _scanner_enabled(cfg: config_mod.Config, name: str) -> bool:
|
|
417
|
+
"""Whether this scanner runs, with the floor supplying the default.
|
|
418
|
+
|
|
419
|
+
A configuration that says nothing about a scanner gets the project's
|
|
420
|
+
declared opinion: floor tools run, opt-in tools do not. Previously every
|
|
421
|
+
registered scanner defaulted to enabled, which quietly turned overlapping
|
|
422
|
+
and copyleft tools on for operators who never chose them.
|
|
423
|
+
"""
|
|
424
|
+
configured = cfg.scanners.get(name)
|
|
425
|
+
# `cfg.scanners` only holds scanners the configuration actually named, so
|
|
426
|
+
# presence here is the operator having an opinion.
|
|
427
|
+
return configured.enabled if configured is not None else floor.default_enabled(name)
|
|
428
|
+
|
|
429
|
+
|
|
339
430
|
def _selected_scanners(args: argparse.Namespace, cfg: config_mod.Config) -> list[str]:
|
|
340
431
|
"""Names the operator actually asked for, in registry order."""
|
|
341
432
|
skip = set(filter(None, (args.skip_scanners or "").split(",")))
|
|
@@ -343,30 +434,63 @@ def _selected_scanners(args: argparse.Namespace, cfg: config_mod.Config) -> list
|
|
|
343
434
|
return [
|
|
344
435
|
name
|
|
345
436
|
for name in scanners.SCANNERS
|
|
346
|
-
if not (only and name not in only)
|
|
347
|
-
and name not in skip
|
|
348
|
-
and (cfg.scanners.get(name) or config_mod.ScannerConfig()).enabled
|
|
437
|
+
if not (only and name not in only) and name not in skip and _scanner_enabled(cfg, name)
|
|
349
438
|
]
|
|
350
439
|
|
|
351
440
|
|
|
441
|
+
def _drop_excluded(findings: list[Finding], target: Path, cfg: config_mod.Config) -> list[Finding]:
|
|
442
|
+
"""Enforce `paths.exclude_patterns` on findings, not just on file discovery.
|
|
443
|
+
|
|
444
|
+
Five of fifteen adapters push the exclusion down to their tool; the rest
|
|
445
|
+
have no flag for it, or read it from a config file in the audited tree that
|
|
446
|
+
D1 forbids us honouring. So the setting was true for Bandit and a polite
|
|
447
|
+
fiction for Checkov, RuboCop, Trivy, Semgrep and the others.
|
|
448
|
+
|
|
449
|
+
That is worse than cosmetic, because `exclude_patterns` is *also* the
|
|
450
|
+
denominator: `loc_under()` counts only non-excluded files while the
|
|
451
|
+
findings counted against them came from everywhere. A repository excluding
|
|
452
|
+
its vendored tree was scored on vendored findings over first-party lines —
|
|
453
|
+
the numerator and denominator measuring different repositories.
|
|
454
|
+
|
|
455
|
+
Pushing the exclusion into each adapter is still worth doing for speed and
|
|
456
|
+
for smaller tool output. Correctness is enforced here, once, where every
|
|
457
|
+
finding passes regardless of which adapter or SARIF import produced it.
|
|
458
|
+
|
|
459
|
+
Control findings are exempt: they carry the scan root as their path, and
|
|
460
|
+
dropping "bandit could not run" because the root matched a pattern would
|
|
461
|
+
turn a failed scanner back into a silent one — the defect this whole
|
|
462
|
+
project exists to prevent.
|
|
463
|
+
"""
|
|
464
|
+
if not cfg.exclude_patterns:
|
|
465
|
+
return findings
|
|
466
|
+
|
|
467
|
+
root = target if target.is_dir() else target.parent
|
|
468
|
+
kept: list[Finding] = []
|
|
469
|
+
for finding in findings:
|
|
470
|
+
path = finding.file_path
|
|
471
|
+
is_control = path in (target, root)
|
|
472
|
+
if not is_control and is_excluded(path, root, cfg.exclude_patterns):
|
|
473
|
+
continue
|
|
474
|
+
kept.append(finding)
|
|
475
|
+
return kept
|
|
476
|
+
|
|
477
|
+
|
|
352
478
|
def _run_scanners(args: argparse.Namespace, cfg: config_mod.Config, target: Path) -> _ScanResult:
|
|
353
479
|
result = _ScanResult(findings=[], ran=[], unavailable=[], executions=[])
|
|
354
480
|
for name in _selected_scanners(args, cfg):
|
|
355
|
-
sc_cfg = cfg.scanners.get(name) or config_mod.ScannerConfig()
|
|
356
481
|
scanner = scanners.SCANNERS[name]()
|
|
357
482
|
scanner.configure(target, cfg)
|
|
358
483
|
available = scanner.is_available()
|
|
359
484
|
if not available:
|
|
360
485
|
result.unavailable.append(name)
|
|
361
486
|
version = scanner.binary_version() if available else None
|
|
362
|
-
|
|
363
|
-
result.findings.extend(findings)
|
|
364
|
-
execution =
|
|
487
|
+
scan = scanner.scan(target, cfg)
|
|
488
|
+
result.findings.extend(scan.findings)
|
|
489
|
+
execution = execution_from_result(
|
|
365
490
|
name,
|
|
366
|
-
|
|
491
|
+
scan,
|
|
367
492
|
command=scanner.command if available else (),
|
|
368
493
|
version=version,
|
|
369
|
-
scope=_scanner_scope(name, sc_cfg),
|
|
370
494
|
)
|
|
371
495
|
result.executions.append(execution)
|
|
372
496
|
if execution.outcome is ScannerOutcome.COMPLETED:
|
|
@@ -399,15 +523,17 @@ def _ingest_sarif_imports(specs: list[str]) -> tuple[list[Finding], list[Scanner
|
|
|
399
523
|
return findings, executions
|
|
400
524
|
|
|
401
525
|
|
|
402
|
-
def _write_outputs(paths, findings, score, gate, coverage, ran, unavailable) -> None:
|
|
526
|
+
def _write_outputs(paths, findings, score, gate, coverage, ran, unavailable, verdict) -> None:
|
|
403
527
|
if paths.markdown is not None:
|
|
404
|
-
renderers.write_markdown(
|
|
528
|
+
renderers.write_markdown(
|
|
529
|
+
findings, score, gate, paths.markdown, ran, unavailable, coverage, verdict
|
|
530
|
+
)
|
|
405
531
|
if paths.json_out is not None:
|
|
406
|
-
renderers.write_json(findings, score, gate, paths.json_out, coverage)
|
|
532
|
+
renderers.write_json(findings, score, gate, paths.json_out, coverage, verdict)
|
|
407
533
|
if paths.sarif is not None:
|
|
408
534
|
sarif.write(findings, paths.sarif, coverage)
|
|
409
535
|
if paths.comment is not None:
|
|
410
|
-
renderers.write_pr_comment(findings, score, gate, paths.comment, coverage)
|
|
536
|
+
renderers.write_pr_comment(findings, score, gate, paths.comment, coverage, verdict)
|
|
411
537
|
if paths.prompt is not None:
|
|
412
538
|
remediation.write(findings, paths.prompt)
|
|
413
539
|
|
|
@@ -462,18 +588,19 @@ def _under_root(root: Path, value: str) -> Path:
|
|
|
462
588
|
return path.resolve() if path.is_absolute() else (root / path).resolve()
|
|
463
589
|
|
|
464
590
|
|
|
465
|
-
def _print_summary(score, gate, ran, unavailable, coverage, paths) -> None:
|
|
591
|
+
def _print_summary(verdict, score, gate, ran, unavailable, coverage, paths) -> None:
|
|
466
592
|
status = "PASS" if gate.passed else "FAIL"
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
print(
|
|
471
|
-
f"secure-code-agent · {score_label} {score.overall:.2f} ({score.letter}) "
|
|
472
|
-
f"· gate {status}"
|
|
473
|
-
)
|
|
593
|
+
print(f"secure-code-agent · score {verdict.headline()} · gate {status}")
|
|
594
|
+
for reason in verdict.reasons:
|
|
595
|
+
print(f" ! grade withheld: {reason}")
|
|
474
596
|
print(f" scanned LOC: {score.loc_scanned:,}")
|
|
475
597
|
print(f" scanners run: {', '.join(ran) if ran else '(none)'}")
|
|
476
|
-
|
|
598
|
+
coverage_line = f" coverage: {coverage.status.value.upper()}"
|
|
599
|
+
if coverage.unverified:
|
|
600
|
+
coverage_line += (
|
|
601
|
+
f" ({len(coverage.unverified)} unverified: {', '.join(coverage.unverified)})"
|
|
602
|
+
)
|
|
603
|
+
print(coverage_line)
|
|
477
604
|
if unavailable:
|
|
478
605
|
print(f" unavailable: {', '.join(unavailable)}")
|
|
479
606
|
if not gate.passed:
|
|
@@ -493,23 +620,13 @@ def _validate_scanner_config(cfg: config_mod.Config) -> None:
|
|
|
493
620
|
unknown = sorted(set(cfg.scanners) - known)
|
|
494
621
|
if unknown:
|
|
495
622
|
raise ValueError(f"unknown scanner configuration: {', '.join(unknown)}")
|
|
496
|
-
|
|
623
|
+
# Expand the `floor` token before validating, so requiring the declared
|
|
624
|
+
# minimum does not read as a typo'd scanner name.
|
|
625
|
+
required = set(floor.expand_required(cfg.gates.get("require_scanners", [])))
|
|
497
626
|
unknown_required = sorted(required - known)
|
|
498
627
|
if unknown_required:
|
|
499
628
|
raise ValueError(f"unknown required scanner: {', '.join(unknown_required)}")
|
|
500
629
|
|
|
501
630
|
|
|
502
|
-
def _scanner_scope(name: str, cfg: config_mod.ScannerConfig) -> str | None:
|
|
503
|
-
"""Describe configured audit scope without changing scanner semantics."""
|
|
504
|
-
if name != "pip_audit":
|
|
505
|
-
return None
|
|
506
|
-
parts = [f"mode={cfg.mode}"]
|
|
507
|
-
if cfg.inputs:
|
|
508
|
-
parts.append(f"inputs={','.join(cfg.inputs)}")
|
|
509
|
-
if cfg.extra_args:
|
|
510
|
-
parts.append(f"extra_args={' '.join(cfg.extra_args)}")
|
|
511
|
-
return "; ".join(parts)
|
|
512
|
-
|
|
513
|
-
|
|
514
631
|
if __name__ == "__main__":
|
|
515
632
|
sys.exit(main())
|