@clear-capabilities/agentic-security-scanner 0.134.0 → 0.136.9
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 +432 -0
- package/bin/agentic-security-audit.js +2 -1
- package/bin/agentic-security-consistency.js +2 -1
- package/bin/agentic-security.js +448 -74
- package/dist/113.index.js +16 -7
- package/dist/117.index.js +3 -1
- package/dist/178.index.js +1 -1
- package/dist/207.index.js +5 -4
- package/dist/220.index.js +5 -3
- package/dist/238.index.js +4 -4
- package/dist/317.index.js +300 -0
- package/dist/384.index.js +1 -1
- package/dist/435.index.js +196 -21
- package/dist/444.index.js +20 -11
- package/dist/449.index.js +8 -1
- package/dist/513.index.js +7 -3
- package/dist/526.index.js +6 -6
- package/dist/637.index.js +1 -1
- package/dist/675.index.js +7 -5
- package/dist/839.index.js +4 -3
- package/dist/905.index.js +1173 -0
- package/dist/agentic-security.mjs +14 -14
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/compliance-frameworks/ccpa.json +32 -0
- package/dist/compliance-frameworks/eu-ai-act.json +51 -0
- package/dist/compliance-frameworks/gdpr.json +45 -0
- package/dist/compliance-frameworks/hipaa-security-rule.json +56 -0
- package/dist/compliance-frameworks/nist-ai-600-1.json +51 -0
- package/dist/compliance-frameworks/nist-csf-2.json +73 -0
- package/dist/compliance-frameworks/nist-privacy-1-1.json +846 -0
- package/dist/compliance-frameworks/owasp-asvs-5.json +79 -0
- package/dist/compliance-frameworks/owasp-llm-top-10.json +69 -0
- package/package.json +24 -12
- package/src/badge.js +2 -1
- package/src/dataflow/CLAUDE.md +10 -4
- package/src/dataflow/builtin-summaries.js +1 -1
- package/src/dataflow/cross-service-taint.js +2 -1
- package/src/dataflow/engine.js +324 -60
- package/src/dataflow/ifds-precise.js +6 -4
- package/src/dataflow/implicit-flow.js +68 -36
- package/src/dataflow/incremental.js +25 -8
- package/src/dataflow/index.js +2 -1
- package/src/dataflow/proven-clean.js +41 -0
- package/src/dataflow/sanitizer-gate.js +35 -9
- package/src/dataflow/sanitizer-proof.js +21 -3
- package/src/dataflow/stub-aware-filter.js +36 -13
- package/src/dataflow/summaries.js +21 -2
- package/src/discovery/CLAUDE.md +10 -0
- package/src/discovery/index.js +175 -3
- package/src/discovery/llm-invoke.js +90 -1
- package/src/discovery/memory.js +163 -0
- package/src/engine.js +247 -50
- package/src/integrations/tickets.js +7 -6
- package/src/ir/CLAUDE.md +4 -1
- package/src/ir/balanced-call.js +55 -0
- package/src/ir/ir-stats.js +1 -1
- package/src/ir/parser-cpp.js +1 -1
- package/src/ir/parser-cs.js +62 -9
- package/src/ir/parser-go.js +29 -11
- package/src/ir/parser-java.js +96 -19
- package/src/ir/parser-js.js +151 -20
- package/src/ir/parser-php.js +44 -9
- package/src/ir/parser-rb.js +37 -7
- package/src/ir/ssa.js +6 -1
- package/src/leaderboard.js +3 -2
- package/src/llm-validator/consistency.js +6 -2
- package/src/llm-validator/index.js +1 -2
- package/src/lsp/server.js +28 -2
- package/src/mcp/CLAUDE.md +9 -2
- package/src/mcp/audit.js +2 -1
- package/src/mcp/redact.js +26 -0
- package/src/mcp/tools.js +159 -17
- package/src/posture/CLAUDE.md +45 -8
- package/src/posture/accuracy-scorecard.js +67 -1
- package/src/posture/agents-memory.js +5 -3
- package/src/posture/aibom.js +12 -8
- package/src/posture/auditor-walkthrough.js +111 -10
- package/src/posture/auth-posture-import.js +5 -4
- package/src/posture/autopilot.js +8 -1
- package/src/posture/calibration-drift.js +11 -5
- package/src/posture/calibration.js +24 -2
- package/src/posture/compliance-frameworks/nist-privacy-1-1.json +846 -0
- package/src/posture/compliance-frameworks/owasp-asvs-5.json +1 -1
- package/src/posture/compliance-policy.js +40 -10
- package/src/posture/confidence.js +44 -10
- package/src/posture/corpus-enroll.js +9 -5
- package/src/posture/corpus-match.js +19 -0
- package/src/posture/csharp-analysis.js +62 -3
- package/src/posture/custom-rules.js +7 -5
- package/src/posture/cve-alert-daemon.js +6 -5
- package/src/posture/dep-add-guard.js +2 -1
- package/src/posture/deploy-platform.js +4 -1
- package/src/posture/deterministic.js +3 -2
- package/src/posture/drift.js +7 -1
- package/src/posture/epss.js +13 -1
- package/src/posture/evidence-bundle.js +276 -0
- package/src/posture/exploitability-probability.js +15 -2
- package/src/posture/falsification.js +23 -2
- package/src/posture/feature-flags.js +3 -2
- package/src/posture/findings-memory.js +3 -3
- package/src/posture/fix-history.js +5 -2
- package/src/posture/fix-metrics.js +5 -5
- package/src/posture/fix-plan.js +2 -1
- package/src/posture/fix-verify-loop.js +10 -1
- package/src/posture/grader-calibration.js +3 -4
- package/src/posture/iac-reachability.js +14 -8
- package/src/posture/integrity.js +25 -7
- package/src/posture/intent-context.js +2 -1
- package/src/posture/learning.js +4 -3
- package/src/posture/license-attributions.js +5 -7
- package/src/posture/license-graph.js +2 -1
- package/src/posture/license-policy.js +2 -1
- package/src/posture/model-rescan.js +69 -3
- package/src/posture/mttr.js +5 -0
- package/src/posture/network-policy-import.js +3 -2
- package/src/posture/poc-inprocess.js +27 -8
- package/src/posture/pqc-migration-plan.js +7 -5
- package/src/posture/pr-augment.js +8 -5
- package/src/posture/privacy-framework.js +262 -0
- package/src/posture/regression-test-gen.js +23 -8
- package/src/posture/reverse-blast-radius.js +5 -1
- package/src/posture/risk-dollars.js +20 -3
- package/src/posture/router.js +5 -4
- package/src/posture/ruleset-version.js +2 -2
- package/src/posture/runtime-correlation.js +2 -1
- package/src/posture/sbom-diff.js +12 -3
- package/src/posture/sca-policy.js +7 -4
- package/src/posture/scan-checkpoint.js +15 -0
- package/src/posture/secret-history.js +20 -11
- package/src/posture/security-trend.js +7 -1
- package/src/posture/stack-playbook.js +22 -1
- package/src/posture/state-dir.js +34 -0
- package/src/posture/telemetry-ingest.js +4 -3
- package/src/posture/threat-model-auto.js +4 -1
- package/src/posture/threat-model-grounding.js +13 -3
- package/src/posture/time-to-fix.js +3 -2
- package/src/posture/triage-memory.js +3 -2
- package/src/posture/validator-metrics.js +10 -3
- package/src/posture/verifier.js +32 -57
- package/src/posture/waf-ingest.js +6 -5
- package/src/posture/watch-mode.js +4 -3
- package/src/report/index.js +183 -14
- package/src/runScan.js +1 -1
- package/src/sast/_comment-strip.js +15 -4
- package/src/sast/_secret-entropy.js +1 -1
- package/src/sast/authz.js +6 -4
- package/src/sast/bench-shape/index.js +2 -7
- package/src/sast/claude-md-prompt-injection.js +14 -3
- package/src/sast/cloud-iam.js +60 -7
- package/src/sast/code-injection-multilang.js +29 -0
- package/src/sast/cpp-bench-extras.js +1 -1
- package/src/sast/csrf.js +7 -5
- package/src/sast/env-hygiene.js +5 -2
- package/src/sast/iac-terraform.js +25 -0
- package/src/sast/java-bench-extras.js +1 -1
- package/src/sast/java-constant-fold.js +5 -5
- package/src/sast/llm-owasp.js +4 -2
- package/src/sast/mcp-audit.js +7 -0
- package/src/sast/pipeline.js +8 -0
- package/src/sast/prompt-template.js +8 -6
- package/src/sast/prototype-pollution.js +6 -2
- package/src/sast/redos-nfa.js +6 -6
- package/src/sast/secret-concat.js +13 -2
- package/src/sast/ssrf-cloud-metadata.js +6 -3
- package/src/sast/xss-reflected-multilang.js +1 -1
- package/src/sast/xxe.js +1 -1
- package/src/sca/CLAUDE.md +3 -4
- package/src/sca/container.js +35 -3
- package/src/sca/dep-confusion.js +9 -1
- package/src/sca/sarif-ingest.js +0 -187
package/src/ir/parser-js.js
CHANGED
|
@@ -98,7 +98,22 @@ function lhsPath(n) {
|
|
|
98
98
|
if (n.type === 'ThisExpression') return '_this_';
|
|
99
99
|
if (n.type === 'MemberExpression') {
|
|
100
100
|
const base = lhsPath(n.object);
|
|
101
|
-
|
|
101
|
+
// Stage 3 correctness audit (detection depth, path-feasibility): a
|
|
102
|
+
// computed WRITE with a literal key (`obj['secret'] = tainted`) used
|
|
103
|
+
// to collapse straight to the wildcard '*' — never trying to extract
|
|
104
|
+
// the literal, unlike exprOf's MemberExpression case just above,
|
|
105
|
+
// which DOES extract it for reads. That asymmetry meant a tainted
|
|
106
|
+
// write via bracket notation with a literal key produced access path
|
|
107
|
+
// "obj.*", while any later read of that same key (`obj.secret` or
|
|
108
|
+
// `obj['secret']`) resolves via exprOf to the specific path
|
|
109
|
+
// "obj.secret" — isCoveredBy has no wildcard semantics (`'*'` is a
|
|
110
|
+
// literal property name here, not a match-anything token), so the two
|
|
111
|
+
// paths never matched and the taint was silently unreachable from any
|
|
112
|
+
// correctly-computed read. Mirrors exprOf's extraction exactly so a
|
|
113
|
+
// write and a read of the same literal key always agree.
|
|
114
|
+
const prop = n.computed
|
|
115
|
+
? (n.property?.value != null ? String(n.property.value) : '*')
|
|
116
|
+
: (n.property?.name || '*');
|
|
102
117
|
if (!base) return null;
|
|
103
118
|
return base + '.' + prop;
|
|
104
119
|
}
|
|
@@ -138,16 +153,25 @@ export function parseJsFile(file, code) {
|
|
|
138
153
|
const exitId = nextNodeId();
|
|
139
154
|
const fn = {
|
|
140
155
|
qid, name: name || 'anon', line,
|
|
156
|
+
// Plain strings, per the IR shape contract (ir/CLAUDE.md: "params:
|
|
157
|
+
// ['arg1', 'arg2', ...]") — every other parser (py, cs, rb, cpp, ...)
|
|
158
|
+
// already emits this shape. This one used to emit {name,kind[,props]}
|
|
159
|
+
// objects instead, which every consumer (access-paths.js's
|
|
160
|
+
// isCoveredBy, summaries.js's paramNames.indexOf, entryStateFromCall,
|
|
161
|
+
// the k=2 pass's `new Set(fn.params)`) silently failed to match against
|
|
162
|
+
// — Set/string-equality checks against an object never succeed, so
|
|
163
|
+
// mutated-parameter taint and context-sensitive entry states were both
|
|
164
|
+
// unconditionally inert for every JS/TS function. Nothing in src/
|
|
165
|
+
// reads a param's .kind or .props, so the richer shape bought nothing.
|
|
141
166
|
params: (params || []).map(p => {
|
|
142
167
|
if (!p) return null;
|
|
143
|
-
if (p.type === 'Identifier') return
|
|
144
|
-
if (p.type === 'ObjectPattern') return
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
if (p.type === '
|
|
150
|
-
if (p.type === 'RestElement' && p.argument?.type === 'Identifier') return { name: p.argument.name, kind: 'rest' };
|
|
168
|
+
if (p.type === 'Identifier') return p.name;
|
|
169
|
+
if (p.type === 'ObjectPattern') return '<obj>';
|
|
170
|
+
if (p.type === 'ArrayPattern') return '<arr>';
|
|
171
|
+
if (p.type === 'AssignmentPattern' && p.left?.type === 'Identifier') return p.left.name;
|
|
172
|
+
if (p.type === 'AssignmentPattern' && p.left?.type === 'ObjectPattern') return '<obj>';
|
|
173
|
+
if (p.type === 'AssignmentPattern' && p.left?.type === 'ArrayPattern') return '<arr>';
|
|
174
|
+
if (p.type === 'RestElement' && p.argument?.type === 'Identifier') return p.argument.name;
|
|
151
175
|
return null;
|
|
152
176
|
}).filter(Boolean),
|
|
153
177
|
cfg: { entry: entryId, exit: exitId, nodes: new Map() },
|
|
@@ -189,6 +213,38 @@ export function parseJsFile(file, code) {
|
|
|
189
213
|
return node.id;
|
|
190
214
|
};
|
|
191
215
|
|
|
216
|
+
// Babel visits an IfStatement's consequent then its alternate as ordinary
|
|
217
|
+
// children of the same enter/exit pair, with no boundary hook between
|
|
218
|
+
// them — so fn._cursor was never reset before the alternate was
|
|
219
|
+
// traversed: the alternate's first node was linked as a successor of the
|
|
220
|
+
// CONSEQUENT's tail (a false predecessor edge corrupting any taint-state
|
|
221
|
+
// reasoning across the join) rather than a true second branch off the
|
|
222
|
+
// condition (also silently dropping the condition's "false" outgoing
|
|
223
|
+
// edge, which made applyPathFeasibility's constant-condition pruning
|
|
224
|
+
// treat the `if` as unconditional and delete its only edge).
|
|
225
|
+
//
|
|
226
|
+
// Called from the generic `Statement` visitor below AND from the top of
|
|
227
|
+
// every handler whose node type can itself be the direct (unbraced) root
|
|
228
|
+
// of an if's alternate (ReturnStatement, ThrowStatement, IfStatement for
|
|
229
|
+
// else-if chains, the loop statements, TryStatement) — Babel merges
|
|
230
|
+
// alias-derived and type-specific visitors for the same node but runs the
|
|
231
|
+
// type-specific one FIRST, so relying on the `Statement` alias alone
|
|
232
|
+
// misses every alternate root that also has its own specific handler.
|
|
233
|
+
// The one-shot flag makes the (redundant, second) `Statement` firing a
|
|
234
|
+
// safe no-op once the type-specific handler already did the reset.
|
|
235
|
+
const maybeResetAtAlternateBoundary = (fn, path) => {
|
|
236
|
+
const parent = path.parentPath && path.parentPath.node;
|
|
237
|
+
if (!parent || parent.type !== 'IfStatement' || parent.alternate !== path.node) return;
|
|
238
|
+
if (path.node._alternateBoundaryHandled) return;
|
|
239
|
+
path.node._alternateBoundaryHandled = true;
|
|
240
|
+
// fn._cursor is still the consequent's tail (or the condition node
|
|
241
|
+
// itself, if the consequent added none) — converge it into the join
|
|
242
|
+
// before abandoning it, then start the alternate from the condition,
|
|
243
|
+
// exactly like the consequent did.
|
|
244
|
+
linkCfg(fn, fn._cursor, parent._asJoin);
|
|
245
|
+
fn._cursor = parent._asCond;
|
|
246
|
+
};
|
|
247
|
+
|
|
192
248
|
const recordWrite = (fn, target, source, nodeId) => {
|
|
193
249
|
if (!target || typeof target !== 'string') return;
|
|
194
250
|
if (!fn.writes.has(target)) fn.writes.set(target, []);
|
|
@@ -266,17 +322,45 @@ export function parseJsFile(file, code) {
|
|
|
266
322
|
const id = lhsPath(path.node.id);
|
|
267
323
|
if (!id) return;
|
|
268
324
|
const initExpr = exprOf(path.node.init);
|
|
269
|
-
const nodeId = nextNodeId();
|
|
270
325
|
const line = path.node.loc?.start?.line || 0;
|
|
271
|
-
|
|
272
|
-
|
|
326
|
+
if (typeof id === 'string') {
|
|
327
|
+
const nodeId = nextNodeId();
|
|
328
|
+
addNode(fn, { id: nodeId, kind: 'assign', target: id, source: initExpr, line, succ: [], pred: [] });
|
|
329
|
+
recordWrite(fn, id, initExpr, nodeId);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
// Destructuring: `const {a, b: renamed} = obj;` / `const [a, b] = arr;`.
|
|
333
|
+
// Emit one REAL 'assign' CFG node per bound name — not just a
|
|
334
|
+
// recordWrite() bookkeeping entry — because the taint engine's
|
|
335
|
+
// step() 'assign' case reads `node.target` directly and requires
|
|
336
|
+
// a plain string (`typeof node.target === 'string'`); the single
|
|
337
|
+
// node this used to emit had target=the whole {kind:'object-
|
|
338
|
+
// pattern'|'array-pattern', ...} OBJECT, which step() always
|
|
339
|
+
// treated as `target=null` — so taint from `obj`/`arr` never
|
|
340
|
+
// reached ANY destructured binding, for every project that uses
|
|
341
|
+
// this extremely common pattern. fn.writes/recordWrite (used
|
|
342
|
+
// below too) is bookkeeping nothing in dataflow/ ever reads;
|
|
343
|
+
// only real CFG nodes matter to the walk.
|
|
273
344
|
if (id && typeof id === 'object' && id.kind === 'object-pattern') {
|
|
274
|
-
// x = { foo: a, bar: b } — emit one write per property.
|
|
275
345
|
for (const p of id.props) {
|
|
276
346
|
const alias = typeof p.alias === 'string' ? p.alias : null;
|
|
277
347
|
if (!alias) continue;
|
|
278
|
-
|
|
348
|
+
const memberSrc = { kind: 'member', object: initExpr, prop: p.key };
|
|
349
|
+
const nodeId = nextNodeId();
|
|
350
|
+
addNode(fn, { id: nodeId, kind: 'assign', target: alias, source: memberSrc, line, succ: [], pred: [] });
|
|
351
|
+
recordWrite(fn, alias, memberSrc, nodeId);
|
|
279
352
|
}
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
if (id && typeof id === 'object' && id.kind === 'array-pattern') {
|
|
356
|
+
id.elements.forEach((el, i) => {
|
|
357
|
+
const alias = typeof el === 'string' ? el : null;
|
|
358
|
+
if (!alias) return; // elision (`const [, b] = arr`) or nested pattern — not modeled
|
|
359
|
+
const memberSrc = { kind: 'member', object: initExpr, prop: String(i) };
|
|
360
|
+
const nodeId = nextNodeId();
|
|
361
|
+
addNode(fn, { id: nodeId, kind: 'assign', target: alias, source: memberSrc, line, succ: [], pred: [] });
|
|
362
|
+
recordWrite(fn, alias, memberSrc, nodeId);
|
|
363
|
+
});
|
|
280
364
|
}
|
|
281
365
|
},
|
|
282
366
|
|
|
@@ -298,7 +382,29 @@ export function parseJsFile(file, code) {
|
|
|
298
382
|
const parent = path.parent;
|
|
299
383
|
if (parent && (parent.type === 'VariableDeclarator' || parent.type === 'AssignmentExpression')) return;
|
|
300
384
|
const calleeExpr = exprOf(path.node.callee);
|
|
301
|
-
|
|
385
|
+
// An inline arrow/function-expression argument (`arr.map(x => ...)`)
|
|
386
|
+
// becomes {kind:'function-value', qid} instead of exprOf's generic
|
|
387
|
+
// {kind:'unknown'} fallback (exprOf has no case for either node
|
|
388
|
+
// type) — dataflow/higher-order.js's calleeIsResolvableCallback and
|
|
389
|
+
// engine.js's higher-order-invocation push site both special-case
|
|
390
|
+
// 'function-value', but nothing ever produced one for JS/TS, so
|
|
391
|
+
// higher-order taint flow only ever worked for a by-reference
|
|
392
|
+
// callback (`arr.map(processItem)`), never the far more common
|
|
393
|
+
// inline-callback shape. The qid is computed to match EXACTLY what
|
|
394
|
+
// this same node's own ArrowFunctionExpression/FunctionExpression
|
|
395
|
+
// visitor (below) will independently compute when Babel's
|
|
396
|
+
// traversal reaches it: for a function literal passed directly as
|
|
397
|
+
// a call argument, none of that visitor's naming heuristics
|
|
398
|
+
// (VariableDeclarator/AssignmentExpression/ObjectProperty parent)
|
|
399
|
+
// match, so it always resolves to name 'anon' scoped under the
|
|
400
|
+
// CURRENT function — exactly what's available here.
|
|
401
|
+
const args = (path.node.arguments || []).map(a => {
|
|
402
|
+
if (a && (a.type === 'ArrowFunctionExpression' || a.type === 'FunctionExpression')) {
|
|
403
|
+
const argLine = a.loc?.start?.line || 1; // matches enterFn's own fallback
|
|
404
|
+
return { kind: 'function-value', qid: fnQid(file, fn.name, 'anon', argLine) };
|
|
405
|
+
}
|
|
406
|
+
return exprOf(a);
|
|
407
|
+
});
|
|
302
408
|
const line = path.node.loc?.start?.line || 0;
|
|
303
409
|
const nodeId = nextNodeId();
|
|
304
410
|
addNode(fn, { id: nodeId, kind: 'call', callee: calleeExpr, args, line, succ: [], pred: [] });
|
|
@@ -313,6 +419,7 @@ export function parseJsFile(file, code) {
|
|
|
313
419
|
|
|
314
420
|
ReturnStatement(path) {
|
|
315
421
|
const fn = currentFn(); if (!fn) return;
|
|
422
|
+
maybeResetAtAlternateBoundary(fn, path);
|
|
316
423
|
const expr = path.node.argument ? exprOf(path.node.argument) : null;
|
|
317
424
|
const nodeId = nextNodeId();
|
|
318
425
|
const line = path.node.loc?.start?.line || 0;
|
|
@@ -322,12 +429,30 @@ export function parseJsFile(file, code) {
|
|
|
322
429
|
linkCfg(fn, nodeId, fn.cfg.exit);
|
|
323
430
|
},
|
|
324
431
|
|
|
432
|
+
// Fires for every statement node, via Babel's built-in "Statement"
|
|
433
|
+
// alias — the fallback path for whichever concrete type an if's
|
|
434
|
+
// `alternate` turns out to be when that type has no specific
|
|
435
|
+
// handler of its own (BlockStatement, ExpressionStatement,
|
|
436
|
+
// VariableDeclaration, ...). Types that DO have a specific handler
|
|
437
|
+
// below (ReturnStatement, IfStatement, the loop statements,
|
|
438
|
+
// TryStatement) call `maybeResetAtAlternateBoundary` themselves,
|
|
439
|
+
// since Babel runs a node's type-specific visitor BEFORE its
|
|
440
|
+
// alias-derived one for the same node — relying on this alone would
|
|
441
|
+
// miss those cases entirely.
|
|
442
|
+
Statement: {
|
|
443
|
+
enter(path) {
|
|
444
|
+
const fn = currentFn(); if (!fn) return;
|
|
445
|
+
maybeResetAtAlternateBoundary(fn, path);
|
|
446
|
+
},
|
|
447
|
+
},
|
|
448
|
+
|
|
325
449
|
IfStatement: {
|
|
326
450
|
enter(path) {
|
|
327
451
|
// We model branches by inserting a noop "join" after the if; both
|
|
328
452
|
// branches link to it. Without this, the linear cursor model would
|
|
329
453
|
// miss that statements after the if are reachable from either branch.
|
|
330
454
|
const fn = currentFn(); if (!fn) return;
|
|
455
|
+
maybeResetAtAlternateBoundary(fn, path);
|
|
331
456
|
const condNodeId = nextNodeId();
|
|
332
457
|
const joinId = nextNodeId();
|
|
333
458
|
const line = path.node.loc?.start?.line || 0;
|
|
@@ -335,7 +460,6 @@ export function parseJsFile(file, code) {
|
|
|
335
460
|
fn.cfg.nodes.set(joinId, { id: joinId, kind: 'noop', succ: [], pred: [], line });
|
|
336
461
|
path.node._asJoin = joinId;
|
|
337
462
|
path.node._asCond = condNodeId;
|
|
338
|
-
path.node._asBranchSavedCursor = fn._cursor; // == condNodeId
|
|
339
463
|
},
|
|
340
464
|
exit(path) {
|
|
341
465
|
const fn = currentFn(); if (!fn) return;
|
|
@@ -344,9 +468,10 @@ export function parseJsFile(file, code) {
|
|
|
344
468
|
if (!joinId || !condId) return;
|
|
345
469
|
// The visitor visited the body of the if — Babel's body visit ran
|
|
346
470
|
// *after* the enter(), so fn._cursor now points to the tail of the
|
|
347
|
-
// consequent
|
|
348
|
-
//
|
|
349
|
-
// the
|
|
471
|
+
// consequent (or, thanks to maybeResetAtAlternateBoundary, of the
|
|
472
|
+
// alternate when one exists). Connect it to the join, then if no
|
|
473
|
+
// else branch existed, connect the cond directly to the join
|
|
474
|
+
// (representing the "false" edge).
|
|
350
475
|
linkCfg(fn, fn._cursor, joinId);
|
|
351
476
|
if (!path.node.alternate) linkCfg(fn, condId, joinId);
|
|
352
477
|
fn._cursor = joinId;
|
|
@@ -359,6 +484,7 @@ export function parseJsFile(file, code) {
|
|
|
359
484
|
'WhileStatement|ForStatement|DoWhileStatement|ForInStatement|ForOfStatement': {
|
|
360
485
|
enter(path) {
|
|
361
486
|
const fn = currentFn(); if (!fn) return;
|
|
487
|
+
maybeResetAtAlternateBoundary(fn, path);
|
|
362
488
|
const headerId = nextNodeId();
|
|
363
489
|
const exitId = nextNodeId();
|
|
364
490
|
const line = path.node.loc?.start?.line || 0;
|
|
@@ -379,11 +505,16 @@ export function parseJsFile(file, code) {
|
|
|
379
505
|
},
|
|
380
506
|
|
|
381
507
|
TryStatement: {
|
|
382
|
-
enter() {
|
|
508
|
+
enter(path) {
|
|
509
|
+
const fn = currentFn(); if (!fn) return;
|
|
510
|
+
maybeResetAtAlternateBoundary(fn, path);
|
|
511
|
+
/* approximate try/catch as sequential — taint flows through both */
|
|
512
|
+
},
|
|
383
513
|
},
|
|
384
514
|
|
|
385
515
|
ThrowStatement(path) {
|
|
386
516
|
const fn = currentFn(); if (!fn) return;
|
|
517
|
+
maybeResetAtAlternateBoundary(fn, path);
|
|
387
518
|
const expr = exprOf(path.node.argument);
|
|
388
519
|
const nodeId = nextNodeId();
|
|
389
520
|
const line = path.node.loc?.start?.line || 0;
|
package/src/ir/parser-php.js
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
import * as crypto from 'node:crypto';
|
|
21
21
|
import { callSitesFromCfg } from './call-sites.js';
|
|
22
|
+
import { matchBalancedCall } from './balanced-call.js';
|
|
22
23
|
|
|
23
24
|
const FUNC_RE = new RegExp(
|
|
24
25
|
'(?:^|[\\n;{}]|<\\?php|<\\?)\\s*' +
|
|
@@ -66,6 +67,35 @@ function _splitStatements(body) {
|
|
|
66
67
|
function _lowerExpr(text) {
|
|
67
68
|
const s = String(text || '').trim();
|
|
68
69
|
if (!s) return { kind: 'unknown' };
|
|
70
|
+
// Stage 3 correctness audit (detection depth, per-language-IR): PHP
|
|
71
|
+
// double-quoted strings interpolate variables directly
|
|
72
|
+
// ("SELECT ... WHERE id=$id", "hi {$user->name}") — single-quoted
|
|
73
|
+
// strings never do. This must run BEFORE the generic string-literal
|
|
74
|
+
// fallback just below, which otherwise treated ANY quoted string
|
|
75
|
+
// (including a double-quoted one containing a live variable) as an
|
|
76
|
+
// opaque clean literal — silently dropping the interpolated variable's
|
|
77
|
+
// taint. This is exactly `"SELECT ... WHERE id=$id"`, one of the most
|
|
78
|
+
// common real PHP SQL-injection shapes. Simple (`$var`, `$var->prop`,
|
|
79
|
+
// `$var[key]`) and complex (`{$expr}`) interpolation forms are both
|
|
80
|
+
// lowered into a template, same shape as the `.`-concat branch below.
|
|
81
|
+
if (/^"/.test(s) && s.includes('$')) {
|
|
82
|
+
const inner = s.slice(1, -1);
|
|
83
|
+
const re = /\{(\$[^}]+)\}|(\$[A-Za-z_]\w*(?:->[A-Za-z_]\w*|\[[^\]]+\])?)/g;
|
|
84
|
+
let lastIndex = 0;
|
|
85
|
+
const parts = [];
|
|
86
|
+
let matched = false;
|
|
87
|
+
let m;
|
|
88
|
+
while ((m = re.exec(inner)) !== null) {
|
|
89
|
+
matched = true;
|
|
90
|
+
if (m.index > lastIndex) parts.push({ kind: 'literal', value: inner.slice(lastIndex, m.index) });
|
|
91
|
+
parts.push(_lowerExpr(m[1] !== undefined ? m[1] : m[2]));
|
|
92
|
+
lastIndex = re.lastIndex;
|
|
93
|
+
}
|
|
94
|
+
if (matched) {
|
|
95
|
+
if (lastIndex < inner.length) parts.push({ kind: 'literal', value: inner.slice(lastIndex) });
|
|
96
|
+
return { kind: 'tpl', parts };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
69
99
|
if (/^"/.test(s) || /^'/.test(s)) return { kind: 'literal', value: s };
|
|
70
100
|
if (/^\d/.test(s)) return { kind: 'literal', value: s };
|
|
71
101
|
if (/^(true|false|null|NULL)\b/.test(s)) return { kind: 'literal', value: s };
|
|
@@ -81,17 +111,22 @@ function _lowerExpr(text) {
|
|
|
81
111
|
}
|
|
82
112
|
// Variable
|
|
83
113
|
if (/^\$[A-Za-z_]\w*$/.test(s)) return { kind: 'ident', name: s };
|
|
84
|
-
// Method call: $obj->method(args) or ClassName::method(args)
|
|
85
|
-
|
|
114
|
+
// Method call: $obj->method(args) or ClassName::method(args).
|
|
115
|
+
// matchBalancedCall finds the paren that actually balances the FIRST
|
|
116
|
+
// '(' — not the greedy-to-end-of-string match the old `/\((.*)\)\s*$/`
|
|
117
|
+
// used, which corrupted the argument text for a chained call
|
|
118
|
+
// (`sanitize($x)->trim()` produced args="$x)->trim(", which then fell
|
|
119
|
+
// through to {kind:'unknown'} and silently dropped $x).
|
|
120
|
+
const methodCall = matchBalancedCall(s, /^(\$[\w]+(?:->[\w]+)*|[A-Za-z_][\w]*(?:::[\w]+)*)/);
|
|
86
121
|
if (methodCall) {
|
|
87
|
-
const callee = methodCall
|
|
88
|
-
const args = _splitTopLevelCommas(methodCall
|
|
122
|
+
const callee = methodCall.callee.replace(/->/g, '.').replace(/::/g, '.');
|
|
123
|
+
const args = _splitTopLevelCommas(methodCall.argsText).map(_lowerExpr);
|
|
89
124
|
return { kind: 'call', callee, args };
|
|
90
125
|
}
|
|
91
126
|
// Function call: func(args)
|
|
92
|
-
const funcCall = s
|
|
127
|
+
const funcCall = matchBalancedCall(s, /^([A-Za-z_][\w]*)/);
|
|
93
128
|
if (funcCall) {
|
|
94
|
-
return { kind: 'call', callee: funcCall
|
|
129
|
+
return { kind: 'call', callee: funcCall.callee, args: _splitTopLevelCommas(funcCall.argsText).map(_lowerExpr) };
|
|
95
130
|
}
|
|
96
131
|
// Concat with .
|
|
97
132
|
if (s.includes('.') && /["'\$]/.test(s)) {
|
|
@@ -170,10 +205,10 @@ function _lowerStmt(stmt, line) {
|
|
|
170
205
|
return { kind: 'assign', line, target: assign[1], source: _lowerExpr(assign[2]) };
|
|
171
206
|
}
|
|
172
207
|
// Statement-form call
|
|
173
|
-
const call = s
|
|
208
|
+
const call = matchBalancedCall(s, /^(\$[\w]+(?:->[\w]+)*|[A-Za-z_][\w]*(?:::[\w]+)*)/);
|
|
174
209
|
if (call) {
|
|
175
|
-
const callee = call
|
|
176
|
-
return { kind: 'call', line, callee, args: _splitTopLevelCommas(call
|
|
210
|
+
const callee = call.callee.replace(/->/g, '.').replace(/::/g, '.');
|
|
211
|
+
return { kind: 'call', line, callee, args: _splitTopLevelCommas(call.argsText).map(_lowerExpr) };
|
|
177
212
|
}
|
|
178
213
|
return null;
|
|
179
214
|
}
|
package/src/ir/parser-rb.js
CHANGED
|
@@ -21,8 +21,21 @@
|
|
|
21
21
|
// failure (heredocs, multi-line strings can confuse the regex parser).
|
|
22
22
|
|
|
23
23
|
import * as crypto from 'node:crypto';
|
|
24
|
+
import { callSitesFromCfg } from './call-sites.js';
|
|
25
|
+
import { matchBalancedCall } from './balanced-call.js';
|
|
24
26
|
|
|
25
|
-
|
|
27
|
+
// `[ \t]*` before the optional parameter list, NOT `\s*`.
|
|
28
|
+
//
|
|
29
|
+
// `\s*` crosses newlines, so for `def show\n c = params[:c]` the match ran to
|
|
30
|
+
// the next line's indentation. `parseRubyFile` then computes the body start as
|
|
31
|
+
// `indexOf('\n', m.index + m[0].length)`, which landed on the newline at the END
|
|
32
|
+
// of the first statement — so the body was sliced from after it and statement 1
|
|
33
|
+
// of EVERY Ruby method was silently discarded (a single-statement body became
|
|
34
|
+
// empty). In a Rails controller that first statement is almost always the
|
|
35
|
+
// `params` read, i.e. the taint source, which is why `bench/layer-recall`
|
|
36
|
+
// measured Ruby at 0/20 IR-TAINT recall while all 20 corpus entries passed on
|
|
37
|
+
// the regex layer. A parameter list on the same line still matches.
|
|
38
|
+
const DEF_RE = /(?:^|\n)[ \t]*def\s+(?:self\.)?(\w+[?!=]?)[ \t]*(?:\(([^)]*)\))?/g;
|
|
26
39
|
|
|
27
40
|
function _extractRubyBody(src, defEnd) {
|
|
28
41
|
let depth = 1;
|
|
@@ -110,10 +123,15 @@ function _lowerExpr(text) {
|
|
|
110
123
|
if (/^(true|false|nil)\b/.test(s)) return { kind: 'literal', value: s };
|
|
111
124
|
// Symbol
|
|
112
125
|
if (/^:\w+/.test(s)) return { kind: 'literal', value: s };
|
|
113
|
-
// Call: obj.method(args) or method(args)
|
|
114
|
-
|
|
126
|
+
// Call: obj.method(args) or method(args). matchBalancedCall finds the
|
|
127
|
+
// paren that actually balances the FIRST '(' — not the greedy-to-end-of-
|
|
128
|
+
// string match the old `/\((.*)\)\s*$/` used, which corrupted the
|
|
129
|
+
// argument text for a chained call (`sanitize(x).strip` produced
|
|
130
|
+
// args="x).strip", which then fell through to {kind:'unknown'} and
|
|
131
|
+
// silently dropped x).
|
|
132
|
+
const callMatch = matchBalancedCall(s, /^([\w.]+)/);
|
|
115
133
|
if (callMatch) {
|
|
116
|
-
return { kind: 'call', callee: callMatch
|
|
134
|
+
return { kind: 'call', callee: callMatch.callee, args: _splitTopLevelCommas(callMatch.argsText).map(_lowerExpr) };
|
|
117
135
|
}
|
|
118
136
|
// Method call without parens is very common in Ruby but hard to detect
|
|
119
137
|
// reliably with regex. We handle the explicit-paren form above.
|
|
@@ -179,9 +197,9 @@ function _lowerStmt(stmt, line) {
|
|
|
179
197
|
return { kind: 'assign', line, target: assign[1], source: _lowerExpr(assign[2]) };
|
|
180
198
|
}
|
|
181
199
|
// Statement-form call with parens
|
|
182
|
-
const call = s
|
|
200
|
+
const call = matchBalancedCall(s, /^([\w.]+)/);
|
|
183
201
|
if (call) {
|
|
184
|
-
return { kind: 'call', line, callee: call
|
|
202
|
+
return { kind: 'call', line, callee: call.callee, args: _splitTopLevelCommas(call.argsText).map(_lowerExpr) };
|
|
185
203
|
}
|
|
186
204
|
// Statement-form call without parens (common Ruby idiom): redirect_to expr
|
|
187
205
|
const bareCall = s.match(/^([a-z_]\w*)\s+(.+)$/s);
|
|
@@ -298,10 +316,22 @@ export function parseRubyFile(file, code) {
|
|
|
298
316
|
const exit = _addNode(nodes, { kind: 'exit', line: startLine });
|
|
299
317
|
const tail = _buildCfg(extracted.body, nodes, entry, startLine + 1);
|
|
300
318
|
_linkNodes(nodes, tail, exit);
|
|
319
|
+
const cfg = { entry, exit, nodes };
|
|
301
320
|
functions.push({
|
|
302
321
|
qid: _qid(file, name, startLine, extracted.body),
|
|
303
322
|
name, line: startLine, params, file,
|
|
304
|
-
cfg
|
|
323
|
+
cfg,
|
|
324
|
+
// Ruby never emitted `fn.calls` at all (ir/CLAUDE.md documents this
|
|
325
|
+
// as a known gap) — tabulation.js, dataflow/index.js and
|
|
326
|
+
// callgraph.js all read it to build call edges, so an absent array
|
|
327
|
+
// is indistinguishable from "calls nothing," disabling ALL Ruby
|
|
328
|
+
// interprocedural taint. call-sites.js's callSitesFromCfg is the
|
|
329
|
+
// same language-agnostic CFG walk parser-py-cst.js already uses for
|
|
330
|
+
// exactly this; Ruby's node shapes ('call' with callee/args,
|
|
331
|
+
// 'assign' with source, 'return'/'throw' with value, 'if' with
|
|
332
|
+
// cond) match its documented contract already, so no new lowering
|
|
333
|
+
// logic is needed here — only wiring the call.
|
|
334
|
+
calls: callSitesFromCfg(cfg),
|
|
305
335
|
});
|
|
306
336
|
DEF_RE.lastIndex = extracted.end;
|
|
307
337
|
}
|
package/src/ir/ssa.js
CHANGED
|
@@ -43,7 +43,12 @@ export function isSSAEnabled() {
|
|
|
43
43
|
*
|
|
44
44
|
* Returns Map<nodeId, Set<nodeId>> — dom[n] = set of nodes that dominate n.
|
|
45
45
|
*/
|
|
46
|
-
|
|
46
|
+
// Exported (Stage 6 correctness audit) so dataflow/implicit-flow.js can
|
|
47
|
+
// reuse this tested algorithm to correctly scope "is node N genuinely
|
|
48
|
+
// inside branch B" (N is inside iff B dominates N — every path to N passes
|
|
49
|
+
// through B) instead of a path-dependent DFS depth counter that had no way
|
|
50
|
+
// to detect a branch's join point.
|
|
51
|
+
export function computeDominators(cfg) {
|
|
47
52
|
const nodes = Object.keys(cfg.nodes || {});
|
|
48
53
|
const entry = cfg.entry;
|
|
49
54
|
const dom = new Map();
|
package/src/leaderboard.js
CHANGED
|
@@ -27,6 +27,7 @@ import * as fs from 'node:fs';
|
|
|
27
27
|
import * as path from 'node:path';
|
|
28
28
|
import { summarizeForBadge } from './badge.js';
|
|
29
29
|
|
|
30
|
+
import { statePath } from './posture/state-dir.js';
|
|
30
31
|
// Grade thresholds. Critical findings dominate; high/medium contribute
|
|
31
32
|
// secondarily. These numbers are heuristic — calibrate against the
|
|
32
33
|
// public leaderboard corpus once data lands.
|
|
@@ -90,7 +91,7 @@ function _deltaTrend(history) {
|
|
|
90
91
|
*/
|
|
91
92
|
export function leaderboardRowFor({ scanRoot, repo, badgeBase = 'https://agentic-security.dev/badge' } = {}) {
|
|
92
93
|
if (!repo) throw new Error('leaderboardRowFor: repo slug is required');
|
|
93
|
-
const lastScanPath =
|
|
94
|
+
const lastScanPath = statePath(scanRoot || '.', 'last-scan.json');
|
|
94
95
|
let scan = null;
|
|
95
96
|
try { scan = JSON.parse(fs.readFileSync(lastScanPath, 'utf8')); } catch {}
|
|
96
97
|
const summary = summarizeForBadge(scan);
|
|
@@ -98,7 +99,7 @@ export function leaderboardRowFor({ scanRoot, repo, badgeBase = 'https://agentic
|
|
|
98
99
|
const topCwe = _topCwe(scan);
|
|
99
100
|
|
|
100
101
|
// Optional scan history for the trend signal.
|
|
101
|
-
const historyPath =
|
|
102
|
+
const historyPath = statePath(scanRoot || '.', 'scan-history.jsonl');
|
|
102
103
|
let history = [];
|
|
103
104
|
if (fs.existsSync(historyPath)) {
|
|
104
105
|
try {
|
|
@@ -24,6 +24,7 @@ import * as fs from 'node:fs';
|
|
|
24
24
|
import * as path from 'node:path';
|
|
25
25
|
import { validateOne } from './index.js';
|
|
26
26
|
|
|
27
|
+
import { statePath, stateWritesEnabled } from '../posture/state-dir.js';
|
|
27
28
|
// Build a deterministic test finding from the project's last-scan.json,
|
|
28
29
|
// or accept a hand-crafted one. Returns a clone safe to mutate per trial.
|
|
29
30
|
export function makeTrialFinding(template) {
|
|
@@ -60,9 +61,12 @@ export async function measureConsistency({
|
|
|
60
61
|
// If useCache is false, we want each trial to bypass the cache; we
|
|
61
62
|
// simulate by mutating the finding's file in a way the cache key
|
|
62
63
|
// hashes over. Simpler: clear the per-scanRoot cache before each trial.
|
|
63
|
-
|
|
64
|
+
// `stateWritesEnabled()` gates the DELETE as well as any write: removing
|
|
65
|
+
// files from the scanned tree is a mutation like any other, and under a
|
|
66
|
+
// read-only scan there is no cache to clear anyway. (PRD M1)
|
|
67
|
+
if (!useCache && scanRoot && stateWritesEnabled()) {
|
|
64
68
|
try {
|
|
65
|
-
const cacheDir =
|
|
69
|
+
const cacheDir = statePath(scanRoot, 'llm-cache');
|
|
66
70
|
if (fs.existsSync(cacheDir)) {
|
|
67
71
|
for (const e of fs.readdirSync(cacheDir)) fs.unlinkSync(path.join(cacheDir, e));
|
|
68
72
|
}
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
import * as fs from 'node:fs';
|
|
65
65
|
import * as path from 'node:path';
|
|
66
66
|
import * as crypto from 'node:crypto';
|
|
67
|
-
import {
|
|
67
|
+
import { ensureStateDir, safeWriteState, statePath } from '../posture/state-dir.js';
|
|
68
68
|
import { redactSecrets } from './redact.js';
|
|
69
69
|
import { signLastScan } from '../posture/integrity.js';
|
|
70
70
|
|
|
@@ -84,7 +84,6 @@ const MAX_OUTPUT_TOKENS = 512;
|
|
|
84
84
|
let _localPresetRefusal = null;
|
|
85
85
|
|
|
86
86
|
export const PROMPT_VERSION = 'v2.0-hardened';
|
|
87
|
-
const CACHE_DIR = '.agentic-security/llm-cache';
|
|
88
87
|
|
|
89
88
|
// System preamble — embeds a per-request challenge token the model MUST
|
|
90
89
|
// echo, and a strict instruction-priority frame. {{challenge}} and {{nonce}}
|
package/src/lsp/server.js
CHANGED
|
@@ -18,6 +18,8 @@ import * as path from 'node:path';
|
|
|
18
18
|
import * as readline from 'node:readline';
|
|
19
19
|
import { runScan } from '../runScan.js';
|
|
20
20
|
import { resetCustomRulesBudget } from '../posture/custom-rules.js';
|
|
21
|
+
import { redactFinding } from '../mcp/redact.js';
|
|
22
|
+
import { _remediationOf } from '../report/index.js';
|
|
21
23
|
|
|
22
24
|
const PROTOCOL_VERSION = '3.17';
|
|
23
25
|
const SERVER_NAME = 'agentic-security-lsp';
|
|
@@ -52,6 +54,13 @@ function sevToLsp(sev) {
|
|
|
52
54
|
|
|
53
55
|
function findingToDiagnostic(f) {
|
|
54
56
|
const line = Math.max(0, (f.line || 1) - 1);
|
|
57
|
+
// Stage 6 correctness audit: this read f.remediation directly, but raw
|
|
58
|
+
// scan.findings entries (what this consumes, pre-normalizeFindings) come
|
|
59
|
+
// from two conventions — most posture/*.js and newer sast/*.js modules
|
|
60
|
+
// set `remediation`, while ~127 of engine.js's own detectors set a `fix`
|
|
61
|
+
// STRING field instead. _remediationOf carries the same precedence
|
|
62
|
+
// report/index.js already established for this exact split (CMP-3).
|
|
63
|
+
const remediation = _remediationOf(f);
|
|
55
64
|
return {
|
|
56
65
|
range: {
|
|
57
66
|
start: { line, character: 0 },
|
|
@@ -60,7 +69,7 @@ function findingToDiagnostic(f) {
|
|
|
60
69
|
severity: sevToLsp(f.severity),
|
|
61
70
|
source: 'agentic-security',
|
|
62
71
|
code: f.cwe || f.family || 'finding',
|
|
63
|
-
message: `${f.vuln || 'Security finding'}${
|
|
72
|
+
message: `${f.vuln || 'Security finding'}${remediation ? '\n\n' + remediation : ''}`.slice(0, 2000),
|
|
64
73
|
tags: [],
|
|
65
74
|
};
|
|
66
75
|
}
|
|
@@ -142,7 +151,20 @@ async function scanFile(uri) {
|
|
|
142
151
|
// and eventually start skipping custom rules.
|
|
143
152
|
resetCustomRulesBudget(_rootDir);
|
|
144
153
|
const { scan } = await runScan(_rootDir, { fileContents, depFileContents });
|
|
145
|
-
|
|
154
|
+
// Stage 6 correctness audit: this only ever read scan.findings (the SAST
|
|
155
|
+
// channel). scan.secrets and scan.logicVulns are separate arrays on the
|
|
156
|
+
// raw runScan() result — normalizeFindings is what merges all four
|
|
157
|
+
// channels, and that hasn't run here — so a saved file with a hardcoded
|
|
158
|
+
// credential got a clean problem pane, no diagnostic at all. Unlike the
|
|
159
|
+
// MCP surface, this server never applied redactFinding either (nothing
|
|
160
|
+
// here imported mcp/redact.js), which would have been a landmine the
|
|
161
|
+
// moment secrets/logicVulns were added without it: those channels are
|
|
162
|
+
// exactly where raw secret material shows up in `snippet`. Both fixed
|
|
163
|
+
// together — merge the channels AND redact — so the fix for one gap
|
|
164
|
+
// doesn't open the other.
|
|
165
|
+
const findings = [...(scan.findings || []), ...(scan.secrets || []), ...(scan.logicVulns || [])]
|
|
166
|
+
.filter(f => f.file === rel)
|
|
167
|
+
.map(f => redactFinding(f));
|
|
146
168
|
await publishDiagnostics(uri, findings);
|
|
147
169
|
} catch (e) {
|
|
148
170
|
process.stderr.write(`agentic-security-lsp: scan failed: ${e.message}\n`);
|
|
@@ -273,3 +295,7 @@ export function startLspServer() {
|
|
|
273
295
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
274
296
|
startLspServer();
|
|
275
297
|
}
|
|
298
|
+
|
|
299
|
+
function _setRootDir(dir) { _rootDir = dir; _depCache = { rootDir: null, depFileContents: {} }; }
|
|
300
|
+
|
|
301
|
+
export const _internals = { findingToDiagnostic, scanFile, uriToPath, pathToUri, _diagnosticsByUri, _setRootDir };
|
package/src/mcp/CLAUDE.md
CHANGED
|
@@ -12,14 +12,21 @@ MCP server. JSON-RPC 2.0 over NDJSON on stdin/stdout. Bin entry `../../bin/agent
|
|
|
12
12
|
| `find_rule_module` | ✓ | reads `scanner/src/{sast,posture}/` to answer "which file detects CWE-X / family Y" |
|
|
13
13
|
| `lookup_cve` | ✓ | reads local OSV / KEV / EPSS cache; staleness-tiered |
|
|
14
14
|
| `synthesize_fix` | ✓ | reads last-scan; returns the patch text |
|
|
15
|
-
| `verify_fix` |
|
|
15
|
+
| `verify_fix` | ✗ | re-scans patched files in memory, runs lint + the project test suite + the fix-honesty gate + PoC re-check; does not touch the target project's own files, but appends a record to `.agentic-security/fix-metrics.jsonl` per attempt |
|
|
16
16
|
| `apply_fix` | ✗ | writes via `posture/fix-history.js` (with backup) |
|
|
17
17
|
| `append_scratchpad` | ✗ | writes under `.agentic-security/agent-scratchpad/<agent>/<session>/` only |
|
|
18
18
|
| `read_scratchpad` | ✓ | paginated read of scratchpad files |
|
|
19
19
|
| `append_agents_memory` | ✗ | appends to `.agentic-security/AGENTS.md` continual-learning file |
|
|
20
20
|
| `read_agents_memory` | ✓ | tail of `.agentic-security/AGENTS.md` |
|
|
21
|
+
| `synthesize_sca_upgrade` | ✓ | runs an ecosystem dry-run (`npm install --dry-run` etc.); returns the upgrade plan; no writes |
|
|
22
|
+
| `apply_sca_upgrade` | ✗ | backs up manifests, runs the package manager, runs the project test command, restores manifests on test failure |
|
|
23
|
+
| `query_triage_memory` | ✓ | reads past triage decisions (wont-fix/false-positive) by natural-language query |
|
|
24
|
+
| `query_findings_memory` | ✓ | reads accumulated scan memory (findings + triage history + AGENTS.md) by natural-language query |
|
|
25
|
+
| `query_cache_telemetry` | ✓ | reads prompt-cache economics from the current session transcript; no network |
|
|
21
26
|
|
|
22
|
-
|
|
27
|
+
**17 tools, not 12** — this table previously stopped at 12 and the count quoted elsewhere (root `CLAUDE.md`, the non-Claude plugin manifests) said "Six." Re-derive with `grep -c "name: '" scanner/src/mcp/tools.js` rather than trusting a hardcoded number here again.
|
|
28
|
+
|
|
29
|
+
**Two write tools, not one.** `apply_fix` and `apply_sca_upgrade` both write; `verify_fix` also writes (see its row above) though not to the target project's own files. `apply_fix` additionally requires `confirm:true` AND the last-scan HMAC to verify AND the target path not on the reserved-write list; `apply_sca_upgrade` requires `confirm:true` and gates on its own test-restore cycle.
|
|
23
30
|
|
|
24
31
|
## Hardening posture (OWASP MCP Top 10)
|
|
25
32
|
|
package/src/mcp/audit.js
CHANGED
|
@@ -27,6 +27,7 @@ import * as path from 'node:path';
|
|
|
27
27
|
import * as crypto from 'node:crypto';
|
|
28
28
|
import { redactArgsBlob } from './redact.js';
|
|
29
29
|
|
|
30
|
+
import { stateDir } from '../posture/state-dir.js';
|
|
30
31
|
const MAX_ARG_BYTES = 1024;
|
|
31
32
|
const GENESIS = 'GENESIS';
|
|
32
33
|
const REMOTE_TIMEOUT_MS = 1500;
|
|
@@ -87,7 +88,7 @@ export function auditCall({ sessionRoot, tool, args, outcome, reason }) {
|
|
|
87
88
|
let hasMarker = false;
|
|
88
89
|
for (const m of MARKERS) { try { if (fs.existsSync(path.join(sessionRoot, m))) { hasMarker = true; break; } } catch {} }
|
|
89
90
|
if (!hasMarker) return;
|
|
90
|
-
const dir =
|
|
91
|
+
const dir = stateDir(sessionRoot);
|
|
91
92
|
fs.mkdirSync(dir, { recursive: true });
|
|
92
93
|
const logFile = path.join(dir, 'mcp-audit.log');
|
|
93
94
|
const entry = {
|