@clear-capabilities/agentic-security-scanner 0.134.0 → 0.136.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +432 -0
- package/bin/agentic-security-audit.js +2 -1
- package/bin/agentic-security-consistency.js +2 -1
- package/bin/agentic-security.js +448 -74
- package/dist/113.index.js +16 -7
- package/dist/117.index.js +3 -1
- package/dist/178.index.js +1 -1
- package/dist/207.index.js +5 -4
- package/dist/220.index.js +5 -3
- package/dist/238.index.js +4 -4
- package/dist/317.index.js +300 -0
- package/dist/384.index.js +1 -1
- package/dist/435.index.js +196 -21
- package/dist/444.index.js +20 -11
- package/dist/449.index.js +8 -1
- package/dist/513.index.js +7 -3
- package/dist/526.index.js +6 -6
- package/dist/637.index.js +1 -1
- package/dist/675.index.js +7 -5
- package/dist/839.index.js +4 -3
- package/dist/905.index.js +1173 -0
- package/dist/agentic-security.mjs +14 -14
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/compliance-frameworks/ccpa.json +32 -0
- package/dist/compliance-frameworks/eu-ai-act.json +51 -0
- package/dist/compliance-frameworks/gdpr.json +45 -0
- package/dist/compliance-frameworks/hipaa-security-rule.json +56 -0
- package/dist/compliance-frameworks/nist-ai-600-1.json +51 -0
- package/dist/compliance-frameworks/nist-csf-2.json +73 -0
- package/dist/compliance-frameworks/nist-privacy-1-1.json +846 -0
- package/dist/compliance-frameworks/owasp-asvs-5.json +79 -0
- package/dist/compliance-frameworks/owasp-llm-top-10.json +69 -0
- package/package.json +24 -12
- package/src/badge.js +2 -1
- package/src/dataflow/CLAUDE.md +10 -4
- package/src/dataflow/builtin-summaries.js +1 -1
- package/src/dataflow/cross-service-taint.js +2 -1
- package/src/dataflow/engine.js +324 -60
- package/src/dataflow/ifds-precise.js +6 -4
- package/src/dataflow/implicit-flow.js +68 -36
- package/src/dataflow/incremental.js +25 -8
- package/src/dataflow/index.js +2 -1
- package/src/dataflow/proven-clean.js +41 -0
- package/src/dataflow/sanitizer-gate.js +35 -9
- package/src/dataflow/sanitizer-proof.js +21 -3
- package/src/dataflow/stub-aware-filter.js +36 -13
- package/src/dataflow/summaries.js +21 -2
- package/src/discovery/CLAUDE.md +10 -0
- package/src/discovery/index.js +175 -3
- package/src/discovery/llm-invoke.js +90 -1
- package/src/discovery/memory.js +163 -0
- package/src/engine.js +247 -50
- package/src/integrations/tickets.js +7 -6
- package/src/ir/CLAUDE.md +4 -1
- package/src/ir/balanced-call.js +55 -0
- package/src/ir/ir-stats.js +1 -1
- package/src/ir/parser-cpp.js +1 -1
- package/src/ir/parser-cs.js +62 -9
- package/src/ir/parser-go.js +29 -11
- package/src/ir/parser-java.js +96 -19
- package/src/ir/parser-js.js +151 -20
- package/src/ir/parser-php.js +44 -9
- package/src/ir/parser-rb.js +37 -7
- package/src/ir/ssa.js +6 -1
- package/src/leaderboard.js +3 -2
- package/src/llm-validator/consistency.js +6 -2
- package/src/llm-validator/index.js +1 -2
- package/src/lsp/server.js +28 -2
- package/src/mcp/CLAUDE.md +9 -2
- package/src/mcp/audit.js +2 -1
- package/src/mcp/redact.js +26 -0
- package/src/mcp/tools.js +159 -17
- package/src/posture/CLAUDE.md +45 -8
- package/src/posture/accuracy-scorecard.js +67 -1
- package/src/posture/agents-memory.js +5 -3
- package/src/posture/aibom.js +12 -8
- package/src/posture/auditor-walkthrough.js +111 -10
- package/src/posture/auth-posture-import.js +5 -4
- package/src/posture/autopilot.js +8 -1
- package/src/posture/calibration-drift.js +11 -5
- package/src/posture/calibration.js +24 -2
- package/src/posture/compliance-frameworks/nist-privacy-1-1.json +846 -0
- package/src/posture/compliance-frameworks/owasp-asvs-5.json +1 -1
- package/src/posture/compliance-policy.js +40 -10
- package/src/posture/confidence.js +44 -10
- package/src/posture/corpus-enroll.js +9 -5
- package/src/posture/corpus-match.js +19 -0
- package/src/posture/csharp-analysis.js +62 -3
- package/src/posture/custom-rules.js +7 -5
- package/src/posture/cve-alert-daemon.js +6 -5
- package/src/posture/dep-add-guard.js +2 -1
- package/src/posture/deploy-platform.js +4 -1
- package/src/posture/deterministic.js +3 -2
- package/src/posture/drift.js +7 -1
- package/src/posture/epss.js +13 -1
- package/src/posture/evidence-bundle.js +276 -0
- package/src/posture/exploitability-probability.js +15 -2
- package/src/posture/falsification.js +23 -2
- package/src/posture/feature-flags.js +3 -2
- package/src/posture/findings-memory.js +3 -3
- package/src/posture/fix-history.js +5 -2
- package/src/posture/fix-metrics.js +5 -5
- package/src/posture/fix-plan.js +2 -1
- package/src/posture/fix-verify-loop.js +10 -1
- package/src/posture/grader-calibration.js +3 -4
- package/src/posture/iac-reachability.js +14 -8
- package/src/posture/integrity.js +25 -7
- package/src/posture/intent-context.js +2 -1
- package/src/posture/learning.js +4 -3
- package/src/posture/license-attributions.js +5 -7
- package/src/posture/license-graph.js +2 -1
- package/src/posture/license-policy.js +2 -1
- package/src/posture/model-rescan.js +69 -3
- package/src/posture/mttr.js +5 -0
- package/src/posture/network-policy-import.js +3 -2
- package/src/posture/poc-inprocess.js +27 -8
- package/src/posture/pqc-migration-plan.js +7 -5
- package/src/posture/pr-augment.js +8 -5
- package/src/posture/privacy-framework.js +262 -0
- package/src/posture/regression-test-gen.js +23 -8
- package/src/posture/reverse-blast-radius.js +5 -1
- package/src/posture/risk-dollars.js +20 -3
- package/src/posture/router.js +5 -4
- package/src/posture/ruleset-version.js +2 -2
- package/src/posture/runtime-correlation.js +2 -1
- package/src/posture/sbom-diff.js +12 -3
- package/src/posture/sca-policy.js +7 -4
- package/src/posture/scan-checkpoint.js +15 -0
- package/src/posture/secret-history.js +20 -11
- package/src/posture/security-trend.js +7 -1
- package/src/posture/stack-playbook.js +22 -1
- package/src/posture/state-dir.js +34 -0
- package/src/posture/telemetry-ingest.js +4 -3
- package/src/posture/threat-model-auto.js +4 -1
- package/src/posture/threat-model-grounding.js +13 -3
- package/src/posture/time-to-fix.js +3 -2
- package/src/posture/triage-memory.js +3 -2
- package/src/posture/validator-metrics.js +10 -3
- package/src/posture/verifier.js +32 -57
- package/src/posture/waf-ingest.js +6 -5
- package/src/posture/watch-mode.js +4 -3
- package/src/report/index.js +183 -14
- package/src/runScan.js +1 -1
- package/src/sast/_comment-strip.js +15 -4
- package/src/sast/_secret-entropy.js +1 -1
- package/src/sast/authz.js +6 -4
- package/src/sast/bench-shape/index.js +2 -7
- package/src/sast/claude-md-prompt-injection.js +14 -3
- package/src/sast/cloud-iam.js +60 -7
- package/src/sast/code-injection-multilang.js +29 -0
- package/src/sast/cpp-bench-extras.js +1 -1
- package/src/sast/csrf.js +7 -5
- package/src/sast/env-hygiene.js +5 -2
- package/src/sast/iac-terraform.js +25 -0
- package/src/sast/java-bench-extras.js +1 -1
- package/src/sast/java-constant-fold.js +5 -5
- package/src/sast/llm-owasp.js +4 -2
- package/src/sast/mcp-audit.js +7 -0
- package/src/sast/pipeline.js +8 -0
- package/src/sast/prompt-template.js +8 -6
- package/src/sast/prototype-pollution.js +6 -2
- package/src/sast/redos-nfa.js +6 -6
- package/src/sast/secret-concat.js +13 -2
- package/src/sast/ssrf-cloud-metadata.js +6 -3
- package/src/sast/xss-reflected-multilang.js +1 -1
- package/src/sast/xxe.js +1 -1
- package/src/sca/CLAUDE.md +3 -4
- package/src/sca/container.js +35 -3
- package/src/sca/dep-confusion.js +9 -1
- package/src/sca/sarif-ingest.js +0 -187
package/src/engine.js
CHANGED
|
@@ -71,6 +71,8 @@ import { scanWebhook } from './sast/webhook.js';
|
|
|
71
71
|
import { scanClientSide } from './sast/client-side.js';
|
|
72
72
|
import { scanPromptFirewall } from './sast/prompt-firewall.js';
|
|
73
73
|
import { scanLlmRedteam } from './posture/llm-redteam.js';
|
|
74
|
+
import { assessPrivacyFramework, persistPrivacyFramework } from './posture/privacy-framework.js';
|
|
75
|
+
import { safeWriteState as _safeWriteState, statePath, statePath as _statePath } from './posture/state-dir.js';
|
|
74
76
|
import { scanContainer } from './sca/container.js';
|
|
75
77
|
import { detectDepConfusion } from './sca/dep-confusion.js';
|
|
76
78
|
import { loadLicensePolicy, evaluateLicensePolicy } from './posture/license-policy.js';
|
|
@@ -119,7 +121,7 @@ import { scanRuby } from './sast/ruby.js';
|
|
|
119
121
|
import { scanPhp } from './sast/php.js';
|
|
120
122
|
import { classifySecretCandidate as _entropyClassifySecret } from './sast/_secret-entropy.js';
|
|
121
123
|
// Phase 1 — precision-engineering posture modules.
|
|
122
|
-
import { annotateConfidence } from './posture/confidence.js';
|
|
124
|
+
import { annotateConfidence, applyUnvalidatedPenalty } from './posture/confidence.js';
|
|
123
125
|
import { backfillFindingDefaults } from './posture/finding-defaults.js';
|
|
124
126
|
import { annotatePocs } from './posture/poc-generator.js';
|
|
125
127
|
import { annotateExecutionProofs } from './posture/prove-findings.js';
|
|
@@ -146,7 +148,7 @@ import { ingestLogicClaims } from './posture/logic-claims.js';
|
|
|
146
148
|
import { annotateNarration } from './posture/flow-narration.js';
|
|
147
149
|
import { applyPathConstraints } from './posture/path-predicates.js';
|
|
148
150
|
// Phase 3 (Sentinel-parity Layer 1 + 2) — IR + interprocedural taint engine.
|
|
149
|
-
import { buildProjectIR } from './ir/index.js';
|
|
151
|
+
import { buildProjectIR, buildProjectIRAsync } from './ir/index.js';
|
|
150
152
|
import { collectIrStats, irStatsTarget, writeIrStats } from './ir/ir-stats.js';
|
|
151
153
|
import { runDeepAnalysis } from './dataflow/index.js';
|
|
152
154
|
// v3 next-gen — Pillars 1, 4, 5, 6, 8, 9.
|
|
@@ -585,7 +587,16 @@ const SANITIZER_PATTERNS=[{regex:/(?:escape|escapeHtml|htmlspecialchars|encodeUR
|
|
|
585
587
|
{regex:/(?:re\.escape|preg_quote|Regexp\.escape)\s*\(/g,type:"Regex Escaping"}];
|
|
586
588
|
const ROUTE_PATTERNS=[{regex:/(?:app|router)\s*\.\s*(get|post|put|patch|delete|all|options|head)\s*\(\s*['"`]([^'"`]+)['"`]/g,fw:"Express",mI:1,pI:2},{regex:/@(?:app|blueprint|bp)\s*\.\s*route\s*\(\s*['"]([^'"]+)['"]\s*(?:,\s*methods\s*=\s*\[([^\]]+)\])?/g,fw:"Flask",pI:1,mtI:2},{regex:/path\s*\(\s*['"]([^'"]+)['"]/g,fw:"Django",pI:1},{regex:/@(?:app|router)\s*\.\s*(get|post|put|patch|delete)\s*\(\s*['"]([^'"]+)['"]/g,fw:"FastAPI",mI:1,pI:2},{regex:/Route\s*::\s*(get|post|put|patch|delete|any)\s*\(\s*['"]([^'"]+)['"]/g,fw:"Laravel",mI:1,pI:2},{regex:/router\s*\.\s*(get|post|put|patch|delete)\s*\(\s*['"]([^'"]+)['"]/g,fw:"Koa/Express",mI:1,pI:2},{regex:/\[Http(Get|Post|Put|Delete|Patch)\s*\(\s*["']?([^"'\]]*)/g,fw:"ASP.NET",mI:1,pI:2},{regex:/['"`](\/api\/[a-zA-Z0-9\/:_\-{}]+)['"`]/g,fw:"API",pI:1}];
|
|
587
589
|
const AUTH_PATTERNS=[/(?:authenticate|isAuthenticated|requireAuth|passport\.authenticate|jwt\.verify|verifyToken|authMiddleware|checkAuth|protect|authorize)\s*[\(,]/gi,/(?:middleware|use)\s*\(\s*(?:auth|jwt|token|session)/gi,/(?:isAuthorized|expressJwt|security\.isAuthorized|denyAll)\s*[\(]/gi,/passport\.(?:authenticate|initialize|session)\s*\(/gi];
|
|
588
|
-
|
|
590
|
+
// `.agentic-security` is OUR OWN OUTPUT and must never be scanned input.
|
|
591
|
+
// (NON_MUTATING_SCAN_PRD S4.) A scan writes threat-model.json,
|
|
592
|
+
// exploit-bundles.json and scan-history.json into the tree it scanned, and those
|
|
593
|
+
// files contain CWE identifiers. Without this, the second scan of any directory
|
|
594
|
+
// reads the first scan's conclusions as source code — measured on the
|
|
595
|
+
// independent benchmark as 220 polluted trees and 544 state files carrying
|
|
596
|
+
// `CWE-` strings, which silently turned an accuracy measurement into the engine
|
|
597
|
+
// grading itself. Scanning our own state is never useful and is exactly how
|
|
598
|
+
// output becomes input.
|
|
599
|
+
const IGNORE_DIRS=new Set(["node_modules",".git","__pycache__","vendor","dist","build",".next","venv","env",".venv","target","bin","obj",".cache","coverage","bower_components","tests","test","__tests__","spec","mocks",".agentic-security"]);
|
|
589
600
|
const CODE_EXTS=new Set(["js","jsx","ts","tsx","mjs","cjs","py","rb","php","java","go","cs","rs","vue","svelte","html","htm","ejs","hbs","pug","erb","twig","graphql","gql","kt","scala","swift","dart","ex","exs","tf","tfvars","dockerfile","c","cc","cpp","cxx","h","hh","hpp","hxx","sol"]);
|
|
590
601
|
// Feat-2: IaC manifest filenames that aren't extension-based.
|
|
591
602
|
const IAC_FILENAMES = new Set(['Dockerfile', 'Containerfile', 'docker-compose.yml', 'docker-compose.yaml', 'Chart.yaml']);
|
|
@@ -2203,7 +2214,13 @@ const IAC_PATTERNS = [
|
|
|
2203
2214
|
{ match: /\$\{\{\s*github\.event\.(?:issue|pull_request)\.title|\$\{\{\s*github\.event\.comment\.body/i,
|
|
2204
2215
|
fileTypes: /\.github\/workflows\/.*\.ya?ml$/i,
|
|
2205
2216
|
severity: 'high', cwe: 'CWE-78', vuln: 'GitHub Actions: untrusted github.event input interpolated into shell',
|
|
2206
|
-
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*$/ },
|
|
2207
2224
|
];
|
|
2208
2225
|
|
|
2209
2226
|
function scanIaC(fp, raw){
|
|
@@ -2216,6 +2233,7 @@ function scanIaC(fp, raw){
|
|
|
2216
2233
|
let m;
|
|
2217
2234
|
while ((m = re.exec(raw))) {
|
|
2218
2235
|
const line = raw.substring(0, m.index).split('\n').length;
|
|
2236
|
+
if (p.lineSafeRe && p.lineSafeRe.test((lines[line - 1] || '').trim())) continue;
|
|
2219
2237
|
findings.push({
|
|
2220
2238
|
id: `iac:${fp}:${line}:${p.vuln.replace(/\s/g, '_').slice(0, 60)}`,
|
|
2221
2239
|
kind: 'iac', severity: p.severity, vuln: p.vuln,
|
|
@@ -2401,7 +2419,7 @@ async function _loadCustomRules(scanRoot){
|
|
|
2401
2419
|
_customIgnorePaths = [];
|
|
2402
2420
|
let raw = null, parsedObj = null;
|
|
2403
2421
|
for (const ext of ['rules.yml', 'rules.yaml', 'rules.json']) {
|
|
2404
|
-
const p =
|
|
2422
|
+
const p = statePath(scanRoot, ext);
|
|
2405
2423
|
try { raw = fs.readFileSync(p, 'utf8'); } catch { continue; }
|
|
2406
2424
|
try {
|
|
2407
2425
|
if (ext.endsWith('.json')) parsedObj = JSON.parse(raw);
|
|
@@ -2736,10 +2754,24 @@ function scanLogicVulns(fp,raw){
|
|
|
2736
2754
|
while((m=re.exec(haystack))){
|
|
2737
2755
|
const line=lineAt(haystack,m.index);
|
|
2738
2756
|
const snippet=lines[line-1]?.trim()||"";
|
|
2757
|
+
let outSnippet=snippet;
|
|
2739
2758
|
// FP-2: credential FP filter
|
|
2740
2759
|
if(pat.vuln==='Hardcoded Secret'||pat.vuln==='Hardcoded Credential Check'){
|
|
2741
2760
|
const fpCheck=_isFalsePositiveCredential(fp,snippet,m[0]);
|
|
2742
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
|
+
}
|
|
2743
2775
|
}
|
|
2744
2776
|
// FP-6: operational-context gate for selected logic patterns
|
|
2745
2777
|
if (predicate) {
|
|
@@ -2749,7 +2781,7 @@ function scanLogicVulns(fp,raw){
|
|
|
2749
2781
|
continue;
|
|
2750
2782
|
}
|
|
2751
2783
|
}
|
|
2752
|
-
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});
|
|
2753
2785
|
}
|
|
2754
2786
|
}
|
|
2755
2787
|
const routeRe=/(?:app|router)\s*\.\s*(?:get|post|all)\s*\(\s*['"`](\/(?:debug|admin|test|internal|__)[^'"`]*)/gi;let rm;
|
|
@@ -5398,17 +5430,29 @@ function scanEntropySecrets(fp,raw){
|
|
|
5398
5430
|
// FP-5: structural / doc-context suppression
|
|
5399
5431
|
const surrounding=lines.slice(Math.max(0,line-3),Math.min(lines.length,line+1)).join("\n");
|
|
5400
5432
|
const nonSecretReason=_isLikelyNonSecret(v, ctx, surrounding);
|
|
5433
|
+
const masked=v.substring(0,4)+"…"+v.substring(v.length-4);
|
|
5401
5434
|
if (nonSecretReason) {
|
|
5402
|
-
|
|
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});
|
|
5403
5439
|
continue;
|
|
5404
5440
|
}
|
|
5405
|
-
|
|
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.
|
|
5406
5450
|
out.push({
|
|
5407
5451
|
vuln:"High-Entropy Credential Candidate",
|
|
5408
5452
|
severity:"high",cwe:"CWE-798",stride:"Information Disclosure",
|
|
5409
5453
|
fix:"Replace with environment variable or secrets manager reference; rotate the value immediately.",
|
|
5410
5454
|
code:`// BEFORE\nconst secret = "${masked}";\n\n// AFTER\nconst secret = process.env.APP_SECRET;`,
|
|
5411
|
-
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)
|
|
5412
5456
|
});
|
|
5413
5457
|
}
|
|
5414
5458
|
return out;
|
|
@@ -5448,12 +5492,21 @@ function scanConfigFiles(fc){
|
|
|
5448
5492
|
const[,k,v]=m;
|
|
5449
5493
|
if(!v||v==='""'||v==="''"||/^(?:change.?me|your[_-]|placeholder|example|xxx+|todo|<|\$\{)/i.test(v))continue;
|
|
5450
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):"…";
|
|
5451
5504
|
out.push({
|
|
5452
5505
|
vuln:`Committed .env with Real-Looking ${k}`,
|
|
5453
5506
|
severity:"high",cwe:"CWE-538",stride:"Information Disclosure",
|
|
5454
5507
|
fix:`Remove ${k} from committed env files. Use .env.example with placeholders and ignore .env in VCS.`,
|
|
5455
5508
|
code:`# In .gitignore\n.env\n\n# .env.example (commit)\n${k}=<your-${k.toLowerCase()}>`,
|
|
5456
|
-
file:fp,line:i+1,snippet:ln
|
|
5509
|
+
file:fp,line:i+1,snippet:ln.split(v).join(masked),masked
|
|
5457
5510
|
});
|
|
5458
5511
|
}
|
|
5459
5512
|
}
|
|
@@ -6358,7 +6411,11 @@ function scanCredentials(fp,raw){
|
|
|
6358
6411
|
if(seen.has(key))continue;seen.add(key);
|
|
6359
6412
|
const severity=pat.s==="c"?"critical":pat.s==="h"?"high":"medium";
|
|
6360
6413
|
const masked=val.length>12?val.substring(0,6)+"••••••"+val.substring(val.length-4):val.substring(0,3)+"•••";
|
|
6361
|
-
|
|
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,"_")};`});
|
|
6362
6419
|
}
|
|
6363
6420
|
}
|
|
6364
6421
|
return results;
|
|
@@ -6523,7 +6580,7 @@ function _makePurl(ecosystem,name,version,group){
|
|
|
6523
6580
|
const t={npm:'npm',pypi:'pypi'}[ecosystem]||ecosystem;
|
|
6524
6581
|
if(!t)return'';
|
|
6525
6582
|
const ns=group?`${encodeURIComponent(group)}/`:'';
|
|
6526
|
-
return`pkg:${t}/${ns}${encodeURIComponent(name)}${version?'@'+version:''}`;
|
|
6583
|
+
return`pkg:${t}/${ns}${encodeURIComponent(name)}${version?'@'+encodeURIComponent(version):''}`;
|
|
6527
6584
|
}
|
|
6528
6585
|
|
|
6529
6586
|
function _parsePackageJson(text,filePath){
|
|
@@ -7497,7 +7554,7 @@ async function queryRegistries(components){
|
|
|
7497
7554
|
|
|
7498
7555
|
// Node port: takes { fileContents, depFileContents } maps directly instead of a JSZip object.
|
|
7499
7556
|
// fileContents = code files keyed by relative path; depFileContents = manifest/lockfiles keyed by relative path.
|
|
7500
|
-
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);
|
|
7501
7558
|
// Pre-pass: build cross-file Java tainted-method index so per-file taint
|
|
7502
7559
|
// analysis can recognize calls to user-input-returning helper methods
|
|
7503
7560
|
// defined in OTHER files (Juliet's DataflowThruInnerClass / Vector / Stream
|
|
@@ -7843,6 +7900,11 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
|
|
|
7843
7900
|
// R8 (PRD §5): OS packages from an extracted container image's package DBs
|
|
7844
7901
|
// (dpkg/apk) — baked-in deps the Dockerfile never names. Feed the OSV/SBOM pipeline.
|
|
7845
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(_){}
|
|
7846
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(_){}
|
|
7847
7909
|
const reach=buildReachabilitySet(fc);
|
|
7848
7910
|
const reachabilitySet=reach.imported;
|
|
@@ -8093,7 +8155,13 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
|
|
|
8093
8155
|
};
|
|
8094
8156
|
_runAnnotator('annotateStableIds', () => annotateStableIds(finalFindings));
|
|
8095
8157
|
_runAnnotator("clusterByRootCause", () => { finalFindings = clusterByRootCause(finalFindings); });
|
|
8096
|
-
_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
|
+
});
|
|
8097
8165
|
// Premortem #8: backfill parser/family BEFORE confidence and calibration,
|
|
8098
8166
|
// because both consume those fields and silently no-op when they're null.
|
|
8099
8167
|
_runAnnotator("backfillFindingDefaults", () => { backfillFindingDefaults(finalFindings); });
|
|
@@ -8113,22 +8181,25 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
|
|
|
8113
8181
|
// Generalised sanitizer consumption (dataflow/sanitizer-gate.js): labels
|
|
8114
8182
|
// findings whose flow passes a catalog sanitizer matching their family
|
|
8115
8183
|
// (xss/url/cmd, not just sql) so the proof gate below can demote them the
|
|
8116
|
-
// same way it demotes proven-clean SQL.
|
|
8117
|
-
//
|
|
8118
|
-
//
|
|
8119
|
-
//
|
|
8120
|
-
//
|
|
8121
|
-
//
|
|
8122
|
-
// `
|
|
8123
|
-
//
|
|
8124
|
-
//
|
|
8125
|
-
//
|
|
8126
|
-
//
|
|
8127
|
-
//
|
|
8128
|
-
// (1) dataflow/engine.js honouring `kind === 'sanitizer'` at a call site,
|
|
8129
|
-
// and (2) that call site's callee name threaded onto the finding
|
|
8130
|
-
// 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.
|
|
8131
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
|
+
}
|
|
8132
8203
|
_runAnnotator("applySanitizerGate", () => { applySanitizerGate(finalFindings, { sanitizersOnPath }); });
|
|
8133
8204
|
_runAnnotator("annotateProofGate", () => { annotateProofGate(finalFindings); });
|
|
8134
8205
|
}
|
|
@@ -8211,7 +8282,7 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
|
|
|
8211
8282
|
if (r && r.piiFields) {
|
|
8212
8283
|
try {
|
|
8213
8284
|
const dpia = emitDpiaArtifact(r.piiFields, r.findings || []);
|
|
8214
|
-
|
|
8285
|
+
_safeWriteState(_statePath(scanRoot, 'dpia.md'), dpia);
|
|
8215
8286
|
} catch (_) {}
|
|
8216
8287
|
}
|
|
8217
8288
|
});
|
|
@@ -8277,8 +8348,10 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
|
|
|
8277
8348
|
// v3 next-gen: per-attacker-persona score matrix (FR-ADV-2). Must run AFTER
|
|
8278
8349
|
// crown-jewels + mitigation composite so it sees those signals.
|
|
8279
8350
|
_runAnnotator("annotatePersonaScores", () => { annotatePersonaScores(finalFindings); });
|
|
8280
|
-
// v3 next-gen: SCA reverse-blast-radius enrichment (FR-ADV-5).
|
|
8281
|
-
|
|
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); });
|
|
8282
8355
|
// v3 next-gen: bug-bounty payout prediction (FR-ADV-3). Composes with the
|
|
8283
8356
|
// mitigation composite — gated/unreachable findings get the bounty scaled
|
|
8284
8357
|
// down rather than zeroed.
|
|
@@ -8409,10 +8482,25 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
|
|
|
8409
8482
|
// gets strictly more wall-clock for the taint analysis itself than an
|
|
8410
8483
|
// uninstrumented run with the same budget.
|
|
8411
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));
|
|
8412
8500
|
const _irStatsTarget = irStatsTarget();
|
|
8413
8501
|
if (_irStatsTarget) {
|
|
8414
8502
|
try {
|
|
8415
|
-
_sharedIR =
|
|
8503
|
+
_sharedIR = await _buildIR();
|
|
8416
8504
|
writeIrStats(_irStatsTarget, collectIrStats(fc, _sharedIR.perFile, _sharedIR.callGraph));
|
|
8417
8505
|
} catch (e) {
|
|
8418
8506
|
// Instrumentation must never fail a scan. Surface only when debugging.
|
|
@@ -8421,16 +8509,27 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
|
|
|
8421
8509
|
}
|
|
8422
8510
|
}
|
|
8423
8511
|
}
|
|
8424
|
-
|
|
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';
|
|
8425
8524
|
const _inCi = !!(process.env.CI || process.env.GITHUB_ACTIONS || process.env.GITLAB_CI ||
|
|
8426
8525
|
process.env.BUILDKITE || process.env.CIRCLECI || process.env.JENKINS_URL);
|
|
8427
|
-
const _deepInCiAllowed = process.env.AGENTIC_SECURITY_DEEP_IN_CI === '1';
|
|
8526
|
+
const _deepInCiAllowed = deepInCi === true || process.env.AGENTIC_SECURITY_DEEP_IN_CI === '1';
|
|
8428
8527
|
const _deepEnabled = _deepRequested && (!_inCi || _deepInCiAllowed);
|
|
8429
8528
|
if (_deepEnabled) {
|
|
8430
8529
|
const budgetMs = parseInt(process.env.AGENTIC_SECURITY_DEEP_TIMEOUT_MS || '300000', 10);
|
|
8431
8530
|
const t0 = Date.now();
|
|
8432
8531
|
try {
|
|
8433
|
-
const { perFile, callGraph } = _sharedIR || (_sharedIR =
|
|
8532
|
+
const { perFile, callGraph } = _sharedIR || (_sharedIR = await _buildIR());
|
|
8434
8533
|
// The runDeepAnalysis call is synchronous in this codebase; we can't
|
|
8435
8534
|
// truly interrupt it without re-architecting the worklist. We pass a
|
|
8436
8535
|
// deadlineMs hint that the inner loops check; if absent, we still cap
|
|
@@ -8460,6 +8559,29 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
|
|
|
8460
8559
|
f.validator_verdict = 'unvalidated';
|
|
8461
8560
|
}
|
|
8462
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
|
+
}
|
|
8463
8585
|
// Pragma pass 2 of 2 — see the pass-1 comment far above. Deep-mode IR
|
|
8464
8586
|
// findings land here, long after pass 1 ran, so without this an
|
|
8465
8587
|
// `agentic-security-ignore` on an ir-taint finding is inert. Deep mode is
|
|
@@ -8515,11 +8637,18 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
|
|
|
8515
8637
|
confidence: 1.0,
|
|
8516
8638
|
});
|
|
8517
8639
|
}
|
|
8518
|
-
// Phase 2 (Sentinel-parity): LLM validator stage.
|
|
8519
|
-
//
|
|
8520
|
-
//
|
|
8521
|
-
//
|
|
8522
|
-
//
|
|
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.
|
|
8523
8652
|
try {
|
|
8524
8653
|
// Concurrency defaults to 1 (the validator's deterministic-default).
|
|
8525
8654
|
// Operators raise via AGENTIC_SECURITY_LLM_CONCURRENCY at the cost of
|
|
@@ -8532,7 +8661,23 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
|
|
|
8532
8661
|
vuln: d.vuln, file: d.file, line: d.line, snippet: d.snippet,
|
|
8533
8662
|
reason: 'llm-validator:reject:' + (d.validator_reasoning || '').slice(0, 80),
|
|
8534
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 (_) {}
|
|
8535
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 (_) {}
|
|
8536
8681
|
try {
|
|
8537
8682
|
const { kept, suppressed } = applyLearnedFeedback(scanRoot, finalFindings);
|
|
8538
8683
|
finalFindings = kept;
|
|
@@ -8574,7 +8719,7 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
|
|
|
8574
8719
|
// second party could corroborate it.
|
|
8575
8720
|
try {
|
|
8576
8721
|
if (scanRoot) {
|
|
8577
|
-
const raw = fs.readFileSync(
|
|
8722
|
+
const raw = fs.readFileSync(statePath(scanRoot, 'logic-claims.json'), 'utf8');
|
|
8578
8723
|
const parsed = JSON.parse(raw);
|
|
8579
8724
|
const incoming = Array.isArray(parsed) ? parsed : (parsed && parsed.claims) || [];
|
|
8580
8725
|
if (incoming.length) {
|
|
@@ -8599,8 +8744,11 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
|
|
|
8599
8744
|
/(?:^|\/)test_[^/]*\.py$/i.test(f) || /_test\.(?:py|go)$/i.test(f));
|
|
8600
8745
|
annotateScaVerdicts(supplyChain, { testsDetected: _testsDetected });
|
|
8601
8746
|
} catch (_) {}
|
|
8602
|
-
// 0.9.0 Feat-15: dep confusion
|
|
8603
|
-
|
|
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(_){}
|
|
8604
8752
|
// Deployment-platform security checklist
|
|
8605
8753
|
try{const dpf=scanDeployPlatform(scanRoot);aLogic.push(...dpf);}catch(_){}
|
|
8606
8754
|
// Stack-specific security playbook
|
|
@@ -8770,6 +8918,7 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
|
|
|
8770
8918
|
// Each is opt-in via env var. They produce machine-readable artifacts
|
|
8771
8919
|
// (threat-model.json/.md, dpia.md, compliance-evidence.json/.md,
|
|
8772
8920
|
// sbom-history/<sha>.json, exploit-bundles/) under .agentic-security/.
|
|
8921
|
+
let _privacyFramework = null;
|
|
8773
8922
|
let _threatModel = null, _apiContractFindings = [], _sbomDiff = null,
|
|
8774
8923
|
_complianceReport = null, _exploitBundles = null, _pqcPlan = null,
|
|
8775
8924
|
_licenseGraph = null, _attributions = null, _taxonomySummary = null;
|
|
@@ -8802,8 +8951,22 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
|
|
|
8802
8951
|
if (process.env.AGENTIC_SECURITY_NO_COMPLIANCE !== '1') {
|
|
8803
8952
|
try {
|
|
8804
8953
|
const policy = loadCompliancePolicy(scanRoot);
|
|
8805
|
-
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.
|
|
8806
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
|
+
});
|
|
8807
8970
|
emitComplianceJsonLd(_complianceReport, scanRoot);
|
|
8808
8971
|
emitComplianceMarkdown(_complianceReport, scanRoot);
|
|
8809
8972
|
}
|
|
@@ -8837,6 +9000,29 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
|
|
|
8837
9000
|
if (_pqcPlan) persistPqcPlan(scanRoot, _pqcPlan);
|
|
8838
9001
|
} catch (_) {}
|
|
8839
9002
|
}
|
|
9003
|
+
// NIST Privacy Framework 1.1 assessment.
|
|
9004
|
+
//
|
|
9005
|
+
// The ASSESSMENT is default-on and lands on `scan.privacyFramework`, like
|
|
9006
|
+
// every other posture artifact. Its FINDINGS are opt-in
|
|
9007
|
+
// (AGENTIC_SECURITY_PRIVACY_FRAMEWORK=1), because appending them to
|
|
9008
|
+
// scan.findings would change every severity count, gate verdict and
|
|
9009
|
+
// baseline in every downstream consumer — a compliance opinion should not
|
|
9010
|
+
// silently become a build failure for projects that never asked for it.
|
|
9011
|
+
// Turn them on and they flow through triage and /fix like any finding.
|
|
9012
|
+
if (process.env.AGENTIC_SECURITY_NO_PRIVACY_FRAMEWORK !== '1') {
|
|
9013
|
+
try {
|
|
9014
|
+
_privacyFramework = assessPrivacyFramework(scanRoot, {
|
|
9015
|
+
findings: finalFindings, components: annotatedComponents,
|
|
9016
|
+
// filesScanned feeds the vacuous-satisfaction guard: a clean signal
|
|
9017
|
+
// from a run that read no files is not evidence of compliance.
|
|
9018
|
+
filesScanned: files.length,
|
|
9019
|
+
});
|
|
9020
|
+
if (_privacyFramework) persistPrivacyFramework(scanRoot, _privacyFramework);
|
|
9021
|
+
if (_privacyFramework && process.env.AGENTIC_SECURITY_PRIVACY_FRAMEWORK === '1') {
|
|
9022
|
+
finalFindings.push(..._privacyFramework.findings);
|
|
9023
|
+
}
|
|
9024
|
+
} catch (_) {}
|
|
9025
|
+
}
|
|
8840
9026
|
// Exploit bundles — per-family PoC + Jest + pytest + remediation for
|
|
8841
9027
|
// top-N critical/high findings.
|
|
8842
9028
|
if (process.env.AGENTIC_SECURITY_NO_EXPLOIT_BUNDLES !== '1') {
|
|
@@ -8845,9 +9031,11 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
|
|
|
8845
9031
|
if (bundles.size) {
|
|
8846
9032
|
_exploitBundles = {};
|
|
8847
9033
|
for (const [id, b] of bundles) _exploitBundles[id] = b;
|
|
8848
|
-
|
|
8849
|
-
|
|
8850
|
-
|
|
9034
|
+
// Through the seam, so `--no-state` withholds the artifact. The
|
|
9035
|
+
// bundles stay on the scan result either way — a read-only scan must
|
|
9036
|
+
// report the same thing, it just must not leave it behind.
|
|
9037
|
+
_safeWriteState(_statePath(scanRoot, 'exploit-bundles.json'),
|
|
9038
|
+
JSON.stringify(_exploitBundles, null, 2));
|
|
8851
9039
|
}
|
|
8852
9040
|
} catch (_) {}
|
|
8853
9041
|
}
|
|
@@ -8858,7 +9046,16 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
|
|
|
8858
9046
|
let _analysisTier = null, _unmodeledSinks = null;
|
|
8859
9047
|
try { _analysisTier = computeAnalysisTiers(Object.keys(fc)); } catch {}
|
|
8860
9048
|
try { _unmodeledSinks = countUnmodeledSinkCandidates(fc, finalFindings); } catch {}
|
|
8861
|
-
|
|
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}};
|
|
8862
9059
|
// R8: the scan completed, so the checkpoint has been fully consumed — remove
|
|
8863
9060
|
// it. Anything that threw before this point leaves it in place to resume from.
|
|
8864
9061
|
try { closeCheckpoint(_ckpt, { complete: true }); } catch (_) {}
|
|
@@ -8881,7 +9078,7 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
|
|
|
8881
9078
|
// Addition #3 — root-cause sweep: from confirmed findings, find sibling instances
|
|
8882
9079
|
// detectors missed, with total-count accounting. Confirmed-only (cheap by default).
|
|
8883
9080
|
let _rootCauseSweep = null; try { _rootCauseSweep = sweepRootCauses(finalFindings, fc); } catch { _rootCauseSweep = null; }
|
|
8884
|
-
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,executionProof:_executionProofSummary,logicClaims:_logicClaims,vulnHistory:_vulnHistory,threatModel:_threatModel,sbomDiff:_sbomDiff,complianceReport:_complianceReport,exploitBundles:_exploitBundles,pqcPlan:_pqcPlan,licenseGraph:_licenseGraph,attributions:_attributions,attackTaxonomy:_taxonomySummary};}
|
|
9081
|
+
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,executionProof:_executionProofSummary,logicClaims:_logicClaims,vulnHistory:_vulnHistory,threatModel:_threatModel,privacyFramework:_privacyFramework,sbomDiff:_sbomDiff,complianceReport:_complianceReport,exploitBundles:_exploitBundles,pqcPlan:_pqcPlan,licenseGraph:_licenseGraph,attributions:_attributions,attackTaxonomy:_taxonomySummary};}
|
|
8885
9082
|
|
|
8886
9083
|
// Post-aggregation classification: every source becomes "unsafe"|"safe"; every sink becomes "confirmed"|"safe".
|
|
8887
9084
|
// Orphans (no finding linkage) are bucketed by file-local heuristic so the UI shows binary states only.
|
|
@@ -19,17 +19,18 @@ import * as cp from 'node:child_process';
|
|
|
19
19
|
import { buildJiraIssue } from './index.js';
|
|
20
20
|
import { escapeMarkdown } from '../util/untrusted.js';
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
import { statePath } from '../posture/state-dir.js';
|
|
23
|
+
function _ticketsPath(scanRoot) {
|
|
24
|
+
return statePath(scanRoot, 'tickets.json');
|
|
24
25
|
}
|
|
25
26
|
export function readState(scanRoot) {
|
|
26
|
-
const fp =
|
|
27
|
+
const fp = _ticketsPath(scanRoot);
|
|
27
28
|
if (!fs.existsSync(fp)) return {};
|
|
28
29
|
try { return JSON.parse(fs.readFileSync(fp, 'utf8')); } catch { return {}; }
|
|
29
30
|
}
|
|
30
31
|
function writeState(scanRoot, state) {
|
|
31
|
-
fs.mkdirSync(path.dirname(
|
|
32
|
-
fs.writeFileSync(
|
|
32
|
+
fs.mkdirSync(path.dirname(_ticketsPath(scanRoot)), { recursive: true });
|
|
33
|
+
fs.writeFileSync(_ticketsPath(scanRoot), JSON.stringify(state, null, 2));
|
|
33
34
|
}
|
|
34
35
|
|
|
35
36
|
function findingTitle(f) {
|
|
@@ -148,7 +149,7 @@ const SEV_RANK = { critical: 4, high: 3, medium: 2, low: 1, info: 0 };
|
|
|
148
149
|
|
|
149
150
|
export async function syncTickets({ scanRoot, provider, severity = 'high', repo, teamId, dryRun = false }) {
|
|
150
151
|
const minRank = SEV_RANK[severity] ?? 3;
|
|
151
|
-
const lastScanPath =
|
|
152
|
+
const lastScanPath = statePath(scanRoot, 'last-scan.json');
|
|
152
153
|
if (!fs.existsSync(lastScanPath)) return { ok: false, error: 'no last-scan.json — run a scan first' };
|
|
153
154
|
const last = JSON.parse(fs.readFileSync(lastScanPath, 'utf8'));
|
|
154
155
|
const allFindings = [...(last.findings || []), ...(last.secrets || []), ...(last.supplyChain || [])];
|
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
|
|