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.
Files changed (33) hide show
  1. secure_code_agent-0.2.0.dist-info/METADATA +328 -0
  2. secure_code_agent-0.2.0.dist-info/RECORD +33 -0
  3. secure_code_agent-0.2.0.dist-info/WHEEL +5 -0
  4. secure_code_agent-0.2.0.dist-info/entry_points.txt +3 -0
  5. secure_code_agent-0.2.0.dist-info/licenses/LICENSE +21 -0
  6. secure_code_agent-0.2.0.dist-info/top_level.txt +1 -0
  7. secure_code_audit/__init__.py +3 -0
  8. secure_code_audit/baseline.py +110 -0
  9. secure_code_audit/cli.py +258 -0
  10. secure_code_audit/config.py +115 -0
  11. secure_code_audit/findings.py +165 -0
  12. secure_code_audit/git_tools.py +82 -0
  13. secure_code_audit/instructions.py +141 -0
  14. secure_code_audit/remediation.py +168 -0
  15. secure_code_audit/renderers.py +253 -0
  16. secure_code_audit/sarif.py +221 -0
  17. secure_code_audit/scanners/__init__.py +50 -0
  18. secure_code_audit/scanners/bandit_scanner.py +83 -0
  19. secure_code_audit/scanners/base.py +194 -0
  20. secure_code_audit/scanners/builtin_rules.py +183 -0
  21. secure_code_audit/scanners/checkov_scanner.py +69 -0
  22. secure_code_audit/scanners/gitleaks_scanner.py +86 -0
  23. secure_code_audit/scanners/hadolint_scanner.py +107 -0
  24. secure_code_audit/scanners/npm_audit_scanner.py +101 -0
  25. secure_code_audit/scanners/osv_scanner.py +108 -0
  26. secure_code_audit/scanners/pip_audit_scanner.py +83 -0
  27. secure_code_audit/scanners/scorecard_scanner.py +156 -0
  28. secure_code_audit/scanners/semgrep_scanner.py +119 -0
  29. secure_code_audit/scanners/trivy_scanner.py +87 -0
  30. secure_code_audit/scanners/trufflehog_scanner.py +91 -0
  31. secure_code_audit/scoring.py +280 -0
  32. secure_code_audit/standards.py +391 -0
  33. 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)