secure-code-agent 0.2.0__py3-none-any.whl
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.2.0.dist-info/METADATA +328 -0
- secure_code_agent-0.2.0.dist-info/RECORD +33 -0
- secure_code_agent-0.2.0.dist-info/WHEEL +5 -0
- secure_code_agent-0.2.0.dist-info/entry_points.txt +3 -0
- secure_code_agent-0.2.0.dist-info/licenses/LICENSE +21 -0
- secure_code_agent-0.2.0.dist-info/top_level.txt +1 -0
- secure_code_audit/__init__.py +3 -0
- secure_code_audit/baseline.py +110 -0
- secure_code_audit/cli.py +258 -0
- secure_code_audit/config.py +115 -0
- secure_code_audit/findings.py +165 -0
- secure_code_audit/git_tools.py +82 -0
- secure_code_audit/instructions.py +141 -0
- secure_code_audit/remediation.py +168 -0
- secure_code_audit/renderers.py +253 -0
- secure_code_audit/sarif.py +221 -0
- secure_code_audit/scanners/__init__.py +50 -0
- secure_code_audit/scanners/bandit_scanner.py +83 -0
- secure_code_audit/scanners/base.py +194 -0
- secure_code_audit/scanners/builtin_rules.py +183 -0
- secure_code_audit/scanners/checkov_scanner.py +69 -0
- secure_code_audit/scanners/gitleaks_scanner.py +86 -0
- secure_code_audit/scanners/hadolint_scanner.py +107 -0
- secure_code_audit/scanners/npm_audit_scanner.py +101 -0
- secure_code_audit/scanners/osv_scanner.py +108 -0
- secure_code_audit/scanners/pip_audit_scanner.py +83 -0
- secure_code_audit/scanners/scorecard_scanner.py +156 -0
- secure_code_audit/scanners/semgrep_scanner.py +119 -0
- secure_code_audit/scanners/trivy_scanner.py +87 -0
- secure_code_audit/scanners/trufflehog_scanner.py +91 -0
- secure_code_audit/scoring.py +280 -0
- secure_code_audit/standards.py +391 -0
- secure_code_audit/suppressions.py +175 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Per-agent standards file generator — `--init-agent-standards`.
|
|
2
|
+
|
|
3
|
+
For each target host (codex, claude-code, cursor, copilot, windsurf, generic),
|
|
4
|
+
emit a standing-instructions file that tells the agent how to behave when
|
|
5
|
+
asked to fix security findings in this repo. Additive to repo-specific rules
|
|
6
|
+
in AGENTS.md / CLAUDE.md.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Iterable
|
|
12
|
+
|
|
13
|
+
# Map target → (filename, friendly_label)
|
|
14
|
+
_TARGETS: dict[str, tuple[str, str]] = {
|
|
15
|
+
"codex": ("AGENTS.md", "Codex (AGENTS.md)"),
|
|
16
|
+
"claude-code": ("CLAUDE.md", "Claude Code (CLAUDE.md)"),
|
|
17
|
+
"cursor": (".cursor/rules/security.mdc", "Cursor (.cursor/rules/security.mdc)"),
|
|
18
|
+
"copilot": (".github/copilot-instructions.md", "GitHub Copilot (.github/copilot-instructions.md)"),
|
|
19
|
+
"windsurf": (".windsurf/rules/security.md", "Windsurf (.windsurf/rules/security.md)"),
|
|
20
|
+
"generic": ("AI-SECURITY-STANDARDS.md", "Generic agent (AI-SECURITY-STANDARDS.md)"),
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
_BODY = """\
|
|
25
|
+
# AI agent security standards (generated by secure-code-agent)
|
|
26
|
+
|
|
27
|
+
This file is **appended** to the repo's existing AI-agent instructions.
|
|
28
|
+
It encodes the bounded-remediation rules `secure-code-agent` enforces. If
|
|
29
|
+
this file conflicts with the repo's own `AGENTS.md` / `CLAUDE.md`,
|
|
30
|
+
**repo-specific rules win**.
|
|
31
|
+
|
|
32
|
+
## When you are asked to fix a security finding
|
|
33
|
+
|
|
34
|
+
1. **Read `secure-code-remediation-prompt.md` first if it exists.** It
|
|
35
|
+
is the canonical, finding-scoped task brief.
|
|
36
|
+
2. **If no prompt exists**, run `secure-code-agent --prompt-output
|
|
37
|
+
secure-code-remediation-prompt.md` first, then read it.
|
|
38
|
+
3. Follow the prompt's hard constraints verbatim.
|
|
39
|
+
|
|
40
|
+
## Standing rules (apply to ALL security-related work in this repo)
|
|
41
|
+
|
|
42
|
+
- **Do not change cryptographic algorithms, key derivation, IV/nonce
|
|
43
|
+
handling, padding modes, or random sources** unless a finding
|
|
44
|
+
explicitly names them as the defect.
|
|
45
|
+
- **Do not change authentication flows, session handling, token
|
|
46
|
+
lifetime, cookie attributes, or authorization gates** unless a
|
|
47
|
+
finding explicitly names them.
|
|
48
|
+
- **Do not weaken input validation, output encoding, sanitization,
|
|
49
|
+
bounds checks, regex strictness, or rate limits** to make existing
|
|
50
|
+
tests pass.
|
|
51
|
+
- **Do not disable, delete, or skip security tests.** Do not remove
|
|
52
|
+
`@_limiter.limit`, `@require_auth`, `@require_csrf`, or similar
|
|
53
|
+
decorators.
|
|
54
|
+
- **Do not silence linter warnings** via `# nosec`, `# noqa`,
|
|
55
|
+
`# type: ignore`, `eslint-disable`, `sonar-disable`, or
|
|
56
|
+
equivalent.
|
|
57
|
+
- **Do not introduce new third-party dependencies.** Prefer stdlib
|
|
58
|
+
or already-vendored libraries. If a new dependency is necessary,
|
|
59
|
+
stop and ask the operator first.
|
|
60
|
+
- **Preserve behavior.** Same inputs must produce the same outputs
|
|
61
|
+
unless a finding explicitly proves the current behavior is
|
|
62
|
+
unsafe.
|
|
63
|
+
- **Add a focused test** that exercises the specific security
|
|
64
|
+
boundary you fixed. The test must FAIL on the pre-fix code and
|
|
65
|
+
PASS on the post-fix code.
|
|
66
|
+
- **Keep patches small.** If you find yourself rewriting a function
|
|
67
|
+
rather than patching it, stop and report the structural issue
|
|
68
|
+
to the operator.
|
|
69
|
+
|
|
70
|
+
## Standards anchors
|
|
71
|
+
|
|
72
|
+
Findings carry: CWE id, OWASP Top 10 bucket, OWASP ASVS section,
|
|
73
|
+
NIST SSDF practice. Read the standards entries before editing —
|
|
74
|
+
they are the authoritative description of the weakness.
|
|
75
|
+
|
|
76
|
+
- CWE: https://cwe.mitre.org/data/definitions/<num>.html
|
|
77
|
+
- OWASP Top 10: https://owasp.org/Top10/2021/
|
|
78
|
+
- OWASP ASVS: https://github.com/OWASP/ASVS
|
|
79
|
+
- NIST SSDF: https://csrc.nist.gov/pubs/sp/800/218/final
|
|
80
|
+
|
|
81
|
+
## False positives
|
|
82
|
+
|
|
83
|
+
If a finding is a false positive, do NOT silently apply a fix.
|
|
84
|
+
Add a suppression entry to `.scignore.yaml` with:
|
|
85
|
+
|
|
86
|
+
- `rule_id` (or `*` scoped to `file`/`paths`)
|
|
87
|
+
- `reason` (required, non-empty)
|
|
88
|
+
- `expires` (required, ISO date, ≤ 365 days from today)
|
|
89
|
+
|
|
90
|
+
The operator will review the suppression in PR.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def render() -> str:
|
|
95
|
+
"""Return the generated body. Operators may append additional
|
|
96
|
+
repo-specific rules to the emitted file after the first run."""
|
|
97
|
+
return _BODY
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def write_for_target(target: str, output_dir: Path) -> Path:
|
|
101
|
+
if target not in _TARGETS:
|
|
102
|
+
raise ValueError(
|
|
103
|
+
f"Unknown target {target!r}. Known: {', '.join(sorted(_TARGETS))}."
|
|
104
|
+
)
|
|
105
|
+
rel_path, _ = _TARGETS[target]
|
|
106
|
+
out = output_dir / rel_path
|
|
107
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
108
|
+
body = render()
|
|
109
|
+
|
|
110
|
+
# If the target file exists, append below a fenced section so the
|
|
111
|
+
# operator's existing content is preserved.
|
|
112
|
+
if out.exists():
|
|
113
|
+
existing = out.read_text(encoding="utf-8")
|
|
114
|
+
marker = "<!-- secure-code-agent:standards -->"
|
|
115
|
+
if marker in existing:
|
|
116
|
+
# Already initialized; refresh between markers.
|
|
117
|
+
before, _, rest = existing.partition(marker)
|
|
118
|
+
_, _, after_close = rest.partition("<!-- /secure-code-agent:standards -->")
|
|
119
|
+
new = f"{before}{marker}\n{body}\n<!-- /secure-code-agent:standards -->{after_close}"
|
|
120
|
+
else:
|
|
121
|
+
new = (
|
|
122
|
+
f"{existing.rstrip()}\n\n"
|
|
123
|
+
f"{marker}\n{body}\n<!-- /secure-code-agent:standards -->\n"
|
|
124
|
+
)
|
|
125
|
+
else:
|
|
126
|
+
new = (
|
|
127
|
+
"<!-- secure-code-agent:standards -->\n"
|
|
128
|
+
f"{body}\n"
|
|
129
|
+
"<!-- /secure-code-agent:standards -->\n"
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
out.write_text(new, encoding="utf-8")
|
|
133
|
+
return out
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def write_for_targets(targets: Iterable[str], output_dir: Path) -> list[Path]:
|
|
137
|
+
return [write_for_target(t, output_dir) for t in targets]
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def known_targets() -> list[str]:
|
|
141
|
+
return sorted(_TARGETS.keys())
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Remediation prompt generator — the differentiator.
|
|
2
|
+
|
|
3
|
+
Produces an LLM-ready prompt scoped to the actual findings with explicit
|
|
4
|
+
guardrails against the documented failure modes for AI security fixes.
|
|
5
|
+
|
|
6
|
+
See docs/remediation.md for the full template + rationale.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Iterable
|
|
12
|
+
|
|
13
|
+
from secure_code_audit.findings import Finding, Severity
|
|
14
|
+
from secure_code_audit.standards import cwe_url, owasp_label
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
_HARD_CONSTRAINTS = """\
|
|
18
|
+
## Hard constraints (MUST NOT violate)
|
|
19
|
+
|
|
20
|
+
1. Fix only the findings listed in §FINDINGS. Do not touch unrelated
|
|
21
|
+
code, files, or modules.
|
|
22
|
+
2. Do not change cryptographic algorithms, key derivation, IV/nonce
|
|
23
|
+
handling, padding modes, or random sources unless a finding in
|
|
24
|
+
§FINDINGS explicitly names them as the defect.
|
|
25
|
+
3. Do not change authentication flows, session handling, token
|
|
26
|
+
lifetime, cookie attributes, or authorization gates unless a
|
|
27
|
+
finding in §FINDINGS explicitly names them.
|
|
28
|
+
4. Do not weaken input validation, output encoding, sanitization,
|
|
29
|
+
bounds checks, regex strictness, or rate limits to make existing
|
|
30
|
+
tests pass.
|
|
31
|
+
5. Do not disable, delete, or skip security tests. Do not remove
|
|
32
|
+
`@_limiter.limit`, `@require_auth`, `@require_csrf`, or similar
|
|
33
|
+
decorators.
|
|
34
|
+
6. Do not silence linter warnings via `# nosec`, `# noqa`, `# type:
|
|
35
|
+
ignore`, `eslint-disable`, `sonar-disable`, or equivalent.
|
|
36
|
+
7. Do not introduce new third-party dependencies. Prefer stdlib or
|
|
37
|
+
already-vendored libraries. If a new dependency is necessary,
|
|
38
|
+
stop and ask the operator first.
|
|
39
|
+
8. Preserve behavior. Same inputs must produce the same outputs
|
|
40
|
+
unless a finding explicitly proves the current behavior is
|
|
41
|
+
unsafe (in which case, name the input/output pair that changes
|
|
42
|
+
in the patch description).
|
|
43
|
+
9. Add a focused test that exercises the specific security boundary
|
|
44
|
+
you fixed. The test must FAIL on the pre-fix code and PASS on the
|
|
45
|
+
post-fix code. No "TODO: add test later".
|
|
46
|
+
10. Keep the patch small. If you find yourself rewriting a function
|
|
47
|
+
rather than patching it, stop and report the structural issue
|
|
48
|
+
to the operator instead.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
_PATCH_PROTOCOL = """\
|
|
53
|
+
## Patch protocol
|
|
54
|
+
|
|
55
|
+
For each finding:
|
|
56
|
+
|
|
57
|
+
1. Quote the specific lines you will change (file:line_start-line_end).
|
|
58
|
+
2. State the minimum change that resolves the finding.
|
|
59
|
+
3. State the test you will add.
|
|
60
|
+
4. Apply the change.
|
|
61
|
+
5. Run the test. Confirm it fails on the pre-fix code (via git stash
|
|
62
|
+
or equivalent) and passes after.
|
|
63
|
+
6. Re-run the audit (the operator's CI will do this — you don't need
|
|
64
|
+
to invoke secure-code-agent yourself).
|
|
65
|
+
|
|
66
|
+
## Reporting
|
|
67
|
+
|
|
68
|
+
When done, emit a single summary block per finding:
|
|
69
|
+
|
|
70
|
+
· Finding id:
|
|
71
|
+
· Files changed (file:line ranges):
|
|
72
|
+
· Test added (file:line range):
|
|
73
|
+
· Behavior change (yes/no — if yes, name input → old output / new output):
|
|
74
|
+
· Standards satisfied:
|
|
75
|
+
|
|
76
|
+
If you discover the finding is a false positive, do NOT apply a fix.
|
|
77
|
+
Instead, emit a suppression candidate for `.scignore.yaml` with the
|
|
78
|
+
justification and a proposed `expires` date (max 90 days). Operator
|
|
79
|
+
will review.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def generate(findings: Iterable[Finding]) -> str:
|
|
84
|
+
"""Build the full remediation prompt. Operators write the output to a
|
|
85
|
+
file and hand it to their agent (Claude Code, Codex, Cursor, Copilot)."""
|
|
86
|
+
actionable = [f for f in findings if not f.suppressed and f.severity != Severity.INFORMATIONAL]
|
|
87
|
+
if not actionable:
|
|
88
|
+
return (
|
|
89
|
+
"# Security remediation — no actionable findings\n\n"
|
|
90
|
+
"secure-code-agent did not surface any actionable security findings "
|
|
91
|
+
"for this run. Nothing to fix.\n"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
parts: list[str] = []
|
|
95
|
+
parts.append("# Security remediation — bounded scope\n")
|
|
96
|
+
parts.append(
|
|
97
|
+
"You are fixing the security findings listed in §FINDINGS below.\n"
|
|
98
|
+
"This is a constrained task, not a refactor.\n"
|
|
99
|
+
)
|
|
100
|
+
parts.append(_HARD_CONSTRAINTS)
|
|
101
|
+
parts.append(_PATCH_PROTOCOL)
|
|
102
|
+
parts.append("## Standards context\n")
|
|
103
|
+
parts.append(
|
|
104
|
+
"Each finding below carries its CWE id, OWASP Top 10 bucket, OWASP\n"
|
|
105
|
+
"ASVS section, and NIST SSDF practice. Read the linked standards\n"
|
|
106
|
+
"entries before editing — they are the authoritative description\n"
|
|
107
|
+
"of the weakness.\n"
|
|
108
|
+
)
|
|
109
|
+
parts.append("## §FINDINGS\n")
|
|
110
|
+
|
|
111
|
+
# Sort: severity desc, then by file
|
|
112
|
+
sorted_findings = sorted(
|
|
113
|
+
actionable,
|
|
114
|
+
key=lambda f: (-f.severity.rank, f.file_path.as_posix(), f.line_start),
|
|
115
|
+
)
|
|
116
|
+
for n, f in enumerate(sorted_findings, start=1):
|
|
117
|
+
parts.append(_finding_block(n, f))
|
|
118
|
+
|
|
119
|
+
parts.append(_footer())
|
|
120
|
+
return "\n".join(parts)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _finding_block(n: int, f: Finding) -> str:
|
|
124
|
+
lines: list[str] = []
|
|
125
|
+
lines.append(f"### Finding {n}: `{f.rule_id}` — {f.short_desc or f.message[:120]}")
|
|
126
|
+
lines.append("")
|
|
127
|
+
lines.append(f"- **Severity:** {f.severity.value} ({f.confidence.value} confidence)")
|
|
128
|
+
if f.canonical_cwe:
|
|
129
|
+
top25 = " (MITRE Top 25)" if f.cwe_top25 else ""
|
|
130
|
+
lines.append(f"- **CWE:** [{f.canonical_cwe}]({cwe_url(f.canonical_cwe)}){top25}")
|
|
131
|
+
if f.owasp_top10:
|
|
132
|
+
lines.append(f"- **OWASP Top 10:** {owasp_label(f.owasp_top10)}")
|
|
133
|
+
if f.asvs_section:
|
|
134
|
+
lines.append(f"- **OWASP ASVS:** `{f.asvs_section}`")
|
|
135
|
+
if f.nist_ssdf:
|
|
136
|
+
lines.append(f"- **NIST SSDF:** `{f.nist_ssdf}`")
|
|
137
|
+
lines.append(f"- **Scanner:** `{f.scanner}`")
|
|
138
|
+
lines.append(f"- **Category:** `{f.category.value}`")
|
|
139
|
+
lines.append("")
|
|
140
|
+
lines.append(
|
|
141
|
+
f"**Location:** `{f.file_path.as_posix()}:{f.line_start}"
|
|
142
|
+
+ (f"-{f.line_end}" if f.line_end and f.line_end != f.line_start else "")
|
|
143
|
+
+ "`"
|
|
144
|
+
)
|
|
145
|
+
if f.code_snippet:
|
|
146
|
+
lines.append("")
|
|
147
|
+
lines.append("```")
|
|
148
|
+
lines.append(f.code_snippet.rstrip())
|
|
149
|
+
lines.append("```")
|
|
150
|
+
lines.append("")
|
|
151
|
+
lines.append(f"**Why this matters:** {f.message}")
|
|
152
|
+
if f.fix_hint:
|
|
153
|
+
lines.append("")
|
|
154
|
+
lines.append(f"**Suggested approach:** {f.fix_hint}")
|
|
155
|
+
lines.append("")
|
|
156
|
+
return "\n".join(lines)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _footer() -> str:
|
|
160
|
+
return (
|
|
161
|
+
"---\n\n"
|
|
162
|
+
"End of findings. Apply the patch protocol above per finding. "
|
|
163
|
+
"Report each one in the summary block format.\n"
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def write(findings: Iterable[Finding], path: Path) -> None:
|
|
168
|
+
path.write_text(generate(findings), encoding="utf-8")
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""Renderers — markdown report, canonical JSON, PR-comment summary."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import datetime
|
|
5
|
+
import json
|
|
6
|
+
from collections import defaultdict
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Iterable
|
|
9
|
+
|
|
10
|
+
from secure_code_audit import __version__
|
|
11
|
+
from secure_code_audit.findings import Category, Finding, Severity
|
|
12
|
+
from secure_code_audit.scoring import GateResult, ScoreReport, letter_grade
|
|
13
|
+
from secure_code_audit.standards import cwe_url, owasp_label
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
# Canonical JSON
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
def to_json(findings: Iterable[Finding], score: ScoreReport, gate: GateResult) -> dict:
|
|
21
|
+
findings = list(findings)
|
|
22
|
+
return {
|
|
23
|
+
"version": __version__,
|
|
24
|
+
"generated": datetime.datetime.now(datetime.UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
25
|
+
"score": {
|
|
26
|
+
"overall": score.overall,
|
|
27
|
+
"letter": score.letter,
|
|
28
|
+
"loc_scanned": score.loc_scanned,
|
|
29
|
+
"worst_category": score.worst_category.value if score.worst_category else None,
|
|
30
|
+
"per_category": {c.value: round(v, 2) for c, v in score.per_category.items()},
|
|
31
|
+
"per_category_count": {c.value: n for c, n in score.per_category_count.items()},
|
|
32
|
+
"per_severity_count": {s.value: n for s, n in score.per_severity_count.items()},
|
|
33
|
+
},
|
|
34
|
+
"gate": {
|
|
35
|
+
"passed": gate.passed,
|
|
36
|
+
"reasons": list(gate.reasons),
|
|
37
|
+
"tripped": list(gate.tripped),
|
|
38
|
+
},
|
|
39
|
+
"findings": [_finding_to_dict(f) for f in findings],
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _finding_to_dict(f: Finding) -> dict:
|
|
44
|
+
return {
|
|
45
|
+
"rule_id": f.rule_id,
|
|
46
|
+
"scanner": f.scanner,
|
|
47
|
+
"fingerprint": f.fingerprint,
|
|
48
|
+
"canonical_cwe": f.canonical_cwe,
|
|
49
|
+
"owasp_top10": f.owasp_top10,
|
|
50
|
+
"asvs_section": f.asvs_section,
|
|
51
|
+
"nist_ssdf": f.nist_ssdf,
|
|
52
|
+
"category": f.category.value,
|
|
53
|
+
"severity": f.severity.value,
|
|
54
|
+
"confidence": f.confidence.value,
|
|
55
|
+
"cwe_top25": f.cwe_top25,
|
|
56
|
+
"file_path": f.file_path.as_posix(),
|
|
57
|
+
"line_start": f.line_start,
|
|
58
|
+
"line_end": f.line_end,
|
|
59
|
+
"code_snippet": f.code_snippet,
|
|
60
|
+
"message": f.message,
|
|
61
|
+
"short_desc": f.short_desc,
|
|
62
|
+
"fix_hint": f.fix_hint,
|
|
63
|
+
"suppressed": f.suppressed,
|
|
64
|
+
"suppression_note": f.suppression_note,
|
|
65
|
+
"is_new": f.is_new,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def write_json(findings: Iterable[Finding], score: ScoreReport, gate: GateResult, path: Path) -> None:
|
|
70
|
+
path.write_text(json.dumps(to_json(findings, score, gate), indent=2), encoding="utf-8")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
# Markdown report
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
def write_markdown(
|
|
78
|
+
findings: list[Finding],
|
|
79
|
+
score: ScoreReport,
|
|
80
|
+
gate: GateResult,
|
|
81
|
+
path: Path,
|
|
82
|
+
scanners_run: list[str],
|
|
83
|
+
scanners_unavailable: list[str],
|
|
84
|
+
) -> None:
|
|
85
|
+
path.write_text(_markdown(findings, score, gate, scanners_run, scanners_unavailable), encoding="utf-8")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _markdown(
|
|
89
|
+
findings: list[Finding],
|
|
90
|
+
score: ScoreReport,
|
|
91
|
+
gate: GateResult,
|
|
92
|
+
scanners_run: list[str],
|
|
93
|
+
scanners_unavailable: list[str],
|
|
94
|
+
) -> str:
|
|
95
|
+
lines: list[str] = []
|
|
96
|
+
lines.append(f"# secure-code-agent report\n")
|
|
97
|
+
lines.append(f"Generated: {datetime.datetime.now(datetime.UTC).strftime('%Y-%m-%dT%H:%M:%SZ')} · "
|
|
98
|
+
f"agent v{__version__}\n")
|
|
99
|
+
|
|
100
|
+
lines.append(_summary_section(score, gate))
|
|
101
|
+
lines.append(_categories_table(score))
|
|
102
|
+
lines.append(_severity_table(score))
|
|
103
|
+
lines.append(_scanners_section(scanners_run, scanners_unavailable))
|
|
104
|
+
lines.append(_findings_sections(findings))
|
|
105
|
+
return "\n".join(lines)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _summary_section(score: ScoreReport, gate: GateResult) -> str:
|
|
109
|
+
status = "✅ PASS" if gate.passed else "❌ FAIL"
|
|
110
|
+
out = ["## Summary", ""]
|
|
111
|
+
out.append(f"- **Score:** {score.overall:.2f} / 5.00 — **{score.letter}**")
|
|
112
|
+
out.append(f"- **Gate:** {status}")
|
|
113
|
+
if not gate.passed:
|
|
114
|
+
out.append("- **Tripped gates:**")
|
|
115
|
+
for reason, key in zip(gate.reasons, gate.tripped):
|
|
116
|
+
out.append(f" - `{key}` — {reason}")
|
|
117
|
+
out.append(f"- **LOC scanned:** {score.loc_scanned:,}")
|
|
118
|
+
if score.worst_category is not None:
|
|
119
|
+
out.append(f"- **Worst category:** `{score.worst_category.value}`")
|
|
120
|
+
out.append("")
|
|
121
|
+
return "\n".join(out)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _categories_table(score: ScoreReport) -> str:
|
|
125
|
+
out = ["## Categories", "", "| Category | Grade | Score | Findings |", "|---|---|---:|---:|"]
|
|
126
|
+
for cat_name, grade_letter, grade_score, count in score.as_table():
|
|
127
|
+
out.append(f"| `{cat_name}` | **{grade_letter}** | {grade_score:.2f} | {count} |")
|
|
128
|
+
out.append("")
|
|
129
|
+
return "\n".join(out)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _severity_table(score: ScoreReport) -> str:
|
|
133
|
+
out = ["## Severity breakdown", "", "| Severity | Count |", "|---|---:|"]
|
|
134
|
+
for sev in (Severity.CRITICAL, Severity.HIGH, Severity.MEDIUM, Severity.LOW, Severity.INFORMATIONAL):
|
|
135
|
+
out.append(f"| **{sev.value}** | {score.per_severity_count.get(sev, 0)} |")
|
|
136
|
+
out.append("")
|
|
137
|
+
return "\n".join(out)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _scanners_section(scanners_run: list[str], scanners_unavailable: list[str]) -> str:
|
|
141
|
+
out = ["## Scanners", ""]
|
|
142
|
+
out.append(f"- Run: {', '.join(scanners_run) if scanners_run else '_none_'}")
|
|
143
|
+
if scanners_unavailable:
|
|
144
|
+
out.append(f"- Skipped (binary not on PATH): {', '.join(scanners_unavailable)}")
|
|
145
|
+
out.append("")
|
|
146
|
+
return "\n".join(out)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _findings_sections(findings: list[Finding]) -> str:
|
|
150
|
+
if not findings:
|
|
151
|
+
return "## Findings\n\n_None._\n"
|
|
152
|
+
|
|
153
|
+
# Sort: severity desc, then category, then file
|
|
154
|
+
severity_order = list(Severity)
|
|
155
|
+
sorted_findings = sorted(
|
|
156
|
+
findings,
|
|
157
|
+
key=lambda f: (
|
|
158
|
+
-f.severity.rank,
|
|
159
|
+
0 if f.is_new else 1,
|
|
160
|
+
f.category.value,
|
|
161
|
+
f.file_path.as_posix(),
|
|
162
|
+
f.line_start,
|
|
163
|
+
),
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
by_sev: dict[Severity, list[Finding]] = defaultdict(list)
|
|
167
|
+
for f in sorted_findings:
|
|
168
|
+
if f.suppressed:
|
|
169
|
+
continue
|
|
170
|
+
by_sev[f.severity].append(f)
|
|
171
|
+
|
|
172
|
+
out = ["## Findings", ""]
|
|
173
|
+
for sev in severity_order:
|
|
174
|
+
bucket = by_sev.get(sev) or []
|
|
175
|
+
if not bucket:
|
|
176
|
+
continue
|
|
177
|
+
out.append(f"### {sev.value.title()}")
|
|
178
|
+
out.append("")
|
|
179
|
+
for f in bucket:
|
|
180
|
+
out.extend(_one_finding_md(f))
|
|
181
|
+
out.append("")
|
|
182
|
+
|
|
183
|
+
suppressed = [f for f in findings if f.suppressed]
|
|
184
|
+
if suppressed:
|
|
185
|
+
out.append("---")
|
|
186
|
+
out.append("")
|
|
187
|
+
out.append("## Acknowledged (suppressed)")
|
|
188
|
+
out.append("")
|
|
189
|
+
for f in suppressed:
|
|
190
|
+
out.append(f"- `{f.rule_id}` — `{f.file_path.as_posix()}:{f.line_start}` — "
|
|
191
|
+
f"{f.suppression_note or 'no note'}")
|
|
192
|
+
out.append("")
|
|
193
|
+
return "\n".join(out)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _one_finding_md(f: Finding) -> list[str]:
|
|
197
|
+
out: list[str] = []
|
|
198
|
+
new_marker = " 🆕" if f.is_new else ""
|
|
199
|
+
out.append(f"#### `{f.rule_id}` — {f.short_desc or f.message[:120]}{new_marker}")
|
|
200
|
+
out.append("")
|
|
201
|
+
out.append(f"- **Severity:** {f.severity.value} ({f.confidence.value} confidence)")
|
|
202
|
+
out.append(f"- **Category:** `{f.category.value}`")
|
|
203
|
+
out.append(f"- **Scanner:** `{f.scanner}`")
|
|
204
|
+
if f.canonical_cwe:
|
|
205
|
+
cwe_marker = " (Top 25)" if f.cwe_top25 else ""
|
|
206
|
+
out.append(f"- **CWE:** [{f.canonical_cwe}]({cwe_url(f.canonical_cwe)}){cwe_marker}")
|
|
207
|
+
if f.owasp_top10:
|
|
208
|
+
out.append(f"- **OWASP Top 10:** {owasp_label(f.owasp_top10)}")
|
|
209
|
+
if f.asvs_section:
|
|
210
|
+
out.append(f"- **OWASP ASVS:** `{f.asvs_section}`")
|
|
211
|
+
if f.nist_ssdf:
|
|
212
|
+
out.append(f"- **NIST SSDF:** `{f.nist_ssdf}`")
|
|
213
|
+
out.append(f"- **Location:** `{f.file_path.as_posix()}:{f.line_start}"
|
|
214
|
+
+ (f"-{f.line_end}" if f.line_end and f.line_end != f.line_start else "")
|
|
215
|
+
+ "`")
|
|
216
|
+
if f.code_snippet:
|
|
217
|
+
out.append("")
|
|
218
|
+
out.append("```")
|
|
219
|
+
out.append(f.code_snippet.rstrip())
|
|
220
|
+
out.append("```")
|
|
221
|
+
if f.fix_hint:
|
|
222
|
+
out.append("")
|
|
223
|
+
out.append(f"> 💡 **Fix:** {f.fix_hint}")
|
|
224
|
+
return out
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# ---------------------------------------------------------------------------
|
|
228
|
+
# PR comment (short, scannable)
|
|
229
|
+
# ---------------------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
def write_pr_comment(findings: list[Finding], score: ScoreReport, gate: GateResult, path: Path) -> None:
|
|
232
|
+
path.write_text(_pr_comment(findings, score, gate), encoding="utf-8")
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _pr_comment(findings: list[Finding], score: ScoreReport, gate: GateResult) -> str:
|
|
236
|
+
status = "✅" if gate.passed else "❌"
|
|
237
|
+
n_crit = score.per_severity_count.get(Severity.CRITICAL, 0)
|
|
238
|
+
n_high = score.per_severity_count.get(Severity.HIGH, 0)
|
|
239
|
+
n_new = sum(1 for f in findings if f.is_new and not f.suppressed)
|
|
240
|
+
|
|
241
|
+
out = [
|
|
242
|
+
f"### secure-code-agent {status} — score **{score.letter}** ({score.overall:.2f}/5.00)",
|
|
243
|
+
"",
|
|
244
|
+
f"- Critical: **{n_crit}** · High: **{n_high}** · New since baseline: **{n_new}**",
|
|
245
|
+
f"- Worst category: `{score.worst_category.value if score.worst_category else '_none_'}`",
|
|
246
|
+
]
|
|
247
|
+
if not gate.passed:
|
|
248
|
+
out.append("- Tripped gates:")
|
|
249
|
+
for r in gate.reasons:
|
|
250
|
+
out.append(f" - {r}")
|
|
251
|
+
out.append("")
|
|
252
|
+
out.append("_Full report uploaded as artifact._")
|
|
253
|
+
return "\n".join(out)
|