@clear-capabilities/agentic-security-scanner 0.124.1 → 0.128.1

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 (40) hide show
  1. package/CHANGELOG.md +206 -0
  2. package/bin/agentic-security.js +75 -2
  3. package/dist/11.index.js +353 -0
  4. package/dist/113.index.js +525 -0
  5. package/dist/178.index.js +1 -1
  6. package/dist/220.index.js +193 -0
  7. package/dist/384.index.js +1 -1
  8. package/dist/435.index.js +2406 -0
  9. package/dist/449.index.js +135 -0
  10. package/dist/637.index.js +1 -1
  11. package/dist/752.index.js +7 -4
  12. package/dist/801.index.js +87 -0
  13. package/dist/826.index.js +4 -1
  14. package/dist/838.index.js +1 -1
  15. package/dist/agentic-security.mjs +1 -2
  16. package/dist/agentic-security.mjs.sha256 +1 -1
  17. package/package.json +6 -6
  18. package/src/engine.js +31 -1
  19. package/src/integrations/tickets.js +9 -3
  20. package/src/ir/CLAUDE.md +22 -17
  21. package/src/llm-validator/index.js +47 -12
  22. package/src/mcp/tools.js +108 -3
  23. package/src/posture/CLAUDE.md +10 -1
  24. package/src/posture/cache-economics.js +7 -4
  25. package/src/posture/deterministic-fix.js +65 -0
  26. package/src/posture/entrypoint-inventory.js +248 -0
  27. package/src/posture/falsification.js +121 -0
  28. package/src/posture/fix-honesty-gate.js +175 -0
  29. package/src/posture/fix-verify.js +18 -3
  30. package/src/posture/model-routing.js +126 -0
  31. package/src/posture/mttr.js +25 -0
  32. package/src/posture/provider-catalog.js +108 -0
  33. package/src/posture/root-cause-sweep.js +262 -0
  34. package/src/posture/secret-live-check.js +71 -0
  35. package/src/pr-comment.js +3 -1
  36. package/src/sast/CLAUDE.md +1 -1
  37. package/src/sast/api-authz.js +36 -0
  38. package/src/sast/file-upload.js +118 -0
  39. package/src/sast/llm-cost-advisor.js +88 -0
  40. package/src/util/untrusted.js +148 -0
@@ -0,0 +1,126 @@
1
+ // Capability-based model routing for cost-sensitive subagent dispatch.
2
+ //
3
+ // A declarative CWE/severity → model policy. When the orchestrator is about to
4
+ // dispatch a delegable, cost-sensitive subagent for a finding (fixer, triager,
5
+ // PoC generator, attack-chain synthesizer), it can ask this module which model
6
+ // tier the work actually warrants — spend Opus reasoning on the hard classes
7
+ // (crypto, auth, deserialization, XXE, cross-file taint) and let Haiku handle
8
+ // the mechanical hardening findings.
9
+ //
10
+ // Mirrors the model IDs + tier vocabulary of hooks/model-cost-advisor.js:
11
+ // strongest = claude-opus-4-8 (high effort)
12
+ // mid = claude-sonnet-4-6 (medium effort)
13
+ // cheapest = claude-haiku-4-5 (low effort)
14
+ //
15
+ // This is a *preference*, not a ceiling — a caller may always upgrade when the
16
+ // specific task clearly needs more capability (same spirit as the
17
+ // subagentOverride contract documented in the root CLAUDE.md).
18
+ //
19
+ // Pure + deterministic — no I/O, no network, never throws.
20
+
21
+ // Model IDs (module-local; the public API is the routing functions below).
22
+ const MODEL_STRONGEST = 'claude-opus-4-8';
23
+ const MODEL_MID = 'claude-sonnet-4-6';
24
+ const MODEL_CHEAPEST = 'claude-haiku-4-5';
25
+
26
+ const LABEL = {
27
+ [MODEL_STRONGEST]: 'Opus 4.8',
28
+ [MODEL_MID]: 'Sonnet 4.6',
29
+ [MODEL_CHEAPEST]: 'Haiku 4.5',
30
+ };
31
+
32
+ // Hard classes — subtle, high-blast-radius bugs where a wrong fix is worse than
33
+ // no fix: auth, TLS/cert validation, weak crypto/hashing/randomness, signature
34
+ // verification, unsafe deserialization, XXE. Worth Opus when they land at high
35
+ // or critical severity.
36
+ const HARD_CWES = new Set([
37
+ 'CWE-287', // improper authentication
38
+ 'CWE-295', // improper certificate validation
39
+ 'CWE-327', // broken / risky crypto algorithm
40
+ 'CWE-328', // weak hash
41
+ 'CWE-330', // use of insufficiently random values
42
+ 'CWE-347', // improper verification of cryptographic signature
43
+ 'CWE-502', // deserialization of untrusted data
44
+ 'CWE-611', // XML external entity (XXE)
45
+ ]);
46
+
47
+ // Mid classes — the common injection / traversal / CSRF / SSRF families.
48
+ // Well-understood remediations; Sonnet handles them cost-effectively.
49
+ const MID_CWES = new Set([
50
+ 'CWE-22', // path traversal
51
+ 'CWE-78', // OS command injection
52
+ 'CWE-79', // cross-site scripting
53
+ 'CWE-89', // SQL injection
54
+ 'CWE-94', // code injection
55
+ 'CWE-352', // cross-site request forgery
56
+ 'CWE-434', // unrestricted file upload
57
+ 'CWE-601', // open redirect
58
+ 'CWE-918', // server-side request forgery
59
+ ]);
60
+
61
+ // Extract the canonical `CWE-<n>` token from a finding.cwe value that may be a
62
+ // bare id ("CWE-89") or a descriptive string ("CWE-89: SQL Injection").
63
+ // Returns the uppercased id, or null when nothing parseable is present.
64
+ export function parseCwe(raw) {
65
+ if (typeof raw !== 'string') return null;
66
+ const m = raw.match(/CWE-\d+/i);
67
+ return m ? m[0].toUpperCase() : null;
68
+ }
69
+
70
+ // Route a single finding to a model tier. First match wins.
71
+ // Returns { model, effort, reason }.
72
+ export function routeModelForFinding(finding) {
73
+ const f = finding || {};
74
+ const severity = typeof f.severity === 'string' ? f.severity.toLowerCase() : '';
75
+ const cwe = parseCwe(f.cwe);
76
+ const multiFile = f.multiFile === true || f.isCrossFile === true;
77
+ const highOrCritical = severity === 'high' || severity === 'critical';
78
+
79
+ // ── Tier 1: strongest (Opus, high effort) ──
80
+ if (severity === 'critical') {
81
+ return { model: MODEL_STRONGEST, effort: 'high',
82
+ reason: `Critical severity — worth ${LABEL[MODEL_STRONGEST]} at high effort.` };
83
+ }
84
+ if (cwe && HARD_CWES.has(cwe) && highOrCritical) {
85
+ return { model: MODEL_STRONGEST, effort: 'high',
86
+ reason: `${cwe} at ${severity} severity is a hard class (crypto / auth / deserialization / XXE) — ${LABEL[MODEL_STRONGEST]} at high effort.` };
87
+ }
88
+ if (multiFile) {
89
+ return { model: MODEL_STRONGEST, effort: 'high',
90
+ reason: `Cross-file finding — needs ${LABEL[MODEL_STRONGEST]} at high effort to reason across files.` };
91
+ }
92
+
93
+ // ── Tier 2: mid (Sonnet, medium effort) ──
94
+ if (cwe && MID_CWES.has(cwe)) {
95
+ return { model: MODEL_MID, effort: 'medium',
96
+ reason: `${cwe} is a common injection / traversal class — ${LABEL[MODEL_MID]} at medium effort.` };
97
+ }
98
+ if (severity === 'high') {
99
+ return { model: MODEL_MID, effort: 'medium',
100
+ reason: `High severity — ${LABEL[MODEL_MID]} at medium effort.` };
101
+ }
102
+
103
+ // ── Tier 3: cheapest (Haiku, low effort) ──
104
+ return { model: MODEL_CHEAPEST, effort: 'low',
105
+ reason: `${cwe ? `${cwe} at ` : ''}${severity || 'low'} severity is a simple / hardening class — ${LABEL[MODEL_CHEAPEST]} at low effort.` };
106
+ }
107
+
108
+ // Route a list of findings. Returns [{ finding, model, effort, reason }, …].
109
+ export function routeModelForFindings(findings) {
110
+ const list = Array.isArray(findings) ? findings : [];
111
+ return list.map((finding) => ({ finding, ...routeModelForFinding(finding) }));
112
+ }
113
+
114
+ // Tally how many findings land on each model tier.
115
+ // Returns { 'claude-opus-4-8': n, 'claude-sonnet-4-6': n, 'claude-haiku-4-5': n }.
116
+ export function summarizeRouting(findings) {
117
+ const counts = {
118
+ [MODEL_STRONGEST]: 0,
119
+ [MODEL_MID]: 0,
120
+ [MODEL_CHEAPEST]: 0,
121
+ };
122
+ for (const { model } of routeModelForFindings(findings)) {
123
+ if (model in counts) counts[model] += 1;
124
+ }
125
+ return counts;
126
+ }
@@ -63,6 +63,31 @@ export function findingsExceedingSLA(findings, slaDays = null) {
63
63
  });
64
64
  }
65
65
 
66
+ // Median age (days) of the currently-open findings — a single-scan proxy for
67
+ // "how long has this debt been sitting". True MTTR (computeMTTR) needs the set
68
+ // of findings that were FIXED; this reports the open backlog's median age so a
69
+ // scan can show whether debt is getting older. Returns null on empty input.
70
+ // Local — surfaced only through renderSlaSummary (its sole consumer).
71
+ function medianOpenAgeDays(findings) {
72
+ const ages = (findings || []).map(f => f.ageDays || 0).sort((a, b) => a - b);
73
+ if (!ages.length) return null;
74
+ return ages[Math.floor(ages.length / 2)];
75
+ }
76
+
77
+ // One-line SLA-breach summary for surfacing after a scan (#10). Returns null
78
+ // when nothing is past its per-severity SLA. Pairs with medianOpenAgeDays for a
79
+ // "is my security debt aging" readout that the vibecoder can act on.
80
+ export function renderSlaSummary(findings, slaDays = null) {
81
+ const breached = findingsExceedingSLA(findings || [], slaDays);
82
+ if (!breached.length) return null;
83
+ const bySev = {};
84
+ for (const f of breached) bySev[f.severity] = (bySev[f.severity] || 0) + 1;
85
+ const parts = ['critical', 'high', 'medium', 'low', 'info'].filter(s => bySev[s]).map(s => `${bySev[s]} ${s}`);
86
+ const median = medianOpenAgeDays(findings);
87
+ const ageNote = median != null ? ` (median open age ${median}d)` : '';
88
+ return `${breached.length} finding(s) past remediation SLA: ${parts.join(', ')}${ageNote}`;
89
+ }
90
+
66
91
  // Compute MTTR statistics from a series of saved scans (each with firstSeen/lastSeen).
67
92
  // Useful for trend reporting.
68
93
  export function computeMTTR(removedFindings) {
@@ -0,0 +1,108 @@
1
+ // Multi-provider model + pricing + cache catalog (PRD CACHE_ECONOMICS_V2, P2).
2
+ //
3
+ // One abstraction over four very different frameworks so the cost/cache advisor
4
+ // (sast/llm-cost-advisor.js) and the economics modules work uniformly. Captures,
5
+ // per provider: the model ladder (cheap→capable, with $/1M rates), the "depth"
6
+ // knob (Anthropic effort / OpenAI+xAI reasoning_effort / Gemini thinkingBudget),
7
+ // and the cache model (explicit vs automatic vs implicit; read/write economics).
8
+ //
9
+ // ⚠️ PRICES + MODEL NAMES DRIFT MONTHLY — this is a DATED snapshot, not truth.
10
+ // Anything that needs a live number must read it here and treat `sourcedAt` as
11
+ // the staleness signal; refresh from each provider's pricing/models API (or the
12
+ // claude-api skill for Anthropic) at implementation time. No network at runtime.
13
+ export const SOURCED_AT = '2026-07-05';
14
+
15
+ // $/1M tokens. `cached` = effective cached-input rate (Anthropic = read multiplier
16
+ // applied to `in`; others publish a cached-input price directly).
17
+ export const PROVIDERS = {
18
+ anthropic: {
19
+ label: 'Anthropic (Claude)',
20
+ importMarkers: [/@anthropic-ai\b/, /\banthropic\b/, /\bClaude\b/],
21
+ modelMarkers: [/\bclaude-/i, /\bfable\b/i, /\bopus\b/i, /\bsonnet\b/i, /\bhaiku\b/i],
22
+ depth: { knob: 'effort', cheap: 'low', expensive: ['high', 'xhigh', 'max'], levels: ['low', 'medium', 'high', 'xhigh', 'max'] },
23
+ cache: { kind: 'explicit', readMult: 0.1, writeMult: 1.25, minPrefixTokens: 1024, modelScoped: true, manualControl: true },
24
+ // The /sonnet/i row prices both Sonnet 4.6 and Sonnet 5 ($3/$15). Fable 5 is
25
+ // the current flagship (above Opus) at $10/$50 — a distinct rate, so it gets
26
+ // its own top-tier row so the over-provisioned rule can suggest Opus/Sonnet.
27
+ models: [
28
+ { id: 'claude-haiku-4-5', match: /haiku/i, tier: 0, in: 1, out: 5, cached: 0.10 },
29
+ { id: 'claude-sonnet-4-6', match: /sonnet/i, tier: 1, in: 3, out: 15, cached: 0.30 },
30
+ { id: 'claude-opus-4-8', match: /opus/i, tier: 2, in: 5, out: 25, cached: 0.50 },
31
+ { id: 'claude-fable-5', match: /fable|mythos/i, tier: 3, in: 10, out: 50, cached: 1.00 },
32
+ ],
33
+ },
34
+ openai: {
35
+ label: 'OpenAI',
36
+ importMarkers: [/\bfrom openai\b/, /\bimport openai\b/, /@?openai\b/, /\bAzureOpenAI\b/],
37
+ modelMarkers: [/\bgpt-/i, /\bo[34]-?mini\b/i, /\bo3\b/i],
38
+ depth: { knob: 'reasoning_effort', cheap: 'low', expensive: ['high'], levels: ['minimal', 'low', 'medium', 'high'] },
39
+ cache: { kind: 'automatic', cachedDiscount: 0.90, minPrefixTokens: 1024, modelScoped: true, manualControl: false },
40
+ models: [
41
+ { id: 'gpt-4.1-nano', match: /gpt-4\.1-nano/i, tier: 0, in: 0.10, out: 0.40, cached: 0.025 },
42
+ { id: 'gpt-5-mini', match: /gpt-5[.\d]*-mini/i, tier: 1, in: 0.60, out: 2.40, cached: 0.06 },
43
+ { id: 'gpt-5.4', match: /gpt-5\.4(?!-mini)/i, tier: 2, in: 2.50, out: 15, cached: 0.25 },
44
+ { id: 'gpt-5.5', match: /gpt-5\.5/i, tier: 3, in: 5, out: 30, cached: 0.50 },
45
+ ],
46
+ reasoningModels: [/\bo3\b/i, /\bo4-?mini\b/i],
47
+ },
48
+ google: {
49
+ label: 'Google (Gemini)',
50
+ importMarkers: [/google\.generativeai/, /\bgenai\b/, /@google\/genai/, /\bGenerativeModel\b/],
51
+ modelMarkers: [/\bgemini-/i],
52
+ depth: { knob: 'thinkingBudget', cheap: 'low budget', expensive: ['high budget'], levels: ['0', 'low', 'high', 'dynamic'] },
53
+ cache: { kind: 'implicit-explicit', cachedDiscount: 0.90, minPrefixTokens: 1024, modelScoped: true, manualControl: 'optional', storagePriced: true },
54
+ models: [
55
+ { id: 'gemini-flash-lite', match: /flash-lite/i, tier: 0, in: 0.10, out: 0.40, cached: 0.025 },
56
+ { id: 'gemini-3.5-flash', match: /3\.5-flash|2\.5-flash(?!-lite)/i, tier: 1, in: 1.50, out: 9, cached: 0.15 },
57
+ { id: 'gemini-3.1-pro', match: /3\.1-pro|2\.5-pro/i, tier: 2, in: 2, out: 12, cached: 0.30 },
58
+ ],
59
+ },
60
+ xai: {
61
+ label: 'xAI (Grok)',
62
+ importMarkers: [/\bxai\b/i, /api\.x\.ai/, /\bGROK\b/i],
63
+ modelMarkers: [/\bgrok-/i],
64
+ depth: { knob: 'reasoning_effort', cheap: 'low', expensive: ['high'], levels: ['none', 'low', 'medium', 'high'] },
65
+ cache: { kind: 'automatic', cachedDiscount: 0.85, minPrefixTokens: 1024, modelScoped: true, manualControl: false },
66
+ models: [
67
+ { id: 'grok-4.1-fast', match: /grok-4\.1-fast/i, tier: 0, in: 0.20, out: 0.50, cached: 0.05 },
68
+ { id: 'grok-4.3', match: /grok-4\.(3|20)/i, tier: 1, in: 1.25, out: 2.50, cached: 0.20 },
69
+ ],
70
+ },
71
+ };
72
+
73
+ // Detect the provider a file targets, from SDK import markers (preferred) then
74
+ // model-string markers. Returns a provider key or null.
75
+ export function detectProvider(raw) {
76
+ if (typeof raw !== 'string' || !raw) return null;
77
+ for (const [key, p] of Object.entries(PROVIDERS)) {
78
+ if (p.importMarkers.some(re => re.test(raw))) return key;
79
+ }
80
+ for (const [key, p] of Object.entries(PROVIDERS)) {
81
+ if (p.modelMarkers.some(re => re.test(raw))) return key;
82
+ }
83
+ return null;
84
+ }
85
+
86
+ // Which model-ladder entry does a model string map to (within a provider)?
87
+ export function modelEntry(provider, modelStr) {
88
+ const p = PROVIDERS[provider];
89
+ if (!p || typeof modelStr !== 'string') return null;
90
+ return p.models.find(m => m.match.test(modelStr)) || null;
91
+ }
92
+
93
+ // A cheaper model in the same provider's ladder (one tier down), or null if the
94
+ // model is already the cheapest / unknown.
95
+ export function cheaperModel(provider, modelStr) {
96
+ const p = PROVIDERS[provider];
97
+ const cur = modelEntry(provider, modelStr);
98
+ if (!p || !cur || cur.tier === 0) return null;
99
+ const below = p.models.filter(m => m.tier < cur.tier).sort((a, b) => b.tier - a.tier)[0];
100
+ return below || null;
101
+ }
102
+
103
+ export function depthAxis(provider) {
104
+ return PROVIDERS[provider]?.depth || null;
105
+ }
106
+ export function cacheModel(provider) {
107
+ return PROVIDERS[provider]?.cache || null;
108
+ }
@@ -0,0 +1,262 @@
1
+ // Addition #3 — Root-cause sweep with total-count accounting.
2
+ //
3
+ // A detector fires on the instance it can prove. But the same root cause is
4
+ // usually copy-pasted across the codebase, and most of those siblings never
5
+ // trip a rule (different variable names, an assignment wrapper, a file the
6
+ // scanner didn't reach with taint). This module takes CONFIRMED findings and
7
+ // sweeps every source line for structural siblings of the same sink, then
8
+ // accounts for every match honestly:
9
+ //
10
+ // found === candidates + mitigated (per sweep, always)
11
+ //
12
+ // where `found` is every structural match across the repo EXCLUDING the
13
+ // finding's own origin site, `mitigated` is the subset a detector already
14
+ // covered (a finding exists at that file:line), and `candidates` is the
15
+ // remainder — new instances nobody has looked at yet. Nothing is dropped.
16
+ //
17
+ // Matching reuses semantic-clone's normalized token-shape hashing (`shapeHash`)
18
+ // so that `db.query(a)` and `db.query(b)` collapse to one shape. Pure shape is
19
+ // too loose on its own (`db.query(x)` and `console.log(x)` both normalize to
20
+ // `ID.ID(ID)`), so we anchor on the LITERAL callee (`db.query`) and use the
21
+ // shape only to confirm the argument arity/structure. Anchor + shape = precise.
22
+ //
23
+ // Like semantic-clone this is a coarse structural approximation, not a proof of
24
+ // semantic equivalence. It catches the common "same call, cloned around" case.
25
+
26
+ import { shapeHash } from './semantic-clone.js';
27
+
28
+ // shapeHash defaults to minTokens:8 (tuned to avoid trivial clone collisions on
29
+ // whole functions). A single call expression is short — `foo(a)` is 4 tokens,
30
+ // `db.query(a)` is 6 — so we lower the floor for call-granular matching.
31
+ const MIN_SHAPE_TOKENS = 3;
32
+
33
+ // A callee whose final segment is one of these is control flow, not a sink call.
34
+ const CONTROL_KEYWORDS = new Set([
35
+ 'if', 'for', 'while', 'switch', 'catch', 'return', 'function', 'with', 'do', 'await',
36
+ ]);
37
+
38
+ function escapeRegex(s) {
39
+ return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
40
+ }
41
+
42
+ // Accept both a Map and a plain { path: source } object.
43
+ function toMap(fileContents) {
44
+ if (fileContents instanceof Map) return fileContents;
45
+ const m = new Map();
46
+ if (fileContents && typeof fileContents === 'object') {
47
+ for (const k of Object.keys(fileContents)) m.set(k, fileContents[k]);
48
+ }
49
+ return m;
50
+ }
51
+
52
+ // A finding qualifies for a sweep when it is confirmed. With confirmedOnly
53
+ // disabled we sweep everything (the caller has opted out of the gate).
54
+ function qualifies(finding, confirmedOnly) {
55
+ if (!confirmedOnly) return true;
56
+ return finding.confirmed === true || finding.confidenceTier === 'high';
57
+ }
58
+
59
+ // The origin site is the finding's own location; siblings must exclude it.
60
+ function originSite(finding) {
61
+ if (finding.sink && finding.sink.file && finding.sink.line != null) {
62
+ return { file: finding.sink.file, line: finding.sink.line };
63
+ }
64
+ return { file: finding.file ?? null, line: finding.line ?? null };
65
+ }
66
+
67
+ // Every file:line that already carries a finding — used to classify a match as
68
+ // 'mitigated-or-known' vs. a fresh 'candidate'.
69
+ function buildKnownLocations(findings) {
70
+ const set = new Set();
71
+ for (const f of Array.isArray(findings) ? findings : []) {
72
+ if (!f || typeof f !== 'object') continue;
73
+ if (f.file != null && f.line != null) set.add(`${f.file}:${f.line}`);
74
+ if (f.sink && f.sink.file != null && f.sink.line != null) set.add(`${f.sink.file}:${f.sink.line}`);
75
+ }
76
+ return set;
77
+ }
78
+
79
+ // Pull the leading callee path out of a call snippet: `db.query(x)` → `db.query`.
80
+ function extractCallee(snippet) {
81
+ if (!snippet || typeof snippet !== 'string') return null;
82
+ const m = snippet.match(/([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)\s*\(/);
83
+ if (!m) return null;
84
+ const callee = m[1];
85
+ const last = callee.split('.').pop();
86
+ if (CONTROL_KEYWORDS.has(last)) return null;
87
+ return callee;
88
+ }
89
+
90
+ // Extract the full balanced call expression for `callee` from `text`:
91
+ // `const r = db.query(f(1), g);` → `db.query(f(1), g)`. Null if absent/unbalanced.
92
+ function extractCall(text, callee) {
93
+ if (!text || typeof text !== 'string') return null;
94
+ const re = new RegExp('(?:^|[^\\w$.])' + escapeRegex(callee) + '\\s*\\(');
95
+ const m = re.exec(text);
96
+ if (!m) return null;
97
+ const calleeStart = m.index + m[0].indexOf(callee); // callee offset within the match
98
+
99
+ const open = text.indexOf('(', calleeStart);
100
+ if (open < 0) return null;
101
+ let depth = 0;
102
+ for (let i = open; i < text.length; i++) {
103
+ const ch = text[i];
104
+ if (ch === '(') depth++;
105
+ else if (ch === ')') {
106
+ depth--;
107
+ if (depth === 0) return text.slice(calleeStart, i + 1);
108
+ }
109
+ }
110
+ return null; // unbalanced on this line
111
+ }
112
+
113
+ // Structural shape of a sink snippet: hash of the normalized call expression
114
+ // (callee + args reduced to token kinds), reusing semantic-clone's hasher.
115
+ function sinkShapeOf(snippet) {
116
+ if (!snippet || typeof snippet !== 'string') return null;
117
+ const callee = extractCallee(snippet);
118
+ const call = callee ? (extractCall(snippet, callee) || snippet) : snippet;
119
+ return shapeHash(call, { minTokens: MIN_SHAPE_TOKENS });
120
+ }
121
+
122
+ // Build a searchable pattern from a finding's sink (preferred) or fall back to
123
+ // vuln/cwe keywords when no snippet is available.
124
+ function deriveSinkPattern(finding) {
125
+ const snippet = finding?.sink?.snippet || finding?.snippet || '';
126
+ const callee = extractCallee(snippet);
127
+ if (callee) {
128
+ return {
129
+ kind: 'call',
130
+ callee,
131
+ shape: sinkShapeOf(snippet),
132
+ regex: new RegExp('(?:^|[^\\w$.])' + escapeRegex(callee) + '\\s*\\('),
133
+ display: `${callee}(…)`,
134
+ };
135
+ }
136
+ const kw = keywordFor(finding);
137
+ if (kw) {
138
+ return { kind: 'keyword', keyword: kw, shape: null, regex: new RegExp(escapeRegex(kw), 'i'), display: kw };
139
+ }
140
+ return null;
141
+ }
142
+
143
+ // Source pattern is reported for context; the sweep itself is sink-driven.
144
+ function deriveSourcePattern(finding) {
145
+ const s = finding?.source?.snippet;
146
+ if (s && typeof s === 'string' && s.trim()) return { display: s.trim() };
147
+ const kw = keywordFor(finding);
148
+ if (kw) return { display: kw };
149
+ return null;
150
+ }
151
+
152
+ function keywordFor(finding) {
153
+ const v = (finding?.vuln ?? '').toString().trim();
154
+ if (v) return v;
155
+ const cwe = (finding?.cwe ?? '').toString().trim();
156
+ if (cwe) return cwe;
157
+ return null;
158
+ }
159
+
160
+ // Does a single source line structurally match the sink pattern?
161
+ function matchLine(pattern, line) {
162
+ if (!pattern || typeof line !== 'string') return false;
163
+ if (pattern.kind === 'call') {
164
+ if (!pattern.regex.test(line)) return false; // literal callee anchor
165
+ if (pattern.shape == null) return true; // anchor-only (snippet too short to shape)
166
+ const call = extractCall(line, pattern.callee);
167
+ if (!call) return false;
168
+ return shapeHash(call, { minTokens: MIN_SHAPE_TOKENS }) === pattern.shape;
169
+ }
170
+ if (pattern.kind === 'keyword') {
171
+ return pattern.regex.test(line);
172
+ }
173
+ return false;
174
+ }
175
+
176
+ /**
177
+ * Sweep confirmed findings for sibling instances of the same root cause.
178
+ *
179
+ * @param {Array<object>} findings scan findings (confirmed ones drive sweeps)
180
+ * @param {Map|object} fileContents { path: source } — Map or plain object
181
+ * @param {object} opts { confirmedOnly = true }
182
+ * @returns {{ sweeps: Array<object>, totals: {found,candidates,mitigated} }}
183
+ */
184
+ export function sweepRootCauses(findings, fileContents, opts = {}) {
185
+ const confirmedOnly = opts?.confirmedOnly !== false;
186
+ const list = Array.isArray(findings) ? findings : [];
187
+ const files = toMap(fileContents);
188
+ const knownLocations = buildKnownLocations(list);
189
+
190
+ const sweeps = [];
191
+ const totals = { found: 0, candidates: 0, mitigated: 0 };
192
+
193
+ for (const finding of list) {
194
+ if (!finding || typeof finding !== 'object') continue;
195
+ if (!qualifies(finding, confirmedOnly)) continue;
196
+
197
+ const sinkPattern = deriveSinkPattern(finding);
198
+ if (!sinkPattern) continue; // nothing searchable — skip rather than fabricate
199
+ const sourcePattern = deriveSourcePattern(finding);
200
+ const origin = originSite(finding);
201
+
202
+ const instances = [];
203
+ for (const [path, source] of files) {
204
+ if (source == null) continue;
205
+ const lines = String(source).split(/\r?\n/);
206
+ for (let i = 0; i < lines.length; i++) {
207
+ const line = lines[i];
208
+ if (!matchLine(sinkPattern, line)) continue;
209
+ const lineNo = i + 1;
210
+ if (path === origin.file && lineNo === origin.line) continue; // exclude the finding's own site
211
+ const status = knownLocations.has(`${path}:${lineNo}`) ? 'mitigated-or-known' : 'candidate';
212
+ instances.push({ file: path, line: lineNo, snippet: line.trim(), status });
213
+ }
214
+ }
215
+
216
+ const candidates = instances.filter((x) => x.status === 'candidate').length;
217
+ const mitigated = instances.filter((x) => x.status === 'mitigated-or-known').length;
218
+ const found = instances.length; // every match is exactly one status → invariant holds by construction
219
+
220
+ sweeps.push({
221
+ fromFindingId: finding.id ?? finding.stableId ?? null,
222
+ sourcePattern: sourcePattern ? sourcePattern.display : null,
223
+ sinkPattern: sinkPattern.display,
224
+ found,
225
+ candidates,
226
+ mitigated,
227
+ remaining: candidates, // unaccounted instances that still need triage
228
+ instances,
229
+ });
230
+
231
+ totals.found += found;
232
+ totals.candidates += candidates;
233
+ totals.mitigated += mitigated;
234
+ }
235
+
236
+ return { sweeps, totals };
237
+ }
238
+
239
+ /**
240
+ * One short human line per sweep, e.g.:
241
+ * "root-cause sweep: 20 found, 3 candidate, 17 mitigated"
242
+ */
243
+ export function formatSweepLedger(result) {
244
+ if (!result || !Array.isArray(result.sweeps)) return '';
245
+ return result.sweeps
246
+ .map((s) => `root-cause sweep: ${s.found} found, ${s.candidates} candidate, ${s.mitigated} mitigated`)
247
+ .join('\n');
248
+ }
249
+
250
+ export const _internals = {
251
+ MIN_SHAPE_TOKENS,
252
+ sinkShapeOf,
253
+ deriveSinkPattern,
254
+ deriveSourcePattern,
255
+ extractCallee,
256
+ extractCall,
257
+ matchLine,
258
+ qualifies,
259
+ originSite,
260
+ buildKnownLocations,
261
+ toMap,
262
+ };
@@ -0,0 +1,71 @@
1
+ // Live-secret validation (#22) — label a detected secret live | dead | unknown.
2
+ //
3
+ // "This Stripe/GitHub key is LIVE and was committed 40 commits ago" is a P0 the
4
+ // vibecoder must rotate now; "you have a high-entropy string" is noise. This
5
+ // closes that gap for the providers with a cheap, read-only "whoami" check.
6
+ //
7
+ // STRICTLY opt-in (a --validate-secrets flag / AGENTIC_SECURITY_VALIDATE_SECRETS)
8
+ // and OFFLINE-DEGRADING: any network error, timeout, or unrecognized provider
9
+ // yields 'unknown' — never a false 'dead'. No runtime cloud calls by default,
10
+ // per the scanner's no-network-by-default convention. The request builder is
11
+ // pure (no I/O) so it's testable without hitting a provider.
12
+
13
+ // Map a detected secret to a read-only validation request, or null when we have
14
+ // no safe check for that provider. Only providers whose token is a self-
15
+ // contained bearer/token credential (no signing, no extra params) are covered.
16
+ function buildLiveCheckRequest(secret) {
17
+ const val = (secret && (secret.match || secret.value || secret.secret || secret.token)) || '';
18
+ if (typeof val !== 'string' || val.length < 8) return null;
19
+
20
+ // GitHub PAT / OAuth token → GET /user (200 = live, 401 = dead).
21
+ if (/^gh[posru]_[A-Za-z0-9]{20,}$/.test(val) || /^github_pat_[A-Za-z0-9_]{20,}$/.test(val)) {
22
+ return { provider: 'github', method: 'GET', url: 'https://api.github.com/user',
23
+ headers: { Authorization: `token ${val}`, 'User-Agent': 'agentic-security', Accept: 'application/vnd.github+json' } };
24
+ }
25
+ // Stripe secret key → GET /v1/account (200 = live, 401 = dead).
26
+ if (/^sk_live_[A-Za-z0-9]{16,}$/.test(val) || /^rk_live_[A-Za-z0-9]{16,}$/.test(val)) {
27
+ return { provider: 'stripe', method: 'GET', url: 'https://api.stripe.com/v1/account',
28
+ headers: { Authorization: `Bearer ${val}` } };
29
+ }
30
+ // OpenAI key → GET /v1/models.
31
+ if (/^sk-[A-Za-z0-9]{20,}$/.test(val) && !/^sk_live_/.test(val)) {
32
+ return { provider: 'openai', method: 'GET', url: 'https://api.openai.com/v1/models',
33
+ headers: { Authorization: `Bearer ${val}` } };
34
+ }
35
+ // SendGrid key → GET /v3/scopes.
36
+ if (/^SG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}$/.test(val)) {
37
+ return { provider: 'sendgrid', method: 'GET', url: 'https://api.sendgrid.com/v3/scopes',
38
+ headers: { Authorization: `Bearer ${val}` } };
39
+ }
40
+ return null;
41
+ }
42
+
43
+ // Classify an HTTP status into a liveness verdict. 200-2xx = live; 401/403 =
44
+ // dead (rejected credential); anything else = unknown (rate-limit, 5xx, etc. —
45
+ // we don't know, so don't claim dead).
46
+ function classifyStatus(status) {
47
+ if (status >= 200 && status < 300) return 'live';
48
+ if (status === 401 || status === 403) return 'dead';
49
+ return 'unknown';
50
+ }
51
+
52
+ // Perform the validation. Returns { verdict: 'live'|'dead'|'unknown', provider }.
53
+ // Offline-degrading: on any error/timeout, verdict is 'unknown'.
54
+ export async function checkSecretLive(secret, { timeoutMs = 4000 } = {}) {
55
+ const req = buildLiveCheckRequest(secret);
56
+ if (!req) return { verdict: 'unknown', provider: null };
57
+ const ctrl = new AbortController();
58
+ const t = setTimeout(() => ctrl.abort(), timeoutMs);
59
+ try {
60
+ const r = await fetch(req.url, { method: req.method, headers: req.headers, signal: ctrl.signal });
61
+ return { verdict: classifyStatus(r.status), provider: req.provider };
62
+ } catch {
63
+ return { verdict: 'unknown', provider: req.provider };
64
+ } finally {
65
+ clearTimeout(t);
66
+ }
67
+ }
68
+
69
+ // Pure surfaces exposed for tests (no network) — kept off the public API so the
70
+ // dead-module guard doesn't flag them; `checkSecretLive` is the wired entry.
71
+ export const _internal = { buildLiveCheckRequest, classifyStatus };
package/src/pr-comment.js CHANGED
@@ -25,6 +25,8 @@
25
25
  // route through an LLM for richer prose when AGENTIC_SECURITY_LLM_ENDPOINT
26
26
  // is configured.
27
27
 
28
+ import { escapeMarkdown } from './util/untrusted.js';
29
+
28
30
  const SEVERITY_GLYPH = {
29
31
  critical: '🟥',
30
32
  high: '🟧',
@@ -136,7 +138,7 @@ export function renderPrComment(delta, { repoName, prNumber, prTitle } = {}) {
136
138
  const sev = SEVERITY_GLYPH[f.severity] || '⬜';
137
139
  const route = _route(f);
138
140
  const where = route ? `\`${route}\` (\`${f.file}:${f.line}\`)` : `\`${f.file}:${f.line}\``;
139
- lines.push(`${sev} **${meta?.name || f.vuln}** — ${where}`);
141
+ lines.push(`${sev} **${meta?.name || escapeMarkdown(f.vuln)}** — ${where}`);
140
142
  if (meta) lines.push(` > ${meta.why}`);
141
143
  if (f.remediation) {
142
144
  const onelineFix = String(f.remediation).split('\n')[0].slice(0, 240);
@@ -23,7 +23,7 @@ SAST detector modules. Each file exports one or more `scan*()` functions returni
23
23
 
24
24
  **Framework structural (taint-independent, JS/Py recall)** — `js-framework-structural.js` (Express/Koa/NestJS/TypeORM: SQLi via `.query`/`.execute` concat-template, koa-send path, `ctx.body` XSS, HttpService SSRF, deep-merge prototype pollution) `python-structural.js` (Flask `render_template_string` XSS/SSTI, Django `.raw`/`.extra` + `cursor.execute` SQLi — robust string-literal matching spans embedded quotes like `"… name = '" + x`; `open()`/`send_file` path traversal via concat/f-string, CWE-22 deferring to `dropGuardedFindings`), and `go-structural.js` (db query + `fmt.Sprintf`/concat SQLi, `os.Open` + concat/Sprintf path traversal). High precision: parameterized/escaped/`{{ }}`-Jinja/`%s`-placeholder forms do NOT match; SSRF/path findings defer to `engine.js dropGuardedFindings` (which also drops a reflected-XSS finding when the reflected value passed through a captured HTML escaper — a *discarded* `escapeHtml(s);` does not count — and skips already-`isSanitized` findings so the suppression pipeline keeps its bookkeeping).
25
25
 
26
- **Cross-cutting vuln classes** — `authz.js`, `csrf.js` (POST/PUT/PATCH/DELETE state-changing routes without CSRF defence; defence-aware suppression covers Express/Fastify/Flask/Django/FastAPI/Spring/Symfony **and Go (gin/echo/mux), Rails routes, ASP.NET MVC** — recognizes `gorilla/csrf`/`protect_from_forgery`/`[ValidateAntiForgeryToken]` defences and exempts token-auth (`[ApiController]`, Bearer scheme); bare ASP.NET `[Authorize]` still flags as cookie auth is CSRF-vulnerable), `code-injection-multilang.js` (CWE-94 for Java/C#/Go/Kotlin — dynamic code/expression evaluators on a NON-LITERAL argument: javax.script `eval`, GroovyShell, Spring SpEL `parseExpression`, MVEL/OGNL, Roslyn `CSharpScript`, `DataTable.Compute`, yaegi `interp.Eval`, `text/template` Parse of a user-controlled body; literal arguments don't match. JS/Python/Ruby eval stay with the flow engine + per-language modules), `csv-injection.js` (formula injection into spreadsheet cells, CWE-1236), `secret-concat.js` (language-agnostic hardcoded-secret SPLIT across concatenated literals — `'AKIA' + 'IOSF…'` / `'ghp' + '_…'` / `'sk' + '_live_…'` — reassembled and matched against provider prefixes; complements the contiguous-token secrets scanner and the C#-only split-concat rule), `host-header.js`, `jndi.js`, `jwt-exp.js`, `ldap-injection.js` (CWE-90 across JS/Java/Python **and** PHP/Go/C#/Ruby/Kotlin — filter built by concat/interpolation; an inline call-guard and a file-level escape-API guard suppress `ldap_escape`/`EscapeFilter`/`escape_filter_chars`/`Net::LDAP::Filter`/`EqualityFilter` forms), `xpath-injection.js` (CWE-643 across Java/Python/JS **and** PHP/Go/Ruby/C#/Kotlin — XPath expression built by concat/interpolation: `DOMXPath->query`, `SelectNodes`, Nokogiri `.xpath`, htmlquery/xmlpath, `XPath.compile`; embedded-quote-tolerant literal matching; parameterized/variable-bound APIs and static literals don't match), `mass-assignment.js`, `mutation-xss.js`, `nosql-injection.js`, `prototype-pollution.js`, `response-splitting.js` (CWE-113 CRLF/header injection across JS/Python/Java/PHP/Go/Ruby/C#/Kotlin — a response header value set from a request source without stripping CR/LF; recognizes CRLF-strip sanitizers — `.replace(/[\r\n]/)`, chained `.replace("\r")`, Ruby `gsub`/`delete`, Go `strings.NewReplacer`, PHP `str_replace` — and a request-scope param heuristic for the JVM/C# single-file shape), `ssrf-cloud-metadata.js`, `xss-reflected-multilang.js` (cross-language reflected XSS for Go/Ruby/PHP/C#/Kotlin/Java — user input written into an HTML response via concat/interpolation, e.g. Java servlet `response.getWriter().write("<…" + q)`, with a per-language escaper exclusion so `htmlspecialchars`/`HtmlEncode`/`template.HTMLEscapeString`/ERB `<%= %>`/OWASP `Encode.forHtml` forms don't match; JS/Python XSS stays with the flow engine + framework structural detectors), `stored-taint.js` (second-order / stored injection — **opt-in** via `AGENTIC_SECURITY_STORED_TAINT=1`), `toctou.js`, `wrong-context-sanitizer.js` (HTML-entity encoder used in a URL context — wrong-context output encoding, CWE-79), `zip-slip.js`.
26
+ **Cross-cutting vuln classes** — `authz.js`, `csrf.js` (POST/PUT/PATCH/DELETE state-changing routes without CSRF defence; defence-aware suppression covers Express/Fastify/Flask/Django/FastAPI/Spring/Symfony **and Go (gin/echo/mux), Rails routes, ASP.NET MVC** — recognizes `gorilla/csrf`/`protect_from_forgery`/`[ValidateAntiForgeryToken]` defences and exempts token-auth (`[ApiController]`, Bearer scheme); bare ASP.NET `[Authorize]` still flags as cookie auth is CSRF-vulnerable), `code-injection-multilang.js` (CWE-94 for Java/C#/Go/Kotlin — dynamic code/expression evaluators on a NON-LITERAL argument: javax.script `eval`, GroovyShell, Spring SpEL `parseExpression`, MVEL/OGNL, Roslyn `CSharpScript`, `DataTable.Compute`, yaegi `interp.Eval`, `text/template` Parse of a user-controlled body; literal arguments don't match. JS/Python/Ruby eval stay with the flow engine + per-language modules), `csv-injection.js` (formula injection into spreadsheet cells, CWE-1236), `secret-concat.js` (language-agnostic hardcoded-secret SPLIT across concatenated literals — `'AKIA' + 'IOSF…'` / `'ghp' + '_…'` / `'sk' + '_live_…'` — reassembled and matched against provider prefixes; complements the contiguous-token secrets scanner and the C#-only split-concat rule), `host-header.js`, `jndi.js`, `jwt-exp.js`, `ldap-injection.js` (CWE-90 across JS/Java/Python **and** PHP/Go/C#/Ruby/Kotlin — filter built by concat/interpolation; an inline call-guard and a file-level escape-API guard suppress `ldap_escape`/`EscapeFilter`/`escape_filter_chars`/`Net::LDAP::Filter`/`EqualityFilter` forms), `xpath-injection.js` (CWE-643 across Java/Python/JS **and** PHP/Go/Ruby/C#/Kotlin — XPath expression built by concat/interpolation: `DOMXPath->query`, `SelectNodes`, Nokogiri `.xpath`, htmlquery/xmlpath, `XPath.compile`; embedded-quote-tolerant literal matching; parameterized/variable-bound APIs and static literals don't match), `mass-assignment.js`, `mutation-xss.js`, `nosql-injection.js`, `prototype-pollution.js`, `response-splitting.js` (CWE-113 CRLF/header injection across JS/Python/Java/PHP/Go/Ruby/C#/Kotlin — a response header value set from a request source without stripping CR/LF; recognizes CRLF-strip sanitizers — `.replace(/[\r\n]/)`, chained `.replace("\r")`, Ruby `gsub`/`delete`, Go `strings.NewReplacer`, PHP `str_replace` — and a request-scope param heuristic for the JVM/C# single-file shape), `ssrf-cloud-metadata.js`, `xss-reflected-multilang.js` (cross-language reflected XSS for Go/Ruby/PHP/C#/Kotlin/Java — user input written into an HTML response via concat/interpolation, e.g. Java servlet `response.getWriter().write("<…" + q)`, with a per-language escaper exclusion so `htmlspecialchars`/`HtmlEncode`/`template.HTMLEscapeString`/ERB `<%= %>`/OWASP `Encode.forHtml` forms don't match; JS/Python XSS stays with the flow engine + framework structural detectors), `stored-taint.js` (second-order / stored injection — **opt-in** via `AGENTIC_SECURITY_STORED_TAINT=1`), `toctou.js`, `wrong-context-sanitizer.js` (HTML-entity encoder used in a URL context — wrong-context output encoding, CWE-79), `zip-slip.js`, `file-upload.js` (CWE-434 unrestricted file upload for JS/Python — Multer configured with no `fileFilter`/`limits`, and a write whose destination is built from the client-supplied filename (`originalname`/`req.files.*.name`/`.filename`); suppressed by a `basename`/uuid/`secure_filename`/sanitizer in the window).
27
27
 
28
28
  **Cloud/infra** — `db-rls.js` (Supabase RLS), `env-hygiene.js` (NEXT_PUBLIC_ leaks, .env.example real values), `mobile-manifest.js`, `pipeline.js` (CI/CD integrity), `rate-limit.js`, `webhook.js`.
29
29