@clear-capabilities/agentic-security-scanner 0.128.1 → 0.130.0
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 +101 -0
- package/bin/agentic-security.js +33 -0
- package/dist/11.index.js +2 -2
- package/dist/113.index.js +209 -7
- package/dist/178.index.js +1 -1
- package/dist/207.index.js +217 -0
- package/dist/384.index.js +1 -1
- package/dist/415.index.js +1 -1
- package/dist/435.index.js +2 -2
- package/dist/526.index.js +555 -0
- package/dist/637.index.js +1 -1
- package/dist/830.index.js +1 -1
- package/dist/agentic-security.mjs +113 -162
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +22 -14
- package/src/dataflow/CLAUDE.md +4 -1
- package/src/dataflow/async-sequencing.js +8 -3
- package/src/dataflow/catalog.js +278 -11
- package/src/dataflow/cross-repo.js +1 -1
- package/src/dataflow/cross-service-taint.js +1 -1
- package/src/dataflow/engine.js +182 -61
- package/src/dataflow/ifds.js +10 -5
- package/src/dataflow/index.js +15 -3
- package/src/dataflow/points-to.js +8 -2
- package/src/dataflow/proof-gate.js +7 -0
- package/src/dataflow/sanitizer-gate.js +89 -0
- package/src/dataflow/tabulation.js +14 -3
- package/src/engine.js +154 -7
- package/src/integrations/index.js +1 -1
- package/src/ir/CLAUDE.md +49 -4
- package/src/ir/call-sites.js +66 -0
- package/src/ir/callgraph.js +174 -7
- package/src/ir/class-hierarchy.js +22 -2
- package/src/ir/index.js +138 -51
- package/src/ir/ir-stats.js +126 -0
- package/src/ir/parser-cpp.js +829 -0
- package/src/ir/parser-cs.js +4 -1
- package/src/ir/parser-go.js +4 -1
- package/src/ir/parser-js.js +5 -1
- package/src/ir/parser-kt.js +4 -1
- package/src/ir/parser-php.js +10 -3
- package/src/ir/parser-py-cst.js +62 -10
- package/src/ir/tree-sitter-loader.js +13 -1
- package/src/llm-validator/index.js +9 -2
- package/src/llm-validator/redact.js +157 -0
- package/src/posture/CLAUDE.md +115 -0
- package/src/posture/accuracy-scorecard.js +317 -0
- package/src/posture/api-contract.js +1 -1
- package/src/posture/attestation.js +199 -0
- package/src/posture/auditor-walkthrough.js +12 -3
- package/src/posture/compliance-policy.js +1 -1
- package/src/posture/cross-lang-openapi.js +1 -1
- package/src/posture/custom-rules.js +1 -1
- package/src/posture/execution-proof.js +52 -0
- package/src/posture/exploitability-probability.js +1 -1
- package/src/posture/falsification.js +45 -1
- package/src/posture/fix-verify.js +55 -2
- package/src/posture/license-policy.js +1 -1
- package/src/posture/profile.js +1 -1
- package/src/posture/proof-tier.js +33 -0
- package/src/posture/relevance.js +379 -0
- package/src/posture/rule-overrides.js +1 -1
- package/src/posture/sca-policy.js +1 -1
- package/src/posture/scan-checkpoint.js +277 -0
- package/src/posture/suppressions.js +1 -1
- package/src/posture/test-runner.js +147 -0
- package/src/posture/verification-separation.js +131 -0
- package/src/report/index.js +11 -0
- package/src/runScan.js +3 -1
- package/src/sandbox/CLAUDE.md +218 -0
- package/src/sandbox/backend-disabled.js +14 -0
- package/src/sandbox/backend-namespace.js +83 -0
- package/src/sandbox/backend-userspace.js +100 -0
- package/src/sandbox/capabilities.js +53 -0
- package/src/sandbox/index.js +30 -0
- package/src/sandbox/limits.js +42 -0
- package/src/sandbox/result.js +104 -0
- package/src/sca/dep-confusion.js +1 -1
- package/src/util/yaml.js +24 -0
package/src/engine.js
CHANGED
|
@@ -6,7 +6,7 @@ import * as fs from 'node:fs';
|
|
|
6
6
|
import * as path from 'node:path';
|
|
7
7
|
import * as os from 'node:os';
|
|
8
8
|
import * as crypto from 'node:crypto';
|
|
9
|
-
import * as yaml from 'js
|
|
9
|
+
import * as yaml from './util/yaml.js';
|
|
10
10
|
import { createRequire } from 'node:module';
|
|
11
11
|
const _require = createRequire(import.meta.url);
|
|
12
12
|
import { scanLLM } from './sast/llm.js';
|
|
@@ -143,6 +143,7 @@ import { annotateNarration } from './posture/flow-narration.js';
|
|
|
143
143
|
import { applyPathConstraints } from './posture/path-predicates.js';
|
|
144
144
|
// Phase 3 (Sentinel-parity Layer 1 + 2) — IR + interprocedural taint engine.
|
|
145
145
|
import { buildProjectIR } from './ir/index.js';
|
|
146
|
+
import { collectIrStats, irStatsTarget, writeIrStats } from './ir/ir-stats.js';
|
|
146
147
|
import { runDeepAnalysis } from './dataflow/index.js';
|
|
147
148
|
// v3 next-gen — Pillars 1, 4, 5, 6, 8, 9.
|
|
148
149
|
import { annotateCloneClusters, findCloneOutliers } from './posture/semantic-clone.js';
|
|
@@ -174,9 +175,11 @@ import { applyLearnedCalibration } from './posture/triage-learning.js';
|
|
|
174
175
|
import { annotateFormalVerification } from './dataflow/formal-verify.js';
|
|
175
176
|
import { annotatePathFeasibility } from './dataflow/smt-feasibility.js';
|
|
176
177
|
import { annotateProofGate } from './dataflow/proof-gate.js';
|
|
178
|
+
import { applySanitizerGate } from './dataflow/sanitizer-gate.js';
|
|
177
179
|
import { annotateFalsification } from './posture/falsification.js';
|
|
178
180
|
import { routeModelForFinding } from './posture/model-routing.js';
|
|
179
181
|
import { buildEntrypointInventory } from './posture/entrypoint-inventory.js';
|
|
182
|
+
import { annotateRelevance } from './posture/relevance.js';
|
|
180
183
|
import { sweepRootCauses } from './posture/root-cause-sweep.js';
|
|
181
184
|
import { computeAnalysisTiers, countUnmodeledSinkCandidates } from './posture/coverage-report.js';
|
|
182
185
|
import { annotatePrivacyTaint, emitDpiaArtifact } from './dataflow/privacy-taint.js';
|
|
@@ -212,6 +215,13 @@ import { buildTrustBoundaryDiagram } from './posture/trust-boundary-diagram.js';
|
|
|
212
215
|
import { scanConcurrency } from './posture/concurrency-checker.js';
|
|
213
216
|
import { annotateBountyPrediction } from './posture/bounty-prediction.js';
|
|
214
217
|
import { annotateAttackPlaybooks } from './posture/attack-playbooks.js';
|
|
218
|
+
// R8: opt-in scan checkpointing/resume for the per-file loop.
|
|
219
|
+
import {
|
|
220
|
+
openCheckpoint, recordFileDone, completedFiles, resumeFindings, closeCheckpoint,
|
|
221
|
+
computeRunKey, bundleShaForRunKey,
|
|
222
|
+
} from './posture/scan-checkpoint.js';
|
|
223
|
+
import { SCANNER_VERSION as _ENGINE_VERSION } from './posture/version.js';
|
|
224
|
+
import { effectiveVersion as _effectiveRulesetVersion } from './posture/ruleset-version.js';
|
|
215
225
|
|
|
216
226
|
// Disk-backed cache replacing browser sessionStorage. One JSON blob per key under ~/.claude/agentic-security/osv-cache/.
|
|
217
227
|
const _CACHE_DIR = path.join(os.homedir(), '.claude', 'agentic-security', 'osv-cache');
|
|
@@ -985,7 +995,7 @@ function performASTAnalysis(fp, code) {
|
|
|
985
995
|
try {
|
|
986
996
|
babelTransformSync(code, {
|
|
987
997
|
filename: fp,
|
|
988
|
-
presets: [presetReact, [presetTypescript, {
|
|
998
|
+
presets: [presetReact, [presetTypescript, { ignoreExtensions: true }]],
|
|
989
999
|
plugins: [astTaintTrackerPlugin],
|
|
990
1000
|
ast: false, code: false,
|
|
991
1001
|
babelrc: false, configFile: false,
|
|
@@ -2266,6 +2276,10 @@ function _isFalsePositiveCredential(fp, snippet, fullMatch){
|
|
|
2266
2276
|
// Module-level suppression log; cleared at the start of each runFullScan invocation.
|
|
2267
2277
|
const _suppressionLog = [];
|
|
2268
2278
|
function _resetSuppressions(){ _suppressionLog.length = 0; }
|
|
2279
|
+
// R8: the per-file taint result minus the four arrays that are also appended
|
|
2280
|
+
// wholesale to the aggregates. Resume rebuilds those from the aggregate slices
|
|
2281
|
+
// so pfr[p] and the aggregates share object identity, exactly as in a normal run.
|
|
2282
|
+
function _pfrMetaOnly(ta){ if(!ta||typeof ta!=='object')return {}; const o={}; for(const k of Object.keys(ta)){ if(k==='findings'||k==='sources'||k==='sinks'||k==='sanitizers')continue; o[k]=ta[k]; } return o; }
|
|
2269
2283
|
function _getSuppressions(){ return [..._suppressionLog]; }
|
|
2270
2284
|
|
|
2271
2285
|
// FP-9 / Feat-4: custom rules loaded from .agentic-security/rules.{yml,yaml,json}
|
|
@@ -4631,7 +4645,7 @@ function _buildCallGraphAST(fp, code){
|
|
|
4631
4645
|
|
|
4632
4646
|
babelTransformSync(code, {
|
|
4633
4647
|
filename: fp,
|
|
4634
|
-
presets: [presetReact, [presetTypescript, {
|
|
4648
|
+
presets: [presetReact, [presetTypescript, { ignoreExtensions: true }]],
|
|
4635
4649
|
plugins: [callTrackerPlugin],
|
|
4636
4650
|
ast: false, code: false,
|
|
4637
4651
|
babelrc: false, configFile: false,
|
|
@@ -7389,7 +7403,7 @@ async function queryRegistries(components){
|
|
|
7389
7403
|
|
|
7390
7404
|
// Node port: takes { fileContents, depFileContents } maps directly instead of a JSZip object.
|
|
7391
7405
|
// fileContents = code files keyed by relative path; depFileContents = manifest/lockfiles keyed by relative path.
|
|
7392
|
-
async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null}, setProgress=()=>{}){_resetSuppressions();_buildProjectIndex(fileContents);await _loadCustomRules(scanRoot);
|
|
7406
|
+
async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null, resume=undefined}, setProgress=()=>{}){_resetSuppressions();_buildProjectIndex(fileContents);await _loadCustomRules(scanRoot);
|
|
7393
7407
|
// Pre-pass: build cross-file Java tainted-method index so per-file taint
|
|
7394
7408
|
// analysis can recognize calls to user-input-returning helper methods
|
|
7395
7409
|
// defined in OTHER files (Juliet's DataflowThruInnerClass / Vector / Stream
|
|
@@ -7399,7 +7413,75 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null},
|
|
|
7399
7413
|
const _perFileTimeoutMs = parseInt(process.env.AGENTIC_SECURITY_PER_FILE_TIMEOUT_MS || '10000', 10);
|
|
7400
7414
|
const _fileTimings = [];
|
|
7401
7415
|
let _filesSkipped = 0, _filesTimedOut = 0, _filesDenseSkipped = 0;
|
|
7402
|
-
const files=Object.keys(fileContents).filter(f=>shouldScan(f) && !_isPathIgnored(f));const fc={},pfr={};const aR=[],aF=[],aSrc=[],aSink=[],aSan=[],aLogic=[],aSupply=[],aSecrets=[],aCiphersRest=[],aCiphersTransit=[];
|
|
7416
|
+
const files=Object.keys(fileContents).filter(f=>shouldScan(f) && !_isPathIgnored(f));const fc={},pfr={};const aR=[],aF=[],aSrc=[],aSink=[],aSan=[],aLogic=[],aSupply=[],aSecrets=[],aCiphersRest=[],aCiphersTransit=[];
|
|
7417
|
+
// ---- R8: opt-in per-file checkpointing (AGENTIC_SECURITY_RESUME=1, or
|
|
7418
|
+
// runScan({resume:true})). Default OFF, so existing behaviour is untouched.
|
|
7419
|
+
// Only this loop is checkpointed; every cross-file pass below re-runs, so
|
|
7420
|
+
// nothing that depends on the whole tree can be stale on resume.
|
|
7421
|
+
const _ckptEnabled = (resume === undefined ? process.env.AGENTIC_SECURITY_RESUME === '1' : !!resume) && !!scanRoot;
|
|
7422
|
+
let _ckpt = null; const _ckptPayloads = new Map(); let _ckptDone = new Set(); let _ckptResumed = 0, _ckptWrites = 0;
|
|
7423
|
+
const _ckptAbortAfter = parseInt(process.env.AGENTIC_SECURITY_CHECKPOINT_ABORT_AFTER || '0', 10) || 0;
|
|
7424
|
+
if (_ckptEnabled) {
|
|
7425
|
+
try {
|
|
7426
|
+
const _runKey = computeRunKey({
|
|
7427
|
+
engineVersion: _ENGINE_VERSION,
|
|
7428
|
+
rulesetVersion: (_effectiveRulesetVersion(scanRoot) || {}).version,
|
|
7429
|
+
bundleSha: bundleShaForRunKey(),
|
|
7430
|
+
fileContents, depFileContents,
|
|
7431
|
+
});
|
|
7432
|
+
_ckpt = openCheckpoint(scanRoot, { runKey: _runKey });
|
|
7433
|
+
for (const r of resumeFindings(_ckpt)) { if (r && r.findings) _ckptPayloads.set(r.file, r.findings); }
|
|
7434
|
+
_ckptDone = completedFiles(_ckpt);
|
|
7435
|
+
} catch (_) { _ckpt = null; }
|
|
7436
|
+
}
|
|
7437
|
+
// Replay a checkpointed file's ENTIRE contribution, in the same array order
|
|
7438
|
+
// and with the same object identities between pfr[p] and the aggregate arrays
|
|
7439
|
+
// that an uninterrupted run would have produced.
|
|
7440
|
+
const _ckptReplay = (p) => {
|
|
7441
|
+
const d = _ckptPayloads.get(p);
|
|
7442
|
+
if (!d) return false;
|
|
7443
|
+
const c = fileContents[p]; if (!c) return false;
|
|
7444
|
+
fc[p] = c;
|
|
7445
|
+
const f0 = aF.length, s0 = aSrc.length, k0 = aSink.length, n0 = aSan.length;
|
|
7446
|
+
for (const x of (d.findings || [])) aF.push(x);
|
|
7447
|
+
for (const x of (d.routes || [])) aR.push(x);
|
|
7448
|
+
for (const x of (d.sources || [])) aSrc.push(x);
|
|
7449
|
+
for (const x of (d.sinks || [])) aSink.push(x);
|
|
7450
|
+
for (const x of (d.sanitizers || [])) aSan.push(x);
|
|
7451
|
+
for (const x of (d.logic || [])) aLogic.push(x);
|
|
7452
|
+
for (const x of (d.secrets || [])) aSecrets.push(x);
|
|
7453
|
+
for (const x of (d.ciphersRest || [])) aCiphersRest.push(x);
|
|
7454
|
+
for (const x of (d.ciphersTransit || [])) aCiphersTransit.push(x);
|
|
7455
|
+
for (const x of (d.suppressions || [])) _suppressionLog.push(x);
|
|
7456
|
+
const ta = Object.assign({}, d.pfr || {});
|
|
7457
|
+
ta.findings = aF.slice(f0, f0 + (d.pfrFindings || 0));
|
|
7458
|
+
ta.sources = aSrc.slice(s0); ta.sinks = aSink.slice(k0); ta.sanitizers = aSan.slice(n0);
|
|
7459
|
+
pfr[p] = ta;
|
|
7460
|
+
_fileTimings.push({ file: p, ms: d.ms || 0 });
|
|
7461
|
+
_ckptResumed++;
|
|
7462
|
+
return true;
|
|
7463
|
+
};
|
|
7464
|
+
const _ckptRecord = (p, mk, ms, ta) => {
|
|
7465
|
+
if (!_ckpt || !_ckpt.enabled) return;
|
|
7466
|
+
recordFileDone(_ckpt, p, {
|
|
7467
|
+
routes: aR.slice(mk.aR), findings: aF.slice(mk.aF),
|
|
7468
|
+
sources: aSrc.slice(mk.aSrc), sinks: aSink.slice(mk.aSink), sanitizers: aSan.slice(mk.aSan),
|
|
7469
|
+
logic: aLogic.slice(mk.aLogic), secrets: aSecrets.slice(mk.aSecrets),
|
|
7470
|
+
ciphersRest: aCiphersRest.slice(mk.aCR), ciphersTransit: aCiphersTransit.slice(mk.aCT),
|
|
7471
|
+
suppressions: _suppressionLog.slice(mk.sup),
|
|
7472
|
+
pfr: _pfrMetaOnly(ta),
|
|
7473
|
+
pfrFindings: (ta && Array.isArray(ta.findings)) ? ta.findings.length : 0,
|
|
7474
|
+
ms,
|
|
7475
|
+
});
|
|
7476
|
+
_ckptWrites++;
|
|
7477
|
+
// Fault injection for the resume test: a hard exit with no unwinding, which
|
|
7478
|
+
// is what the checkpoint format has to survive. Never set in normal use.
|
|
7479
|
+
if (_ckptAbortAfter > 0 && _ckptWrites >= _ckptAbortAfter) process.exit(137);
|
|
7480
|
+
};
|
|
7481
|
+
let i=0;for(const p of files){i++;const _ft0=Date.now();setProgress({current:i,total:files.length,file:p.split("/").pop(),phase:"Scanning"});
|
|
7482
|
+
if(_ckptDone.has(p)&&_ckptReplay(p))continue;
|
|
7483
|
+
const _mk={aR:aR.length,aF:aF.length,aSrc:aSrc.length,aSink:aSink.length,aSan:aSan.length,aLogic:aLogic.length,aSecrets:aSecrets.length,aCR:aCiphersRest.length,aCT:aCiphersTransit.length,sup:_suppressionLog.length};
|
|
7484
|
+
try{const c=fileContents[p];if(!c||c.length>500000){_filesSkipped++;continue;}const _avgLine=c.length/Math.max(c.split('\n').length,1);if(_avgLine>400&&c.length>10000){_filesDenseSkipped++;continue;}fc[p]=c;aR.push(...scanRoutes(p,c));const ta=performAnalysis(p,c);pfr[p]=ta;aF.push(...ta.findings);aSrc.push(...ta.sources);aSink.push(...ta.sinks);aSan.push(...ta.sanitizers);aLogic.push(...scanLogicVulns(p,c));aSecrets.push(...scanCredentials(p,c));aF.push(...scanStructuralVulns(p,c));aF.push(...scanExtraStructural(p,c));aF.push(...scanAliasedSinks(p,c));aF.push(...scanJavaSAST(p,c));aF.push(...scanJavaBenchExtras(p,c));aLogic.push(...scanMiddlewareOrdering(p,c));aLogic.push(...scanReDoS(p,c));if(/\.(?:java|cs|kt|py|php|phtml)$/i.test(p)){try{aLogic.push(...scanRegexReDoS(p,c));}catch(_){}}aLogic.push(...scanTodosNearSecurity(p,c));aSecrets.push(...scanEntropySecrets(p,c));const cp=scanCiphers(p,c);aCiphersRest.push(...cp.atRest);aCiphersTransit.push(...cp.inTransit);if(/\.(graphql|gql)$/i.test(p))aF.push(...scanGraphQL(p,c));aF.push(...scanIaC(p,c));aF.push(...scanTerraform(p,c));
|
|
7403
7485
|
aF.push(...scanLLM(p,c));
|
|
7404
7486
|
aF.push(...scanLLMOwasp(p,c));
|
|
7405
7487
|
aF.push(...scanLlmCost(p,c));
|
|
@@ -7504,6 +7586,7 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null},
|
|
|
7504
7586
|
const _ftElapsed=Date.now()-_ft0;
|
|
7505
7587
|
if(_ftElapsed>_perFileTimeoutMs){aF.push({id:`file-timeout:${p}`,file:p,line:0,vuln:`File analysis exceeded ${_perFileTimeoutMs}ms (${_ftElapsed}ms)`,severity:'info',parser:'ENGINE',confidence:0.5,_timeout:true});_filesTimedOut++;}
|
|
7506
7588
|
_fileTimings.push({file:p,ms:_ftElapsed});
|
|
7589
|
+
_ckptRecord(p,_mk,_ftElapsed,ta);
|
|
7507
7590
|
}catch(_){_fileTimings.push({file:p,ms:Date.now()-_ft0,error:true});}if(i%5===0)await new Promise(r=>setTimeout(r,0));}
|
|
7508
7591
|
// Deserialization-gadget detector runs once with full-tree context (it needs
|
|
7509
7592
|
// manifest contents to know which gadget libs are on the classpath).
|
|
@@ -7914,6 +7997,26 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null},
|
|
|
7914
7997
|
// on; opt out with AGENTIC_SECURITY_NO_PROOF_GATE=1. Demotes proven-clean /
|
|
7915
7998
|
// proven-infeasible flows (confidence + tiers only, never severity).
|
|
7916
7999
|
if (process.env.AGENTIC_SECURITY_NO_PROOF_GATE !== '1') {
|
|
8000
|
+
// Generalised sanitizer consumption (dataflow/sanitizer-gate.js): labels
|
|
8001
|
+
// findings whose flow passes a catalog sanitizer matching their family
|
|
8002
|
+
// (xss/url/cmd, not just sql) so the proof gate below can demote them the
|
|
8003
|
+
// same way it demotes proven-clean SQL. `sanitizersOnPath` would need to
|
|
8004
|
+
// be `{ [findingId]: string[] of sanitizer callees observed on that
|
|
8005
|
+
// finding's flow }`. There is nothing to build that map from: the live
|
|
8006
|
+
// taint walk in dataflow/engine.js does NOT consult sanitizer catalog
|
|
8007
|
+
// entries at all. `matchSinkOrSanitizer()` returns every catalog hit for
|
|
8008
|
+
// a callee, but every consumer in dataflow/*.js selects only
|
|
8009
|
+
// `e.kind === 'sink'` — there is no `'sanitizer'` branch anywhere in that
|
|
8010
|
+
// tree. Taint is killed only by clean re-assignment of a variable
|
|
8011
|
+
// (removePathAndDescendants, engine.js:374), which happens regardless of
|
|
8012
|
+
// whether the RHS call is a catalog sanitizer.
|
|
8013
|
+
// So this is `{}` and the gate below is INERT — not "awaiting plumbing"
|
|
8014
|
+
// but awaiting the sanitizer walk itself. Making it live needs two things:
|
|
8015
|
+
// (1) dataflow/engine.js honouring `kind === 'sanitizer'` at a call site,
|
|
8016
|
+
// and (2) that call site's callee name threaded onto the finding
|
|
8017
|
+
// alongside the trace/chain that proven-clean.js already reads.
|
|
8018
|
+
const sanitizersOnPath = {};
|
|
8019
|
+
_runAnnotator("applySanitizerGate", () => { applySanitizerGate(finalFindings, { sanitizersOnPath }); });
|
|
7917
8020
|
_runAnnotator("annotateProofGate", () => { annotateProofGate(finalFindings); });
|
|
7918
8021
|
}
|
|
7919
8022
|
// Addition #1 — default falsification pass. Actively tries to DISPROVE each
|
|
@@ -8156,6 +8259,33 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null},
|
|
|
8156
8259
|
// - Global timeout via AGENTIC_SECURITY_DEEP_TIMEOUT_MS (default 300_000 = 5 min)
|
|
8157
8260
|
// - Auto-disabled in CI unless AGENTIC_SECURITY_DEEP_IN_CI=1 is also set,
|
|
8158
8261
|
// so a pathological file can't hang the whole pipeline.
|
|
8262
|
+
// ── IR parse-coverage sidecar (proof-corpus instrumentation, default off) ──
|
|
8263
|
+
// Built ahead of the deep-mode gate so coverage is measurable without paying
|
|
8264
|
+
// for taint analysis, and stashed in _sharedIR so the deep block below reuses
|
|
8265
|
+
// it rather than parsing the project twice.
|
|
8266
|
+
//
|
|
8267
|
+
// NOTE (affects instrumented runs only, i.e. AGENTIC_SECURITY_IR_STATS set):
|
|
8268
|
+
// when this block runs, buildProjectIR() happens here, BEFORE the deep-mode
|
|
8269
|
+
// budget timer (t0) below is started. On an uninstrumented run, IR
|
|
8270
|
+
// construction instead happens inside the timed block via the
|
|
8271
|
+
// `_sharedIR || (_sharedIR = buildProjectIR(fc))` line, so its cost counts
|
|
8272
|
+
// against AGENTIC_SECURITY_DEEP_TIMEOUT_MS. That means the deep budget does
|
|
8273
|
+
// NOT account for parse time when stats are enabled — an instrumented run
|
|
8274
|
+
// gets strictly more wall-clock for the taint analysis itself than an
|
|
8275
|
+
// uninstrumented run with the same budget.
|
|
8276
|
+
let _sharedIR = null;
|
|
8277
|
+
const _irStatsTarget = irStatsTarget();
|
|
8278
|
+
if (_irStatsTarget) {
|
|
8279
|
+
try {
|
|
8280
|
+
_sharedIR = buildProjectIR(fc);
|
|
8281
|
+
writeIrStats(_irStatsTarget, collectIrStats(fc, _sharedIR.perFile, _sharedIR.callGraph));
|
|
8282
|
+
} catch (e) {
|
|
8283
|
+
// Instrumentation must never fail a scan. Surface only when debugging.
|
|
8284
|
+
if (process.env.AGENTIC_SECURITY_IR_STATS_DEBUG === '1') {
|
|
8285
|
+
process.stderr.write(`ir-stats: ${e && e.message}\n`);
|
|
8286
|
+
}
|
|
8287
|
+
}
|
|
8288
|
+
}
|
|
8159
8289
|
const _deepRequested = process.env.AGENTIC_SECURITY_DEEP === '1';
|
|
8160
8290
|
const _inCi = !!(process.env.CI || process.env.GITHUB_ACTIONS || process.env.GITLAB_CI ||
|
|
8161
8291
|
process.env.BUILDKITE || process.env.CIRCLECI || process.env.JENKINS_URL);
|
|
@@ -8165,7 +8295,7 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null},
|
|
|
8165
8295
|
const budgetMs = parseInt(process.env.AGENTIC_SECURITY_DEEP_TIMEOUT_MS || '300000', 10);
|
|
8166
8296
|
const t0 = Date.now();
|
|
8167
8297
|
try {
|
|
8168
|
-
const { perFile, callGraph } = buildProjectIR(fc);
|
|
8298
|
+
const { perFile, callGraph } = _sharedIR || (_sharedIR = buildProjectIR(fc));
|
|
8169
8299
|
// The runDeepAnalysis call is synchronous in this codebase; we can't
|
|
8170
8300
|
// truly interrupt it without re-architecting the worklist. We pass a
|
|
8171
8301
|
// deadlineMs hint that the inner loops check; if absent, we still cap
|
|
@@ -8562,9 +8692,26 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null},
|
|
|
8562
8692
|
let _analysisTier = null, _unmodeledSinks = null;
|
|
8563
8693
|
try { _analysisTier = computeAnalysisTiers(Object.keys(fc)); } catch {}
|
|
8564
8694
|
try { _unmodeledSinks = countUnmodeledSinkCandidates(fc, finalFindings); } catch {}
|
|
8565
|
-
const _scanMeta={filesScanned:files.length,filesSkipped:_filesSkipped,filesDenseSkipped:_filesDenseSkipped,filesTimedOut:_filesTimedOut,analysisTier:_analysisTier,unmodeledSinkCandidates:_unmodeledSinks,fileTimings:_fileTimings.sort((a,b)=>b.ms-a.ms).slice(0,20),findingsBySeverity:{critical:finalFindings.filter(f=>f.severity==='critical').length,high:finalFindings.filter(f=>f.severity==='high').length,medium:finalFindings.filter(f=>f.severity==='medium').length,low:finalFindings.filter(f=>f.severity==='low').length,info:finalFindings.filter(f=>f.severity==='info').length}};
|
|
8695
|
+
const _scanMeta={filesScanned:files.length,filesSkipped:_filesSkipped,filesDenseSkipped:_filesDenseSkipped,filesTimedOut:_filesTimedOut,analysisTier:_analysisTier,unmodeledSinkCandidates:_unmodeledSinks,fileTimings:_fileTimings.sort((a,b)=>b.ms-a.ms).slice(0,20),findingsBySeverity:{critical:finalFindings.filter(f=>f.severity==='critical').length,high:finalFindings.filter(f=>f.severity==='high').length,medium:finalFindings.filter(f=>f.severity==='medium').length,low:finalFindings.filter(f=>f.severity==='low').length,info:finalFindings.filter(f=>f.severity==='info').length},checkpoint:{enabled:!!(_ckpt&&_ckpt.enabled),resumed:_ckptResumed,total:files.length}};
|
|
8696
|
+
// R8: the scan completed, so the checkpoint has been fully consumed — remove
|
|
8697
|
+
// it. Anything that threw before this point leaves it in place to resume from.
|
|
8698
|
+
try { closeCheckpoint(_ckpt, { complete: true }); } catch (_) {}
|
|
8566
8699
|
// Addition #2 — attack-surface completeness inventory (entry points → dispositions).
|
|
8567
8700
|
let _entrypointInventory = {}; try { _entrypointInventory = buildEntrypointInventory(fc, { routes: aR, findings: finalFindings }); } catch { _entrypointInventory = {}; }
|
|
8701
|
+
// R9 + R6 — relevance scoping. Runs HERE, after every finding has been
|
|
8702
|
+
// appended (multi-sink chains, cross-language chains) and after the
|
|
8703
|
+
// entry-point inventory exists, so no finding escapes annotation and the
|
|
8704
|
+
// attack surface it is scored against is the complete one. Recall-
|
|
8705
|
+
// preserving: never removes a finding, never touches severity, and only
|
|
8706
|
+
// asserts `unreachable` on positive evidence (see posture/relevance.js).
|
|
8707
|
+
_runAnnotator("annotateRelevance", () => {
|
|
8708
|
+
annotateRelevance(finalFindings, {
|
|
8709
|
+
fileContents: fc,
|
|
8710
|
+
entrypointInventory: _entrypointInventory,
|
|
8711
|
+
routes: aR,
|
|
8712
|
+
threatModel: _v3 && _v3.threatModel,
|
|
8713
|
+
});
|
|
8714
|
+
});
|
|
8568
8715
|
// Addition #3 — root-cause sweep: from confirmed findings, find sibling instances
|
|
8569
8716
|
// detectors missed, with total-count accounting. Confirmed-only (cheap by default).
|
|
8570
8717
|
let _rootCauseSweep = null; try { _rootCauseSweep = sweepRootCauses(finalFindings, fc); } catch { _rootCauseSweep = null; }
|
package/src/ir/CLAUDE.md
CHANGED
|
@@ -11,7 +11,8 @@ consumed by `scanner/src/dataflow/` for taint analysis.
|
|
|
11
11
|
| Python | `parser-py-cst.js` | Python 3.8+ stdlib `ast` via subprocess (default when available) |
|
|
12
12
|
| Python | `parser-py.js` | Hand-rolled regex parser (fallback when python3 missing) |
|
|
13
13
|
| Java | `parser-java.js` | `java-parser` npm package (async) |
|
|
14
|
-
|
|
|
14
|
+
| C / C++ | `parser-cpp.js` | Hand-rolled parser (functions, qualified names, CFG lowering). Dispatched by extension (`c/cc/cpp/cxx/h/hh/hpp/hxx`) in both `buildProjectIR` and `buildProjectIRAsync`. |
|
|
15
|
+
| Long-tail (rust/solidity/go/swift/dart) | `tree-sitter-loader.js` | **Optional** `web-tree-sitter` + `tree-sitter-wasms` (ABI-pinned 0.20.8 ↔ 0.1.13), lazy + degrades when absent. Powers `sast/tree-sitter-sinks.js` (opt-in via `AGENTIC_SECURITY_TREE_SITTER=1`). Marked `--external` in the build so the committed bundle never embeds WASM. |
|
|
15
16
|
|
|
16
17
|
## Python parser — dual-path with auto fallback
|
|
17
18
|
|
|
@@ -42,8 +43,9 @@ every dropped function.
|
|
|
42
43
|
The CST parser (`parser-py-cst.js`) shells out to a small Python helper
|
|
43
44
|
script (`parser-py.helper.py`) that uses the stdlib `ast` module — zero
|
|
44
45
|
external dependencies, ships with every Python 3.8+ install. The helper
|
|
45
|
-
emits the same IR shape (`{functions[{qid,name,line,params,cfg,file}],
|
|
46
|
-
topLevel}`) as the regex parser
|
|
46
|
+
emits the same core IR shape (`{functions[{qid,name,line,params,cfg,file}],
|
|
47
|
+
topLevel}`) as the regex parser, **with one exception below.** The CFG is
|
|
48
|
+
built from the real AST, so:
|
|
47
49
|
|
|
48
50
|
- decorators don't drop the function record
|
|
49
51
|
- async def is recognized
|
|
@@ -53,6 +55,16 @@ topLevel}`) as the regex parser. The CFG is built from the real AST, so:
|
|
|
53
55
|
`[x for x in untrusted]`
|
|
54
56
|
- nested function defs become separate entries in `functions[]`
|
|
55
57
|
- `def f(x=Foo(1,2))` and `db.execute(sanitize(x))` parse correctly
|
|
58
|
+
- **`fn.calls` (CST path only)** — `parser-py-cst.js` derives
|
|
59
|
+
`calls: [{site, callee, args, line}]` from the CFG after parsing (statement-
|
|
60
|
+
position calls, plus calls embedded in an assign's RHS, a return/throw
|
|
61
|
+
value, or an if condition). **The regex parser (`parser-py.js`) does not
|
|
62
|
+
emit `fn.calls` at all.** In practice: a scan that falls back to the regex
|
|
63
|
+
parser (no `python3` on PATH, too old, or the helper failing) still
|
|
64
|
+
produces findings, but Python loses cross-function (interprocedural) taint
|
|
65
|
+
tracking for that run — `tabulation.js`, `dataflow/index.js` and
|
|
66
|
+
`callgraph.js` all read `fn.calls`, and an absent/empty array there means a
|
|
67
|
+
function's call sites are invisible to them, same as if it called nothing.
|
|
56
68
|
|
|
57
69
|
### Cost
|
|
58
70
|
|
|
@@ -70,12 +82,41 @@ topLevel}`) as the regex parser. The CFG is built from the real AST, so:
|
|
|
70
82
|
- `python3` / `python` not on PATH
|
|
71
83
|
- Python version < 3.8
|
|
72
84
|
- helper script's stdin JSON corruption
|
|
73
|
-
- helper subprocess timeout (
|
|
85
|
+
- helper subprocess timeout (30 s for the whole batch — generous;
|
|
86
|
+
`AGENTIC_SECURITY_PY_BATCH_TIMEOUT_MS`)
|
|
87
|
+
- capability probe timeout (5 s; `AGENTIC_SECURITY_PY_PROBE_TIMEOUT_MS`)
|
|
74
88
|
- helper output isn't parseable JSON
|
|
75
89
|
|
|
76
90
|
Each of these is a real failure mode; the regex fallback keeps the scan
|
|
77
91
|
producing findings instead of returning empty.
|
|
78
92
|
|
|
93
|
+
### Fallbacks are recorded, not silent
|
|
94
|
+
|
|
95
|
+
Every fallback above calls `noteParserDegradation(reason)`. Read it with
|
|
96
|
+
`pythonParserDegradation()` and clear it with `resetPythonParserDegradation()`
|
|
97
|
+
(both re-exported from `./index.js`).
|
|
98
|
+
|
|
99
|
+
This exists because a silent fallback is indistinguishable from a detection
|
|
100
|
+
regression: no `fn.calls` means no interprocedural Python taint, so a test
|
|
101
|
+
that *depends* on interprocedural analysis just stops finding anything. The
|
|
102
|
+
CVE-replay corpus gate (`bench/cve-replay/runner.mjs`) resets the record
|
|
103
|
+
before each deep Python entry and checks it after, reporting `env-error` and
|
|
104
|
+
exiting 3 instead of scoring a phantom `pre:FN`. It had already produced a
|
|
105
|
+
real flake: exit 1 `REGRESSED` then exit 0 on the same clean tree, minutes
|
|
106
|
+
apart, purely from machine load.
|
|
107
|
+
|
|
108
|
+
Two hardening changes back this:
|
|
109
|
+
- The probe timeout is **not cached**. `spawnSync` reports a timeout via
|
|
110
|
+
`r.error.code === 'ETIMEDOUT'` with a null status — that is a load symptom,
|
|
111
|
+
not "python is missing", so it does not poison `_capability` for the rest
|
|
112
|
+
of the process. Only a genuine `no-python3-on-path` is cached.
|
|
113
|
+
- Budgets were raised (1.5 s → 5 s probe, 10 s → 30 s batch) and made
|
|
114
|
+
env-tunable for constrained runners.
|
|
115
|
+
|
|
116
|
+
If you add a new caller that requires the CST path, check
|
|
117
|
+
`pythonParserDegradation()` rather than assuming the parse you got is the
|
|
118
|
+
parse you asked for.
|
|
119
|
+
|
|
79
120
|
### What CST models (and the one remaining limit)
|
|
80
121
|
|
|
81
122
|
The helper now lowers — and the dataflow engine propagates taint through — all
|
|
@@ -176,3 +217,7 @@ down enterprise runners sometimes don't. Targets for retirement:
|
|
|
176
217
|
via the optional telemetry surface.
|
|
177
218
|
- Or, the `AGENTIC_SECURITY_PY_PARSER` env defaults to `cst` (strict
|
|
178
219
|
mode) for one release with no customer complaint tickets filed.
|
|
220
|
+
- The `fn.calls` gap above is a concrete, ongoing argument for retirement:
|
|
221
|
+
every fallback to the regex parser is now also a silent loss of Python
|
|
222
|
+
interprocedural analysis, not just the older, vaguer "some constructs get
|
|
223
|
+
dropped" cost.
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Shared call-site extraction.
|
|
2
|
+
//
|
|
3
|
+
// Reads only the IR contract documented in ./CLAUDE.md — it walks cfg.nodes and
|
|
4
|
+
// collects call expressions from `call`, `assign`, `return`, `throw` and `if`
|
|
5
|
+
// nodes — so nothing here is language-specific.
|
|
6
|
+
//
|
|
7
|
+
// It lives in one place deliberately. Phase 1 put a resolver guard at a single
|
|
8
|
+
// call site; the next task re-broke it and it had to be moved into callgraph.js.
|
|
9
|
+
// Five copies of this would recreate that exactly.
|
|
10
|
+
|
|
11
|
+
// Recursively collect every 'call' subexpression inside a lowered expr tree
|
|
12
|
+
// (a call's own args can themselves contain calls, e.g. `foo(bar(x))`).
|
|
13
|
+
// `elements` (array literals) and `props` (object literals) are walked too —
|
|
14
|
+
// added when parser-py-cst.js's own copy of this walker was folded in here,
|
|
15
|
+
// so that e.g. `xs = [foo(x), bar(y)]` still surfaces both call sites for
|
|
16
|
+
// Python. Harmless for other languages: those fields are simply absent from
|
|
17
|
+
// their expr shapes.
|
|
18
|
+
function _collectCallExprs(expr, out) {
|
|
19
|
+
if (!expr || typeof expr !== 'object') return;
|
|
20
|
+
if (expr.kind === 'call') {
|
|
21
|
+
out.push(expr);
|
|
22
|
+
for (const a of expr.args || []) _collectCallExprs(a, out);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
if (Array.isArray(expr.parts)) for (const p of expr.parts) _collectCallExprs(p, out);
|
|
26
|
+
if (Array.isArray(expr.branches)) for (const b of expr.branches) _collectCallExprs(b, out);
|
|
27
|
+
if (Array.isArray(expr.elements)) for (const e of expr.elements) _collectCallExprs(e, out);
|
|
28
|
+
if (Array.isArray(expr.props)) for (const p of expr.props) _collectCallExprs(p && p.value, out);
|
|
29
|
+
if (expr.left) _collectCallExprs(expr.left, out);
|
|
30
|
+
if (expr.right) _collectCallExprs(expr.right, out);
|
|
31
|
+
if (expr.object) _collectCallExprs(expr.object, out);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Build the `fn.calls` list documented at parser-js.js:19 —
|
|
35
|
+
// `[{ site, callee, args, line }]` — from the CFG. A call can appear at
|
|
36
|
+
// statement position (its own 'call' node) or embedded in another node's
|
|
37
|
+
// expression (an assignment's RHS, a return/throw value, an if condition —
|
|
38
|
+
// `char* p = getenv("CMD")` is exactly the source-introducing shape the
|
|
39
|
+
// taint engine needs to see and must not be missed just because it isn't a
|
|
40
|
+
// bare statement).
|
|
41
|
+
//
|
|
42
|
+
// Known boundaries (not modeled): a call in a `for`-loop's step expression
|
|
43
|
+
// (`for (;; advance(p))`) is not surfaced — the CFG only lowers the loop's
|
|
44
|
+
// test into the `if` node's `cond`; a call as an assignment's LHS/target
|
|
45
|
+
// (not a real C++ shape but a malformed one a fuzz input could produce) is
|
|
46
|
+
// never inspected, only `source`; and a `kind: 'unknown'` statement (a
|
|
47
|
+
// construct `_lowerStmt` couldn't classify) contributes no call sites even
|
|
48
|
+
// if it textually contains one.
|
|
49
|
+
export function callSitesFromCfg(cfg) {
|
|
50
|
+
const sites = [];
|
|
51
|
+
for (const [nodeId, node] of Object.entries((cfg && cfg.nodes) || {})) {
|
|
52
|
+
if (!node) continue;
|
|
53
|
+
let root = null;
|
|
54
|
+
if (node.kind === 'call') root = { kind: 'call', callee: node.callee, args: node.args };
|
|
55
|
+
else if (node.kind === 'assign') root = node.source;
|
|
56
|
+
else if (node.kind === 'return' || node.kind === 'throw') root = node.value;
|
|
57
|
+
else if (node.kind === 'if') root = node.cond;
|
|
58
|
+
if (!root) continue;
|
|
59
|
+
const found = [];
|
|
60
|
+
_collectCallExprs(root, found);
|
|
61
|
+
for (const c of found) {
|
|
62
|
+
sites.push({ site: nodeId, callee: c.callee, args: c.args, line: node.line });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return sites;
|
|
66
|
+
}
|