@clear-capabilities/agentic-security-scanner 0.153.2 → 0.154.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +34 -0
- package/bin/agentic-security.js +123 -19
- package/dist/1444.index.js +64 -19
- package/dist/1905.index.js +46 -3
- package/dist/2862.index.js +325 -0
- package/dist/3499.index.js +5 -1
- package/dist/3531.index.js +261 -0
- package/dist/3596.index.js +103 -0
- package/dist/4113.index.js +15 -3
- package/dist/4171.index.js +319 -0
- package/dist/5637.index.js +10 -4
- package/dist/6359.index.js +433 -0
- package/dist/7838.index.js +15 -3
- package/dist/8551.index.js +1164 -0
- package/dist/8587.index.js +410 -0
- package/dist/8714.index.js +98 -0
- package/dist/8798.index.js +218 -0
- package/dist/9220.index.js +35 -3
- package/dist/9560.index.js +26 -3
- package/dist/agentic-security.mjs +51 -51
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +17 -2
- package/src/dataflow/CLAUDE.md +9 -0
- package/src/dataflow/catalog-haskell.js +37 -0
- package/src/dataflow/catalog.js +4 -0
- package/src/dataflow/engine.js +33 -3
- package/src/dataflow/privacy-catalog-haskell.js +50 -0
- package/src/dataflow/privacy-catalog.js +5 -1
- package/src/discovery/CLAUDE.md +6 -0
- package/src/discovery/index.js +12 -3
- package/src/discovery/lenses.js +4 -1
- package/src/discovery/partition.js +28 -0
- package/src/egress/redact.js +8 -0
- package/src/engine.js +125 -14
- package/src/ir/index.js +39 -2
- package/src/language/CLAUDE.md +74 -0
- package/src/language/accuracy.js +90 -0
- package/src/language/aibom.js +307 -0
- package/src/language/assurance.js +193 -0
- package/src/language/bom-validate.js +227 -0
- package/src/language/bom.js +236 -0
- package/src/language/bridges.js +392 -0
- package/src/language/compliance-map.js +61 -0
- package/src/language/context.js +199 -0
- package/src/language/contracts.js +339 -0
- package/src/language/discovery.js +359 -0
- package/src/language/engine-pass.js +120 -0
- package/src/language/fix-lifecycle.js +120 -0
- package/src/language/haskell-adapter.js +51 -0
- package/src/language/haskell-disclosure.js +170 -0
- package/src/language/haskell-findings.js +44 -0
- package/src/language/haskell-fix.js +409 -0
- package/src/language/haskell-grammar.js +78 -0
- package/src/language/haskell-guards.js +382 -0
- package/src/language/haskell-ir.js +1377 -0
- package/src/language/haskell-llm.js +128 -0
- package/src/language/haskell-manifests.js +1290 -0
- package/src/language/haskell-models.js +278 -0
- package/src/language/haskell-parser.js +1073 -0
- package/src/language/haskell-resolved-graph.js +414 -0
- package/src/language/haskell-sca.js +387 -0
- package/src/language/haskell-security-rules.js +332 -0
- package/src/language/haskell-supply.js +140 -0
- package/src/language/haskell-sweep.js +85 -0
- package/src/language/haskell-syntax.js +895 -0
- package/src/language/haskell-web.js +749 -0
- package/src/language/nix-adapter.js +42 -0
- package/src/language/nix-agents.js +155 -0
- package/src/language/nix-build-trust.js +790 -0
- package/src/language/nix-closure.js +331 -0
- package/src/language/nix-eval-isolation.js +274 -0
- package/src/language/nix-fix.js +268 -0
- package/src/language/nix-grammar.js +97 -0
- package/src/language/nix-inventory.js +379 -0
- package/src/language/nix-ir.js +642 -0
- package/src/language/nix-parser.js +908 -0
- package/src/language/nix-privacy.js +114 -0
- package/src/language/nix-sca.js +358 -0
- package/src/language/nix-script-taint.js +885 -0
- package/src/language/nix-secrets.js +396 -0
- package/src/language/nixos-hardening.js +564 -0
- package/src/language/nixos-module-resolver.js +595 -0
- package/src/language/nixos-option-catalog.js +96 -0
- package/src/language/pragma.js +150 -0
- package/src/language/resolved-pass.js +146 -0
- package/src/language/secrets.js +323 -0
- package/src/language/state-artifacts.js +78 -0
- package/src/language/support-registry.js +224 -0
- package/src/language/witness.js +185 -0
- package/src/lineage/CLAUDE.md +8 -0
- package/src/lineage/coverage.js +2 -0
- package/src/lineage/cross-repo-link.js +22 -0
- package/src/lineage/graph-builder.js +5 -1
- package/src/lineage/haskell-view.js +319 -0
- package/src/lineage/index.js +8 -1
- package/src/lineage/nix-view.js +198 -0
- package/src/lineage/sink-registry.js +10 -1
- package/src/lineage/source-registry.js +3 -0
- package/src/lineage/transform-catalog.js +12 -0
- package/src/llm-validator/index.js +16 -2
- package/src/lsp/server.js +69 -9
- package/src/mcp/CLAUDE.md +7 -0
- package/src/mcp/tools.js +26 -3
- package/src/pipeline/coverage-ledger.js +0 -0
- package/src/pipeline/scan-health.js +19 -1
- package/src/posture/accuracy-scorecard.js +51 -0
- package/src/posture/aibom.js +61 -3
- package/src/posture/artifact-registry.js +12 -0
- package/src/posture/auditor-walkthrough.js +41 -0
- package/src/posture/clustering.js +2 -2
- package/src/posture/evidence-bundle.js +7 -1
- package/src/posture/execution-proof.js +15 -0
- package/src/posture/falsification.js +7 -0
- package/src/posture/family-resolve.js +7 -0
- package/src/posture/fix-history.js +40 -0
- package/src/posture/fix-verify.js +15 -3
- package/src/posture/provenance/git-evidence.js +24 -0
- package/src/posture/provenance/origin-resolver.js +14 -2
- package/src/posture/provenance/predicate-replay.js +11 -2
- package/src/posture/provenance/sca-origin.js +20 -3
- package/src/posture/sbom-diff.js +7 -6
- package/src/posture/sbom.js +85 -32
- package/src/posture/sca-policy.js +10 -1
- package/src/posture/sca-verdict.js +8 -1
- package/src/posture/secret-history.js +62 -18
- package/src/posture/stable-id.js +21 -2
- package/src/posture/state-dir.js +21 -0
- package/src/posture/threat-model-auto.js +4 -4
- package/src/posture/triage.js +18 -1
- package/src/posture/watch-mode.js +34 -3
- package/src/pr-delta.js +9 -4
- package/src/report/index.js +35 -9
- package/src/runScan.js +56 -5
- package/src/sast/pipeline.js +34 -1
package/src/engine.js
CHANGED
|
@@ -208,6 +208,8 @@ import { annotateNarration } from './posture/flow-narration.js';
|
|
|
208
208
|
import { applyPathConstraints } from './posture/path-predicates.js';
|
|
209
209
|
// Phase 3 (Sentinel-parity Layer 1 + 2) — IR + interprocedural taint engine.
|
|
210
210
|
import { buildProjectIR, buildProjectIRAsync } from './ir/index.js';
|
|
211
|
+
import _v8 from 'node:v8';
|
|
212
|
+
import _vm from 'node:vm';
|
|
211
213
|
import { collectIrStats, irStatsTarget, writeIrStats } from './ir/ir-stats.js';
|
|
212
214
|
import { runDeepAnalysis } from './dataflow/index.js';
|
|
213
215
|
import { buildLineageGraph } from './lineage/index.js';
|
|
@@ -301,6 +303,15 @@ import {
|
|
|
301
303
|
computeGlobalKey, globalKeyMeta, invalidatedFiles, bundleShaForRunKey,
|
|
302
304
|
} from './posture/scan-checkpoint.js';
|
|
303
305
|
import { SCANNER_VERSION as _ENGINE_VERSION } from './posture/version.js';
|
|
306
|
+
import { isLanguageSource, isLanguageExcludedPath, withLanguageClosure } from './language/discovery.js';
|
|
307
|
+
import { annotateHaskellFindings } from './language/haskell-findings.js';
|
|
308
|
+
import { annotateHaskellLlm, analyzeHaskellLlmRules } from './language/haskell-llm.js';
|
|
309
|
+
import { languagePragmaOnLine as _languagePragmaOnLine, languagePragmaSuppresses as _languagePragmaSuppresses } from './language/pragma.js';
|
|
310
|
+
import { applyHaskellGuards, consolidateFlowSinks } from './language/haskell-guards.js';
|
|
311
|
+
import { annotateUnresolvedTargets, attachEvidenceChains } from './language/haskell-disclosure.js';
|
|
312
|
+
import { analyzeHaskellRules } from './language/haskell-security-rules.js';
|
|
313
|
+
import { scanLanguageSecretConcat, scanDependencyUrlCredentials, providerInfo as _secretProvider } from './language/secrets.js';
|
|
314
|
+
import { analyzeHaskellWeb, toEngineRoutes } from './language/haskell-web.js';
|
|
304
315
|
import { effectiveVersion as _effectiveRulesetVersion } from './posture/ruleset-version.js';
|
|
305
316
|
|
|
306
317
|
// Disk-backed cache replacing browser sessionStorage. One JSON blob per key under ~/.claude/agentic-security/osv-cache/.
|
|
@@ -818,7 +829,9 @@ function shouldScan(p){if(/\.(test|spec|mock)\./i.test(p))return false;if(/_test
|
|
|
818
829
|
if (/(?:^|[\\/])Info\.plist$/i.test(p)) return true;
|
|
819
830
|
if (/(?:^|[\\/])module\.json5$/i.test(p)) return true;
|
|
820
831
|
if (/^\.env(?:\.[\w-]+)?$/.test(base)) return true;
|
|
821
|
-
|
|
832
|
+
// Haskell/Nix sources come from the shared registry; build output and store
|
|
833
|
+
// paths stay out. Scoped to those sources so no other language's semantics move.
|
|
834
|
+
return CODE_EXTS.has(getExt(p)) || _isIaCFile(p) || (isLanguageSource(p) && !isLanguageExcludedPath(p));}
|
|
822
835
|
function lineAt(c,i){return c.substring(0,i).split("\n").length;}
|
|
823
836
|
|
|
824
837
|
|
|
@@ -2776,8 +2789,19 @@ function _applyIgnorePragmas(arr, fc){
|
|
|
2776
2789
|
const file = f && (f.file || f.sink?.file);
|
|
2777
2790
|
const line = f && Number(f.line ?? f.sink?.line);
|
|
2778
2791
|
if (!file || !Number.isInteger(line)) continue;
|
|
2779
|
-
|
|
2780
|
-
|
|
2792
|
+
// Haskell and Nix pragmas are recognised only inside real comments (never in a string or an operator) and match a
|
|
2793
|
+
// rule id EXACTLY; every other language keeps the line-regex pragma below.
|
|
2794
|
+
let pragma; let suppresses;
|
|
2795
|
+
if (/\.(?:l?hs|hs-boot|hsc|nix)$/i.test(file)) {
|
|
2796
|
+
const lp = _languagePragmaOnLine(file, fc[file], line);
|
|
2797
|
+
if (lp && lp.malformed && !lp.bare && !lp.rules.length) { f.ignorePragmaIgnored = `malformed agentic-security-ignore: ${lp.malformed}`; continue; }
|
|
2798
|
+
suppresses = _languagePragmaSuppresses(lp, f);
|
|
2799
|
+
pragma = lp ? { rule: lp.bare ? '' : lp.rules.join(', ') } : null;
|
|
2800
|
+
} else {
|
|
2801
|
+
pragma = _pragmaOnLine(fc[file], line);
|
|
2802
|
+
suppresses = _pragmaSuppresses(pragma, f);
|
|
2803
|
+
}
|
|
2804
|
+
if (!suppresses) continue;
|
|
2781
2805
|
_suppressionLog.push({
|
|
2782
2806
|
vuln: f.vuln, file, line, snippet: f.snippet || '',
|
|
2783
2807
|
reason: `inline pragma: agentic-security-ignore${pragma.rule ? ': ' + pragma.rule : ''}`,
|
|
@@ -6534,7 +6558,9 @@ function dedupeFindingsWithEvidence(findings){
|
|
|
6534
6558
|
// Multiple sources reaching the same sink collapse — the merged finding
|
|
6535
6559
|
// accumulates them in `evidence` and `dedupedSources` instead.
|
|
6536
6560
|
const sinkLine = f.sink?.line || f.line || 0;
|
|
6537
|
-
|
|
6561
|
+
// Haskell taint findings carry a site key: two distinct sinks of one family on one line stay two findings,
|
|
6562
|
+
// so a discharged (sanitized) sink can never absorb its unprotected sibling.
|
|
6563
|
+
const key=`${file}:${sinkLine}:${fam}${f.siteKey?':'+f.siteKey:''}`;
|
|
6538
6564
|
if(!buckets.has(key)){buckets.set(key,f);continue;}
|
|
6539
6565
|
const kept=buckets.get(key);
|
|
6540
6566
|
// Winner selection: an IR-TAINT finding (the deep engine's real
|
|
@@ -7237,7 +7263,7 @@ function scanCredentials(fp,raw){
|
|
|
7237
7263
|
// unredacted-snippet leak as scanEntropySecrets — `snippet` carried
|
|
7238
7264
|
// the raw source line (full credential value) straight through to
|
|
7239
7265
|
// every report format. Redact the exact matched value here too.
|
|
7240
|
-
results.push({vuln:pat.n,severity,cwe:"CWE-798",stride:"Information Disclosure",file:fp,line,_urlCreds:!!pat.urlCreds,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,"_")};`});
|
|
7266
|
+
results.push({vuln:pat.n,severity,cwe:"CWE-798",stride:"Information Disclosure",file:fp,line,_urlCreds:!!pat.urlCreds,snippet:snippet.split(val).join(masked),masked,..._secretProvider(val),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,"_")};`});
|
|
7241
7267
|
}
|
|
7242
7268
|
}
|
|
7243
7269
|
// One secret, one finding. A `postgres://user:pass@host/db` matches both the
|
|
@@ -8692,6 +8718,7 @@ async function queryRegistries(components){
|
|
|
8692
8718
|
_aF.push(...(runDetector(_detectorErrors,p,'scanPythonStructural',()=>scanPythonStructural(p,cc))||[]));
|
|
8693
8719
|
_aF.push(...(runDetector(_detectorErrors,p,'scanGoStructural',()=>scanGoStructural(p,cc))||[]));
|
|
8694
8720
|
_aF.push(...(runDetector(_detectorErrors,p,'scanSecretConcat',()=>scanSecretConcat(p,cc))||[]));
|
|
8721
|
+
_aF.push(...(runDetector(_detectorErrors,p,'scanLanguageSecretConcat',()=>scanLanguageSecretConcat(p,c))||[]));
|
|
8695
8722
|
_aF.push(...(runDetector(_detectorErrors,p,'scanXssReflectedMultilang',()=>scanXssReflectedMultilang(p,cc))||[]));
|
|
8696
8723
|
_aF.push(...(runDetector(_detectorErrors,p,'scanCodeInjectionMultilang',()=>scanCodeInjectionMultilang(p,cc))||[]));
|
|
8697
8724
|
_aF.push(...(runDetector(_detectorErrors,p,'scanResponseSplitting',()=>scanResponseSplitting(p,cc))||[]));
|
|
@@ -8742,7 +8769,14 @@ async function queryRegistries(components){
|
|
|
8742
8769
|
// because a direct runFullScan caller supplying no file-subsetting options is
|
|
8743
8770
|
// scanning everything it was given; runScan.js narrows it for --changed-since
|
|
8744
8771
|
// and for caller-supplied fileContents.
|
|
8745
|
-
|
|
8772
|
+
let _gcFn = null;
|
|
8773
|
+
function _gcHint(minHeapMiB) {
|
|
8774
|
+
if (process.memoryUsage().heapUsed < minHeapMiB * 1048576) return;
|
|
8775
|
+
if (!_gcFn) { _v8.setFlagsFromString('--expose_gc'); _gcFn = _vm.runInNewContext('gc'); }
|
|
8776
|
+
_gcFn();
|
|
8777
|
+
}
|
|
8778
|
+
|
|
8779
|
+
async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null, resume=undefined, deep=undefined, deepInCi=undefined, provenance=true, completeScan=true, skipAnnotators=false, policyExcluded=[], sizeSkipped=[]}, setProgress=()=>{}){_resetSuppressions();_buildProjectIndex(fileContents);await _loadCustomRules(scanRoot);
|
|
8746
8780
|
// Pre-pass: build cross-file Java tainted-method index so per-file taint
|
|
8747
8781
|
// analysis can recognize calls to user-input-returning helper methods
|
|
8748
8782
|
// defined in OTHER files (Juliet's DataflowThruInnerClass / Vector / Stream
|
|
@@ -8794,7 +8828,9 @@ function _deterministicFileTimings(timings) {
|
|
|
8794
8828
|
_ckpt = openCheckpoint(scanRoot, {
|
|
8795
8829
|
globalKey: computeGlobalKey(_ckptIdentity),
|
|
8796
8830
|
meta: globalKeyMeta(_ckptIdentity),
|
|
8797
|
-
|
|
8831
|
+
// Haskell/Nix sources hash with their import closure, so an imported
|
|
8832
|
+
// module's change invalidates its importers' checkpointed work too.
|
|
8833
|
+
fileContents: withLanguageClosure(fileContents, depFileContents),
|
|
8798
8834
|
});
|
|
8799
8835
|
for (const r of resumeFindings(_ckpt)) { if (r && r.findings) _ckptPayloads.set(r.file, r.findings); }
|
|
8800
8836
|
_ckptDone = completedFiles(_ckpt);
|
|
@@ -9190,6 +9226,9 @@ function _deterministicFileTimings(timings) {
|
|
|
9190
9226
|
}
|
|
9191
9227
|
}
|
|
9192
9228
|
const annotatedComponents=components.map(c=>{const key=`${c.ecosystem}:${c.name}:${c.version}`;const vulns=vulnsByKey[key]||[];const riKey=c.ecosystem==='maven'&&c.group?`maven:${c.group}/${c.name}`:`${c.ecosystem}:${c.name}`;const ri=registryInfo.get(riKey)||{};const latestVersion=ri.latestVersion||'';const vd=(ri.versions||{})[c.version]||{};const isDeprecated=typeof vd.deprecated==='string'&&vd.deprecated.length>0;const deprecationMessage=isDeprecated?vd.deprecated:'';const isOutdated=!isDeprecated&&typeof vd.outdated==='string'&&vd.outdated.length>0;const outdatedMessage=isOutdated?vd.outdated:'';const license=ri.license||vd.license||'';return{...c,vulns,hasVulns:vulns.length>0,hasAttackPath:attackResult.flagged.has(key),attackPaths:attackResult.pathsByKey.get(key)||[],latestVersion,isDeprecated,deprecationMessage,isOutdated,outdatedMessage,license};});
|
|
9229
|
+
// X-010: Hackage and Nix components, dependency edges and target provenance. Kept beside (not inside) the ordinary
|
|
9230
|
+
// component list so the existing consumers are untouched; the BOM emitters and the SBOM diff read it explicitly.
|
|
9231
|
+
let _languageBom=null;try{if(Object.keys(allFileContents).some(f=>/\.(?:l?hs|nix)$|\.cabal$|(?:^|\/)(?:cabal\.project(?:\.freeze)?|package\.yaml|stack\.yaml(?:\.lock)?|flake\.lock)$/i.test(f))){const{languageBom}=await import('./language/bom.js');const{resolvedHaskellGraph,nixClosureOf}=await import('./language/resolved-pass.js');const _rg=resolvedHaskellGraph(allFileContents);const _nc=nixClosureOf(allFileContents);_languageBom=languageBom(allFileContents,{...(_rg?{resolved:_rg.graph}:{}),...(_nc?{closure:_nc.closure}:{})});}}catch(_){_languageBom=null;}
|
|
9193
9232
|
aF.push(...(runDetector(_detectorErrors,'<project>','scanDbTaintCrossFile',()=>scanDbTaintCrossFile(fc))||[]));
|
|
9194
9233
|
aF.push(...(runDetector(_detectorErrors,'<project>','scanCsharpCrossFile',()=>scanCsharpCrossFile(fc))||[]));
|
|
9195
9234
|
// SARD_80_F1 W5.41/W5.42 — unlike the ADD-only cross-file passes above,
|
|
@@ -9291,10 +9330,17 @@ function _deterministicFileTimings(timings) {
|
|
|
9291
9330
|
// believing it enables deep mode — it never did, so those tests were
|
|
9292
9331
|
// exercising whatever coincidentally fires without the deep engine, not
|
|
9293
9332
|
// the interprocedural machinery they're named for.
|
|
9294
|
-
|
|
9333
|
+
// Haskell has NO analysis outside the deep (IR) layer: its taint findings, sink checks and guard refutations all
|
|
9334
|
+
// live there. So, unlike the other languages, a project with Haskell sources runs deep mode by default, in CI too,
|
|
9335
|
+
// unless it is explicitly switched off (AGENTIC_SECURITY_DEEP=0 / --no-deep / deep:false). An explicit off is not
|
|
9336
|
+
// silent: the missing analyzer is a scan-health condition (see language/assurance.js) and strict assurance fails.
|
|
9337
|
+
const _hasHaskellSource = Object.keys(fc).some((f) => /\.l?hs$/i.test(f));
|
|
9338
|
+
const _deepExplicitlyOff = deep === false || process.env.AGENTIC_SECURITY_DEEP === '0';
|
|
9339
|
+
const _deepForHaskell = _hasHaskellSource && !_deepExplicitlyOff;
|
|
9340
|
+
const _deepRequested = deep === true || process.env.AGENTIC_SECURITY_DEEP === '1' || _deepForHaskell;
|
|
9295
9341
|
const _inCi = !!(process.env.CI || process.env.GITHUB_ACTIONS || process.env.GITLAB_CI ||
|
|
9296
9342
|
process.env.BUILDKITE || process.env.CIRCLECI || process.env.JENKINS_URL);
|
|
9297
|
-
const _deepInCiAllowed = deepInCi === true || process.env.AGENTIC_SECURITY_DEEP_IN_CI === '1';
|
|
9343
|
+
const _deepInCiAllowed = deepInCi === true || process.env.AGENTIC_SECURITY_DEEP_IN_CI === '1' || _deepForHaskell;
|
|
9298
9344
|
const _deepEnabled = _deepRequested && (!_inCi || _deepInCiAllowed);
|
|
9299
9345
|
let _deepCallGraph = null;
|
|
9300
9346
|
let _deepFailure = null;
|
|
@@ -9343,6 +9389,7 @@ function _deterministicFileTimings(timings) {
|
|
|
9343
9389
|
f.unvalidated = true;
|
|
9344
9390
|
f.validator_verdict = 'unvalidated';
|
|
9345
9391
|
}
|
|
9392
|
+
try { applyHaskellGuards(irFindings, perFile); consolidateFlowSinks(irFindings, perFile); annotateUnresolvedTargets(irFindings, perFile); attachEvidenceChains(irFindings, perFile); annotateHaskellFindings(irFindings, fc); annotateHaskellLlm(irFindings, fc); } catch (_) { /* metadata only; the finding stands without it */ }
|
|
9346
9393
|
aF.push(...irFindings);
|
|
9347
9394
|
} catch (e) {
|
|
9348
9395
|
// Deep mode is best-effort. A parser blowup in one file shouldn't kill
|
|
@@ -9506,6 +9553,13 @@ function _deterministicFileTimings(timings) {
|
|
|
9506
9553
|
}
|
|
9507
9554
|
} catch { /* Java SCA enrichment is best-effort */ }
|
|
9508
9555
|
}
|
|
9556
|
+
// Haskell crypto / randomness / resource / XML / sensitive-logging rules (project-level: imports resolve across modules).
|
|
9557
|
+
try{aF.push(...analyzeHaskellRules(fc).findings);}catch(_){}
|
|
9558
|
+
try{aF.push(...analyzeHaskellLlmRules(fc));}catch(_){}
|
|
9559
|
+
try{const{analyzeLanguageFindings}=await import('./language/engine-pass.js');aF.push(...analyzeLanguageFindings(allFileContents).findings);}catch(_){}
|
|
9560
|
+
let _languageBridges=null;try{if(Object.keys(allFileContents).some(f=>/\.(?:l?hs|nix)$/i.test(f))){const{analyzePolyglotBridges}=await import('./language/bridges.js');_languageBridges=analyzePolyglotBridges(allFileContents);}}catch(_){}
|
|
9561
|
+
try{for(const[_p,_t]of Object.entries(allFileContents))if(typeof _t==='string')aF.push(...scanDependencyUrlCredentials(_p,_t));}catch(_){}
|
|
9562
|
+
try{const _hw=analyzeHaskellWeb(fc);aF.push(..._hw.findings);aR.push(...toEngineRoutes(_hw));}catch(_){}
|
|
9509
9563
|
let finalFindings;try{finalFindings=dedupeFindingsWithEvidence(aF);}catch(_){finalFindings=dd(aF,f=>f.id);}
|
|
9510
9564
|
// Inline `agentic-security-ignore` pragmas, pass 1 of 2. This covers every
|
|
9511
9565
|
// finding that exists BY THIS POINT — the pattern detectors, the cross-file
|
|
@@ -9708,6 +9762,10 @@ function _deterministicFileTimings(timings) {
|
|
|
9708
9762
|
// reports fewer steps than the total and the progress bar does not
|
|
9709
9763
|
// necessarily reach it — that's the same "stale total, real motion" shape
|
|
9710
9764
|
// the pre-existing "Linking" phase already accepts.
|
|
9765
|
+
// A large scan has just finished its heaviest allocation phase (IR, taint). Ask V8 for a collection before the annotators start so
|
|
9766
|
+
// the transient garbage of that phase is not still resident when the annotation phase peaks (the V8 heuristics would otherwise let
|
|
9767
|
+
// the process grow first). Only when the heap is already large: a small scan never pays for it.
|
|
9768
|
+
try { _gcHint(400); } catch (_) { /* a hint, never a requirement */ }
|
|
9711
9769
|
const _ANNOTATOR_TOTAL_ESTIMATE = 52;
|
|
9712
9770
|
let _annotatorIdx = 0;
|
|
9713
9771
|
const _runAnnotator = (phase, fn) => {
|
|
@@ -10207,6 +10265,8 @@ function _deterministicFileTimings(timings) {
|
|
|
10207
10265
|
// aF — aF was already snapshotted into finalFindings at dedupeFindingsWithEvidence
|
|
10208
10266
|
// above, so pushing into aF here silently discards every result.
|
|
10209
10267
|
try{const dc=detectDepConfusion(annotatedComponents,scanRoot);supplyChain.push(...dc);}catch(_){}
|
|
10268
|
+
// Nix fetch/build/cache trust (static): supply-chain entries, so they live in this bucket.
|
|
10269
|
+
try{const{analyzeLanguageSupplyChain}=await import('./language/engine-pass.js');supplyChain.push(...analyzeLanguageSupplyChain(allFileContents,{scanRoot}).supplyChain);}catch(_){}
|
|
10210
10270
|
// Deployment-platform security checklist
|
|
10211
10271
|
try{const dpf=scanDeployPlatform(scanRoot);aLogic.push(...dpf);}catch(_){}
|
|
10212
10272
|
// Stack-specific security playbook
|
|
@@ -10422,7 +10482,7 @@ function _deterministicFileTimings(timings) {
|
|
|
10422
10482
|
// SBOM diff — drift detection across releases.
|
|
10423
10483
|
if (process.env.AGENTIC_SECURITY_NO_SBOM_DIFF !== '1') {
|
|
10424
10484
|
try {
|
|
10425
|
-
_sbomDiff = runSbomDiff(scanRoot, annotatedComponents || []);
|
|
10485
|
+
_sbomDiff = runSbomDiff(scanRoot, [...(annotatedComponents || []), ...((_languageBom && _languageBom.components) || [])]);
|
|
10426
10486
|
if (_sbomDiff && Array.isArray(_sbomDiff.findings)) finalFindings.push(..._sbomDiff.findings);
|
|
10427
10487
|
} catch (_) {}
|
|
10428
10488
|
}
|
|
@@ -10459,14 +10519,18 @@ function _deterministicFileTimings(timings) {
|
|
|
10459
10519
|
if (process.env.AGENTIC_SECURITY_NO_LICENSE_GRAPH !== '1') {
|
|
10460
10520
|
try {
|
|
10461
10521
|
const lgPolicy = loadLicenseGraphPolicy(scanRoot);
|
|
10462
|
-
|
|
10522
|
+
// A Haskell or Nix component carries no license data of its own, and "no license" is not evidence of a licensing problem:
|
|
10523
|
+
// judging it would add a review finding per dependency for every project. They are left out and the gap is disclosed
|
|
10524
|
+
// (language/assurance.js `licenseUnavailable`) rather than turned into noise.
|
|
10525
|
+
const _langLic = ((_languageBom && _languageBom.components) || []).filter((c) => c && (c.license || (Array.isArray(c.licenses) && c.licenses.length)));
|
|
10526
|
+
_licenseGraph = analyzeLicenseGraph([...(annotatedComponents || []), ..._langLic], lgPolicy);
|
|
10463
10527
|
if (_licenseGraph && Array.isArray(_licenseGraph.findings)) finalFindings.push(..._licenseGraph.findings);
|
|
10464
10528
|
} catch (_) {}
|
|
10465
10529
|
}
|
|
10466
10530
|
// Attributions: emit ATTRIBUTIONS.md (and NOTICE if Apache deps present).
|
|
10467
10531
|
if (process.env.AGENTIC_SECURITY_NO_ATTRIBUTIONS !== '1') {
|
|
10468
10532
|
try {
|
|
10469
|
-
_attributions = generateAttributions(annotatedComponents || []);
|
|
10533
|
+
_attributions = generateAttributions([...(annotatedComponents || []), ...((_languageBom && _languageBom.components) || [])]);
|
|
10470
10534
|
if (_attributions && _attributions.componentCount) persistAttributions(scanRoot, _attributions);
|
|
10471
10535
|
} catch (_) {}
|
|
10472
10536
|
}
|
|
@@ -10477,7 +10541,7 @@ function _deterministicFileTimings(timings) {
|
|
|
10477
10541
|
// .json 03.04.10, nist-ai-600-1.json MG-4.1-001) could never clear.
|
|
10478
10542
|
if (process.env.AGENTIC_SECURITY_NO_AIBOM !== '1') {
|
|
10479
10543
|
try {
|
|
10480
|
-
const _aibom = buildAIBOM({ components: annotatedComponents || [] }, fc, {});
|
|
10544
|
+
const _aibom = buildAIBOM({ components: annotatedComponents || [] }, fc, { manifests: depFileContents });
|
|
10481
10545
|
if (_aibom && (_aibom.models.length || _aibom.promptTemplates.length || _aibom.frameworks.length)) {
|
|
10482
10546
|
persistAIBOM(scanRoot, _aibom);
|
|
10483
10547
|
}
|
|
@@ -10728,6 +10792,14 @@ function _deterministicFileTimings(timings) {
|
|
|
10728
10792
|
// exists on dependency COMPONENTS but was never carried onto the
|
|
10729
10793
|
// vulnerable_dep entries, so the negation was true for every entry and the
|
|
10730
10794
|
// filter excluded nothing. `isDirect` is now propagated at materialization.
|
|
10795
|
+
// Hackage entries come from a declared dependency; the VERSION may be decided by a freeze/lock/extra-deps file, and
|
|
10796
|
+
// that file is where the version transition (the origin) is recorded, so it is the manifest provenance reads.
|
|
10797
|
+
for (const s of (supplyChain || [])) {
|
|
10798
|
+
if (!s || s.type !== 'vulnerable_dep' || s.ecosystem !== 'hackage') continue;
|
|
10799
|
+
if (s.isDirect === undefined) s.isDirect = true;
|
|
10800
|
+
if (!s.fixedVersions && Array.isArray(s.fixedIn)) s.fixedVersions = s.fixedIn;
|
|
10801
|
+
if (!s.filePath) s.filePath = (s.versionSource && s.versionSource.file) || s.file;
|
|
10802
|
+
}
|
|
10731
10803
|
const directDeps = (supplyChain || []).filter((s) => s && s.type === 'vulnerable_dep' && s.isDirect);
|
|
10732
10804
|
// `resolveDirectSCAOrigin`/`scaStableId` key on `filePath`; the vulnerable_dep
|
|
10733
10805
|
// entries built above carry the manifest path as `file` (report/index.js
|
|
@@ -10923,6 +10995,18 @@ function _deterministicFileTimings(timings) {
|
|
|
10923
10995
|
// (_rootCauseSweep/_proofCoverage/_coverageLedger/_scanHealth are hoisted
|
|
10924
10996
|
// above the `skipAnnotators` guard — FR-PROV-029.)
|
|
10925
10997
|
try { _rootCauseSweep = sweepRootCauses(finalFindings, fc); } catch { _rootCauseSweep = null; }
|
|
10998
|
+
// X-008: Haskell siblings are found on the IR by resolved callee (the generic sweep keys on a call-text shape that
|
|
10999
|
+
// Haskell never produces). Merged into the same structure so found === candidates + mitigated still holds overall.
|
|
11000
|
+
try {
|
|
11001
|
+
if (finalFindings.some(f => f && f.parser === 'IR-TAINT' && /\.l?hs$/i.test(f.file || ''))) {
|
|
11002
|
+
const { buildProjectIR } = await import('./ir/index.js');
|
|
11003
|
+
const { sweepHaskellSiblings } = await import('./language/haskell-sweep.js');
|
|
11004
|
+
const hsFiles = Object.fromEntries(Object.entries(fc).filter(([k]) => /\.l?hs$/i.test(k)));
|
|
11005
|
+
const hs = sweepHaskellSiblings(finalFindings, buildProjectIR(hsFiles).callGraph);
|
|
11006
|
+
const base = _rootCauseSweep || { sweeps: [], totals: { found: 0, candidates: 0, mitigated: 0 } };
|
|
11007
|
+
_rootCauseSweep = { ...base, sweeps: [...base.sweeps, ...hs.sweeps.map(x => ({ ...x, language: 'haskell' }))], totals: { found: base.totals.found + hs.totals.found, candidates: base.totals.candidates + hs.totals.candidates, mitigated: base.totals.mitigated + hs.totals.mitigated } };
|
|
11008
|
+
}
|
|
11009
|
+
} catch { /* the Haskell sweep is additive; the generic sweep result stands */ }
|
|
10926
11010
|
// PRD F7.2: publish what CANNOT be proven alongside what can. A proof RATE
|
|
10927
11011
|
// computed over the provable subset makes a narrow subset look like strength;
|
|
10928
11012
|
// the three-bucket split (provable / declined-on-purpose / not-yet-classified)
|
|
@@ -10938,6 +11022,31 @@ function _deterministicFileTimings(timings) {
|
|
|
10938
11022
|
// summary, computed from signals the engine already collects.
|
|
10939
11023
|
// `analyzers` was `null` (see pipeline/scan-health.js's prior comment)
|
|
10940
11024
|
// until FR-203's coverage ledger existed to compute it for real.
|
|
11025
|
+
// X-007: Haskell/Nix assurance (parse outcomes, manifests, advisory currency, required capabilities, optional
|
|
11026
|
+
// modes) feeds the SAME scan-health input every other analyzer uses, so advisory/standard/strict and the
|
|
11027
|
+
// deploy verdict apply unchanged. A failure here is recorded as a condition; findings are never dropped.
|
|
11028
|
+
let _languageCoverage = null;
|
|
11029
|
+
try {
|
|
11030
|
+
if (Object.keys(allFileContents).some(f => /\.(?:l?hs|hs-boot|hsc|nix)$|\.cabal$|(?:^|\/)(?:cabal\.project|package\.yaml|stack\.yaml)/i.test(f))) {
|
|
11031
|
+
const { assessLanguageAssurance } = await import('./language/assurance.js');
|
|
11032
|
+
const { configuredAdvisoryDb } = await import('./language/haskell-supply.js');
|
|
11033
|
+
const env = process.env;
|
|
11034
|
+
const { resolvedHackageComponents, analyzeNixClosure, runSelectedNixEval } = await import('./language/resolved-pass.js');
|
|
11035
|
+
let _languageSupplyGaps = [];
|
|
11036
|
+
try { _languageSupplyGaps = [...resolvedHackageComponents(allFileContents).gaps, ...((analyzeNixClosure(allFileContents, { scanRoot }) || {}).gaps || [])]; } catch (e) { _languageSupplyGaps = [{ kind: 'resolved-analysis-failed', detail: String((e && e.message) || e).slice(0, 160) }]; }
|
|
11037
|
+
const _nixEvalResult = await runSelectedNixEval(scanRoot, env);
|
|
11038
|
+
_languageCoverage = (await assessLanguageAssurance({
|
|
11039
|
+
files: allFileContents, findings: finalFindings, timeoutMs: Number(env.AGENTIC_SECURITY_LANG_TIMEOUT_MS) || 0,
|
|
11040
|
+
disabled: [...String(env.AGENTIC_SECURITY_LANG_DISABLE || '').split(',').map(x => x.trim()).filter(Boolean), ...((_hasHaskellSource && !_deepEnabled) ? ['haskell:taint'] : [])],
|
|
11041
|
+
advisoryDb: configuredAdvisoryDb(scanRoot).db,
|
|
11042
|
+
policyExcluded,
|
|
11043
|
+
sizeSkipped,
|
|
11044
|
+
supplyGaps: _languageSupplyGaps,
|
|
11045
|
+
licenseUnavailable: ((_languageBom && _languageBom.components) || []).filter((c) => c && !c.license && !(Array.isArray(c.licenses) && c.licenses.length)).length,
|
|
11046
|
+
optional: { 'nix-eval': { selected: env.AGENTIC_SECURITY_NIX_EVAL === '1', result: _nixEvalResult }, 'cabal-plan': { selected: false }, 'hackage-live': { selected: false } },
|
|
11047
|
+
})).languageCoverage;
|
|
11048
|
+
}
|
|
11049
|
+
} catch (e) { _languageCoverage = { totals: null, byKind: null, conditions: [`language assurance could not be computed: ${String((e && e.message) || e)}`] }; }
|
|
10941
11050
|
_scanHealth = computeScanHealth({
|
|
10942
11051
|
scanMeta: _scanMeta,
|
|
10943
11052
|
annotatorErrors: _annotatorErrors,
|
|
@@ -10945,6 +11054,8 @@ function _deterministicFileTimings(timings) {
|
|
|
10945
11054
|
deepStatus: _deepStatus,
|
|
10946
11055
|
analyzerCoverage: summarizeCoverageForScanHealth(_coverageLedger),
|
|
10947
11056
|
lineageStatus: _lineageStatus,
|
|
11057
|
+
languageCoverage: _languageCoverage,
|
|
11058
|
+
supplyChain,
|
|
10948
11059
|
});
|
|
10949
11060
|
// FR-207: stale vulnerability feeds, calibration data, and compliance
|
|
10950
11061
|
// evidence are real assurance gaps, not just findings the feed omits --
|
|
@@ -10960,7 +11071,7 @@ function _deterministicFileTimings(timings) {
|
|
|
10960
11071
|
compliance: _complianceReport ? { stale: _complianceReport.summary?.stale || 0 } : null,
|
|
10961
11072
|
});
|
|
10962
11073
|
} // end if (!skipAnnotators) — FR-PROV-029
|
|
10963
|
-
return{entrypointInventory:_entrypointInventory,rootCauseSweep:_rootCauseSweep,proofCoverage:_proofCoverage,kevCatalog:kevCatalogMeta(),routes:dd(aR,r=>`${r.method}:${r.path}:${r.file}:${r.line}`),findings:finalFindings,sources:aSrc,sinks:aSink,sanitizers:aSan,filesScanned:files.length,linesScanned:Object.values(fc).reduce((_n,_c)=>_n+(typeof _c==='string'?_c.split("\n").length:0),0),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,detectorErrors:_detectorErrors,executionProof:_executionProofSummary,logicClaims:_logicClaims,vulnHistory:_vulnHistory,threatModel:_threatModel,privacyFramework:_privacyFramework,privacyIrBacked:_privacyIrBacked,privacyTaxonomyVersion:_privacyTaxonomyVersion,sbomDiff:_sbomDiff,complianceReport:_complianceReport,exploitBundles:_exploitBundles,pqcPlan:_pqcPlan,licenseGraph:_licenseGraph,attributions:_attributions,attackTaxonomy:_taxonomySummary,scanHealth:_scanHealth,coverageLedger:_coverageLedger,lineageGraph:_lineageGraph,lineageStatus:_lineageStatus,aiAssistance:_aiAssistance};}
|
|
11074
|
+
return{entrypointInventory:_entrypointInventory,rootCauseSweep:_rootCauseSweep,proofCoverage:_proofCoverage,kevCatalog:kevCatalogMeta(),routes:dd(aR,r=>`${r.method}:${r.path}:${r.file}:${r.line}`),findings:finalFindings,sources:aSrc,sinks:aSink,sanitizers:aSan,filesScanned:files.length,linesScanned:Object.values(fc).reduce((_n,_c)=>_n+(typeof _c==='string'?_c.split("\n").length:0),0),crossFileCount:cf.length,logicVulns:aLogic,supplyChain,components:annotatedComponents,secrets:aSecrets,ciphers:{atRest:aCiphersRest,inTransit:aCiphersTransit},pfr,fc,depFileContents,suppressions:_getSuppressions(),_v3,_scanMeta,_engineErrors:{cppDataflowParseErrors:_cppDataflowParseErrors.value},annotatorErrors:_annotatorErrors,detectorErrors:_detectorErrors,executionProof:_executionProofSummary,logicClaims:_logicClaims,vulnHistory:_vulnHistory,threatModel:_threatModel,privacyFramework:_privacyFramework,privacyIrBacked:_privacyIrBacked,privacyTaxonomyVersion:_privacyTaxonomyVersion,sbomDiff:_sbomDiff,complianceReport:_complianceReport,exploitBundles:_exploitBundles,pqcPlan:_pqcPlan,licenseGraph:_licenseGraph,attributions:_attributions,attackTaxonomy:_taxonomySummary,scanHealth:_scanHealth,coverageLedger:_coverageLedger,lineageGraph:_lineageGraph,lineageStatus:_lineageStatus,languageBridges:_languageBridges,languageBom:_languageBom,aiAssistance:_aiAssistance};}
|
|
10964
11075
|
|
|
10965
11076
|
// Post-aggregation classification: every source becomes "unsafe"|"safe"; every sink becomes "confirmed"|"safe".
|
|
10966
11077
|
// Orphans (no finding linkage) are bucketed by file-local heuristic so the UI shows binary states only.
|
package/src/ir/index.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// Build per-file IR for every JS/TS/Python/Java file in a project, then
|
|
4
4
|
// build the cross-file call graph on top.
|
|
5
5
|
|
|
6
|
+
import { buildHaskellIR } from '../language/haskell-ir.js';
|
|
6
7
|
import { parseJsFile } from './parser-js.js';
|
|
7
8
|
import { parseCSharpFile } from './parser-cs.js';
|
|
8
9
|
import { parseKotlinFile } from './parser-kt.js';
|
|
@@ -137,6 +138,7 @@ function _parsePythonFiles(pyEntries) {
|
|
|
137
138
|
export function buildProjectIR(fileContents) {
|
|
138
139
|
const perFile = {};
|
|
139
140
|
const pyBatch = [];
|
|
141
|
+
const hsFiles = {};
|
|
140
142
|
for (const [file, code] of Object.entries(fileContents || {})) {
|
|
141
143
|
// Each branch is wrapped so one pathological file (e.g. a deeply nested
|
|
142
144
|
// expression tree that blows the regex-parser's recursive descent, seen
|
|
@@ -148,6 +150,8 @@ export function buildProjectIR(fileContents) {
|
|
|
148
150
|
if (/\.(?:js|jsx|ts|tsx|mjs|cjs)$/i.test(file)) {
|
|
149
151
|
const ir = parseJsFile(file, code);
|
|
150
152
|
if (ir) perFile[file] = ir;
|
|
153
|
+
} else if (/\.hs$/i.test(file)) {
|
|
154
|
+
hsFiles[file] = code; // lowered together: Haskell resolution is whole-program (imports, re-exports)
|
|
151
155
|
} else if (/\.py$/i.test(file)) {
|
|
152
156
|
// Defer Python files to a single batched subprocess call.
|
|
153
157
|
pyBatch.push({ file, content: code });
|
|
@@ -185,6 +189,7 @@ export function buildProjectIR(fileContents) {
|
|
|
185
189
|
// the CFGs (SSA numbering included), since PHP's `include` genuinely
|
|
186
190
|
// executes in the caller's variable namespace. See php-include-merge.js's
|
|
187
191
|
// module header for the full rationale and scope.
|
|
192
|
+
const hsCg = mergeHaskellIR(perFile, hsFiles);
|
|
188
193
|
try { mergePhpIncludes(perFile); } catch {}
|
|
189
194
|
if (isSSAEnabled()) {
|
|
190
195
|
for (const ir of Object.values(perFile)) {
|
|
@@ -199,15 +204,44 @@ export function buildProjectIR(fileContents) {
|
|
|
199
204
|
// passed — without it, a call to an aliased re-export can never resolve
|
|
200
205
|
// (no function is literally named the alias anywhere), a dropped edge,
|
|
201
206
|
// not just an imprecise one.
|
|
202
|
-
const cg = buildCallGraph(perFile, fileContents);
|
|
207
|
+
const cg = withHaskellCallGraph(buildCallGraph(perFile, fileContents), hsCg);
|
|
203
208
|
const cha = buildClassHierarchy(perFile);
|
|
204
209
|
return { perFile, callGraph: cg, cha };
|
|
205
210
|
}
|
|
206
211
|
|
|
212
|
+
// Haskell is lowered as one project (module resolution crosses files), then its
|
|
213
|
+
// per-file IR joins the same perFile map every consumer already reads. Its call graph
|
|
214
|
+
// keeps its richer edge statuses and is merged into the shared one below.
|
|
215
|
+
function mergeHaskellIR(perFile, hsFiles) {
|
|
216
|
+
if (!Object.keys(hsFiles).length) return null;
|
|
217
|
+
try {
|
|
218
|
+
const hs = buildHaskellIR(hsFiles);
|
|
219
|
+
for (const [file, ir] of Object.entries(hs.perFile)) perFile[file] = ir;
|
|
220
|
+
for (const d of hs.diagnostics) _noteParseFailure(d.file, new Error(`haskell ${d.kind}: ${d.detail}`));
|
|
221
|
+
return hs.callGraph;
|
|
222
|
+
} catch (err) {
|
|
223
|
+
for (const f of Object.keys(hsFiles)) _noteParseFailure(f, err);
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function withHaskellCallGraph(cg, hsCg) {
|
|
229
|
+
if (!hsCg) return cg;
|
|
230
|
+
const functions = new Map([...cg.functions, ...hsCg.functions]);
|
|
231
|
+
const edges = [...(cg.edges || []), ...hsCg.edges];
|
|
232
|
+
const callersOf = new Map(cg.callersOf || []);
|
|
233
|
+
for (const [k, v] of hsCg.callersOf || []) callersOf.set(k, [...(callersOf.get(k) || []), ...v]);
|
|
234
|
+
const isHs = (f) => typeof f === 'string' && /\.hs$/i.test(f);
|
|
235
|
+
const resolve = (name, callerFile) => (isHs(callerFile) ? hsCg.resolve(name) : cg.resolve(name, callerFile));
|
|
236
|
+
const resolveKnownCallee = (name, callerFile) => (isHs(callerFile) ? hsCg.resolveKnownCallee(name) : cg.resolveKnownCallee(name, callerFile));
|
|
237
|
+
return { ...cg, functions, edges, callersOf, resolve, resolveKnownCallee, haskell: hsCg };
|
|
238
|
+
}
|
|
239
|
+
|
|
207
240
|
// Async variant — includes Java IR via java-parser.
|
|
208
241
|
export async function buildProjectIRAsync(fileContents) {
|
|
209
242
|
const perFile = {};
|
|
210
243
|
const pyBatch = [];
|
|
244
|
+
const hsFiles = {};
|
|
211
245
|
for (const [file, code] of Object.entries(fileContents || {})) {
|
|
212
246
|
// See buildProjectIR's comment: per-file try/catch so one pathological
|
|
213
247
|
// file can't abort IR construction for the whole project.
|
|
@@ -215,6 +249,8 @@ export async function buildProjectIRAsync(fileContents) {
|
|
|
215
249
|
if (/\.(?:js|jsx|ts|tsx|mjs|cjs)$/i.test(file)) {
|
|
216
250
|
const ir = parseJsFile(file, code);
|
|
217
251
|
if (ir) perFile[file] = ir;
|
|
252
|
+
} else if (/\.hs$/i.test(file)) {
|
|
253
|
+
hsFiles[file] = code;
|
|
218
254
|
} else if (/\.py$/i.test(file)) {
|
|
219
255
|
pyBatch.push({ file, content: code });
|
|
220
256
|
} else if (/\.cs$/i.test(file)) {
|
|
@@ -256,6 +292,7 @@ export async function buildProjectIRAsync(fileContents) {
|
|
|
256
292
|
// the CFGs (SSA numbering included), since PHP's `include` genuinely
|
|
257
293
|
// executes in the caller's variable namespace. See php-include-merge.js's
|
|
258
294
|
// module header for the full rationale and scope.
|
|
295
|
+
const hsCg = mergeHaskellIR(perFile, hsFiles);
|
|
259
296
|
try { mergePhpIncludes(perFile); } catch {}
|
|
260
297
|
if (isSSAEnabled()) {
|
|
261
298
|
for (const ir of Object.values(perFile)) {
|
|
@@ -270,7 +307,7 @@ export async function buildProjectIRAsync(fileContents) {
|
|
|
270
307
|
// passed — without it, a call to an aliased re-export can never resolve
|
|
271
308
|
// (no function is literally named the alias anywhere), a dropped edge,
|
|
272
309
|
// not just an imprecise one.
|
|
273
|
-
const cg = buildCallGraph(perFile, fileContents);
|
|
310
|
+
const cg = withHaskellCallGraph(buildCallGraph(perFile, fileContents), hsCg);
|
|
274
311
|
const cha = buildClassHierarchy(perFile);
|
|
275
312
|
return { perFile, callGraph: cg, cha };
|
|
276
313
|
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# scanner/src/language/
|
|
2
|
+
|
|
3
|
+
Haskell and Nix/NixOS support: parsers, IRs, analyzers, fixers, support registry. **Read this before adding a rule, a model entry
|
|
4
|
+
or a capability.** Product-facing guides: `docs/guides/haskell.md`, `docs/guides/nix-nixos.md`. Measured status:
|
|
5
|
+
`docs/language-support.md` (generated).
|
|
6
|
+
|
|
7
|
+
## Invariants (the ones you will get wrong without reading)
|
|
8
|
+
|
|
9
|
+
- **Static by default.** A default scan never runs GHC, Cabal, Stack, `nix`, `Setup.hs`, Template Haskell or a build. Compile
|
|
10
|
+
checks (`haskell-fix.js` `compileGate`) and evaluation (`nix-eval-isolation.js`) are opt-in, and a tool that is absent is a
|
|
11
|
+
stated `not-run` / `unsupported`, never a pass. A required criterion that needs a tool **fails** (and the support registry
|
|
12
|
+
marks the capability `blocked`); it never skips.
|
|
13
|
+
- **Never derive an answer from ground truth.** The engine must not read the corpus under `test/language/corpora/`, fixture
|
|
14
|
+
names, comments or label metadata (a test enforces the corpus half). Detection keys on code semantics; `bench/mutation/`
|
|
15
|
+
and the metamorphic pairs in `bench/language-support/` are the anti-overfitting check.
|
|
16
|
+
- **Every limit is disclosed.** An unresolved construct, a conditional Nix option, a stale plan, a missing advisory feed, a
|
|
17
|
+
malformed export, a size-capped file, a disabled analyzer: each becomes a `scanHealth` condition or a limitation
|
|
18
|
+
(`assurance.js`). Findings are never dropped because a condition appeared, and an absent feed is "not assessed", never clean.
|
|
19
|
+
- **Findings schema.** Optional contract fields (`language`, `capability`, `evidenceKind`, `scope`, `uncertainty`,
|
|
20
|
+
`originalLocation`) in `contracts.js`; the legacy required fields are unchanged. A producer must be registered
|
|
21
|
+
(`producer-registry.js`) or its results are discarded and reported.
|
|
22
|
+
- **Locations index the ORIGINAL file** (1-based lines, 0-based UTF-16 columns, UTF-8 bytes). Preprocessing (CPP, literate,
|
|
23
|
+
hsc) overwrites with same-length spaces.
|
|
24
|
+
- **State safety.** Persisted language artifacts go through `posture/state-dir.js` (`statePath`, `safeWriteState`); a symlinked
|
|
25
|
+
state dir or file is refused; fix backups are encrypted when encryption is configured and are `confidential`. A fix target
|
|
26
|
+
outside the project root is refused (`fix-lifecycle.js` `isRootRelativePath`).
|
|
27
|
+
- **Secrets never leave redacted.** `secrets.js` `redactLanguageSecrets` joins split literals before masking; egress and model
|
|
28
|
+
prompts use it. Credential values are never written to any output or state file.
|
|
29
|
+
- **Pragma** (`pragma.js`): comment-aware, line-scoped, exact rule-id match, logged. A line-less finding cannot be suppressed.
|
|
30
|
+
|
|
31
|
+
## Module map
|
|
32
|
+
|
|
33
|
+
| Group | Files |
|
|
34
|
+
|---|---|
|
|
35
|
+
| Shared | `discovery.js` (sources, manifests, explicit exports, exclusions, import graph, invalidation digests), `contracts.js`, `assurance.js` (scan-health inputs, conditions, limitations), `engine-pass.js` (language supply chain wired into the engine), `resolved-pass.js` (plan / Stack export / Nix closure / opt-in evaluation reachable from the CLI), `context.js` (project context for partial scans, fix previews and dependency upgrades, model prompt extras), `state-artifacts.js`, `pragma.js`, `secrets.js`, `bom.js`, `bom-validate.js`, `aibom.js`, `compliance-map.js`, `bridges.js`, `witness.js`, `fix-lifecycle.js` (the one fix lifecycle: path, syntax, rescan, compile, backup, history, undo) |
|
|
36
|
+
| Haskell | `haskell-parser.js`, `haskell-grammar.js`, `haskell-adapter.js`, `haskell-ir.js`, `haskell-models.js` (the single registry of sources, sinks, sanitizers; `dataflow/catalog-haskell.js` is generated from it), `haskell-guards.js`, `haskell-security-rules.js`, `haskell-web.js`, `haskell-findings.js`, `haskell-syntax.js`, `haskell-sweep.js`, `haskell-disclosure.js`, `haskell-llm.js`, `haskell-manifests.js`, `haskell-resolved-graph.js`, `haskell-sca.js`, `haskell-supply.js`, `haskell-fix.js` |
|
|
37
|
+
| Nix | `nix-parser.js`, `nix-grammar.js`, `nix-ir.js`, `nix-adapter.js`, `nixos-module-resolver.js`, `nixos-option-catalog.js`, `nixos-hardening.js`, `nix-build-trust.js`, `nix-secrets.js`, `nix-script-taint.js`, `nix-privacy.js`, `nix-agents.js`, `nix-inventory.js`, `nix-closure.js`, `nix-sca.js`, `nix-eval-isolation.js`, `nix-fix.js` |
|
|
38
|
+
| Measurement | `accuracy.js` (family-scoped scoring, per-layer), `support-registry.js` (status rules, promotion refusals, frozen-hash demotion) |
|
|
39
|
+
|
|
40
|
+
Related, outside this directory: `dataflow/catalog-haskell.js`, `lineage/haskell-view.js`, `lineage/nix-view.js`,
|
|
41
|
+
`ir/` Haskell/Nix adapters, `report/` format handling, `egress/redact.js`, `llm-validator/`, `discovery/` (hunt partitions Nix).
|
|
42
|
+
|
|
43
|
+
## Tests and benches
|
|
44
|
+
|
|
45
|
+
| Scope | Command | Notes |
|
|
46
|
+
|---|---|---|
|
|
47
|
+
| Haskell | `npm run test:haskell` | parser, IR, taint, web/auth, SCA, resolved graph, remediation |
|
|
48
|
+
| Nix | `npm run test:nix` | parser/IR, module resolution, hardening, build trust, secrets, closure, SCA, eval isolation, fixes |
|
|
49
|
+
| Language-wide | `npm run test:language` | corpus integrity, accuracy regression, end-to-end CLI matrix, package/CI, BOM/AI-BOM, privacy, secrets, governance, assurance, scan modes |
|
|
50
|
+
| Stress | `npm run test:language-stress` | offline scale and a peak-memory ceiling; excluded from `npm test` (it must own the machine) |
|
|
51
|
+
| Needs a compiler | `npm run test:language-tools` | HS-006.AC01 and the support gate; excluded from `npm test`; **fails** without `ghc` |
|
|
52
|
+
| Support registry | `npm run bench:language-support:check` | recomputes the registry from the STORED measurement; fails on any difference |
|
|
53
|
+
|
|
54
|
+
The support registry is measured **once per promotion** on a frozen holdout (`bench/language-support/promote.mjs`); never tune
|
|
55
|
+
against the holdout, and any further holdout run counts as repeated use. A change to the engine, the corpus or a label changes the
|
|
56
|
+
frozen hashes and demotes rows until a deliberate re-measure. `docs/language-support.*` are generated; do not edit by hand.
|
|
57
|
+
|
|
58
|
+
## Docs generated from here
|
|
59
|
+
|
|
60
|
+
`node scripts/render-language-docs.mjs` renders tables and counts from the registries (models, hardening, build-trust, secrets,
|
|
61
|
+
support, metrics) and from `docs/examples/language/captured.json`, which `node scripts/capture-language-examples.mjs` captures
|
|
62
|
+
from the BUILT bundle over `examples/`. Both have `--check` modes (`npm run docs:check-language`). Rebuild the bundle first:
|
|
63
|
+
the capture reads `dist/`.
|
|
64
|
+
|
|
65
|
+
## Adding a rule or a model entry
|
|
66
|
+
|
|
67
|
+
1. Haskell API: add it to `haskell-models.js` (module-qualified). The catalog and lineage registries follow, and **pinned counts in
|
|
68
|
+
`test/lineage/{source,sink,transform}-registry.test.js` change deliberately** (update them and say why).
|
|
69
|
+
2. Nix rule: add it to the rule table of its analyzer (`nixos-hardening.js`, `nix-build-trust.js`, `nix-secrets.js`); the guide's
|
|
70
|
+
table is generated, so re-render.
|
|
71
|
+
3. Add a minimal vulnerable / safe pair to the QA-001 templates (`test/language/corpora/templates.mjs`), regenerate the corpus, and
|
|
72
|
+
record the revision in `provenance.json`. Do not hand-edit labels.
|
|
73
|
+
4. Run `test:haskell` / `test:nix` / `test:language`, `bench:mutation:check`, `bench:layer-recall:check` and
|
|
74
|
+
`bench:language-support:check`; re-promote deliberately if a measured number legitimately changes.
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Accuracy scoring for the Haskell/Nix capability gates (PRD section 9.2).
|
|
2
|
+
//
|
|
3
|
+
// One-to-one matching: every expected label is matched by AT MOST ONE finding (same file, same family or
|
|
4
|
+
// CWE, and the finding's line within `lineTolerance` of the labeled line when one is given), and every
|
|
5
|
+
// finding matches at most one label. An unmatched finding is a false positive, an unmatched label a false
|
|
6
|
+
// negative, so the same vulnerability reported twice is not inflated into two true positives, and a
|
|
7
|
+
// missing parse or discovery is a miss, never an exclusion.
|
|
8
|
+
//
|
|
9
|
+
// A zero or absent denominator is `notMeasured`, never 100%. Layers are scored independently (a finding
|
|
10
|
+
// belongs to exactly one layer) and are never combined into one headline number, so a strong layer cannot
|
|
11
|
+
// hide a weak one.
|
|
12
|
+
|
|
13
|
+
export const LAYERS = Object.freeze({
|
|
14
|
+
'security-taint': (f) => f.parser === 'IR-TAINT',
|
|
15
|
+
'structural-sast': (f) => f.parser === 'HS-RULES' || f.parser === 'NIX-RULES',
|
|
16
|
+
'privacy-lineage': (f) => f.parser === 'LINEAGE' || f.kind === 'privacy',
|
|
17
|
+
'config-sast': (f) => f.parser === 'NIXOS-HARDENING',
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const ratio = (n, d) => (d > 0 ? n / d : null);
|
|
21
|
+
|
|
22
|
+
export function prf(tp, fp, fn) {
|
|
23
|
+
const precision = ratio(tp, tp + fp);
|
|
24
|
+
const recall = ratio(tp, tp + fn);
|
|
25
|
+
const f1 = precision !== null && recall !== null && precision + recall > 0 ? (2 * precision * recall) / (precision + recall) : (precision !== null && recall !== null ? 0 : null);
|
|
26
|
+
return { tp, fp, fn, precision, recall, f1, measured: precision !== null && recall !== null };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const matches = (label, f, tol) => {
|
|
30
|
+
if (label.file && f.file !== label.file) return false;
|
|
31
|
+
// `families`: the finding's own family must be one of these (a detector's family vocabulary differs from a corpus's; the
|
|
32
|
+
// alias table lives with the harness that declares it, never in the engine). Otherwise a single family, otherwise the CWE.
|
|
33
|
+
if (Array.isArray(label.families)) { if (!label.families.includes(f.family)) return false; }
|
|
34
|
+
else if (label.family ? f.family !== label.family : (label.cwe && f.cwe !== label.cwe)) return false;
|
|
35
|
+
if (!Array.isArray(label.families) && label.cwe && label.family && f.cwe !== label.cwe) return false;
|
|
36
|
+
if (Number.isInteger(label.line) && Number.isInteger(f.line) && Math.abs(label.line - f.line) > tol) return false;
|
|
37
|
+
return true;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// A finding the proof gate discharged (sanitized / guarded) is reported but is not a live detection.
|
|
41
|
+
export const isLive = (f) => !(f.proof && /^proven-/.test(f.proof.verdict));
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param {Array<{id:string, file:string, label?:'vuln'|'safe', expect:Array<{file?:string,cwe?:string,family?:string,line?:number}>}>} cases
|
|
45
|
+
* @param {object[]} findings all findings of the scan
|
|
46
|
+
* @param {{layer:string, lineTolerance?:number, live?:(f:object)=>boolean}} opts
|
|
47
|
+
*/
|
|
48
|
+
export function scoreLayer(cases, findings, opts) {
|
|
49
|
+
const inLayer = typeof opts.inLayer === 'function' ? opts.inLayer : LAYERS[opts.layer];
|
|
50
|
+
if (!inLayer) throw new Error(`unknown layer ${opts.layer}`);
|
|
51
|
+
const tol = opts.lineTolerance ?? 0;
|
|
52
|
+
const live = opts.live || isLive;
|
|
53
|
+
const pool = findings.filter((f) => inLayer(f) && live(f)).map((f) => ({ f, used: false }));
|
|
54
|
+
const perFamily = {};
|
|
55
|
+
let tp = 0, fp = 0, fn = 0, tn = 0;
|
|
56
|
+
const misses = [];
|
|
57
|
+
const strays = [];
|
|
58
|
+
const bump = (fam, key) => { (perFamily[fam] ||= { tp: 0, fp: 0, fn: 0 })[key]++; };
|
|
59
|
+
for (const c of cases) {
|
|
60
|
+
const here = pool.filter((p) => p.f.file === c.file);
|
|
61
|
+
const expected = c.expect || [];
|
|
62
|
+
for (const label of expected) {
|
|
63
|
+
const hit = here.find((p) => !p.used && matches({ file: c.file, ...label }, p.f, tol));
|
|
64
|
+
const fam = c.family || label.family || label.cwe || 'unknown';
|
|
65
|
+
if (hit) { hit.used = true; tp++; bump(fam, 'tp'); } else { fn++; bump(fam, 'fn'); misses.push({ case: c.id, label }); }
|
|
66
|
+
}
|
|
67
|
+
if (!expected.length && !here.length) tn++;
|
|
68
|
+
}
|
|
69
|
+
let crossFamily = 0;
|
|
70
|
+
for (const p of pool) {
|
|
71
|
+
if (p.used) continue;
|
|
72
|
+
// Family-scoped scoring (the convention of per-category benchmarks): a finding of a DIFFERENT CWE than the case it sits in
|
|
73
|
+
// answers a different question and is counted separately, never as a false positive of this case's family. Strict mode
|
|
74
|
+
// (the default) counts it as a stray, so both numbers can always be reported side by side.
|
|
75
|
+
if (opts.familyScoped) {
|
|
76
|
+
const own = cases.find((c) => c.file === p.f.file);
|
|
77
|
+
if (own && Array.isArray(own.families) ? !own.families.includes(p.f.family) : (own && own.cwe && p.f.cwe !== own.cwe)) { crossFamily++; continue; }
|
|
78
|
+
}
|
|
79
|
+
// only findings located in labeled files count against precision: an unrelated file is not scored
|
|
80
|
+
const owner = cases.find((c) => c.file === p.f.file);
|
|
81
|
+
if (owner) { fp++; bump(owner.family || p.f.family || p.f.cwe || 'unknown', 'fp'); strays.push({ file: p.f.file, line: p.f.line, cwe: p.f.cwe }); }
|
|
82
|
+
}
|
|
83
|
+
const families = Object.fromEntries(Object.entries(perFamily).map(([k, v]) => [k, prf(v.tp, v.fp, v.fn)]));
|
|
84
|
+
return { layer: opts.layer, cases: cases.length, tn, ...prf(tp, fp, fn), families, misses, strays, ...(opts.familyScoped ? { crossFamilyFindingsNotScored: crossFamily } : {}) };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Per-layer results side by side. Deliberately NO combined headline number. */
|
|
88
|
+
export function scoreAllLayers(cases, findings, layerList, opts = {}) {
|
|
89
|
+
return Object.fromEntries(layerList.map((layer) => [layer, scoreLayer(cases, findings, { ...opts, layer })]));
|
|
90
|
+
}
|