spacehub

surshar / claude-code

Claude Code is an agentic coding tool that lives in your terminal, understands your codebase, and helps you code faster by executing routine tasks, explaining complex code, and handling git workflows - all through natural language commands.

Built by Anthropic · github.com/anthropics

claude-code / plugins / security-guidance / hooks / review_api.py
760 lines · 41.2 KBRaw
1"""Public review API for the security-guidance agentic commit reviewer.
2
3This module is the importable surface for callers that want to run the
4same two-stage agentic security review as the CC plugin (investigate →
5self-refute) without going through the CC hook protocol. External
6agentic harnesses can import this directly so their commit reviewer uses
7the exact prompts, schemas, and filters the plugin uses.
8
9``security_reminder_hook.py`` imports every symbol below; the hook
10script's own underscored names are aliases. Keep this file free of CC
11hook-event coupling (no stdin parsing, no env-var feature gates, no
12``debug_log``/state-file IO) so non-CC callers can import it without
13side effects.
14"""
15from __future__ import annotations
16
17import json
18import os
19import re
20from typing import Any
21
22import extensibility
23
24# ---------------------------------------------------------------------------
25# Diff capping
26# ---------------------------------------------------------------------------
27
28DIFF_PER_FILE_BYTES = int(os.environ.get("DIFF_PER_FILE_BYTES", "80000"))
29DIFF_TOTAL_BYTES = int(os.environ.get("DIFF_TOTAL_BYTES", "400000"))
30
31
32def cap_diff_for_prompt(
33 files: list[tuple[str, str]],
34) -> tuple[list[tuple[str, str]], int]:
35 """Cap per-file and total diff bytes; return (capped_files, bytes_dropped).
36
37 Truncation markers are written inside the content so the reviewer
38 knows the file is incomplete.
39 """
40 out: list[tuple[str, str]] = []
41 dropped = 0
42 total = 0
43 for fp, content in files:
44 if len(content) > DIFF_PER_FILE_BYTES:
45 dropped += len(content) - DIFF_PER_FILE_BYTES
46 content = (
47 content[:DIFF_PER_FILE_BYTES]
48 + "\n... [truncated by security-guidance: file exceeds per-file byte cap]"
49 )
50 room = DIFF_TOTAL_BYTES - total
51 if room <= 0:
52 dropped += len(content)
53 out.append(
54 (fp, "[omitted by security-guidance: total diff byte cap reached]")
55 )
56 continue
57 if len(content) > room:
58 dropped += len(content) - room
59 content = (
60 content[:room]
61 + "\n... [truncated by security-guidance: total diff byte cap reached]"
62 )
63 total += len(content)
64 out.append((fp, content))
65 return out, dropped
66
67
68# ---------------------------------------------------------------------------
69# Stage 1 — investigate
70# ---------------------------------------------------------------------------
71
72AGENTIC_INVESTIGATE_SYSTEM = """You are a senior application-security engineer performing a deep security review of a code change. You have read-only filesystem tools (Read, Grep, Glob) scoped to the repository — USE THEM AGGRESSIVELY. The diff alone is not enough.
73
74The #1 cause of missed vulnerabilities is not reading the file that contains them. Before any analysis: Read EVERY changed file in full (not just the diff hunks). Then Grep for the changed function/class names to find callers. A vulnerability that requires cross-file context is still your responsibility.
75
76METHOD:
77
78Phase 1 — Map entry points and sinks touched by this change.
79 Entry points: HTTP handlers/routes, RPC methods, CLI args, webhook receivers, message consumers, file/upload handlers, OAuth callbacks, GitHub Actions inputs, MCP tools, hook handlers, IPC receivers (main/privileged process handling messages from a sandboxed/renderer/less-privileged process).
80 Sinks: shell/exec/subprocess, SQL/ORM raw, eval/new Function, filesystem paths (open/read/write/unlink), outbound HTTP (SSRF), HTML render/innerHTML, deserialization (pickle/yaml/json with object_hook), template engines, subprocess env, IAM/RBAC bindings, dynamic code/plugin/extension loaders (any API that loads+executes code from a path), log/telemetry/metrics dimensions (only when value matches a PII shape — email, token, free-text field; NOT a static enum/type name), cache-control / Vary headers (cache poisoning), DDL that drops a constraint/FK/trigger (referential-integrity), response bodies/headers, prompts sent to LLMs.
81 For each changed file, Grep for the function/class names in the diff to find their callers and what data reaches them.
82
83Phase 2 — Trace data flow.
84 For every value that reaches a sink, determine whether it is attacker-influenceable. Read upstream: where does the variable come from? Is there validation/sanitization between source and sink? Check sibling handlers in the same file — if they enforce a check this one omits, the omission IS the finding. Cross-component flows (input enters in module A, dangerous operation in module B) are where the high-value findings live; follow them.
85 FOLLOW RETURNS: when a changed function builds a tainted value (command string, SQL, URL, path, template) and RETURNS it rather than executing locally, the sink is in a CALLER — Grep for the function name and read the call sites before deciding it's safe.
86 SIBLING-PATH GATE PARITY: when + lines add a guard/check/tenant-scope/visibility-filter/invalidation/cleanup to ONE branch, ONE handler, or ONE layer, enumerate ALL sibling branches, early-returns, error/except paths, and peer handlers in the same router/service that touch the same resource — report any that lack an equivalent gate. ONLY emit when (a) both the guarded path AND the sibling reach a state-changing or boundary-crossing sink, AND (b) the sibling's input is controllable by a different principal than the guard checks for. Skip if the file has a "generated / DO NOT EDIT" header or lives under generated/openapi/autogen.
87
88Phase 2b — Parser/validator differentials (a top miss category).
89 When the change adds or modifies parsing, validation, normalization, or matching logic (regexes, URL/path parsers, allowlists, content-type checks, decoders, AST/shell parsers), ask: does an input exist that the validator ACCEPTS but the downstream consumer interprets differently? Look for: unanchored/partial regexes; case/encoding/unicode normalization mismatches; URL parsers that disagree on userinfo/host/path; allowlists checked with substring/startswith; decoders that accept malformed input; quoting/escaping the parser strips but the consumer doesn't. The finding is the differential itself — name both sides.
90
91Phase 2c — High-miss patterns. Check ONLY against + lines in the diff — do NOT flag pre-existing code you read while exploring.
92 - SENSITIVE-TO-OBSERVABILITY: a + line emits to a log/trace/span/metric/exception-message sink. Trace EVERY field (including URLs, paths, error-object .message, f-string vars, **kwargs) to its source and flag credentials, PII, customer content, or model free-text reaching the sink — especially on error/except branches where happy-path redaction is bypassed and external-service error messages can echo URL-embedded secrets. Skip if: a sanitizer wraps the value at the call site; the log is gated by a debug/dev env flag; or the value is static request metadata (method/path/host).
93 - IaC OMITTED ARG: a + line instantiates a Terraform/Pulumi/CDK module and OMITS an optional security-relevant arg — read the module's variables and check whether the default is the secure value.
94 - CI/CD TRUST: + lines add or change a GitHub Actions trigger to workflow_dispatch / repository_dispatch / pull_request_target without a branches: filter, AND the job reads secrets or has write permissions.
95 - ALLOWLIST SEMANTIC ESCAPE: + lines add an entry to a safe-command/safe-endpoint/capability allowlist OR add a `||` disjunct to a permission matcher OR edit a validator that gates exec/eval/subprocess. Verify no allowed entry achieves a denied effect via its arguments, flags, abbreviations, side-channels (DNS, config-write, env), or scope mismatch vs. enforcement (e.g., allowlist matches argv[0] but consumer reads full argv).
96 - OVER-BROAD GRANT: when + lines add a principal/identity to a broad-scope permission (global/service-wide allowlist, standing admin role binding, reuse of another principal's credential), check whether the SAME changed file or its immediate module already exposes a narrower-scope mechanism for the same need (per-resource/per-RPC allowlist, break-glass/2PC role, dedicated principal). If it does, the broad grant is the finding. Do NOT flag if no narrower mechanism is visible in the changed files.
97 - STALE IDENTITY MAPPING: + lines change teardown/unregister of an identity primitive (hostname/DNS, IP, service route, lease, auth token, service-registry entry) where a window leaves it resolvable to the wrong tenant. NOT in-process data caches.
98 - CONTROL REGRESSION: when - lines DELETE a fail-closed validator (allowlist returning False by default, _is_safe_*, deny-by-default) and + lines replace it with a single condition, the replacement IS the finding.
99 - FAIL-OPEN STATE DRIFT: when a security decision reads parsed/cached/tracked/callback state, verify error, cancellation, TOCTOU, cache-skew, and unhandled-variant paths do not yield a default that skips enforcement — broad-except→pass, unwrap_or({}), missing-finally cleanup, ignored verifier params, or stale validator maps all fail open. The finding is the path where the fallback value is the allow outcome. Also: when + lines compare against a security threshold, check whether the EXACT boundary value yields the permissive branch; when an error path triggers retry/redelivery, check whether the retry can emit a decision that overrides a stricter first decision; when sync logic reads persisted state, check whether state surviving a data wipe causes destructive sync.
100 - SECURITY-REGISTRY FANOUT: when + lines add a new entity (field, enum value, credential type, alias, model variant, port, scope), Grep unchanged files for every security registry keyed on that entity class — sanitizer field-lists, redaction sets, revocation handlers, strip denylists, capability allowlists, translation maps — and flag if the new entry is missing from any. Conversely, when + lines ADD entries to such a registry, Grep for where that registry is consumed and verify each new entry's literal matches the consumer's key format (namespace prefix, case, composite key) — a mismatched entry is a silent no-op that defeats the control.
101 - GATE/ACTION FIELD MISMATCH: when + lines add or modify an authorization/policy check, identify which request field(s) the gate reads vs which field(s) the downstream operation uses to select the target resource. If they differ (gate checks `parent`, action derives target from `name`; gate checks org A, action writes to org from a separate param), the gate is bypassable.
102 - RESOURCE-BOUND PLACEMENT: when + lines parse/decompress/fetch/loop over attacker-influenced input, verify size/time/count caps guard the ACTUAL peak allocation — not a post-flush output, post-decompress buffer, per-iteration (not total) timeout, unclamped arithmetic (subtraction underflow, multiplication overflow), or first-element-only invariant. The finding is the cap defeat, not the DoS itself.
103 - UNDER-VALIDATED SINK ARG: when + lines interpolate any externally-influenced value (incl. IPC, VCS-checkout content, env var, model output, domain-syntax strings) into a shell/path/loader/URI/structured-format sink, verify quoting, traversal/UNC/symlink stripping, and prod-mode guards apply to THIS arg — existing validators on sibling args do not cover it.
104
105Phase 3 — Assess.
106 Report when you can name (a) the source, (b) the sink, (c) the path with no effective mitigation. Medium-confidence is fine — a separate adjudication pass will filter; your job is RECALL, not precision. Do report logic/authorization bugs (missing ownership check, inverted condition, parser differential) even when no classic "sink" is involved.
107
108Do NOT report: missing best-practice/hardening with no concrete impact, test/mock files, outdated deps, or volumetric DoS (attacker just sends a lot). DO report DoS when the diff introduces a code defect that defeats an existing resource cap (cap on wrong accumulator, dead timeout handler, unclamped arithmetic, encoding amplification at flush) — those are logic errors with security impact.
109
110Distrust safety claims in comments ("validated upstream", "internal only"). Verify in code.
111
112Keep scanning after the first finding. Do NOT emit findings until you have Read EVERY touched file at least once — a more obvious pattern in file A does not excuse skipping file B. Aim for at least one candidate or explicit "no sink" verdict per touched file.
113
114Return an object with key `findings` — a list of {filePath, category,
115vulnerableCode, explanation, fix, severity, confidence} records. severity
116is "critical", "high", or "medium". Return findings:[] ONLY after you have
117Read every changed file in full and traced every new sink to a trusted
118source.
119
120BUDGET: you have at most ~15 tool calls. Spend them reading the changed files first, then 3-5 targeted Greps for callers/sinks. Do NOT exhaustively explore the repo — once you can name source→sink for each candidate (or rule it out), STOP. Partial findings are better than none."""
121
122
123FINDINGS_SCHEMA = {
124 "type": "object",
125 "properties": {
126 "findings": {
127 "type": "array",
128 "items": {
129 "type": "object",
130 "properties": {
131 "filePath": {"type": "string"},
132 "category": {"type": "string"},
133 "vulnerableCode": {"type": "string"},
134 "explanation": {"type": "string"},
135 "fix": {"type": "string"},
136 "severity": {
137 "type": "string",
138 "enum": ["critical", "high", "medium", "low"],
139 },
140 "confidence": {"type": "number"},
141 },
142 "required": [
143 "filePath",
144 "category",
145 "vulnerableCode",
146 "explanation",
147 "fix",
148 "severity",
149 ],
150 },
151 },
152 },
153 "required": ["findings"],
154}
155
156
157def build_investigate_prompt(
158 touched_paths: list[str],
159 diff_files: list[tuple[str, str]],
160 *,
161 context_note: str = "",
162) -> str:
163 capped, _ = cap_diff_for_prompt(diff_files)
164 diff_text = "\n\n".join(
165 f"=== DIFF: {fp} ===\n{content}" for fp, content in capped
166 )
167 return (
168 "Review this change for security vulnerabilities.\n\n"
169 "Changed files (you may Read these and any other file in the repo):\n"
170 + "\n".join(f" - {p}" for p in touched_paths[:50])
171 + context_note
172 + "\n\nUnified diff (only + lines are new):\n\n"
173 + diff_text
174 + extensibility.guidance_block()
175 + "\n\nInvestigate per the method in your instructions, then return "
176 "the findings list."
177 )
178
179
180# ---------------------------------------------------------------------------
181# Stage 2 — self-refute
182# ---------------------------------------------------------------------------
183
184AGENTIC_REFUTE_SYSTEM = (
185 "You adversarially verify security findings. You have "
186 "Read/Grep over the repo. Default = SURVIVES unless you "
187 "find concrete refuting evidence."
188)
189
190
191SURVIVED_SCHEMA = {
192 "type": "object",
193 "properties": {
194 "survived": {"type": "array", "items": {"type": "integer"}},
195 "refuted": {
196 "type": "array",
197 "items": {
198 "type": "object",
199 "properties": {
200 "idx": {"type": "integer"},
201 "reason": {"type": "string"},
202 },
203 "required": ["idx", "reason"],
204 },
205 },
206 },
207 "required": ["survived"],
208}
209
210
211def build_refute_prompt(candidates: list[dict[str, Any]], diff_text: str) -> str:
212 return (
213 "You previously flagged these candidate vulnerabilities:\n\n"
214 + json.dumps(candidates, indent=2)
215 + "\n\nDIFF:\n" + diff_text[:8000]
216 + "\n\nNow adversarially try to DISPROVE each one. For each "
217 "candidate, FIRST identify the attacker (who controls the "
218 "input) and the victim (who is harmed). REFUTE if the only "
219 "victim is the attacker themselves on their own machine. KEEP "
220 "if the attacker is a legitimate user/tenant but the impact "
221 "reaches other users/tenants, shared infra, or server-side "
222 "resources.\n\n"
223 "DIFF-ANCHOR: candidates are sorted `in_diff` first, then "
224 "`off_diff`. Process them in order. `in_diff` candidates "
225 "use the standard KEEP/REFUTE bar above. `off_diff` "
226 "candidates require STRICTER evidence: you must identify "
227 "the specific +/- line in the diff that ENABLES the "
228 "off-diff sink (a removed guard, a new caller, a changed "
229 "argument feeding it). If you cannot name that enabling "
230 "diff line, REFUTE the off_diff candidate. Additionally, "
231 "REFUTE any off_diff candidate whose sink is already "
232 "covered by a surviving in_diff candidate.\n\n"
233 "Then Read the cited file and refute with cited file:line "
234 "evidence if ANY of these holds:\n"
235 "- PRE-EXISTING: the cited vulnerableCode does NOT appear on "
236 "any + line in the DIFF block above — it is unchanged context "
237 "in a touched file. The diff did not introduce it.\n"
238 "- A sanitizer/validator/authz check prevents the described "
239 "exploit.\n"
240 "- The sink is non-dangerous: typed-schema decoder (msgspec/"
241 "pydantic, not pickle/yaml), hardcoded https://<host>/ URL "
242 "with non-:path params, autogen client stub, value is "
243 "statically number/boolean.\n"
244 "- NO PRIVILEGE BOUNDARY: attacker == victim. The input "
245 "comes from env var / CLI arg / $HOME dotfile / HKCU / "
246 "~/Library prefs / OS-user config — and the process runs at "
247 "the same privilege as whoever writes that source. Also: "
248 "the 'allow' decision is advisory self-gating returned to "
249 "the same caller; or the prefix/suffix check is a secondary "
250 "filter behind a parent-domain pin.\n"
251 " NEVER apply NO-PRIVILEGE-BOUNDARY to: SSRF/outbound-"
252 "network sinks; LLM-agent capability gates (PreToolUse/"
253 "PostToolUse hooks, bash allow/denylists, workspace path "
254 "jails — the model is the attacker, the user is the "
255 "victim); data-exposure findings (CWE-200/359/532, secrets-"
256 "in-logs — the question is who READS the sink, not who "
257 "controls the input); project-working-directory config "
258 "(.claude/settings, .vscode/, package.json scripts — repo "
259 "author ≠ repo cloner); cross-process metadata sources "
260 "(psutil.Process(...), /proc/<pid>/* — different process "
261 "owner is a different principal).\n"
262 "- TRUSTED-HEADER NAMESPACE: the flagged header is from a "
263 "namespace the same handler already trusts for actor "
264 "identity/authz (e.g. control-plane-injected X-Amzn-*).\n"
265 "- FRONTEND-ONLY GATE: the loosened check is in frontend "
266 "code AND the backend handler independently enforces it.\n"
267 "- DELEGATED VALIDATION: the unvalidated credential is "
268 "immediately forwarded to an upstream that validates.\n"
269 "- THROWAWAY-CODE: all touched files live under scripts/, "
270 "dev/, tools/, examples/, testdata/, fixtures/, or behind "
271 "a __main__ dev guard.\n"
272 "- CONTROL MOVED TO LIBRARY: the diff removes a security "
273 "control AND bumps a dependency that documents providing "
274 "that control — the control was delegated, not removed.\n"
275 "- Config/feature-flag gates the path with no per-request "
276 "user control over the gate value.\n"
277 "- Protective-control polarity: the change loosens a guard "
278 "around a PROTECTIVE control (prompt/audit/confirm).\n"
279 "Do NOT speculate — refute only with cited evidence. Default "
280 "= SURVIVES.\n\n"
281 "Return `survived` — the indices of candidates you could NOT "
282 "refute — and `refuted` — {idx, reason} records for each you "
283 "did. An empty `survived` means every candidate was refuted."
284 )
285
286
287# ---------------------------------------------------------------------------
288# Mechanical filters and rendering
289# ---------------------------------------------------------------------------
290
291
292def tag_diff_anchor(
293 candidates: list[dict[str, Any]], diff_text: str
294) -> list[dict[str, Any]]:
295 """SOFT diff-intersect: tag each candidate ``_diff_anchor: "in_diff" |
296 "off_diff"`` and sort in_diff first; do NOT drop.
297
298 Investigate reads full files and often cites pre-existing patterns in
299 unchanged context (the largest false-positive source). Hard-dropping
300 those also discards correct findings whose sink is off-diff but
301 enabled by an in-diff change. The refute pass's DIFF-ANCHOR block
302 keys on the ``_diff_anchor`` tag to apply stricter evidence to
303 off_diff candidates instead of dropping them.
304
305 Mutates ``candidates`` in place; returns it for chaining.
306 """
307 added = [
308 ln[1:]
309 for ln in diff_text.splitlines()
310 if ln.startswith("+") and not ln.startswith("+++")
311 ]
312 removed = [
313 ln[1:]
314 for ln in diff_text.splitlines()
315 if ln.startswith("-") and not ln.startswith("---")
316 ]
317
318 def _norm(s: str) -> str:
319 return " ".join(t for t in " ".join(s.split()).split() if len(t) > 2)
320
321 added_norm = _norm("\n".join(added))
322 removed_norm = _norm("\n".join(removed))
323
324 def _intersects(cand: dict[str, Any]) -> bool:
325 vc = _norm(" ".join(str(cand.get("vulnerableCode") or "").split()))
326 if len(vc) < 8:
327 return True
328 toks = vc.split()
329 for i in range(max(1, len(toks) - 2)):
330 if " ".join(toks[i : i + 3]) in added_norm:
331 return True
332 for ln in added:
333 ln_n = _norm(ln)
334 if len(ln_n) >= 8 and ln_n in vc:
335 return True
336 if len(added) < len(removed):
337 for i in range(max(1, len(toks) - 2)):
338 if " ".join(toks[i : i + 3]) in removed_norm:
339 return True
340 return False
341
342 for c in candidates:
343 c["_diff_anchor"] = "in_diff" if _intersects(c) else "off_diff"
344 candidates.sort(key=lambda c: c.get("_diff_anchor") != "in_diff")
345 return candidates
346
347
348# ---------------------------------------------------------------------------
349# Secret-value redaction for feedback text
350# ---------------------------------------------------------------------------
351#
352# Findings quote the diff (`vulnerableCode`, `fix`, `evidenceLine`), and the
353# formatted block is fed back into the main conversation and kept in session
354# state. For a hardcoded-secret finding that quote IS the secret, so a value
355# that only ever lived in a working-tree file gets copied into model context,
356# the transcript and ~/.claude. The reviewer is deliberately NOT asked to
357# elide values itself: diff anchoring, dedupe and the refute pass all compare
358# the quoted code against the diff. Masking happens here, on the way out.
359# Keep the key name and the last two characters so the finding still points
360# at the right line; when unsure, mask: a garbled evidence line costs less
361# than an echoed credential. Pattern matching, not a guarantee — the README
362# says so and points at Read(...) deny rules for files that must never leave.
363
364# Identifier segments that make an assignment's left-hand side a credential.
365_SECRET_KEY_WORDS = frozenset((
366 "password", "passwd", "pwd", "pass", "passphrase", "secret", "secrets",
367 "token", "apikey", "apitoken", "authtoken", "accesstoken", "refreshtoken",
368 "secretkey", "privatekey", "accesskey", "signingkey", "masterkey",
369 "credential", "credentials", "creds", "authorization", "bearer", "dsn",
370 "connectionstring", "connstr", "connstring",
371))
372# Joined spellings: PGPASSWORD, DBPASSWORD, CLIENTSECRET, GHTOKEN.
373_SECRET_KEY_SUFFIXES = ("password", "passwd", "passphrase", "secret", "token", "apikey")
374_SECRET_KEY_PAIRS = frozenset((
375 ("api", "key"), ("private", "key"), ("secret", "key"), ("access", "key"),
376 ("signing", "key"), ("master", "key"), ("encryption", "key"),
377 ("client", "secret"), ("auth", "token"), ("auth", "key"),
378 ("conn", "str"), ("connection", "string"),
379))
380# ...unless another segment says the value is metadata about a credential.
381_NOT_SECRET_KEY_WORDS = frozenset((
382 "timeout", "expiry", "expires", "expire", "expiration", "ttl", "age",
383 "length", "len", "max", "min", "size", "count", "limit", "attempts",
384 "url", "uri", "endpoint", "host", "path", "file", "filename", "dir",
385 "name", "names", "id", "ids", "type", "field", "fields", "header", "param",
386 "hash", "hashed", "hasher", "hashers", "digest", "alg", "algorithm",
387 "reset", "csrf", "xsrf", "enabled", "disabled", "required", "use",
388 "policy", "rotation", "validator", "validators", "regex", "pattern",
389))
390# A dotted "key" ending in one of these is a file name (`secrets.yml:12`).
391_FILE_EXTENSIONS = frozenset((
392 "yml", "yaml", "json", "toml", "ini", "cfg", "conf", "env", "properties",
393 "xml", "txt", "md", "py", "js", "ts", "jsx", "tsx", "go", "rb", "java",
394 "kt", "rs", "ex", "exs", "php", "cs", "sh", "swift", "scala", "c", "h",
395 "cpp", "tf", "tfvars", "sql", "lock", "pem", "key",
396))
397_BARE_VALUE_SKIP = frozenset((
398 "true", "false", "null", "none", "nil", "yes", "no", "required",
399 "optional", "string", "str", "redacted", "await", "new",
400))
401_CAMEL_RE = re.compile(r"(?<=[a-z0-9])(?=[A-Z])")
402_SEGMENT_SPLIT_RE = re.compile(r"[_\-.$\s]+")
403# Assignment/comparison operator. The key before it is found by walking back
404# over identifier characters, which keeps the scan linear on hostile input
405# (the quoted code comes from the repository under review).
406_ASSIGN_OP_RE = re.compile(r"(?:[!=]?==|!=|=>|:=|[:=])[ \t]*")
407_KEY_CHARS = frozenset("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_$.-")
408# `password: str = "..."`, `apiKey: string = '...'`, `password: &str = "..."`.
409_TYPE_ANNOTATION_RE = re.compile(r"[&*]?[A-Za-z_][\w.<>\[\]&|?]*(?:[ \t]+=(?!=)[ \t]*|=(?=[\"'bfru@]))")
410_STRING_PREFIX_RE = re.compile(r"(?:[bBfFrRuU]{1,2}|@)(?=[\"'])")
411_AUTH_SCHEME_RE = re.compile(r"(?i)(?:basic|bearer|token|digest|apikey|negotiate)[ \t]+")
412_BARE_TOKEN_RE = re.compile(r"[^\s,;&)}\]\"'`]+")
413_DOTTED_REFERENCE_RE = re.compile(r"[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)+$")
414_CONSTANT_NAME_RE = re.compile(r"[A-Z][A-Z0-9_]*$")
415# What may precede a key that opens a config line: indentation, a diff or
416# list marker, a quote, a `file:line:` prefix, `export`/`ENV`/`set`.
417_LINE_LEAD_RE = re.compile(
418 r"[\s+>\"'-]*(?:[\w./\\-]+:\d+:[ \t]*)?(?:(?:export|ENV|ARG|set|SET|setx)[ \t]+)?$"
419)
420# Dockerfile `ENV DB_PASSWORD value` (the `=`-less form).
421_DOCKER_ENV_RE = re.compile(r"(?m)^([ \t+-]*(?:ENV|ARG)[ \t]+([A-Za-z_]\w*)[ \t]+)([^\s=\"']+)$")
422# <password>value</password> style markup.
423_XML_VALUE_RE = re.compile(r"(<([A-Za-z_][\w.-]*)[^>]*>)([^<]{3,})(</)")
424# curl -u user:pass / --user 'user:pass'
425_CURL_USER_RE = re.compile(r"((?:\s-u[ \t]*|--user[= \t]+)[\"']?[^\s:'\"]*:)([^\s'\"]+)")
426# --password VALUE / --token VALUE / --api-key VALUE
427_CLI_FLAG_RE = re.compile(
428 r"(?i)((?<![\w-])--?(?:password|passwd|passphrase|token|secret|api[_-]?key|access[_-]?key"
429 r"|secret[_-]?key|client[_-]?secret|auth[_-]?token)[ \t]+)([^\s\"'$<{-][^\s\"']*)"
430)
431# Bearer <token> outside an Authorization assignment (prose, logs).
432_BEARER_RE = re.compile(r"(?i)(\bbearer\s+)((?=[A-Za-z0-9._~+/=-]*\d)[A-Za-z0-9._~+/=-]{8,})")
433# scheme://user:password@host and scheme://:password@host
434_URL_CRED_RE = re.compile(
435 r"((?<![A-Za-z0-9+.-])[A-Za-z][A-Za-z0-9+.-]*://[^\s/:@]*:)([^\s/]{3,})(@)(?=[^\s/@]*(?:[/?#]|$))"
436)
437# Well-known token shapes, wherever they appear.
438_TOKEN_SHAPES_RE = re.compile(
439 r"\b(?:sk-ant-[\w-]{10,}|sk_(?:live|test)_\w{8,}|sk-[\w-]{20,}"
440 r"|AKIA[0-9A-Z]{16}|ASIA[0-9A-Z]{16}|gh[pousr]_\w{20,}|github_pat_\w{20,}"
441 r"|xox[abprs]-[\w-]{10,}|glpat-[\w-]{20,}|AIza[\w-]{35}"
442 r"|eyJ[\w-]{10,}\.[\w-]{10,}\.[\w-]{10,})\b"
443)
444_PEM_BODY_RE = re.compile(
445 r"(-----BEGIN [A-Z ]*PRIVATE KEY[A-Z ]*-----)[\s\S]*?(-----END [A-Z ]*PRIVATE KEY[A-Z ]*-----|$)"
446)
447# A finding the reviewer itself classed as an exposed credential also gets
448# the broad masks: quoted literals and long opaque runs anywhere in the text,
449# since the value may sit in a positional argument or in the fix prose.
450_SECRET_CATEGORY_RE = re.compile(
451 r"(?i)secret|credential|passw|passphrase|api[ _-]?key|private[ _-]?key"
452 r"|access[ _-]?key|token|key material|cwe-?(?:259|798|321|312|313)"
453)
454_NOT_SECRET_CATEGORY_RE = re.compile(
455 r"(?i)csrf|xsrf|reset|expir|invalidat|missing|validat|verif|hash|length"
456 r"|strength|polic|logging|logged|fixation|predictab|entropy|jwt alg"
457)
458_QUOTED_RE = re.compile(r"([\"'`])([^\"'`\s]{8,})(\1)")
459# Quoted things that are not values: names, file names, templates.
460_QUOTED_KEEP_RE = re.compile(r"[A-Z][A-Z0-9_]*$|[A-Za-z][A-Za-z_-]*[-_][A-Za-z]+$|[${<%(~.]")
461_LABEL_RE = re.compile(r"[A-Za-z][A-Za-z_ .-]{0,30}[=:][ \t]*$") # "password=" log labels
462_ATTRIBUTE_NAME_RE = re.compile(r"(?i)(?:key|name|id|for|class|type)=$")
463_PATH_SEGMENT_RE = re.compile(r"[a-z0-9_.@+~-]+$")
464_FILE_EXT_RE = re.compile(r"\w\.[A-Za-z][A-Za-z0-9]{0,4}$")
465_OPAQUE_RUN_RE = re.compile(r"(?<![\w/.*+-])[A-Za-z0-9_+/-]{16,}={0,2}(?![\w*=]|\.[A-Za-z])")
466
467
468def _mask(value: str) -> str:
469 return "****" + value[-2:] if len(value) >= 8 else "****"
470
471
472def _looks_like_path(text: str) -> bool:
473 if text.startswith(("/", "./", "../", "~/")) or "://" in text:
474 return True
475 segments = [seg for seg in re.split(r"[/\\]", text) if seg]
476 return len(segments) > 1 and (
477 all(_PATH_SEGMENT_RE.match(seg) for seg in segments)
478 or any(_FILE_EXT_RE.search(seg) for seg in segments)
479 )
480
481
482def _keep_assigned(value: str) -> bool:
483 """A quoted right-hand side is a literal; keep it only when it is a
484 template/reference placeholder or a path, never for its shape."""
485 return bool(
486 value.startswith(("****", "$", "%(", "<", "{{", "~/"))
487 or ("{" in value and "}" in value) or "%s" in value or "$(" in value
488 or ("/" in value and _looks_like_path(value))
489 or value.lower() in _BARE_VALUE_SKIP
490 )
491
492
493def _keep_quoted(inner: str) -> bool:
494 """True for a quoted string that reads as a name, reference, path, URL
495 or template rather than a value: those carry the finding's location."""
496 return bool(
497 inner.startswith("****")
498 or _LABEL_RE.match(inner)
499 or ("{" in inner and "}" in inner) or "%s" in inner or "$(" in inner
500 or _QUOTED_KEEP_RE.match(inner)
501 or _DOTTED_REFERENCE_RE.match(inner)
502 or _FILE_EXT_RE.search(inner)
503 or (("/" in inner or "\\" in inner) and _looks_like_path(inner))
504 )
505
506
507def _is_secret_key(key: str) -> bool:
508 segments = [
509 seg for seg in _SEGMENT_SPLIT_RE.split(_CAMEL_RE.sub("_", key).lower()) if seg
510 ]
511 if not segments or any(seg in _NOT_SECRET_KEY_WORDS for seg in segments):
512 return False
513 if "." in key and key.rsplit(".", 1)[-1].lower() in _FILE_EXTENSIONS:
514 return False
515 for seg in segments:
516 if seg in _SECRET_KEY_WORDS:
517 return True
518 if not seg.startswith(("csrf", "xsrf")) and seg.endswith(_SECRET_KEY_SUFFIXES) \
519 and any(0 < len(seg) - len(w) <= 6 for w in _SECRET_KEY_SUFFIXES if seg.endswith(w)):
520 return True
521 return any(pair in _SECRET_KEY_PAIRS for pair in zip(segments, segments[1:]))
522
523
524def _is_secret_category(category: str) -> bool:
525 return bool(_SECRET_CATEGORY_RE.search(category)) and not _NOT_SECRET_CATEGORY_RE.search(category)
526
527
528def _mask_assignments(s: str) -> str:
529 """`key = value`, `key: value`, `"key": "value"`, `cfg['key'] = 'value'`,
530 `key: Type = "value"`, `key == 'value'` where the key names a credential.
531 A quoted value is masked whole (spaces, punctuation and all); a bare
532 value is masked as one token, or to end of line when the key opens the
533 line (YAML, .env, ini, a quoted `+` diff line)."""
534 out: list[str] = []
535 pos = 0
536 length = len(s)
537 while True:
538 m = _ASSIGN_OP_RE.search(s, pos)
539 if not m:
540 out.append(s[pos:])
541 return "".join(out)
542 i = m.end()
543 out.append(s[pos:i])
544 pos = i
545 # Walk back: spaces, an optional quote/bracket closing a subscript or
546 # JSON key, then the identifier itself.
547 j = m.start()
548 while j > 0 and s[j - 1] in " \t":
549 j -= 1
550 key_quote = ""
551 if j > 0 and s[j - 1] == "]":
552 j -= 1
553 if j > 0 and s[j - 1] in "\"'":
554 key_quote = s[j - 1]
555 j -= 1
556 key_end = key_start = j
557 while key_start > 0 and s[key_start - 1] in _KEY_CHARS:
558 key_start -= 1
559 while key_start < key_end and s[key_start] in "-.$0123456789":
560 key_start += 1
561 key = s[key_start:key_end]
562 if i >= length or not key or not _is_secret_key(key):
563 continue
564 ch = s[i]
565 line_start = s.rfind("\n", 0, key_start) + 1
566 # `log("password=" + pw)`, `"...?api_key=" + key`: an odd number of
567 # quotes before this one means it closes the literal the key sits in,
568 # and what follows is code.
569 if not key_quote and ch in "\"'" and s.count(ch, line_start, i) % 2 == 1:
570 continue
571 if m.group(0).rstrip() == ":":
572 typed = _TYPE_ANNOTATION_RE.match(s, i)
573 if typed:
574 out.append(typed.group(0))
575 pos = i = typed.end()
576 if i >= length:
577 continue
578 ch = s[i]
579 prefix_m = _STRING_PREFIX_RE.match(s, i)
580 if prefix_m:
581 out.append(prefix_m.group(0))
582 pos = i = prefix_m.end()
583 ch = s[i]
584 if ch in "\"'`":
585 close = s.find(ch, i + 1)
586 newline = s.find("\n", i + 1)
587 if close == -1 or (newline != -1 and newline < close):
588 close = newline if newline != -1 else length
589 inner = s[i + 1:close]
590 scheme = _AUTH_SCHEME_RE.match(inner)
591 prefix = scheme.group(0) if scheme else ""
592 value = inner[len(prefix):]
593 if value and not _keep_assigned(value):
594 value = _mask(value)
595 out.append(ch + prefix + value)
596 pos = close
597 continue
598 scheme = _AUTH_SCHEME_RE.match(s, i)
599 if scheme:
600 out.append(scheme.group(0))
601 pos = i = scheme.end()
602 token_m = _BARE_TOKEN_RE.match(s, i)
603 if not token_m:
604 continue
605 token = token_m.group(0)
606 end = token_m.end()
607 if token.startswith(("****", "$", "{", "<", "%", "&", "*", "@")) or "(" in token \
608 or "[" in token or token.lower() in _BARE_VALUE_SKIP \
609 or _DOTTED_REFERENCE_RE.match(token):
610 continue
611 opens_line = bool(_LINE_LEAD_RE.match(s[line_start:key_start]))
612 # `-H 'X-Api-Key: value'`: the key opens a quoted string, the value ends it.
613 opens_string = not key_quote and key_start > line_start and s[key_start - 1] in "\"'"
614 has_digit = any(c.isdigit() for c in token)
615 # In code a bare right-hand side is a reference; bare literals live in
616 # config lines (YAML, .env, ini, Dockerfile), where the key opens the
617 # line or a quoted string. Mid-line, only a token carrying digits
618 # reads as a value, and an ALL_CAPS one reads as a constant's name.
619 if not (opens_line or opens_string) and (_CONSTANT_NAME_RE.match(token) or not has_digit):
620 continue
621 # `key: v a l u e` runs on to the end of the line (or of the string);
622 # `KEY=value cmd`, a shell prefix assignment, is one token.
623 if (opens_line or opens_string) and (m.group(0)[-1] in " \t" or m.group(0).startswith(":")):
624 line_end = s.find("\n", end)
625 line_end = length if line_end == -1 else line_end
626 if opens_string:
627 close = s.find(s[key_start - 1], end, line_end)
628 line_end = close if close != -1 else line_end
629 rest = s[end:line_end]
630 comment = rest.find(" #")
631 if comment != -1:
632 rest = rest[:comment]
633 if len(rest.split()) > 3 and not opens_string:
634 if not has_digit:
635 continue # more than a few words after `Key:` is a sentence
636 elif "(" not in rest and "[" not in rest:
637 end += len(rest.rstrip())
638 token = s[i:end]
639 out.append(_mask(token))
640 pos = end
641
642
643def _mask_docker_env(m: "re.Match[str]") -> str:
644 value = m.group(3)
645 if not _is_secret_key(m.group(2)) or value.startswith(("$", "{", "<", "****")):
646 return m.group(0)
647 return m.group(1) + _mask(value)
648
649
650def _mask_xml(m: "re.Match[str]") -> str:
651 value = m.group(3).strip()
652 if not _is_secret_key(m.group(2)) or value.lower() in _BARE_VALUE_SKIP \
653 or value.startswith(("$", "{", "%", "<", "****")):
654 return m.group(0)
655 return m.group(1) + _mask(value) + m.group(4)
656
657
658def _mask_quoted(m: "re.Match[str]") -> str:
659 inner, text, start, end = m.group(2), m.string, m.start(), m.end()
660 after = text[end:end + 4].lstrip(" \t")
661 is_key = after[:1] in (":", "=") and after[1:2] not in ("=", ">", ":")
662 is_subscript = text[max(0, start - 1):start] == "[" and text[end:end + 1] == "]"
663 is_attribute_name = _ATTRIBUTE_NAME_RE.search(text[max(0, start - 8):start])
664 if is_key or is_subscript or is_attribute_name or _keep_quoted(inner):
665 return m.group(0)
666 return m.group(1) + _mask(inner) + m.group(3)
667
668
669def _mask_opaque(m: "re.Match[str]") -> str:
670 run, text = m.group(0), m.string
671 if text[m.end():m.end() + 1] == "(" or text[max(0, m.start() - 7):m.start()].lower() in ("commit ", "sha "):
672 return run # a call site / a commit reference, not a value
673 digits = sum(ch.isdigit() for ch in run)
674 letters = sum(ch.isalpha() for ch in run)
675 mixed_case = run.lower() != run and run.upper() != run
676 opaque = letters >= 2 and (digits >= 2 or (digits and mixed_case and len(run) >= 32))
677 if not opaque or ("/" in run and _looks_like_path(run)):
678 return run
679 return _mask(run)
680
681
682def redact_secret_values(text: Any, *, category: Any = "") -> str:
683 """Mask credential-shaped values in a finding field before it is echoed
684 into the main conversation or stored. ``category`` is the finding's own
685 category; one that names an exposed credential turns on the broad masks."""
686 s = str(text or "")
687 if not s:
688 return s
689 s = _PEM_BODY_RE.sub(lambda m: m.group(1) + " **** " + m.group(2), s)
690 s = _TOKEN_SHAPES_RE.sub(lambda m: _mask(m.group(0)), s)
691 s = _URL_CRED_RE.sub(lambda m: m.group(1) + _mask(m.group(2)) + m.group(3), s)
692 s = _CURL_USER_RE.sub(lambda m: m.group(1) + _mask(m.group(2)), s)
693 s = _CLI_FLAG_RE.sub(lambda m: m.group(1) + _mask(m.group(2)), s)
694 s = _BEARER_RE.sub(lambda m: m.group(1) + _mask(m.group(2)), s)
695 s = _mask_assignments(s)
696 s = _DOCKER_ENV_RE.sub(_mask_docker_env, s)
697 s = _XML_VALUE_RE.sub(_mask_xml, s)
698 if _is_secret_category(str(category or "")):
699 s = _QUOTED_RE.sub(_mask_quoted, s)
700 s = _OPAQUE_RUN_RE.sub(_mask_opaque, s)
701 return s
702
703
704_SEVERITY_ORDER = {"critical": 0, "high": 1, "medium": 2, "low": 3}
705
706
707def filter_by_severity(
708 findings: list[dict[str, Any]], *, include_medium: bool = True
709) -> list[dict[str, Any]]:
710 """Medium-included is the validated default; the model's investigate-stage
711 severity is conservative and dropping mediums before self-refute filters
712 out most real findings.
713 Pass ``include_medium=False`` for the old high/critical-only behavior.
714 """
715 keep = ("critical", "high", "medium") if include_medium else ("critical", "high")
716 out = [
717 v
718 for v in findings
719 if str(v.get("severity", "medium")).strip().lower() in keep
720 ]
721 out.sort(key=lambda v: _SEVERITY_ORDER.get(v.get("severity", "medium"), 2))
722 return out
723
724
725def format_findings(findings: list[dict[str, Any]]) -> str:
726 """Render findings, most severe first, as the text block the CC plugin
727 emits to Claude. Quoted code goes through ``redact_secret_values``."""
728 findings = sorted(findings, key=lambda v: _SEVERITY_ORDER.get(v.get("severity", "medium"), 2))
729 by_file: dict[str, list[dict[str, Any]]] = {}
730 for v in findings:
731 by_file.setdefault(v.get("filePath", "unknown"), []).append(v)
732 lines = [
733 "Security Review: Potential vulnerabilities detected",
734 "",
735 f"Affected files: {', '.join(by_file)}",
736 "The following issues were flagged by automated security review. "
737 "Address each, or briefly note why it doesn't apply. Valid reasons "
738 "to proceed without changes: the user explicitly asked for this and "
739 "you've already surfaced the security tradeoffs, or the pattern "
740 "isn't actually exploitable in this context. Do not dismiss "
741 "findings solely because the service is internal-only — internal "
742 "services are common SSRF/IDOR targets:",
743 "",
744 ]
745 n = 1
746 for fp, vs in by_file.items():
747 lines.append(f" {fp}:")
748 for v in vs:
749 sev = (v.get("severity") or "medium").upper()
750 cat = v.get("category", "Unknown")
751 lines.append(
752 f" {n}. [{sev}] [{cat}] "
753 f"{redact_secret_values(v.get('vulnerableCode', 'N/A'), category=cat)}"
754 )
755 lines.append(
756 f" Suggested fix: {redact_secret_values(v.get('fix', 'N/A'), category=cat)}"
757 )
758 lines.append("")
759 n += 1
760 return "\n".join(lines)