@clear-capabilities/agentic-security-scanner 0.128.1 → 0.132.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 +223 -0
- package/bin/agentic-security.js +52 -2
- package/dist/11.index.js +2 -2
- package/dist/113.index.js +498 -7
- package/dist/178.index.js +1 -1
- package/dist/207.index.js +220 -0
- package/dist/238.index.js +218 -0
- package/dist/259.index.js +975 -0
- package/dist/384.index.js +1 -1
- package/dist/415.index.js +1 -1
- package/dist/435.index.js +4 -4
- package/dist/526.index.js +844 -0
- package/dist/637.index.js +1 -1
- package/dist/830.index.js +1 -1
- package/dist/agentic-security.mjs +106 -194
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +33 -17
- 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 +170 -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 +13 -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/mcp/tools.js +2 -2
- package/src/posture/CLAUDE.md +193 -1
- package/src/posture/accuracy-scorecard.js +317 -0
- package/src/posture/api-contract.js +1 -1
- package/src/posture/attestation.js +202 -0
- package/src/posture/auditor-walkthrough.js +12 -3
- package/src/posture/compliance-policy.js +1 -1
- package/src/posture/corpus-enroll.js +303 -0
- package/src/posture/corpus-match.js +52 -0
- package/src/posture/cross-lang-openapi.js +1 -1
- package/src/posture/custom-rules.js +3 -3
- package/src/posture/execution-proof.js +92 -0
- package/src/posture/exploitability-probability.js +1 -1
- package/src/posture/falsification.js +45 -1
- package/src/posture/fix-metrics.js +197 -0
- package/src/posture/fix-verify.js +129 -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/root-cause-sweep.js +0 -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 +5 -7
- package/src/sandbox/CLAUDE.md +340 -0
- package/src/sandbox/backend-disabled.js +14 -0
- package/src/sandbox/backend-namespace.js +335 -0
- package/src/sandbox/backend-userspace.js +83 -0
- package/src/sandbox/capabilities.js +181 -0
- package/src/sandbox/index.js +30 -0
- package/src/sandbox/limits.js +63 -0
- package/src/sandbox/result.js +104 -0
- package/src/sca/dep-confusion.js +1 -1
- package/src/util/glob.js +173 -0
- 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,15 @@ 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 }]],
|
|
999
|
+
// Decorators are SYNTAX we must accept, never transform — without them the
|
|
1000
|
+
// parser rejects the whole file and every finding in it silently disappears.
|
|
1001
|
+
// Measured on one real target: 201 JS files unparseable, all decorator-using
|
|
1002
|
+
// framework code. 'decorators-legacy' covers the framework and TypeScript
|
|
1003
|
+
// parameter forms; 'decoratorAutoAccessors' adds the modern `accessor` field.
|
|
1004
|
+
// The modern 'decorators' variant was rejected: it cannot parse TS parameter
|
|
1005
|
+
// decorators, so it would trade one blind spot for another.
|
|
1006
|
+
parserOpts: { plugins: ['decorators-legacy', 'decoratorAutoAccessors'] },
|
|
989
1007
|
plugins: [astTaintTrackerPlugin],
|
|
990
1008
|
ast: false, code: false,
|
|
991
1009
|
babelrc: false, configFile: false,
|
|
@@ -2266,6 +2284,10 @@ function _isFalsePositiveCredential(fp, snippet, fullMatch){
|
|
|
2266
2284
|
// Module-level suppression log; cleared at the start of each runFullScan invocation.
|
|
2267
2285
|
const _suppressionLog = [];
|
|
2268
2286
|
function _resetSuppressions(){ _suppressionLog.length = 0; }
|
|
2287
|
+
// R8: the per-file taint result minus the four arrays that are also appended
|
|
2288
|
+
// wholesale to the aggregates. Resume rebuilds those from the aggregate slices
|
|
2289
|
+
// so pfr[p] and the aggregates share object identity, exactly as in a normal run.
|
|
2290
|
+
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
2291
|
function _getSuppressions(){ return [..._suppressionLog]; }
|
|
2270
2292
|
|
|
2271
2293
|
// FP-9 / Feat-4: custom rules loaded from .agentic-security/rules.{yml,yaml,json}
|
|
@@ -4631,7 +4653,15 @@ function _buildCallGraphAST(fp, code){
|
|
|
4631
4653
|
|
|
4632
4654
|
babelTransformSync(code, {
|
|
4633
4655
|
filename: fp,
|
|
4634
|
-
presets: [presetReact, [presetTypescript, {
|
|
4656
|
+
presets: [presetReact, [presetTypescript, { ignoreExtensions: true }]],
|
|
4657
|
+
// Decorators are SYNTAX we must accept, never transform — without them the
|
|
4658
|
+
// parser rejects the whole file and every finding in it silently disappears.
|
|
4659
|
+
// Measured on one real target: 201 JS files unparseable, all decorator-using
|
|
4660
|
+
// framework code. 'decorators-legacy' covers the framework and TypeScript
|
|
4661
|
+
// parameter forms; 'decoratorAutoAccessors' adds the modern `accessor` field.
|
|
4662
|
+
// The modern 'decorators' variant was rejected: it cannot parse TS parameter
|
|
4663
|
+
// decorators, so it would trade one blind spot for another.
|
|
4664
|
+
parserOpts: { plugins: ['decorators-legacy', 'decoratorAutoAccessors'] },
|
|
4635
4665
|
plugins: [callTrackerPlugin],
|
|
4636
4666
|
ast: false, code: false,
|
|
4637
4667
|
babelrc: false, configFile: false,
|
|
@@ -7389,7 +7419,7 @@ async function queryRegistries(components){
|
|
|
7389
7419
|
|
|
7390
7420
|
// Node port: takes { fileContents, depFileContents } maps directly instead of a JSZip object.
|
|
7391
7421
|
// 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);
|
|
7422
|
+
async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null, resume=undefined}, setProgress=()=>{}){_resetSuppressions();_buildProjectIndex(fileContents);await _loadCustomRules(scanRoot);
|
|
7393
7423
|
// Pre-pass: build cross-file Java tainted-method index so per-file taint
|
|
7394
7424
|
// analysis can recognize calls to user-input-returning helper methods
|
|
7395
7425
|
// defined in OTHER files (Juliet's DataflowThruInnerClass / Vector / Stream
|
|
@@ -7399,7 +7429,75 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null},
|
|
|
7399
7429
|
const _perFileTimeoutMs = parseInt(process.env.AGENTIC_SECURITY_PER_FILE_TIMEOUT_MS || '10000', 10);
|
|
7400
7430
|
const _fileTimings = [];
|
|
7401
7431
|
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=[];
|
|
7432
|
+
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=[];
|
|
7433
|
+
// ---- R8: opt-in per-file checkpointing (AGENTIC_SECURITY_RESUME=1, or
|
|
7434
|
+
// runScan({resume:true})). Default OFF, so existing behaviour is untouched.
|
|
7435
|
+
// Only this loop is checkpointed; every cross-file pass below re-runs, so
|
|
7436
|
+
// nothing that depends on the whole tree can be stale on resume.
|
|
7437
|
+
const _ckptEnabled = (resume === undefined ? process.env.AGENTIC_SECURITY_RESUME === '1' : !!resume) && !!scanRoot;
|
|
7438
|
+
let _ckpt = null; const _ckptPayloads = new Map(); let _ckptDone = new Set(); let _ckptResumed = 0, _ckptWrites = 0;
|
|
7439
|
+
const _ckptAbortAfter = parseInt(process.env.AGENTIC_SECURITY_CHECKPOINT_ABORT_AFTER || '0', 10) || 0;
|
|
7440
|
+
if (_ckptEnabled) {
|
|
7441
|
+
try {
|
|
7442
|
+
const _runKey = computeRunKey({
|
|
7443
|
+
engineVersion: _ENGINE_VERSION,
|
|
7444
|
+
rulesetVersion: (_effectiveRulesetVersion(scanRoot) || {}).version,
|
|
7445
|
+
bundleSha: bundleShaForRunKey(),
|
|
7446
|
+
fileContents, depFileContents,
|
|
7447
|
+
});
|
|
7448
|
+
_ckpt = openCheckpoint(scanRoot, { runKey: _runKey });
|
|
7449
|
+
for (const r of resumeFindings(_ckpt)) { if (r && r.findings) _ckptPayloads.set(r.file, r.findings); }
|
|
7450
|
+
_ckptDone = completedFiles(_ckpt);
|
|
7451
|
+
} catch (_) { _ckpt = null; }
|
|
7452
|
+
}
|
|
7453
|
+
// Replay a checkpointed file's ENTIRE contribution, in the same array order
|
|
7454
|
+
// and with the same object identities between pfr[p] and the aggregate arrays
|
|
7455
|
+
// that an uninterrupted run would have produced.
|
|
7456
|
+
const _ckptReplay = (p) => {
|
|
7457
|
+
const d = _ckptPayloads.get(p);
|
|
7458
|
+
if (!d) return false;
|
|
7459
|
+
const c = fileContents[p]; if (!c) return false;
|
|
7460
|
+
fc[p] = c;
|
|
7461
|
+
const f0 = aF.length, s0 = aSrc.length, k0 = aSink.length, n0 = aSan.length;
|
|
7462
|
+
for (const x of (d.findings || [])) aF.push(x);
|
|
7463
|
+
for (const x of (d.routes || [])) aR.push(x);
|
|
7464
|
+
for (const x of (d.sources || [])) aSrc.push(x);
|
|
7465
|
+
for (const x of (d.sinks || [])) aSink.push(x);
|
|
7466
|
+
for (const x of (d.sanitizers || [])) aSan.push(x);
|
|
7467
|
+
for (const x of (d.logic || [])) aLogic.push(x);
|
|
7468
|
+
for (const x of (d.secrets || [])) aSecrets.push(x);
|
|
7469
|
+
for (const x of (d.ciphersRest || [])) aCiphersRest.push(x);
|
|
7470
|
+
for (const x of (d.ciphersTransit || [])) aCiphersTransit.push(x);
|
|
7471
|
+
for (const x of (d.suppressions || [])) _suppressionLog.push(x);
|
|
7472
|
+
const ta = Object.assign({}, d.pfr || {});
|
|
7473
|
+
ta.findings = aF.slice(f0, f0 + (d.pfrFindings || 0));
|
|
7474
|
+
ta.sources = aSrc.slice(s0); ta.sinks = aSink.slice(k0); ta.sanitizers = aSan.slice(n0);
|
|
7475
|
+
pfr[p] = ta;
|
|
7476
|
+
_fileTimings.push({ file: p, ms: d.ms || 0 });
|
|
7477
|
+
_ckptResumed++;
|
|
7478
|
+
return true;
|
|
7479
|
+
};
|
|
7480
|
+
const _ckptRecord = (p, mk, ms, ta) => {
|
|
7481
|
+
if (!_ckpt || !_ckpt.enabled) return;
|
|
7482
|
+
recordFileDone(_ckpt, p, {
|
|
7483
|
+
routes: aR.slice(mk.aR), findings: aF.slice(mk.aF),
|
|
7484
|
+
sources: aSrc.slice(mk.aSrc), sinks: aSink.slice(mk.aSink), sanitizers: aSan.slice(mk.aSan),
|
|
7485
|
+
logic: aLogic.slice(mk.aLogic), secrets: aSecrets.slice(mk.aSecrets),
|
|
7486
|
+
ciphersRest: aCiphersRest.slice(mk.aCR), ciphersTransit: aCiphersTransit.slice(mk.aCT),
|
|
7487
|
+
suppressions: _suppressionLog.slice(mk.sup),
|
|
7488
|
+
pfr: _pfrMetaOnly(ta),
|
|
7489
|
+
pfrFindings: (ta && Array.isArray(ta.findings)) ? ta.findings.length : 0,
|
|
7490
|
+
ms,
|
|
7491
|
+
});
|
|
7492
|
+
_ckptWrites++;
|
|
7493
|
+
// Fault injection for the resume test: a hard exit with no unwinding, which
|
|
7494
|
+
// is what the checkpoint format has to survive. Never set in normal use.
|
|
7495
|
+
if (_ckptAbortAfter > 0 && _ckptWrites >= _ckptAbortAfter) process.exit(137);
|
|
7496
|
+
};
|
|
7497
|
+
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"});
|
|
7498
|
+
if(_ckptDone.has(p)&&_ckptReplay(p))continue;
|
|
7499
|
+
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};
|
|
7500
|
+
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
7501
|
aF.push(...scanLLM(p,c));
|
|
7404
7502
|
aF.push(...scanLLMOwasp(p,c));
|
|
7405
7503
|
aF.push(...scanLlmCost(p,c));
|
|
@@ -7504,6 +7602,7 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null},
|
|
|
7504
7602
|
const _ftElapsed=Date.now()-_ft0;
|
|
7505
7603
|
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
7604
|
_fileTimings.push({file:p,ms:_ftElapsed});
|
|
7605
|
+
_ckptRecord(p,_mk,_ftElapsed,ta);
|
|
7507
7606
|
}catch(_){_fileTimings.push({file:p,ms:Date.now()-_ft0,error:true});}if(i%5===0)await new Promise(r=>setTimeout(r,0));}
|
|
7508
7607
|
// Deserialization-gadget detector runs once with full-tree context (it needs
|
|
7509
7608
|
// manifest contents to know which gadget libs are on the classpath).
|
|
@@ -7914,6 +8013,26 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null},
|
|
|
7914
8013
|
// on; opt out with AGENTIC_SECURITY_NO_PROOF_GATE=1. Demotes proven-clean /
|
|
7915
8014
|
// proven-infeasible flows (confidence + tiers only, never severity).
|
|
7916
8015
|
if (process.env.AGENTIC_SECURITY_NO_PROOF_GATE !== '1') {
|
|
8016
|
+
// Generalised sanitizer consumption (dataflow/sanitizer-gate.js): labels
|
|
8017
|
+
// findings whose flow passes a catalog sanitizer matching their family
|
|
8018
|
+
// (xss/url/cmd, not just sql) so the proof gate below can demote them the
|
|
8019
|
+
// same way it demotes proven-clean SQL. `sanitizersOnPath` would need to
|
|
8020
|
+
// be `{ [findingId]: string[] of sanitizer callees observed on that
|
|
8021
|
+
// finding's flow }`. There is nothing to build that map from: the live
|
|
8022
|
+
// taint walk in dataflow/engine.js does NOT consult sanitizer catalog
|
|
8023
|
+
// entries at all. `matchSinkOrSanitizer()` returns every catalog hit for
|
|
8024
|
+
// a callee, but every consumer in dataflow/*.js selects only
|
|
8025
|
+
// `e.kind === 'sink'` — there is no `'sanitizer'` branch anywhere in that
|
|
8026
|
+
// tree. Taint is killed only by clean re-assignment of a variable
|
|
8027
|
+
// (removePathAndDescendants, engine.js:374), which happens regardless of
|
|
8028
|
+
// whether the RHS call is a catalog sanitizer.
|
|
8029
|
+
// So this is `{}` and the gate below is INERT — not "awaiting plumbing"
|
|
8030
|
+
// but awaiting the sanitizer walk itself. Making it live needs two things:
|
|
8031
|
+
// (1) dataflow/engine.js honouring `kind === 'sanitizer'` at a call site,
|
|
8032
|
+
// and (2) that call site's callee name threaded onto the finding
|
|
8033
|
+
// alongside the trace/chain that proven-clean.js already reads.
|
|
8034
|
+
const sanitizersOnPath = {};
|
|
8035
|
+
_runAnnotator("applySanitizerGate", () => { applySanitizerGate(finalFindings, { sanitizersOnPath }); });
|
|
7917
8036
|
_runAnnotator("annotateProofGate", () => { annotateProofGate(finalFindings); });
|
|
7918
8037
|
}
|
|
7919
8038
|
// Addition #1 — default falsification pass. Actively tries to DISPROVE each
|
|
@@ -8156,6 +8275,33 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null},
|
|
|
8156
8275
|
// - Global timeout via AGENTIC_SECURITY_DEEP_TIMEOUT_MS (default 300_000 = 5 min)
|
|
8157
8276
|
// - Auto-disabled in CI unless AGENTIC_SECURITY_DEEP_IN_CI=1 is also set,
|
|
8158
8277
|
// so a pathological file can't hang the whole pipeline.
|
|
8278
|
+
// ── IR parse-coverage sidecar (proof-corpus instrumentation, default off) ──
|
|
8279
|
+
// Built ahead of the deep-mode gate so coverage is measurable without paying
|
|
8280
|
+
// for taint analysis, and stashed in _sharedIR so the deep block below reuses
|
|
8281
|
+
// it rather than parsing the project twice.
|
|
8282
|
+
//
|
|
8283
|
+
// NOTE (affects instrumented runs only, i.e. AGENTIC_SECURITY_IR_STATS set):
|
|
8284
|
+
// when this block runs, buildProjectIR() happens here, BEFORE the deep-mode
|
|
8285
|
+
// budget timer (t0) below is started. On an uninstrumented run, IR
|
|
8286
|
+
// construction instead happens inside the timed block via the
|
|
8287
|
+
// `_sharedIR || (_sharedIR = buildProjectIR(fc))` line, so its cost counts
|
|
8288
|
+
// against AGENTIC_SECURITY_DEEP_TIMEOUT_MS. That means the deep budget does
|
|
8289
|
+
// NOT account for parse time when stats are enabled — an instrumented run
|
|
8290
|
+
// gets strictly more wall-clock for the taint analysis itself than an
|
|
8291
|
+
// uninstrumented run with the same budget.
|
|
8292
|
+
let _sharedIR = null;
|
|
8293
|
+
const _irStatsTarget = irStatsTarget();
|
|
8294
|
+
if (_irStatsTarget) {
|
|
8295
|
+
try {
|
|
8296
|
+
_sharedIR = buildProjectIR(fc);
|
|
8297
|
+
writeIrStats(_irStatsTarget, collectIrStats(fc, _sharedIR.perFile, _sharedIR.callGraph));
|
|
8298
|
+
} catch (e) {
|
|
8299
|
+
// Instrumentation must never fail a scan. Surface only when debugging.
|
|
8300
|
+
if (process.env.AGENTIC_SECURITY_IR_STATS_DEBUG === '1') {
|
|
8301
|
+
process.stderr.write(`ir-stats: ${e && e.message}\n`);
|
|
8302
|
+
}
|
|
8303
|
+
}
|
|
8304
|
+
}
|
|
8159
8305
|
const _deepRequested = process.env.AGENTIC_SECURITY_DEEP === '1';
|
|
8160
8306
|
const _inCi = !!(process.env.CI || process.env.GITHUB_ACTIONS || process.env.GITLAB_CI ||
|
|
8161
8307
|
process.env.BUILDKITE || process.env.CIRCLECI || process.env.JENKINS_URL);
|
|
@@ -8165,7 +8311,7 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null},
|
|
|
8165
8311
|
const budgetMs = parseInt(process.env.AGENTIC_SECURITY_DEEP_TIMEOUT_MS || '300000', 10);
|
|
8166
8312
|
const t0 = Date.now();
|
|
8167
8313
|
try {
|
|
8168
|
-
const { perFile, callGraph } = buildProjectIR(fc);
|
|
8314
|
+
const { perFile, callGraph } = _sharedIR || (_sharedIR = buildProjectIR(fc));
|
|
8169
8315
|
// The runDeepAnalysis call is synchronous in this codebase; we can't
|
|
8170
8316
|
// truly interrupt it without re-architecting the worklist. We pass a
|
|
8171
8317
|
// deadlineMs hint that the inner loops check; if absent, we still cap
|
|
@@ -8562,9 +8708,26 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null},
|
|
|
8562
8708
|
let _analysisTier = null, _unmodeledSinks = null;
|
|
8563
8709
|
try { _analysisTier = computeAnalysisTiers(Object.keys(fc)); } catch {}
|
|
8564
8710
|
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}};
|
|
8711
|
+
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}};
|
|
8712
|
+
// R8: the scan completed, so the checkpoint has been fully consumed — remove
|
|
8713
|
+
// it. Anything that threw before this point leaves it in place to resume from.
|
|
8714
|
+
try { closeCheckpoint(_ckpt, { complete: true }); } catch (_) {}
|
|
8566
8715
|
// Addition #2 — attack-surface completeness inventory (entry points → dispositions).
|
|
8567
8716
|
let _entrypointInventory = {}; try { _entrypointInventory = buildEntrypointInventory(fc, { routes: aR, findings: finalFindings }); } catch { _entrypointInventory = {}; }
|
|
8717
|
+
// R9 + R6 — relevance scoping. Runs HERE, after every finding has been
|
|
8718
|
+
// appended (multi-sink chains, cross-language chains) and after the
|
|
8719
|
+
// entry-point inventory exists, so no finding escapes annotation and the
|
|
8720
|
+
// attack surface it is scored against is the complete one. Recall-
|
|
8721
|
+
// preserving: never removes a finding, never touches severity, and only
|
|
8722
|
+
// asserts `unreachable` on positive evidence (see posture/relevance.js).
|
|
8723
|
+
_runAnnotator("annotateRelevance", () => {
|
|
8724
|
+
annotateRelevance(finalFindings, {
|
|
8725
|
+
fileContents: fc,
|
|
8726
|
+
entrypointInventory: _entrypointInventory,
|
|
8727
|
+
routes: aR,
|
|
8728
|
+
threatModel: _v3 && _v3.threatModel,
|
|
8729
|
+
});
|
|
8730
|
+
});
|
|
8568
8731
|
// Addition #3 — root-cause sweep: from confirmed findings, find sibling instances
|
|
8569
8732
|
// detectors missed, with total-count accounting. Confirmed-only (cheap by default).
|
|
8570
8733
|
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
|
+
}
|