@clear-capabilities/agentic-security-scanner 0.127.0 → 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.
- package/CHANGELOG.md +60 -0
- package/dist/11.index.js +353 -0
- package/dist/113.index.js +525 -0
- package/dist/178.index.js +1 -1
- package/dist/384.index.js +1 -1
- package/dist/435.index.js +18 -7
- package/dist/637.index.js +1 -1
- package/dist/826.index.js +4 -1
- package/dist/agentic-security.mjs +1 -2
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +3 -3
- package/src/engine.js +27 -1
- package/src/integrations/tickets.js +9 -3
- package/src/mcp/tools.js +17 -6
- package/src/posture/CLAUDE.md +7 -0
- package/src/posture/entrypoint-inventory.js +248 -0
- package/src/posture/falsification.js +121 -0
- package/src/posture/fix-honesty-gate.js +175 -0
- package/src/posture/fix-verify.js +18 -3
- package/src/posture/model-routing.js +126 -0
- package/src/posture/root-cause-sweep.js +262 -0
- package/src/pr-comment.js +3 -1
- package/src/util/untrusted.js +148 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// Deterministic honesty gates on fix / finding output (#7).
|
|
2
|
+
//
|
|
3
|
+
// The project's verification discipline (scanner/CLAUDE.md) exists because
|
|
4
|
+
// several releases shipped broken or false because work was reported as done
|
|
5
|
+
// without confirming the artifact changed. Two of those failure modes are
|
|
6
|
+
// *textual* — they live in the prose an agent emits alongside a fix — and can
|
|
7
|
+
// be caught deterministically, with no LLM and no network:
|
|
8
|
+
//
|
|
9
|
+
// 1. Hand-wave residual-risk prose. "The input is adequately handled",
|
|
10
|
+
// "future work", "tbd", "later" — vague assurances that claim safety
|
|
11
|
+
// without naming a concrete remaining vector. A residual you can't name
|
|
12
|
+
// is a residual you're guessing about; reject the guess.
|
|
13
|
+
//
|
|
14
|
+
// 2. An unbacked "this is a false positive / provably safe" verdict. Marking
|
|
15
|
+
// a finding safe is a coverage *reduction* — it must cite a `file:line`
|
|
16
|
+
// that shows why, exactly like the rules-override gate refuses to silently
|
|
17
|
+
// shrink coverage.
|
|
18
|
+
//
|
|
19
|
+
// Plus a conservative fix-tier classifier so a partial remediation can never be
|
|
20
|
+
// labelled FULL: any workaround-only signal (rate-limit, docs, log-without-
|
|
21
|
+
// reject) is WORKAROUND; anything short of (sink signature changed + all callers
|
|
22
|
+
// routed + a discriminating test) is at most MITIGATION; only the full set with
|
|
23
|
+
// no partial-sanitization caveat earns FULL.
|
|
24
|
+
//
|
|
25
|
+
// Pure functions, no side effects, no throwing — safe to call from a command,
|
|
26
|
+
// a hook, or the MCP verify_fix path.
|
|
27
|
+
|
|
28
|
+
// Vague-assurance phrases that a real residual must never hide behind. Matched
|
|
29
|
+
// case-insensitively with word boundaries so "later" doesn't trip on
|
|
30
|
+
// "collateral" and "tbd" doesn't trip on a longer token.
|
|
31
|
+
const BANNED_RESIDUAL_PHRASES = Object.freeze([
|
|
32
|
+
'adequately handled',
|
|
33
|
+
'adequately handles',
|
|
34
|
+
'properly validated',
|
|
35
|
+
'properly handled',
|
|
36
|
+
'handled properly',
|
|
37
|
+
'handled safely',
|
|
38
|
+
'future work',
|
|
39
|
+
'more work needed',
|
|
40
|
+
'to be done',
|
|
41
|
+
'tbd',
|
|
42
|
+
'later',
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
// A citation shaped like `file:line` — one or more non-space, non-colon chars,
|
|
46
|
+
// a colon, then digits. Unanchored: it need only appear somewhere in the item.
|
|
47
|
+
const CITATION_RE = /[^\s:]+:\d+/;
|
|
48
|
+
|
|
49
|
+
// Verdicts that assert the finding is not real and therefore demand a citation.
|
|
50
|
+
// Compared after normalizing separators (`_`/space → `-`) and lowercasing, so
|
|
51
|
+
// FALSE_POSITIVE, false-positive, and "provably safe" all land here.
|
|
52
|
+
const FP_VERDICTS = Object.freeze(new Set(['false-positive', 'provably-safe', 'safe']));
|
|
53
|
+
|
|
54
|
+
function _escapeRe(s) {
|
|
55
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Reject vague-assurance / hand-wave residual-risk prose.
|
|
60
|
+
*
|
|
61
|
+
* An empty or whitespace-only residual is ok — there is no residual to lie
|
|
62
|
+
* about. A non-empty residual is rejected when it contains any banned phrase;
|
|
63
|
+
* each match yields one violation naming the offending phrase.
|
|
64
|
+
*
|
|
65
|
+
* @param {string} residualText
|
|
66
|
+
* @returns {{ ok: boolean, violations: string[] }}
|
|
67
|
+
*/
|
|
68
|
+
export function checkResidualHonesty(residualText) {
|
|
69
|
+
const text = typeof residualText === 'string' ? residualText : '';
|
|
70
|
+
if (text.trim() === '') return { ok: true, violations: [] };
|
|
71
|
+
|
|
72
|
+
const violations = [];
|
|
73
|
+
for (const phrase of BANNED_RESIDUAL_PHRASES) {
|
|
74
|
+
const re = new RegExp(`\\b${_escapeRe(phrase)}\\b`, 'i');
|
|
75
|
+
if (re.test(text)) {
|
|
76
|
+
violations.push(`vague-assurance phrase: "${phrase}"`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return { ok: violations.length === 0, violations };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function _isCitation(item) {
|
|
83
|
+
if (typeof item === 'string') return CITATION_RE.test(item);
|
|
84
|
+
if (item && typeof item === 'object' && typeof item.location === 'string') {
|
|
85
|
+
return CITATION_RE.test(item.location);
|
|
86
|
+
}
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function _normalizeVerdict(verdict) {
|
|
91
|
+
return String(verdict).trim().toLowerCase().replace(/[_\s]+/g, '-');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Require a file:line citation behind a "this is not real" verdict.
|
|
96
|
+
*
|
|
97
|
+
* For a false-positive / provably-safe / safe verdict (case-insensitive; also
|
|
98
|
+
* accepts FALSE_POSITIVE), at least one evidence item must be a `file:line`
|
|
99
|
+
* citation — either a string matching /[^\s:]+:\d+/ or an object
|
|
100
|
+
* `{ location: "file:line" }`. Any other verdict passes unconditionally.
|
|
101
|
+
*
|
|
102
|
+
* @param {string} verdict
|
|
103
|
+
* @param {Array|string|object} evidence
|
|
104
|
+
* @returns {{ ok: boolean, violations: string[] }}
|
|
105
|
+
*/
|
|
106
|
+
export function requireCitedEvidence(verdict, evidence) {
|
|
107
|
+
if (typeof verdict !== 'string' || !FP_VERDICTS.has(_normalizeVerdict(verdict))) {
|
|
108
|
+
return { ok: true, violations: [] };
|
|
109
|
+
}
|
|
110
|
+
const items = Array.isArray(evidence)
|
|
111
|
+
? evidence
|
|
112
|
+
: evidence == null
|
|
113
|
+
? []
|
|
114
|
+
: [evidence];
|
|
115
|
+
if (items.some(_isCitation)) return { ok: true, violations: [] };
|
|
116
|
+
return {
|
|
117
|
+
ok: false,
|
|
118
|
+
violations: ['false-positive/safe verdict requires a file:line citation'],
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Classify a fix into FULL | MITIGATION | WORKAROUND, conservative-first.
|
|
124
|
+
*
|
|
125
|
+
* @param {object} signals
|
|
126
|
+
* @param {boolean} signals.sinkSignatureChanged
|
|
127
|
+
* @param {boolean} signals.allCallersRouted
|
|
128
|
+
* @param {boolean} signals.testDiscriminates - a test that fails pre-fix, passes post-fix
|
|
129
|
+
* @param {boolean} [signals.rateLimitOnly]
|
|
130
|
+
* @param {boolean} [signals.docsOnly]
|
|
131
|
+
* @param {boolean} [signals.logOnlyNoReject]
|
|
132
|
+
* @param {boolean} [signals.partialSanitization]
|
|
133
|
+
* @returns {'FULL'|'MITIGATION'|'WORKAROUND'}
|
|
134
|
+
*/
|
|
135
|
+
export function computeFixTier(signals) {
|
|
136
|
+
const s = signals && typeof signals === 'object' ? signals : {};
|
|
137
|
+
if (s.rateLimitOnly || s.docsOnly || s.logOnlyNoReject) return 'WORKAROUND';
|
|
138
|
+
const complete = s.sinkSignatureChanged && s.allCallersRouted && s.testDiscriminates;
|
|
139
|
+
if (s.partialSanitization || !complete) return 'MITIGATION';
|
|
140
|
+
return 'FULL';
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Compose the three gates for a single fix's output.
|
|
145
|
+
*
|
|
146
|
+
* ok = residual-honesty ok AND evidence-citation ok, further constrained by the
|
|
147
|
+
* tier/residual consistency invariant:
|
|
148
|
+
* - a FULL tier must NOT carry a residual (a full fix has nothing left);
|
|
149
|
+
* - a non-FULL tier MUST document a residual (say what's still open).
|
|
150
|
+
*
|
|
151
|
+
* @param {{ residual?: string, verdict?: string, evidence?: any, signals?: object }} input
|
|
152
|
+
* @returns {{ ok: boolean, tier: string, violations: string[] }}
|
|
153
|
+
*/
|
|
154
|
+
export function gateFixOutput({ residual, verdict, evidence, signals } = {}) {
|
|
155
|
+
const tier = computeFixTier(signals);
|
|
156
|
+
const residualCheck = checkResidualHonesty(residual);
|
|
157
|
+
const evidenceCheck = requireCitedEvidence(verdict, evidence);
|
|
158
|
+
|
|
159
|
+
const violations = [...residualCheck.violations, ...evidenceCheck.violations];
|
|
160
|
+
let ok = residualCheck.ok && evidenceCheck.ok;
|
|
161
|
+
|
|
162
|
+
const residualEmpty = typeof residual !== 'string' || residual.trim() === '';
|
|
163
|
+
if (tier === 'FULL' && !residualEmpty) {
|
|
164
|
+
violations.push('FULL tier cannot carry a residual');
|
|
165
|
+
ok = false;
|
|
166
|
+
}
|
|
167
|
+
if (tier !== 'FULL' && residualEmpty) {
|
|
168
|
+
violations.push('non-FULL tier must document a residual');
|
|
169
|
+
ok = false;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return { ok, tier, violations };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export const _internals = Object.freeze({ BANNED_RESIDUAL_PHRASES, CITATION_RE, FP_VERDICTS });
|
|
@@ -15,6 +15,7 @@ import { spawnSync } from 'node:child_process';
|
|
|
15
15
|
import * as fs from 'node:fs';
|
|
16
16
|
import * as path from 'node:path';
|
|
17
17
|
import { runFullScan } from '../engine.js';
|
|
18
|
+
import { gateFixOutput } from './fix-honesty-gate.js';
|
|
18
19
|
|
|
19
20
|
const SEVERITY_RANK = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
|
|
20
21
|
|
|
@@ -110,21 +111,35 @@ function runLinter(cwd, cmd, args) {
|
|
|
110
111
|
|
|
111
112
|
// Top-level verify: re-scan + lint. Returns the combined verdict + a
|
|
112
113
|
// human-readable summary string suitable for surfacing to the user.
|
|
114
|
+
// Addition #7 — deterministic honesty gates on fix output. When the caller
|
|
115
|
+
// supplies `fixMeta` ({ residual, verdict, evidence, signals }) — e.g. the
|
|
116
|
+
// security-fixer agent's residual-risk text + completeness signals — the fix's
|
|
117
|
+
// claims are checked mechanically (no hand-wave residual prose, a cited
|
|
118
|
+
// file:line for any FP/safe verdict, and a FULL/MITIGATION/WORKAROUND tier). A
|
|
119
|
+
// dishonest or over-claiming fix fails the gate. When `fixMeta` is absent
|
|
120
|
+
// (the deterministic MCP write path, which has no claims to check) the honesty
|
|
121
|
+
// gate is skipped and behavior is unchanged.
|
|
113
122
|
export async function verifyFix({
|
|
114
123
|
scanRoot,
|
|
115
124
|
originalFindingStableId,
|
|
116
125
|
files,
|
|
117
126
|
depFileContents,
|
|
127
|
+
fixMeta,
|
|
118
128
|
} = {}) {
|
|
119
129
|
const rescan = await verifyPatch({ scanRoot, originalFindingStableId, files, depFileContents });
|
|
120
130
|
const lint = runProjectLinter(scanRoot, Object.keys(files || {}));
|
|
121
|
-
|
|
131
|
+
let honesty = null;
|
|
132
|
+
if (fixMeta && typeof fixMeta === 'object') {
|
|
133
|
+
try { honesty = gateFixOutput(fixMeta); } catch { honesty = null; }
|
|
134
|
+
}
|
|
135
|
+
const ok = rescan.ok && (lint.ok || lint.skipped) && (honesty ? honesty.ok : true);
|
|
122
136
|
const summary = [
|
|
123
137
|
`re-scan: ${rescan.ok ? 'PASS' : 'FAIL — ' + rescan.reason}`,
|
|
124
138
|
`linter: ${lint.runner === 'none' ? 'skipped (no linter config)'
|
|
125
139
|
: lint.skipped ? `${lint.runner} not installed`
|
|
126
140
|
: lint.ok ? `${lint.runner} PASS`
|
|
127
141
|
: `${lint.runner} FAIL (exit ${lint.exitCode})`}`,
|
|
128
|
-
|
|
129
|
-
|
|
142
|
+
honesty ? `honesty: ${honesty.ok ? `PASS (${honesty.tier})` : 'FAIL — ' + honesty.violations.join('; ')}` : null,
|
|
143
|
+
].filter(Boolean).join('\n');
|
|
144
|
+
return { ok, rescan, lint, honesty, summary };
|
|
130
145
|
}
|
|
@@ -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
|
+
}
|
|
@@ -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
|
+
};
|
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);
|