@clear-capabilities/agentic-security-scanner 0.127.0 → 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 +161 -0
- package/bin/agentic-security.js +33 -0
- package/dist/11.index.js +353 -0
- package/dist/113.index.js +727 -0
- 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 +19 -8
- package/dist/526.index.js +555 -0
- package/dist/637.index.js +1 -1
- package/dist/826.index.js +4 -1
- package/dist/830.index.js +1 -1
- package/dist/agentic-security.mjs +113 -163
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +23 -15
- 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 +181 -8
- package/src/integrations/index.js +1 -1
- package/src/integrations/tickets.js +9 -3
- 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/mcp/tools.js +17 -6
- package/src/posture/CLAUDE.md +122 -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/entrypoint-inventory.js +248 -0
- package/src/posture/execution-proof.js +52 -0
- package/src/posture/exploitability-probability.js +1 -1
- package/src/posture/falsification.js +165 -0
- package/src/posture/fix-honesty-gate.js +175 -0
- package/src/posture/fix-verify.js +71 -3
- package/src/posture/license-policy.js +1 -1
- package/src/posture/model-routing.js +126 -0
- 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 +262 -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/pr-comment.js +3 -1
- 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/untrusted.js +148 -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,6 +175,12 @@ 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';
|
|
179
|
+
import { annotateFalsification } from './posture/falsification.js';
|
|
180
|
+
import { routeModelForFinding } from './posture/model-routing.js';
|
|
181
|
+
import { buildEntrypointInventory } from './posture/entrypoint-inventory.js';
|
|
182
|
+
import { annotateRelevance } from './posture/relevance.js';
|
|
183
|
+
import { sweepRootCauses } from './posture/root-cause-sweep.js';
|
|
177
184
|
import { computeAnalysisTiers, countUnmodeledSinkCandidates } from './posture/coverage-report.js';
|
|
178
185
|
import { annotatePrivacyTaint, emitDpiaArtifact } from './dataflow/privacy-taint.js';
|
|
179
186
|
import { buildThreatModel as buildAutoThreatModel, persistThreatModel as persistAutoThreatModel } from './posture/threat-model-auto.js';
|
|
@@ -208,6 +215,13 @@ import { buildTrustBoundaryDiagram } from './posture/trust-boundary-diagram.js';
|
|
|
208
215
|
import { scanConcurrency } from './posture/concurrency-checker.js';
|
|
209
216
|
import { annotateBountyPrediction } from './posture/bounty-prediction.js';
|
|
210
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';
|
|
211
225
|
|
|
212
226
|
// Disk-backed cache replacing browser sessionStorage. One JSON blob per key under ~/.claude/agentic-security/osv-cache/.
|
|
213
227
|
const _CACHE_DIR = path.join(os.homedir(), '.claude', 'agentic-security', 'osv-cache');
|
|
@@ -981,7 +995,7 @@ function performASTAnalysis(fp, code) {
|
|
|
981
995
|
try {
|
|
982
996
|
babelTransformSync(code, {
|
|
983
997
|
filename: fp,
|
|
984
|
-
presets: [presetReact, [presetTypescript, {
|
|
998
|
+
presets: [presetReact, [presetTypescript, { ignoreExtensions: true }]],
|
|
985
999
|
plugins: [astTaintTrackerPlugin],
|
|
986
1000
|
ast: false, code: false,
|
|
987
1001
|
babelrc: false, configFile: false,
|
|
@@ -2262,6 +2276,10 @@ function _isFalsePositiveCredential(fp, snippet, fullMatch){
|
|
|
2262
2276
|
// Module-level suppression log; cleared at the start of each runFullScan invocation.
|
|
2263
2277
|
const _suppressionLog = [];
|
|
2264
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; }
|
|
2265
2283
|
function _getSuppressions(){ return [..._suppressionLog]; }
|
|
2266
2284
|
|
|
2267
2285
|
// FP-9 / Feat-4: custom rules loaded from .agentic-security/rules.{yml,yaml,json}
|
|
@@ -4627,7 +4645,7 @@ function _buildCallGraphAST(fp, code){
|
|
|
4627
4645
|
|
|
4628
4646
|
babelTransformSync(code, {
|
|
4629
4647
|
filename: fp,
|
|
4630
|
-
presets: [presetReact, [presetTypescript, {
|
|
4648
|
+
presets: [presetReact, [presetTypescript, { ignoreExtensions: true }]],
|
|
4631
4649
|
plugins: [callTrackerPlugin],
|
|
4632
4650
|
ast: false, code: false,
|
|
4633
4651
|
babelrc: false, configFile: false,
|
|
@@ -7385,7 +7403,7 @@ async function queryRegistries(components){
|
|
|
7385
7403
|
|
|
7386
7404
|
// Node port: takes { fileContents, depFileContents } maps directly instead of a JSZip object.
|
|
7387
7405
|
// fileContents = code files keyed by relative path; depFileContents = manifest/lockfiles keyed by relative path.
|
|
7388
|
-
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);
|
|
7389
7407
|
// Pre-pass: build cross-file Java tainted-method index so per-file taint
|
|
7390
7408
|
// analysis can recognize calls to user-input-returning helper methods
|
|
7391
7409
|
// defined in OTHER files (Juliet's DataflowThruInnerClass / Vector / Stream
|
|
@@ -7395,7 +7413,75 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null},
|
|
|
7395
7413
|
const _perFileTimeoutMs = parseInt(process.env.AGENTIC_SECURITY_PER_FILE_TIMEOUT_MS || '10000', 10);
|
|
7396
7414
|
const _fileTimings = [];
|
|
7397
7415
|
let _filesSkipped = 0, _filesTimedOut = 0, _filesDenseSkipped = 0;
|
|
7398
|
-
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));
|
|
7399
7485
|
aF.push(...scanLLM(p,c));
|
|
7400
7486
|
aF.push(...scanLLMOwasp(p,c));
|
|
7401
7487
|
aF.push(...scanLlmCost(p,c));
|
|
@@ -7500,6 +7586,7 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null},
|
|
|
7500
7586
|
const _ftElapsed=Date.now()-_ft0;
|
|
7501
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++;}
|
|
7502
7588
|
_fileTimings.push({file:p,ms:_ftElapsed});
|
|
7589
|
+
_ckptRecord(p,_mk,_ftElapsed,ta);
|
|
7503
7590
|
}catch(_){_fileTimings.push({file:p,ms:Date.now()-_ft0,error:true});}if(i%5===0)await new Promise(r=>setTimeout(r,0));}
|
|
7504
7591
|
// Deserialization-gadget detector runs once with full-tree context (it needs
|
|
7505
7592
|
// manifest contents to know which gadget libs are on the classpath).
|
|
@@ -7910,8 +7997,45 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null},
|
|
|
7910
7997
|
// on; opt out with AGENTIC_SECURITY_NO_PROOF_GATE=1. Demotes proven-clean /
|
|
7911
7998
|
// proven-infeasible flows (confidence + tiers only, never severity).
|
|
7912
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 }); });
|
|
7913
8020
|
_runAnnotator("annotateProofGate", () => { annotateProofGate(finalFindings); });
|
|
7914
8021
|
}
|
|
8022
|
+
// Addition #1 — default falsification pass. Actively tries to DISPROVE each
|
|
8023
|
+
// taint-style finding by locating a context-matched control on the path, and
|
|
8024
|
+
// demotes + quarantines the ones it can block. Recall-preserving (never
|
|
8025
|
+
// removes a finding, never touches severity — like the proof gate). Runs
|
|
8026
|
+
// AFTER proof-gate so it layers on the same demotion channel. Deterministic by
|
|
8027
|
+
// default; the LLM tier is only wired when an endpoint is configured. Opt out
|
|
8028
|
+
// with AGENTIC_SECURITY_NO_FALSIFICATION=1.
|
|
8029
|
+
if (process.env.AGENTIC_SECURITY_NO_FALSIFICATION !== '1') {
|
|
8030
|
+
_runAnnotator("annotateFalsification", () => { annotateFalsification(finalFindings, fc); });
|
|
8031
|
+
}
|
|
8032
|
+
// Addition #5 — capability-based model routing. Stamp each finding with the
|
|
8033
|
+
// model tier a cost-sensitive fixer/triager/PoC subagent should be dispatched
|
|
8034
|
+
// on for THIS vuln class (crypto/auth/critical → strongest; injection → mid;
|
|
8035
|
+
// low-sev hardening → cheapest). Advisory metadata consumed at dispatch time.
|
|
8036
|
+
_runAnnotator("annotateDispatchModel", () => {
|
|
8037
|
+
for (const f of finalFindings) { try { f.dispatchModel = routeModelForFinding(f).model; } catch { /* advisory only */ } }
|
|
8038
|
+
});
|
|
7915
8039
|
// v3 next-gen: production-aware context ingest (Pillar 9). Must run BEFORE
|
|
7916
8040
|
// the mitigation composite, persona prioritization, and final why-fired
|
|
7917
8041
|
// record so those see the demotion signals.
|
|
@@ -8135,6 +8259,33 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null},
|
|
|
8135
8259
|
// - Global timeout via AGENTIC_SECURITY_DEEP_TIMEOUT_MS (default 300_000 = 5 min)
|
|
8136
8260
|
// - Auto-disabled in CI unless AGENTIC_SECURITY_DEEP_IN_CI=1 is also set,
|
|
8137
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
|
+
}
|
|
8138
8289
|
const _deepRequested = process.env.AGENTIC_SECURITY_DEEP === '1';
|
|
8139
8290
|
const _inCi = !!(process.env.CI || process.env.GITHUB_ACTIONS || process.env.GITLAB_CI ||
|
|
8140
8291
|
process.env.BUILDKITE || process.env.CIRCLECI || process.env.JENKINS_URL);
|
|
@@ -8144,7 +8295,7 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null},
|
|
|
8144
8295
|
const budgetMs = parseInt(process.env.AGENTIC_SECURITY_DEEP_TIMEOUT_MS || '300000', 10);
|
|
8145
8296
|
const t0 = Date.now();
|
|
8146
8297
|
try {
|
|
8147
|
-
const { perFile, callGraph } = buildProjectIR(fc);
|
|
8298
|
+
const { perFile, callGraph } = _sharedIR || (_sharedIR = buildProjectIR(fc));
|
|
8148
8299
|
// The runDeepAnalysis call is synchronous in this codebase; we can't
|
|
8149
8300
|
// truly interrupt it without re-architecting the worklist. We pass a
|
|
8150
8301
|
// deadlineMs hint that the inner loops check; if absent, we still cap
|
|
@@ -8541,8 +8692,30 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null},
|
|
|
8541
8692
|
let _analysisTier = null, _unmodeledSinks = null;
|
|
8542
8693
|
try { _analysisTier = computeAnalysisTiers(Object.keys(fc)); } catch {}
|
|
8543
8694
|
try { _unmodeledSinks = countUnmodeledSinkCandidates(fc, finalFindings); } catch {}
|
|
8544
|
-
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}};
|
|
8545
|
-
|
|
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 (_) {}
|
|
8699
|
+
// Addition #2 — attack-surface completeness inventory (entry points → dispositions).
|
|
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
|
+
});
|
|
8715
|
+
// Addition #3 — root-cause sweep: from confirmed findings, find sibling instances
|
|
8716
|
+
// detectors missed, with total-count accounting. Confirmed-only (cheap by default).
|
|
8717
|
+
let _rootCauseSweep = null; try { _rootCauseSweep = sweepRootCauses(finalFindings, fc); } catch { _rootCauseSweep = null; }
|
|
8718
|
+
return{entrypointInventory:_entrypointInventory,rootCauseSweep:_rootCauseSweep,routes:dd(aR,r=>`${r.method}:${r.path}:${r.file}:${r.line}`),findings:finalFindings,sources:aSrc,sinks:aSink,sanitizers:aSan,filesScanned:files.length,crossFileCount:cf.length,logicVulns:aLogic,supplyChain,components:annotatedComponents,secrets:aSecrets,ciphers:{atRest:aCiphersRest,inTransit:aCiphersTransit},pfr,fc,suppressions:_getSuppressions(),_v3,_scanMeta,_engineErrors:{cppDataflowParseErrors:_cppDataflowParseErrors.value},annotatorErrors:_annotatorErrors,threatModel:_threatModel,sbomDiff:_sbomDiff,complianceReport:_complianceReport,exploitBundles:_exploitBundles,pqcPlan:_pqcPlan,licenseGraph:_licenseGraph,attributions:_attributions,attackTaxonomy:_taxonomySummary};}
|
|
8546
8719
|
|
|
8547
8720
|
// Post-aggregation classification: every source becomes "unsafe"|"safe"; every sink becomes "confirmed"|"safe".
|
|
8548
8721
|
// Orphans (no finding linkage) are bucketed by file-local heuristic so the UI shows binary states only.
|
|
@@ -17,6 +17,7 @@ import * as fs from 'node:fs';
|
|
|
17
17
|
import * as path from 'node:path';
|
|
18
18
|
import * as cp from 'node:child_process';
|
|
19
19
|
import { buildJiraIssue } from './index.js';
|
|
20
|
+
import { escapeMarkdown } from '../util/untrusted.js';
|
|
20
21
|
|
|
21
22
|
function statePath(scanRoot) {
|
|
22
23
|
return path.join(scanRoot, '.agentic-security', 'tickets.json');
|
|
@@ -32,7 +33,10 @@ function writeState(scanRoot, state) {
|
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
function findingTitle(f) {
|
|
35
|
-
|
|
36
|
+
// vuln/title are lifted from the (untrusted) scanned code — escape before
|
|
37
|
+
// they land in an issue title. See docs/AGENT_THREAT_MODEL.md path #1.
|
|
38
|
+
const label = escapeMarkdown(f.vuln) || escapeMarkdown(f.title) || 'security finding';
|
|
39
|
+
return `[${(f.severity || 'medium').toUpperCase()}] ${label} at ${f.file}:${f.line}`;
|
|
36
40
|
}
|
|
37
41
|
function findingBody(f) {
|
|
38
42
|
const br = f.blastRadius;
|
|
@@ -43,9 +47,11 @@ function findingBody(f) {
|
|
|
43
47
|
f.cwe ? `**CWE:** ${f.cwe}` : null,
|
|
44
48
|
f.epss != null ? `**EPSS:** ${f.epss.toFixed(4)} (percentile ${(f.epssPercentile * 100).toFixed(1)}%)` : null,
|
|
45
49
|
exploited,
|
|
46
|
-
f.description ? `\n${f.description}` : null,
|
|
50
|
+
f.description ? `\n${escapeMarkdown(f.description)}` : null,
|
|
47
51
|
br?.narrative ? `\n**Blast radius:** ${br.narrative}` : null,
|
|
48
|
-
|
|
52
|
+
// snippet is attacker-authored code; escape so a crafted ``` fence or
|
|
53
|
+
// <img>/[x](url) inside it cannot break out of the code block.
|
|
54
|
+
f.snippet ? `\n\`\`\`\n${escapeMarkdown(f.snippet)}\n\`\`\`` : null,
|
|
49
55
|
f.remediation ? `\n**Remediation:** ${f.remediation}` : null,
|
|
50
56
|
`\n---\n_Surfaced by agentic-security · finding id: ${f.id}_`,
|
|
51
57
|
].filter(Boolean).join('\n');
|
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
|
+
}
|