@clear-capabilities/agentic-security-scanner 0.136.2 → 0.136.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +236 -0
- package/bin/agentic-security.js +186 -37
- package/dist/113.index.js +13 -4
- package/dist/178.index.js +1 -1
- package/dist/207.index.js +5 -4
- package/dist/238.index.js +1 -1
- package/dist/317.index.js +36 -6
- package/dist/384.index.js +1 -1
- package/dist/435.index.js +183 -14
- package/dist/444.index.js +20 -11
- package/dist/449.index.js +8 -1
- package/dist/526.index.js +3 -3
- package/dist/637.index.js +1 -1
- package/dist/agentic-security.mjs +14 -14
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/compliance-frameworks/nist-privacy-1-1.json +2 -2
- package/dist/compliance-frameworks/owasp-asvs-5.json +1 -1
- package/package.json +18 -10
- package/src/dataflow/CLAUDE.md +10 -4
- package/src/dataflow/builtin-summaries.js +1 -1
- package/src/dataflow/engine.js +324 -60
- package/src/dataflow/implicit-flow.js +68 -36
- package/src/dataflow/incremental.js +18 -3
- package/src/dataflow/index.js +2 -1
- package/src/dataflow/proven-clean.js +41 -0
- package/src/dataflow/sanitizer-gate.js +35 -9
- package/src/dataflow/sanitizer-proof.js +21 -3
- package/src/dataflow/stub-aware-filter.js +36 -13
- package/src/dataflow/summaries.js +21 -2
- package/src/engine.js +202 -42
- package/src/ir/CLAUDE.md +4 -1
- package/src/ir/balanced-call.js +55 -0
- package/src/ir/parser-cs.js +62 -9
- package/src/ir/parser-go.js +29 -11
- package/src/ir/parser-java.js +96 -19
- package/src/ir/parser-js.js +151 -20
- package/src/ir/parser-php.js +44 -9
- package/src/ir/parser-rb.js +37 -7
- package/src/ir/ssa.js +6 -1
- package/src/lsp/server.js +28 -2
- package/src/mcp/CLAUDE.md +9 -2
- package/src/mcp/redact.js +26 -0
- package/src/mcp/tools.js +155 -14
- package/src/posture/CLAUDE.md +19 -7
- package/src/posture/accuracy-scorecard.js +9 -1
- package/src/posture/aibom.js +12 -8
- package/src/posture/auditor-walkthrough.js +102 -3
- package/src/posture/autopilot.js +8 -1
- package/src/posture/calibration-drift.js +11 -5
- package/src/posture/calibration.js +24 -2
- package/src/posture/compliance-frameworks/nist-privacy-1-1.json +2 -2
- package/src/posture/compliance-frameworks/owasp-asvs-5.json +1 -1
- package/src/posture/compliance-policy.js +33 -1
- package/src/posture/confidence.js +44 -10
- package/src/posture/corpus-enroll.js +9 -5
- package/src/posture/corpus-match.js +19 -0
- package/src/posture/csharp-analysis.js +62 -3
- package/src/posture/deploy-platform.js +4 -1
- package/src/posture/drift.js +7 -1
- package/src/posture/epss.js +13 -1
- package/src/posture/evidence-bundle.js +36 -6
- package/src/posture/exploitability-probability.js +13 -1
- package/src/posture/falsification.js +23 -2
- package/src/posture/fix-metrics.js +1 -1
- package/src/posture/fix-verify-loop.js +10 -1
- package/src/posture/iac-reachability.js +14 -8
- package/src/posture/integrity.js +25 -7
- package/src/posture/model-rescan.js +65 -0
- package/src/posture/mttr.js +5 -0
- package/src/posture/poc-inprocess.js +27 -8
- package/src/posture/regression-test-gen.js +23 -8
- package/src/posture/reverse-blast-radius.js +5 -1
- package/src/posture/risk-dollars.js +18 -1
- package/src/posture/secret-history.js +20 -11
- package/src/posture/security-trend.js +7 -1
- package/src/posture/stack-playbook.js +22 -1
- package/src/posture/threat-model-grounding.js +2 -2
- package/src/posture/validator-metrics.js +10 -3
- package/src/posture/verifier.js +32 -57
- package/src/report/index.js +183 -14
- package/src/runScan.js +1 -1
- package/src/sast/_comment-strip.js +15 -4
- package/src/sast/_secret-entropy.js +1 -1
- package/src/sast/authz.js +6 -4
- package/src/sast/bench-shape/index.js +2 -7
- package/src/sast/claude-md-prompt-injection.js +14 -3
- package/src/sast/cloud-iam.js +60 -7
- package/src/sast/cpp-bench-extras.js +1 -1
- package/src/sast/csrf.js +7 -5
- package/src/sast/env-hygiene.js +5 -2
- package/src/sast/iac-terraform.js +25 -0
- package/src/sast/java-bench-extras.js +1 -1
- package/src/sast/java-constant-fold.js +5 -5
- package/src/sast/llm-owasp.js +4 -2
- package/src/sast/mcp-audit.js +7 -0
- package/src/sast/pipeline.js +8 -0
- package/src/sast/prompt-template.js +8 -6
- package/src/sast/prototype-pollution.js +6 -2
- package/src/sast/redos-nfa.js +6 -6
- package/src/sast/secret-concat.js +13 -2
- package/src/sast/ssrf-cloud-metadata.js +6 -3
- package/src/sast/xss-reflected-multilang.js +1 -1
- package/src/sast/xxe.js +1 -1
- package/src/sca/CLAUDE.md +3 -4
- package/src/sca/container.js +35 -3
- package/src/sca/dep-confusion.js +7 -0
- package/src/sca/sarif-ingest.js +0 -187
package/src/engine.js
CHANGED
|
@@ -121,7 +121,7 @@ import { scanRuby } from './sast/ruby.js';
|
|
|
121
121
|
import { scanPhp } from './sast/php.js';
|
|
122
122
|
import { classifySecretCandidate as _entropyClassifySecret } from './sast/_secret-entropy.js';
|
|
123
123
|
// Phase 1 — precision-engineering posture modules.
|
|
124
|
-
import { annotateConfidence } from './posture/confidence.js';
|
|
124
|
+
import { annotateConfidence, applyUnvalidatedPenalty } from './posture/confidence.js';
|
|
125
125
|
import { backfillFindingDefaults } from './posture/finding-defaults.js';
|
|
126
126
|
import { annotatePocs } from './posture/poc-generator.js';
|
|
127
127
|
import { annotateExecutionProofs } from './posture/prove-findings.js';
|
|
@@ -148,7 +148,7 @@ import { ingestLogicClaims } from './posture/logic-claims.js';
|
|
|
148
148
|
import { annotateNarration } from './posture/flow-narration.js';
|
|
149
149
|
import { applyPathConstraints } from './posture/path-predicates.js';
|
|
150
150
|
// Phase 3 (Sentinel-parity Layer 1 + 2) — IR + interprocedural taint engine.
|
|
151
|
-
import { buildProjectIR } from './ir/index.js';
|
|
151
|
+
import { buildProjectIR, buildProjectIRAsync } from './ir/index.js';
|
|
152
152
|
import { collectIrStats, irStatsTarget, writeIrStats } from './ir/ir-stats.js';
|
|
153
153
|
import { runDeepAnalysis } from './dataflow/index.js';
|
|
154
154
|
// v3 next-gen — Pillars 1, 4, 5, 6, 8, 9.
|
|
@@ -2214,7 +2214,13 @@ const IAC_PATTERNS = [
|
|
|
2214
2214
|
{ match: /\$\{\{\s*github\.event\.(?:issue|pull_request)\.title|\$\{\{\s*github\.event\.comment\.body/i,
|
|
2215
2215
|
fileTypes: /\.github\/workflows\/.*\.ya?ml$/i,
|
|
2216
2216
|
severity: 'high', cwe: 'CWE-78', vuln: 'GitHub Actions: untrusted github.event input interpolated into shell',
|
|
2217
|
-
fix: 'Pass user-controlled fields via env vars and reference them as $VARNAME in the script body, not via ${{ }} interpolation.'
|
|
2217
|
+
fix: 'Pass user-controlled fields via env vars and reference them as $VARNAME in the script body, not via ${{ }} interpolation.',
|
|
2218
|
+
// Same false-positive as src/sast/pipeline.js's overlapping rule: a pure
|
|
2219
|
+
// `KEY: ${{ github.event.… }}` mapping line assigns to an env var at the
|
|
2220
|
+
// workflow-engine level (the fix's own recommended pattern), not into a
|
|
2221
|
+
// shell command string — only a match embedded in a larger line (a run:
|
|
2222
|
+
// script body) is actually dangerous.
|
|
2223
|
+
lineSafeRe: /^[\w.-]+\s*:\s*\$\{\{[^}]*\}\}\s*$/ },
|
|
2218
2224
|
];
|
|
2219
2225
|
|
|
2220
2226
|
function scanIaC(fp, raw){
|
|
@@ -2227,6 +2233,7 @@ function scanIaC(fp, raw){
|
|
|
2227
2233
|
let m;
|
|
2228
2234
|
while ((m = re.exec(raw))) {
|
|
2229
2235
|
const line = raw.substring(0, m.index).split('\n').length;
|
|
2236
|
+
if (p.lineSafeRe && p.lineSafeRe.test((lines[line - 1] || '').trim())) continue;
|
|
2230
2237
|
findings.push({
|
|
2231
2238
|
id: `iac:${fp}:${line}:${p.vuln.replace(/\s/g, '_').slice(0, 60)}`,
|
|
2232
2239
|
kind: 'iac', severity: p.severity, vuln: p.vuln,
|
|
@@ -2747,10 +2754,24 @@ function scanLogicVulns(fp,raw){
|
|
|
2747
2754
|
while((m=re.exec(haystack))){
|
|
2748
2755
|
const line=lineAt(haystack,m.index);
|
|
2749
2756
|
const snippet=lines[line-1]?.trim()||"";
|
|
2757
|
+
let outSnippet=snippet;
|
|
2750
2758
|
// FP-2: credential FP filter
|
|
2751
2759
|
if(pat.vuln==='Hardcoded Secret'||pat.vuln==='Hardcoded Credential Check'){
|
|
2752
2760
|
const fpCheck=_isFalsePositiveCredential(fp,snippet,m[0]);
|
|
2753
2761
|
if(fpCheck.skip){_suppressionLog.push({vuln:pat.vuln,file:fp,line,snippet,reason:fpCheck.reason});continue;}
|
|
2762
|
+
// Stage 4 correctness audit (coverage breadth, secrets): same
|
|
2763
|
+
// unredacted-snippet leak found in engine.js's scanEntropySecrets/
|
|
2764
|
+
// scanCredentials and sast/secret-concat.js — this is a THIRD,
|
|
2765
|
+
// separate detector (LOGIC_PATTERNS' own "Hardcoded Secret" rule)
|
|
2766
|
+
// that also stored the raw source line, with no masking at all.
|
|
2767
|
+
// The regex captures the quoted value inside `m[0]`; mask just
|
|
2768
|
+
// that value within the reported snippet.
|
|
2769
|
+
const valMatch=m[0].match(/['"]([^'"]{3,})['"]/);
|
|
2770
|
+
if(valMatch){
|
|
2771
|
+
const val=valMatch[1];
|
|
2772
|
+
const masked=val.length>8?val.substring(0,4)+"…"+val.substring(val.length-4):"••••";
|
|
2773
|
+
outSnippet=snippet.split(val).join(masked);
|
|
2774
|
+
}
|
|
2754
2775
|
}
|
|
2755
2776
|
// FP-6: operational-context gate for selected logic patterns
|
|
2756
2777
|
if (predicate) {
|
|
@@ -2760,7 +2781,7 @@ function scanLogicVulns(fp,raw){
|
|
|
2760
2781
|
continue;
|
|
2761
2782
|
}
|
|
2762
2783
|
}
|
|
2763
|
-
results.push({vuln:pat.vuln,severity:pat.severity,cwe:pat.cwe,stride:pat.stride,kind:pat.kind,fix:pat.fix,code:pat.code,file:fp,line,snippet});
|
|
2784
|
+
results.push({vuln:pat.vuln,severity:pat.severity,cwe:pat.cwe,stride:pat.stride,kind:pat.kind,fix:pat.fix,code:pat.code,file:fp,line,snippet:outSnippet});
|
|
2764
2785
|
}
|
|
2765
2786
|
}
|
|
2766
2787
|
const routeRe=/(?:app|router)\s*\.\s*(?:get|post|all)\s*\(\s*['"`](\/(?:debug|admin|test|internal|__)[^'"`]*)/gi;let rm;
|
|
@@ -5409,17 +5430,29 @@ function scanEntropySecrets(fp,raw){
|
|
|
5409
5430
|
// FP-5: structural / doc-context suppression
|
|
5410
5431
|
const surrounding=lines.slice(Math.max(0,line-3),Math.min(lines.length,line+1)).join("\n");
|
|
5411
5432
|
const nonSecretReason=_isLikelyNonSecret(v, ctx, surrounding);
|
|
5433
|
+
const masked=v.substring(0,4)+"…"+v.substring(v.length-4);
|
|
5412
5434
|
if (nonSecretReason) {
|
|
5413
|
-
|
|
5435
|
+
// Same redaction concern as the main finding below applies to the
|
|
5436
|
+
// suppression log — it's exposed via --include-suppressed, and a
|
|
5437
|
+
// heuristic "probably not a real secret" call can be wrong.
|
|
5438
|
+
_suppressionLog.push({vuln:"High-Entropy Credential Candidate",file:fp,line,snippet:ctx.trim().split(v).join(masked),reason:'entropy-'+nonSecretReason});
|
|
5414
5439
|
continue;
|
|
5415
5440
|
}
|
|
5416
|
-
|
|
5441
|
+
// Stage 4 correctness audit (coverage breadth, secrets): `snippet` used
|
|
5442
|
+
// to carry the RAW, unmasked source line — including the full secret
|
|
5443
|
+
// value — even though `masked` right next to it was correctly
|
|
5444
|
+
// redacted. Nothing downstream (normalizeFindings, toHTML, toCSV,
|
|
5445
|
+
// toJUnit) ever redacts `snippet` again, so the plaintext credential
|
|
5446
|
+
// this detector exists to find flowed straight into every report
|
|
5447
|
+
// format this scanner emits, including last-scan.json. Redact the
|
|
5448
|
+
// exact matched value out of the snippet at the source, the same way
|
|
5449
|
+
// `masked` already is.
|
|
5417
5450
|
out.push({
|
|
5418
5451
|
vuln:"High-Entropy Credential Candidate",
|
|
5419
5452
|
severity:"high",cwe:"CWE-798",stride:"Information Disclosure",
|
|
5420
5453
|
fix:"Replace with environment variable or secrets manager reference; rotate the value immediately.",
|
|
5421
5454
|
code:`// BEFORE\nconst secret = "${masked}";\n\n// AFTER\nconst secret = process.env.APP_SECRET;`,
|
|
5422
|
-
file:fp,line,snippet:ctx.trim(),masked,entropy:e.toFixed(2)
|
|
5455
|
+
file:fp,line,snippet:ctx.trim().split(v).join(masked),masked,entropy:e.toFixed(2)
|
|
5423
5456
|
});
|
|
5424
5457
|
}
|
|
5425
5458
|
return out;
|
|
@@ -5459,12 +5492,21 @@ function scanConfigFiles(fc){
|
|
|
5459
5492
|
const[,k,v]=m;
|
|
5460
5493
|
if(!v||v==='""'||v==="''"||/^(?:change.?me|your[_-]|placeholder|example|xxx+|todo|<|\$\{)/i.test(v))continue;
|
|
5461
5494
|
if(/(?:password|secret|key|token|api)/i.test(k)){
|
|
5495
|
+
// Stage 6 correctness audit: this was the one secrets-adjacent
|
|
5496
|
+
// detector still shipping the RAW, unmasked source line as
|
|
5497
|
+
// `snippet` — its siblings (scanCredentials/scanEntropySecrets)
|
|
5498
|
+
// were already fixed for this exact leak in the Stage 4 audit.
|
|
5499
|
+
// Nothing downstream re-redacts `snippet`, and the MCP redact.js
|
|
5500
|
+
// catch-all requires a QUOTED value, which standard `.env`
|
|
5501
|
+
// KEY=value syntax never has — so the plaintext committed
|
|
5502
|
+
// secret flowed straight through explain_finding unredacted.
|
|
5503
|
+
const masked=v.length>8?v.substring(0,4)+"…"+v.substring(v.length-4):"…";
|
|
5462
5504
|
out.push({
|
|
5463
5505
|
vuln:`Committed .env with Real-Looking ${k}`,
|
|
5464
5506
|
severity:"high",cwe:"CWE-538",stride:"Information Disclosure",
|
|
5465
5507
|
fix:`Remove ${k} from committed env files. Use .env.example with placeholders and ignore .env in VCS.`,
|
|
5466
5508
|
code:`# In .gitignore\n.env\n\n# .env.example (commit)\n${k}=<your-${k.toLowerCase()}>`,
|
|
5467
|
-
file:fp,line:i+1,snippet:ln
|
|
5509
|
+
file:fp,line:i+1,snippet:ln.split(v).join(masked),masked
|
|
5468
5510
|
});
|
|
5469
5511
|
}
|
|
5470
5512
|
}
|
|
@@ -6369,7 +6411,11 @@ function scanCredentials(fp,raw){
|
|
|
6369
6411
|
if(seen.has(key))continue;seen.add(key);
|
|
6370
6412
|
const severity=pat.s==="c"?"critical":pat.s==="h"?"high":"medium";
|
|
6371
6413
|
const masked=val.length>12?val.substring(0,6)+"••••••"+val.substring(val.length-4):val.substring(0,3)+"•••";
|
|
6372
|
-
|
|
6414
|
+
// Stage 4 correctness audit (coverage breadth, secrets): same
|
|
6415
|
+
// unredacted-snippet leak as scanEntropySecrets — `snippet` carried
|
|
6416
|
+
// the raw source line (full credential value) straight through to
|
|
6417
|
+
// every report format. Redact the exact matched value here too.
|
|
6418
|
+
results.push({vuln:pat.n,severity,cwe:"CWE-798",stride:"Information Disclosure",file:fp,line,snippet:snippet.split(val).join(masked),masked,fix:"Remove the hardcoded credential. Store secrets in environment variables or a secrets manager (AWS Secrets Manager, HashiCorp Vault, GCP Secret Manager). Rotate the exposed credential immediately, treat it as compromised.",code:`// Remove hardcoded value:\n// const secret = "${masked}";\n\n// Use environment variable instead:\nconst secret = process.env.${pat.n.toUpperCase().replace(/[^A-Z0-9]/g,"_")};`});
|
|
6373
6419
|
}
|
|
6374
6420
|
}
|
|
6375
6421
|
return results;
|
|
@@ -6534,7 +6580,7 @@ function _makePurl(ecosystem,name,version,group){
|
|
|
6534
6580
|
const t={npm:'npm',pypi:'pypi'}[ecosystem]||ecosystem;
|
|
6535
6581
|
if(!t)return'';
|
|
6536
6582
|
const ns=group?`${encodeURIComponent(group)}/`:'';
|
|
6537
|
-
return`pkg:${t}/${ns}${encodeURIComponent(name)}${version?'@'+version:''}`;
|
|
6583
|
+
return`pkg:${t}/${ns}${encodeURIComponent(name)}${version?'@'+encodeURIComponent(version):''}`;
|
|
6538
6584
|
}
|
|
6539
6585
|
|
|
6540
6586
|
function _parsePackageJson(text,filePath){
|
|
@@ -7508,7 +7554,7 @@ async function queryRegistries(components){
|
|
|
7508
7554
|
|
|
7509
7555
|
// Node port: takes { fileContents, depFileContents } maps directly instead of a JSZip object.
|
|
7510
7556
|
// fileContents = code files keyed by relative path; depFileContents = manifest/lockfiles keyed by relative path.
|
|
7511
|
-
async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null, resume=undefined}, setProgress=()=>{}){_resetSuppressions();_buildProjectIndex(fileContents);await _loadCustomRules(scanRoot);
|
|
7557
|
+
async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null, resume=undefined, deep=undefined, deepInCi=undefined}, setProgress=()=>{}){_resetSuppressions();_buildProjectIndex(fileContents);await _loadCustomRules(scanRoot);
|
|
7512
7558
|
// Pre-pass: build cross-file Java tainted-method index so per-file taint
|
|
7513
7559
|
// analysis can recognize calls to user-input-returning helper methods
|
|
7514
7560
|
// defined in OTHER files (Juliet's DataflowThruInnerClass / Vector / Stream
|
|
@@ -7854,6 +7900,11 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
|
|
|
7854
7900
|
// R8 (PRD §5): OS packages from an extracted container image's package DBs
|
|
7855
7901
|
// (dpkg/apk) — baked-in deps the Dockerfile never names. Feed the OSV/SBOM pipeline.
|
|
7856
7902
|
try{const{extractImagePackages}=await import('./sca/image-packages.js');for(const ip of extractImagePackages(allFileContents)){if(!components.some(c=>c.ecosystem===ip.ecosystem&&c.name===ip.name&&c.version===ip.version))components.push(ip);}}catch(_){}
|
|
7903
|
+
// Dockerfile-declared apt/apk install-line packages — complementary to
|
|
7904
|
+
// extractImagePackages above (which reads an actually scanned filesystem's
|
|
7905
|
+
// installed-package DB, a different signal: build-time-declared vs.
|
|
7906
|
+
// actually-installed).
|
|
7907
|
+
try{const{extractContainerPackages}=await import('./sca/container.js');for(const cp of extractContainerPackages(allFileContents)){if(!components.some(c=>c.ecosystem===cp.ecosystem&&c.name===cp.name&&c.version===cp.version))components.push(cp);}}catch(_){}
|
|
7857
7908
|
try{const{detectVendoredLibraries}=await import('./sca/vendor-detect.js');const vendored=detectVendoredLibraries(fc);for(const v of vendored){const key=`${v.ecosystem}:${v.name}:${v.version}`;if(!components.some(c=>`${c.ecosystem}:${c.name}:${c.version}`===key))components.push({...v,group:'',purl:`pkg:${v.ecosystem}/${v.name}@${v.version}`,filePath:v.file,isUnpinned:false,reachable:true});}}catch(_){}
|
|
7858
7909
|
const reach=buildReachabilitySet(fc);
|
|
7859
7910
|
const reachabilitySet=reach.imported;
|
|
@@ -8104,7 +8155,13 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
|
|
|
8104
8155
|
};
|
|
8105
8156
|
_runAnnotator('annotateStableIds', () => annotateStableIds(finalFindings));
|
|
8106
8157
|
_runAnnotator("clusterByRootCause", () => { finalFindings = clusterByRootCause(finalFindings); });
|
|
8107
|
-
_runAnnotator("demoteUnreachable", () => {
|
|
8158
|
+
_runAnnotator("demoteUnreachable", () => {
|
|
8159
|
+
demoteUnreachable(finalFindings, { routes: aR });
|
|
8160
|
+
// `type: 'vulnerable_dep'` findings live in supplyChain, not finalFindings
|
|
8161
|
+
// (src/sca/CLAUDE.md) — demoteUnreachable's SCA-tier branch needs this
|
|
8162
|
+
// array passed explicitly or it never sees an SCA finding at all.
|
|
8163
|
+
demoteUnreachable(supplyChain, { routes: aR });
|
|
8164
|
+
});
|
|
8108
8165
|
// Premortem #8: backfill parser/family BEFORE confidence and calibration,
|
|
8109
8166
|
// because both consume those fields and silently no-op when they're null.
|
|
8110
8167
|
_runAnnotator("backfillFindingDefaults", () => { backfillFindingDefaults(finalFindings); });
|
|
@@ -8124,22 +8181,25 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
|
|
|
8124
8181
|
// Generalised sanitizer consumption (dataflow/sanitizer-gate.js): labels
|
|
8125
8182
|
// findings whose flow passes a catalog sanitizer matching their family
|
|
8126
8183
|
// (xss/url/cmd, not just sql) so the proof gate below can demote them the
|
|
8127
|
-
// same way it demotes proven-clean SQL.
|
|
8128
|
-
//
|
|
8129
|
-
//
|
|
8130
|
-
//
|
|
8131
|
-
//
|
|
8132
|
-
//
|
|
8133
|
-
// `
|
|
8134
|
-
//
|
|
8135
|
-
//
|
|
8136
|
-
//
|
|
8137
|
-
//
|
|
8138
|
-
//
|
|
8139
|
-
// (1) dataflow/engine.js honouring `kind === 'sanitizer'` at a call site,
|
|
8140
|
-
// and (2) that call site's callee name threaded onto the finding
|
|
8141
|
-
// alongside the trace/chain that proven-clean.js already reads.
|
|
8184
|
+
// same way it demotes proven-clean SQL.
|
|
8185
|
+
//
|
|
8186
|
+
// The taint walk now records the sanitizer callees observed on the value
|
|
8187
|
+
// reaching each sink argument (`dataflow/engine.js` `_sanitizersForExpr`)
|
|
8188
|
+
// and stamps them on the finding as `_sanitizersOnPath`. This rebuilds the
|
|
8189
|
+
// `{ [findingId]: string[] }` shape the gate wants. Both `id` and
|
|
8190
|
+
// `stableId` are keyed because the gate accepts either and stable ids are
|
|
8191
|
+
// assigned by an earlier annotator.
|
|
8192
|
+
//
|
|
8193
|
+
// The sanitizer never kills the taint in the walk itself: a mislabelled
|
|
8194
|
+
// sanitizer would then hide a real vulnerability outright, whereas a label
|
|
8195
|
+
// only demotes confidence here. Recall-preserving, on purpose.
|
|
8142
8196
|
const sanitizersOnPath = {};
|
|
8197
|
+
for (const f of finalFindings) {
|
|
8198
|
+
const names = f && f._sanitizersOnPath;
|
|
8199
|
+
if (!Array.isArray(names) || !names.length) continue;
|
|
8200
|
+
if (f.id) sanitizersOnPath[f.id] = names;
|
|
8201
|
+
if (f.stableId) sanitizersOnPath[f.stableId] = names;
|
|
8202
|
+
}
|
|
8143
8203
|
_runAnnotator("applySanitizerGate", () => { applySanitizerGate(finalFindings, { sanitizersOnPath }); });
|
|
8144
8204
|
_runAnnotator("annotateProofGate", () => { annotateProofGate(finalFindings); });
|
|
8145
8205
|
}
|
|
@@ -8288,8 +8348,10 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
|
|
|
8288
8348
|
// v3 next-gen: per-attacker-persona score matrix (FR-ADV-2). Must run AFTER
|
|
8289
8349
|
// crown-jewels + mitigation composite so it sees those signals.
|
|
8290
8350
|
_runAnnotator("annotatePersonaScores", () => { annotatePersonaScores(finalFindings); });
|
|
8291
|
-
// v3 next-gen: SCA reverse-blast-radius enrichment (FR-ADV-5).
|
|
8292
|
-
|
|
8351
|
+
// v3 next-gen: SCA reverse-blast-radius enrichment (FR-ADV-5). Annotates
|
|
8352
|
+
// SCA findings (package-name-keyed) — must run against supplyChain, not
|
|
8353
|
+
// finalFindings (SAST), which has no package-name field at all.
|
|
8354
|
+
_runAnnotator("annotateScaReverseBlast", () => { annotateScaReverseBlast(supplyChain, fc); });
|
|
8293
8355
|
// v3 next-gen: bug-bounty payout prediction (FR-ADV-3). Composes with the
|
|
8294
8356
|
// mitigation composite — gated/unreachable findings get the bounty scaled
|
|
8295
8357
|
// down rather than zeroed.
|
|
@@ -8420,10 +8482,25 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
|
|
|
8420
8482
|
// gets strictly more wall-clock for the taint analysis itself than an
|
|
8421
8483
|
// uninstrumented run with the same budget.
|
|
8422
8484
|
let _sharedIR = null;
|
|
8485
|
+
// Java IR requires the ASYNC builder. `parser-java.js` exports an async
|
|
8486
|
+
// `parseJavaFile` (java-parser needs a dynamic import), so the sync
|
|
8487
|
+
// `buildProjectIR` has no Java branch at all — and both deep-path call sites
|
|
8488
|
+
// used it. The result was that no .java file had ever produced an IR function
|
|
8489
|
+
// in deep mode: `bench/layer-recall` measured java at 0/25 while the catalog
|
|
8490
|
+
// carried 7 Java sources and 15 Java sinks that had nothing to run against.
|
|
8491
|
+
// `buildProjectIRAsync` is a full mirror plus Java and had zero callers.
|
|
8492
|
+
//
|
|
8493
|
+
// Gated on the presence of .java rather than always awaiting: the async
|
|
8494
|
+
// builder is a superset, but switching every scan in the product to it to fix
|
|
8495
|
+
// one language would change the execution shape (and attempt the java-parser
|
|
8496
|
+
// import) for projects that contain no Java. `runFullScan` is already async,
|
|
8497
|
+
// so the await costs nothing structurally.
|
|
8498
|
+
const _hasJava = Object.keys(fc || {}).some(f => /\.java$/i.test(f));
|
|
8499
|
+
const _buildIR = async () => (_hasJava ? await buildProjectIRAsync(fc) : buildProjectIR(fc));
|
|
8423
8500
|
const _irStatsTarget = irStatsTarget();
|
|
8424
8501
|
if (_irStatsTarget) {
|
|
8425
8502
|
try {
|
|
8426
|
-
_sharedIR =
|
|
8503
|
+
_sharedIR = await _buildIR();
|
|
8427
8504
|
writeIrStats(_irStatsTarget, collectIrStats(fc, _sharedIR.perFile, _sharedIR.callGraph));
|
|
8428
8505
|
} catch (e) {
|
|
8429
8506
|
// Instrumentation must never fail a scan. Surface only when debugging.
|
|
@@ -8432,16 +8509,27 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
|
|
|
8432
8509
|
}
|
|
8433
8510
|
}
|
|
8434
8511
|
}
|
|
8435
|
-
|
|
8512
|
+
// `deep`/`deepInCi` come from runScan()'s options object (threaded through
|
|
8513
|
+
// unchanged from runScan.js) — an explicit-opt-in override alongside the
|
|
8514
|
+
// env vars, not a replacement for them. Added because `runScan(dir,
|
|
8515
|
+
// {deep:true})` was a silent, total no-op: this options object was
|
|
8516
|
+
// destructured for fileContents/depFileContents/scanRoot/resume only, so
|
|
8517
|
+
// `deep` was dropped on the floor and deep mode stayed off regardless.
|
|
8518
|
+
// Several interprocedural test files (interproc-k2.test.js,
|
|
8519
|
+
// parser-cs-kt.test.js, points-to.test.js) pass exactly this option
|
|
8520
|
+
// believing it enables deep mode — it never did, so those tests were
|
|
8521
|
+
// exercising whatever coincidentally fires without the deep engine, not
|
|
8522
|
+
// the interprocedural machinery they're named for.
|
|
8523
|
+
const _deepRequested = deep === true || process.env.AGENTIC_SECURITY_DEEP === '1';
|
|
8436
8524
|
const _inCi = !!(process.env.CI || process.env.GITHUB_ACTIONS || process.env.GITLAB_CI ||
|
|
8437
8525
|
process.env.BUILDKITE || process.env.CIRCLECI || process.env.JENKINS_URL);
|
|
8438
|
-
const _deepInCiAllowed = process.env.AGENTIC_SECURITY_DEEP_IN_CI === '1';
|
|
8526
|
+
const _deepInCiAllowed = deepInCi === true || process.env.AGENTIC_SECURITY_DEEP_IN_CI === '1';
|
|
8439
8527
|
const _deepEnabled = _deepRequested && (!_inCi || _deepInCiAllowed);
|
|
8440
8528
|
if (_deepEnabled) {
|
|
8441
8529
|
const budgetMs = parseInt(process.env.AGENTIC_SECURITY_DEEP_TIMEOUT_MS || '300000', 10);
|
|
8442
8530
|
const t0 = Date.now();
|
|
8443
8531
|
try {
|
|
8444
|
-
const { perFile, callGraph } = _sharedIR || (_sharedIR =
|
|
8532
|
+
const { perFile, callGraph } = _sharedIR || (_sharedIR = await _buildIR());
|
|
8445
8533
|
// The runDeepAnalysis call is synchronous in this codebase; we can't
|
|
8446
8534
|
// truly interrupt it without re-architecting the worklist. We pass a
|
|
8447
8535
|
// deadlineMs hint that the inner loops check; if absent, we still cap
|
|
@@ -8471,6 +8559,29 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
|
|
|
8471
8559
|
f.validator_verdict = 'unvalidated';
|
|
8472
8560
|
}
|
|
8473
8561
|
finalFindings.push(...irFindings);
|
|
8562
|
+
// Sanitizer + proof gate, pass 2 of 2 — same ordering trap as the
|
|
8563
|
+
// ignore-pragma double pass below, and for the same reason: pass 1 runs
|
|
8564
|
+
// ~2300 lines above, long before deep-mode IR findings exist, so a
|
|
8565
|
+
// sanitized IR-TAINT flow was never labelled and a proven-clean one was
|
|
8566
|
+
// never demoted. Deep mode is what the CLI uses outside CI, so that was
|
|
8567
|
+
// the case that mattered most.
|
|
8568
|
+
//
|
|
8569
|
+
// Scoped to `irFindings` rather than re-running over `finalFindings`:
|
|
8570
|
+
// annotateProofGate demotes confidence, so a second pass over findings
|
|
8571
|
+
// pass 1 already handled would demote them twice.
|
|
8572
|
+
if (process.env.AGENTIC_SECURITY_NO_PROOF_GATE !== '1') {
|
|
8573
|
+
const _irSanitizers = {};
|
|
8574
|
+
for (const f of irFindings) {
|
|
8575
|
+
const names = f && f._sanitizersOnPath;
|
|
8576
|
+
if (!Array.isArray(names) || !names.length) continue;
|
|
8577
|
+
if (f.id) _irSanitizers[f.id] = names;
|
|
8578
|
+
if (f.stableId) _irSanitizers[f.stableId] = names;
|
|
8579
|
+
}
|
|
8580
|
+
_runAnnotator("applySanitizerGate:deep", () => {
|
|
8581
|
+
applySanitizerGate(irFindings, { sanitizersOnPath: _irSanitizers });
|
|
8582
|
+
});
|
|
8583
|
+
_runAnnotator("annotateProofGate:deep", () => { annotateProofGate(irFindings); });
|
|
8584
|
+
}
|
|
8474
8585
|
// Pragma pass 2 of 2 — see the pass-1 comment far above. Deep-mode IR
|
|
8475
8586
|
// findings land here, long after pass 1 ran, so without this an
|
|
8476
8587
|
// `agentic-security-ignore` on an ir-taint finding is inert. Deep mode is
|
|
@@ -8526,11 +8637,18 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
|
|
|
8526
8637
|
confidence: 1.0,
|
|
8527
8638
|
});
|
|
8528
8639
|
}
|
|
8529
|
-
// Phase 2 (Sentinel-parity): LLM validator stage.
|
|
8530
|
-
//
|
|
8531
|
-
//
|
|
8532
|
-
//
|
|
8533
|
-
//
|
|
8640
|
+
// Phase 2 (Sentinel-parity): LLM validator stage. DEFAULT-ON whenever
|
|
8641
|
+
// AGENTIC_SECURITY_LLM_ENDPOINT is configured — not gated on
|
|
8642
|
+
// AGENTIC_SECURITY_LLM_VALIDATE=1 as this comment previously (and wrongly)
|
|
8643
|
+
// said. Opt OUT with AGENTIC_SECURITY_LLM_VALIDATE=0; the legacy
|
|
8644
|
+
// AGENTIC_SECURITY_LLM_VALIDATE=1 still works as an explicit-on no-op. With
|
|
8645
|
+
// no endpoint configured the validator stays a no-op regardless — no
|
|
8646
|
+
// surprise network calls from an unrelated env var. See
|
|
8647
|
+
// llm-validator/index.js's own header, which states this correctly; this
|
|
8648
|
+
// comment was the one that had drifted. When disabled, every finding gets
|
|
8649
|
+
// unvalidated:true and the existing confidence pipeline accounts for that.
|
|
8650
|
+
// When enabled, the validator emits accept/reject/escalate per finding;
|
|
8651
|
+
// rejects are dropped into the suppression log.
|
|
8534
8652
|
try {
|
|
8535
8653
|
// Concurrency defaults to 1 (the validator's deterministic-default).
|
|
8536
8654
|
// Operators raise via AGENTIC_SECURITY_LLM_CONCURRENCY at the cost of
|
|
@@ -8543,7 +8661,23 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
|
|
|
8543
8661
|
vuln: d.vuln, file: d.file, line: d.line, snippet: d.snippet,
|
|
8544
8662
|
reason: 'llm-validator:reject:' + (d.validator_reasoning || '').slice(0, 80),
|
|
8545
8663
|
});
|
|
8664
|
+
// Re-run: annotateVerifierVerdicts ran (~8335) before validator_verdict
|
|
8665
|
+
// existed on any finding (set here, hundreds of lines later), so its
|
|
8666
|
+
// 'verified-by-llm' verdict — documented as one of five possible
|
|
8667
|
+
// outcomes in verifier.js's own header — could never be produced by the
|
|
8668
|
+
// real pipeline. Cheap and idempotent; re-running is the fix, not
|
|
8669
|
+
// moving the original call (confidence/exploitability annotators
|
|
8670
|
+
// upstream of it still need to run before validation, same as before).
|
|
8671
|
+
try { annotateVerifierVerdicts(finalFindings, { fileContents: fc }); } catch (_) {}
|
|
8546
8672
|
} catch(_) {}
|
|
8673
|
+
// Same ordering fix as annotateVerifierVerdicts above: annotateConfidence
|
|
8674
|
+
// (~8111) computes f.confidence before f.unvalidated exists on any
|
|
8675
|
+
// finding, so its 0.85x "LLM validator unavailable" penalty could never
|
|
8676
|
+
// apply in the real pipeline. Runs unconditionally (outside the
|
|
8677
|
+
// llmValidateMany try-block above) because f.unvalidated is also set
|
|
8678
|
+
// directly on deep-mode IR findings appended earlier in this function,
|
|
8679
|
+
// independent of whether the LLM validator itself ran.
|
|
8680
|
+
try { applyUnvalidatedPenalty(finalFindings); } catch (_) {}
|
|
8547
8681
|
try {
|
|
8548
8682
|
const { kept, suppressed } = applyLearnedFeedback(scanRoot, finalFindings);
|
|
8549
8683
|
finalFindings = kept;
|
|
@@ -8610,8 +8744,11 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
|
|
|
8610
8744
|
/(?:^|\/)test_[^/]*\.py$/i.test(f) || /_test\.(?:py|go)$/i.test(f));
|
|
8611
8745
|
annotateScaVerdicts(supplyChain, { testsDetected: _testsDetected });
|
|
8612
8746
|
} catch (_) {}
|
|
8613
|
-
// 0.9.0 Feat-15: dep confusion
|
|
8614
|
-
|
|
8747
|
+
// 0.9.0 Feat-15: dep confusion. These are supply-chain findings (kind:'sca',
|
|
8748
|
+
// derived from `components` not source code) and belong in supplyChain, not
|
|
8749
|
+
// aF — aF was already snapshotted into finalFindings at dedupeFindingsWithEvidence
|
|
8750
|
+
// above, so pushing into aF here silently discards every result.
|
|
8751
|
+
try{const dc=detectDepConfusion(annotatedComponents,scanRoot);supplyChain.push(...dc);}catch(_){}
|
|
8615
8752
|
// Deployment-platform security checklist
|
|
8616
8753
|
try{const dpf=scanDeployPlatform(scanRoot);aLogic.push(...dpf);}catch(_){}
|
|
8617
8754
|
// Stack-specific security playbook
|
|
@@ -8814,8 +8951,22 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
|
|
|
8814
8951
|
if (process.env.AGENTIC_SECURITY_NO_COMPLIANCE !== '1') {
|
|
8815
8952
|
try {
|
|
8816
8953
|
const policy = loadCompliancePolicy(scanRoot);
|
|
8817
|
-
if (policy &&
|
|
8954
|
+
if (policy && policy._error) {
|
|
8955
|
+
// CMP-5: a malformed policy used to be silently treated the same
|
|
8956
|
+
// as "no policy file" — the parse error (which names the exact
|
|
8957
|
+
// problem) was computed and then discarded. verifyPolicy now
|
|
8958
|
+
// reports it as a distinct 'error' status rather than staying
|
|
8959
|
+
// silent, and it is surfaced here too so an interactive scan
|
|
8960
|
+
// shows it.
|
|
8818
8961
|
_complianceReport = verifyCompliancePolicy(policy, { scanRoot, findings: finalFindings });
|
|
8962
|
+
process.stderr.write(`[compliance] ${policy._error}\n`);
|
|
8963
|
+
} else if (policy) {
|
|
8964
|
+
// CMP-5: pass every channel a real scan produces, not just SAST —
|
|
8965
|
+
// a finding-family check for hardcoded-secret or vulnerable-dep
|
|
8966
|
+
// was previously invisible to secrets/SCA findings entirely.
|
|
8967
|
+
_complianceReport = verifyCompliancePolicy(policy, {
|
|
8968
|
+
scanRoot, findings: finalFindings, secrets: aSecrets, logicVulns: aLogic, supplyChain,
|
|
8969
|
+
});
|
|
8819
8970
|
emitComplianceJsonLd(_complianceReport, scanRoot);
|
|
8820
8971
|
emitComplianceMarkdown(_complianceReport, scanRoot);
|
|
8821
8972
|
}
|
|
@@ -8895,7 +9046,16 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
|
|
|
8895
9046
|
let _analysisTier = null, _unmodeledSinks = null;
|
|
8896
9047
|
try { _analysisTier = computeAnalysisTiers(Object.keys(fc)); } catch {}
|
|
8897
9048
|
try { _unmodeledSinks = countUnmodeledSinkCandidates(fc, finalFindings); } catch {}
|
|
8898
|
-
|
|
9049
|
+
// filesScanned counts files actually analyzed (Object.keys(fc), the same
|
|
9050
|
+
// set computeAnalysisTiers above reads) — NOT files.length, the candidate
|
|
9051
|
+
// list before the per-file loop's size/density skips run. Using the
|
|
9052
|
+
// candidate count here double-counted skipped files: they were included
|
|
9053
|
+
// in filesScanned AND separately reported in filesSkipped/filesDenseSkipped,
|
|
9054
|
+
// so coverage-report.js's "scanned=N skipped=M" line implied N+M files were
|
|
9055
|
+
// seen when only N-of-those-candidates were actually analyzed.
|
|
9056
|
+
// checkpoint.total intentionally keeps files.length — that field means the
|
|
9057
|
+
// full candidate set for resume bookkeeping, a different, correct meaning.
|
|
9058
|
+
const _scanMeta={filesScanned:Object.keys(fc).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}};
|
|
8899
9059
|
// R8: the scan completed, so the checkpoint has been fully consumed — remove
|
|
8900
9060
|
// it. Anything that threw before this point leaves it in place to resume from.
|
|
8901
9061
|
try { closeCheckpoint(_ckpt, { complete: true }); } catch (_) {}
|
package/src/ir/CLAUDE.md
CHANGED
|
@@ -10,7 +10,10 @@ consumed by `scanner/src/dataflow/` for taint analysis.
|
|
|
10
10
|
| JS / TS | `parser-js.js` | `@babel/parser` |
|
|
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
|
-
| Java | `parser-java.js` | `java-parser` npm package (async)
|
|
13
|
+
| Java | `parser-java.js` | `java-parser` npm package (**async only** — the deep path in `engine.js` therefore awaits `buildProjectIRAsync` when any `.java` file is present, and uses the sync builder otherwise).
|
|
14
|
+
⚠ Three defects made Java taint impossible until v0.136.3+: the sync-only call site; a CST walk looking for `blockStatement` on a `block` (java-parser nests `block → blockStatements → blockStatement`), which emptied every method CFG; and `exprFromCst` missing the `primary → primaryPrefix + primarySuffix` form that models **every** method call. Guarded by `test/java-taint-flow.test.js`. **Params are still not extracted** (`params: []`, marked "deferred" in the source), so Java interprocedural summaries are limited. |
|
|
15
|
+
| Ruby | `parser-rb.js` | Hand-rolled. **`DEF_RE` must not let `\s*` cross a newline** — it did, and the body slice then started after the method's first statement, silently dropping it from every method (a one-statement body became empty). Measured as Ruby 0/20 IR-TAINT recall in `bench/layer-recall`. Guarded by `test/parser-php-rb.test.js`. ⚠ Also emitted no `fn.calls` at all (every OTHER parser does) — `callgraph.js`'s edges/callersOf/resolveKnownCallee are built entirely from `fn.calls`, so this left dead-code demotion and any interprocedural signal that depends on real call-graph resolution (rather than engine.js's generic tainted-call-argument fallback) permanently blind to Ruby. Fixed by deriving `fn.calls` from the CFG via the shared `call-sites.js#callSitesFromCfg` (the same helper `parser-py-cst.js` uses) — Ruby's node shapes already matched its documented contract. Guarded by `test/parser-rb-calls.test.js`. |
|
|
16
|
+
| C# | `parser-cs.js` | Hand-rolled. ⚠ `_lowerExpr`'s string-concat branch **must** guard on `_splitTopLevelPlus` returning more than one part — when the `+` is nested inside parens the splitter returns the input unchanged and the branch recurses on the identical string (stack overflow, swallowed by `buildProjectIR`'s per-file catch, surfacing only as "no IR"). `new Type(args)` is lowered to a call so taint reaches constructor sinks such as `new SqlCommand`. Guarded by `test/parser-cs-kt.test.js`. |
|
|
14
17
|
| 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
18
|
| 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. |
|
|
16
19
|
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Shared by every hand-rolled regex IR parser (parser-cs.js, parser-go.js,
|
|
2
|
+
// parser-php.js, parser-rb.js) for extracting a call's callee + argument
|
|
3
|
+
// text without corruption when the call is followed by a chained member
|
|
4
|
+
// access (`Sanitize(x).Trim()`, `$obj->clean($x)->trim()`).
|
|
5
|
+
//
|
|
6
|
+
// The naive pattern every one of those files used —
|
|
7
|
+
// `/^(calleeRe)\s*\((.*)\)\s*$/s` — matches `(.*)` GREEDILY against the
|
|
8
|
+
// LAST `)` in the string, not the one balancing the FIRST `(`. For a
|
|
9
|
+
// chained call that swallows the chain's own parens into the argument
|
|
10
|
+
// text (`Sanitize(x).Trim()` produced argsText = "x).Trim("), which then
|
|
11
|
+
// fails to parse as any recognized expression shape in `_lowerExpr` and
|
|
12
|
+
// falls through to `{kind:'unknown'}` — silently losing whatever
|
|
13
|
+
// taint-relevant identifiers were inside the FIRST call's real argument
|
|
14
|
+
// list, exactly the shape a wrapped-and-then-methodcalled sanitizer or
|
|
15
|
+
// helper produces in real code.
|
|
16
|
+
//
|
|
17
|
+
// This scans forward from the first `(` tracking paren/bracket/brace
|
|
18
|
+
// depth and string-literal state (so a `)` or `,` inside a nested call or
|
|
19
|
+
// a string literal doesn't miscount) to find the REAL matching close
|
|
20
|
+
// paren. It deliberately does NOT require that close paren to be the end
|
|
21
|
+
// of the string — a trailing chain (`.Trim()`, `->trim()`, `::foo()`) is
|
|
22
|
+
// simply left unconsumed rather than corrupting anything. Recovering the
|
|
23
|
+
// first call's real signature is a strict improvement over the prior
|
|
24
|
+
// choice between silent corruption and no match at all.
|
|
25
|
+
export function matchBalancedCall(s, calleeRe) {
|
|
26
|
+
if (typeof s !== 'string') return null;
|
|
27
|
+
const m = calleeRe.exec(s);
|
|
28
|
+
if (!m || m.index !== 0) return null;
|
|
29
|
+
let i = m[0].length;
|
|
30
|
+
while (s[i] === ' ' || s[i] === '\t') i++;
|
|
31
|
+
if (s[i] !== '(') return null;
|
|
32
|
+
const openIdx = i;
|
|
33
|
+
let depth = 0;
|
|
34
|
+
let inStr = null;
|
|
35
|
+
let escape = false;
|
|
36
|
+
for (; i < s.length; i++) {
|
|
37
|
+
const c = s[i];
|
|
38
|
+
if (escape) { escape = false; continue; }
|
|
39
|
+
if (inStr) {
|
|
40
|
+
if (c === '\\') { escape = true; continue; }
|
|
41
|
+
if (c === inStr) inStr = null;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (c === '"' || c === '\'') { inStr = c; continue; }
|
|
45
|
+
if (c === '(' || c === '[' || c === '{') { depth++; continue; }
|
|
46
|
+
if (c === ')' || c === ']' || c === '}') {
|
|
47
|
+
depth--;
|
|
48
|
+
if (depth === 0) break;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (depth !== 0 || s[i] !== ')') return null; // unbalanced — refuse to guess
|
|
53
|
+
const callee = m[1] !== undefined ? m[1] : m[0];
|
|
54
|
+
return { callee, argsText: s.slice(openIdx + 1, i) };
|
|
55
|
+
}
|
package/src/ir/parser-cs.js
CHANGED
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
|
|
28
28
|
import * as crypto from 'node:crypto';
|
|
29
29
|
import { callSitesFromCfg } from './call-sites.js';
|
|
30
|
+
import { matchBalancedCall } from './balanced-call.js';
|
|
30
31
|
|
|
31
32
|
const METHOD_RE = new RegExp(
|
|
32
33
|
'(?:^|[\\s;{}])(?:public|private|protected|internal|static|virtual|override|async|sealed|abstract|new|readonly|partial)' +
|
|
@@ -80,17 +81,69 @@ function _lowerExpr(text) {
|
|
|
80
81
|
if (parts.length === 1) return { kind: 'ident', name: parts[0] };
|
|
81
82
|
return _buildMemberChain(parts);
|
|
82
83
|
}
|
|
83
|
-
//
|
|
84
|
-
|
|
84
|
+
// Object creation: `new Type(args)` — lowered to a call so taint flows into
|
|
85
|
+
// constructor arguments. Without this branch the expression fell through to
|
|
86
|
+
// the concat heuristic below, and because the `+` sits INSIDE the parens
|
|
87
|
+
// `_splitTopLevelPlus` returned the input unchanged, so that branch recursed
|
|
88
|
+
// on the identical string until the stack blew. `buildProjectIR` catches
|
|
89
|
+
// per-file, so the crash surfaced only as "this file has no IR" — 12 of 21 C#
|
|
90
|
+
// corpus entries, and the catalog's own `cs-sqlcommand` rule ("SQL Injection
|
|
91
|
+
// (new SqlCommand with concatenated user input)") could never fire.
|
|
92
|
+
const newMatch = matchBalancedCall(s, /^new\s+([\w.]+)/);
|
|
93
|
+
if (newMatch) {
|
|
94
|
+
const callee = newMatch.callee.split('.').pop();
|
|
95
|
+
const args = _splitTopLevelCommas(newMatch.argsText).map(_lowerExpr);
|
|
96
|
+
return { kind: 'call', callee, args, isNew: true };
|
|
97
|
+
}
|
|
98
|
+
// Call: foo.bar(args) or Bar(args). matchBalancedCall finds the paren
|
|
99
|
+
// that actually balances the FIRST '(' — not the greedy-to-end-of-string
|
|
100
|
+
// match the old `/\((.*)\)\s*$/` used, which corrupted the argument text
|
|
101
|
+
// for a chained call (`Sanitize(x).Trim()` produced argsText="x).Trim(",
|
|
102
|
+
// which then fell through to {kind:'unknown'} and silently dropped x).
|
|
103
|
+
const callMatch = matchBalancedCall(s, /^([\w.]+)/);
|
|
85
104
|
if (callMatch) {
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
return { kind: 'call', callee, args };
|
|
105
|
+
const args = _splitTopLevelCommas(callMatch.argsText).map(_lowerExpr);
|
|
106
|
+
return { kind: 'call', callee: callMatch.callee, args };
|
|
89
107
|
}
|
|
90
108
|
// String concat / interpolation — heuristic.
|
|
109
|
+
//
|
|
110
|
+
// The `parts.length > 1` guard is load-bearing, not defensive tidiness: when
|
|
111
|
+
// the `+` is nested inside parens or brackets, `_splitTopLevelPlus` returns
|
|
112
|
+
// the input as a single part, and mapping `_lowerExpr` over it recurses on the
|
|
113
|
+
// identical string forever. Any future expression form that reaches here
|
|
114
|
+
// unsplit would otherwise reintroduce the same stack overflow.
|
|
91
115
|
if (s.includes('+') && /["']/.test(s)) {
|
|
92
|
-
const
|
|
93
|
-
return { kind: 'tpl', parts };
|
|
116
|
+
const rawParts = _splitTopLevelPlus(s);
|
|
117
|
+
if (rawParts.length > 1) return { kind: 'tpl', parts: rawParts.map(_lowerExpr) };
|
|
118
|
+
}
|
|
119
|
+
// Stage 3 correctness audit (detection depth, per-language-IR):
|
|
120
|
+
// interpolated strings ($"id={id}", $@"...", @$"...") were entirely
|
|
121
|
+
// unrecognized by every branch above — not even treated as an opaque
|
|
122
|
+
// literal, since nothing here tested for the leading `$` prefix — so
|
|
123
|
+
// they fell all the way through to {kind:'unknown'}, silently dropping
|
|
124
|
+
// any interpolated variable's taint. This is exactly
|
|
125
|
+
// `new SqlCommand($"SELECT ... WHERE id={id}", conn)`, one of the most
|
|
126
|
+
// common real C# SQL-injection shapes. `{expr}` / `{expr:format}` are
|
|
127
|
+
// lowered into a template, same shape as the `+`-concat branch above.
|
|
128
|
+
if (/^\$@?"/.test(s) || /^@\$"/.test(s)) {
|
|
129
|
+
const bodyStart = s.indexOf('"') + 1;
|
|
130
|
+
const inner = s.slice(bodyStart, -1);
|
|
131
|
+
const re = /\{([^{}:]+)(?::[^{}]*)?\}/g;
|
|
132
|
+
let lastIndex = 0;
|
|
133
|
+
const parts = [];
|
|
134
|
+
let matched = false;
|
|
135
|
+
let m;
|
|
136
|
+
while ((m = re.exec(inner)) !== null) {
|
|
137
|
+
matched = true;
|
|
138
|
+
if (m.index > lastIndex) parts.push({ kind: 'literal', value: inner.slice(lastIndex, m.index) });
|
|
139
|
+
parts.push(_lowerExpr(m[1].trim()));
|
|
140
|
+
lastIndex = re.lastIndex;
|
|
141
|
+
}
|
|
142
|
+
if (matched) {
|
|
143
|
+
if (lastIndex < inner.length) parts.push({ kind: 'literal', value: inner.slice(lastIndex) });
|
|
144
|
+
return { kind: 'tpl', parts };
|
|
145
|
+
}
|
|
146
|
+
return { kind: 'literal', value: s };
|
|
94
147
|
}
|
|
95
148
|
if (/^"|^@"/.test(s)) return { kind: 'literal', value: s };
|
|
96
149
|
if (/^\d/.test(s)) return { kind: 'literal', value: s };
|
|
@@ -169,9 +222,9 @@ function _lowerStmt(stmt, line) {
|
|
|
169
222
|
return { kind: 'assign', line, target, source: _lowerExpr(sourceText) };
|
|
170
223
|
}
|
|
171
224
|
// statement-form call
|
|
172
|
-
const cm = s
|
|
225
|
+
const cm = matchBalancedCall(s, /^([A-Za-z_][\w.]*)/);
|
|
173
226
|
if (cm) {
|
|
174
|
-
return { kind: 'call', line, callee: cm
|
|
227
|
+
return { kind: 'call', line, callee: cm.callee, args: _splitTopLevelCommas(cm.argsText).map(_lowerExpr) };
|
|
175
228
|
}
|
|
176
229
|
return { kind: 'unknown', line, text: s };
|
|
177
230
|
}
|