@clear-capabilities/agentic-security-scanner 0.153.1 → 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 +67 -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 +18 -3
- 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 +140 -19
- 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/code-injection-multilang.js +15 -0
- package/src/sast/go-extended.js +11 -0
- 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 : ''}`,
|
|
@@ -5700,19 +5724,29 @@ function _isLikelyUnsafeRegex(body){
|
|
|
5700
5724
|
// the first required literal). redos-nfa.js is consulted as a second
|
|
5701
5725
|
// opinion for the intrinsic case only, since it does not model the
|
|
5702
5726
|
// polynomial one.
|
|
5703
|
-
|
|
5727
|
+
const ambiguousAlt = _hasOverlappingQuantifiedAlternation(body);
|
|
5728
|
+
if (!_hasNestedQuantifier(body) && !_hasLeadingUnboundedQuantifier(body) && !ambiguousAlt) return false;
|
|
5704
5729
|
// FIX-DISCRIMINATION (GHSA-29g2-3rmr-qm68): an anchored pattern with no
|
|
5705
5730
|
// nested quantifier has neither failure mode — no start-offset multiplier,
|
|
5706
5731
|
// no intrinsic catastrophe. safe-regex's star-height heuristic cannot see
|
|
5707
5732
|
// anchoring at all and flagged the fixed revision identically to the
|
|
5708
5733
|
// vulnerable one, so the finding survived its own fix.
|
|
5709
|
-
if (_isAnchoredRegex(body) && !_hasNestedQuantifier(body)) return false;
|
|
5734
|
+
if (_isAnchoredRegex(body) && !_hasNestedQuantifier(body) && !ambiguousAlt) return false;
|
|
5710
5735
|
if (_safeRegex) {
|
|
5711
5736
|
try { if (!_safeRegex(body)) return true; } catch (_) { /* fall through */ }
|
|
5712
5737
|
}
|
|
5713
|
-
// safe-regex's star-height analysis misses `(a|aa)*`
|
|
5714
|
-
|
|
5715
|
-
|
|
5738
|
+
// safe-regex's star-height analysis misses `(a|aa)*` (see the helper below).
|
|
5739
|
+
return ambiguousAlt;
|
|
5740
|
+
}
|
|
5741
|
+
/**
|
|
5742
|
+
* `(X|Y…)[*+]` whose alternatives can start with the same character — `(a|aa)*`.
|
|
5743
|
+
* The overlap makes the engine fork on every iteration, which is catastrophic
|
|
5744
|
+
* backtracking even at star-height 1, and like a nested quantifier it is
|
|
5745
|
+
* intrinsic: anchoring does not cure it. A third mechanism alongside nested
|
|
5746
|
+
* quantifiers and scan-and-retry; the gate in _isLikelyUnsafeRegex must admit
|
|
5747
|
+
* it, or `/^(a|aa)*$/` is rejected before this check is ever reached.
|
|
5748
|
+
*/
|
|
5749
|
+
function _hasOverlappingQuantifiedAlternation(body){
|
|
5716
5750
|
const altQuantRe = /\((?:\?:)?([^()]+)\)[*+]/g;
|
|
5717
5751
|
let am;
|
|
5718
5752
|
while ((am = altQuantRe.exec(body)) !== null) {
|
|
@@ -6524,7 +6558,9 @@ function dedupeFindingsWithEvidence(findings){
|
|
|
6524
6558
|
// Multiple sources reaching the same sink collapse — the merged finding
|
|
6525
6559
|
// accumulates them in `evidence` and `dedupedSources` instead.
|
|
6526
6560
|
const sinkLine = f.sink?.line || f.line || 0;
|
|
6527
|
-
|
|
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:''}`;
|
|
6528
6564
|
if(!buckets.has(key)){buckets.set(key,f);continue;}
|
|
6529
6565
|
const kept=buckets.get(key);
|
|
6530
6566
|
// Winner selection: an IR-TAINT finding (the deep engine's real
|
|
@@ -7227,7 +7263,7 @@ function scanCredentials(fp,raw){
|
|
|
7227
7263
|
// unredacted-snippet leak as scanEntropySecrets — `snippet` carried
|
|
7228
7264
|
// the raw source line (full credential value) straight through to
|
|
7229
7265
|
// every report format. Redact the exact matched value here too.
|
|
7230
|
-
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,"_")};`});
|
|
7231
7267
|
}
|
|
7232
7268
|
}
|
|
7233
7269
|
// One secret, one finding. A `postgres://user:pass@host/db` matches both the
|
|
@@ -8682,6 +8718,7 @@ async function queryRegistries(components){
|
|
|
8682
8718
|
_aF.push(...(runDetector(_detectorErrors,p,'scanPythonStructural',()=>scanPythonStructural(p,cc))||[]));
|
|
8683
8719
|
_aF.push(...(runDetector(_detectorErrors,p,'scanGoStructural',()=>scanGoStructural(p,cc))||[]));
|
|
8684
8720
|
_aF.push(...(runDetector(_detectorErrors,p,'scanSecretConcat',()=>scanSecretConcat(p,cc))||[]));
|
|
8721
|
+
_aF.push(...(runDetector(_detectorErrors,p,'scanLanguageSecretConcat',()=>scanLanguageSecretConcat(p,c))||[]));
|
|
8685
8722
|
_aF.push(...(runDetector(_detectorErrors,p,'scanXssReflectedMultilang',()=>scanXssReflectedMultilang(p,cc))||[]));
|
|
8686
8723
|
_aF.push(...(runDetector(_detectorErrors,p,'scanCodeInjectionMultilang',()=>scanCodeInjectionMultilang(p,cc))||[]));
|
|
8687
8724
|
_aF.push(...(runDetector(_detectorErrors,p,'scanResponseSplitting',()=>scanResponseSplitting(p,cc))||[]));
|
|
@@ -8732,7 +8769,14 @@ async function queryRegistries(components){
|
|
|
8732
8769
|
// because a direct runFullScan caller supplying no file-subsetting options is
|
|
8733
8770
|
// scanning everything it was given; runScan.js narrows it for --changed-since
|
|
8734
8771
|
// and for caller-supplied fileContents.
|
|
8735
|
-
|
|
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);
|
|
8736
8780
|
// Pre-pass: build cross-file Java tainted-method index so per-file taint
|
|
8737
8781
|
// analysis can recognize calls to user-input-returning helper methods
|
|
8738
8782
|
// defined in OTHER files (Juliet's DataflowThruInnerClass / Vector / Stream
|
|
@@ -8784,7 +8828,9 @@ function _deterministicFileTimings(timings) {
|
|
|
8784
8828
|
_ckpt = openCheckpoint(scanRoot, {
|
|
8785
8829
|
globalKey: computeGlobalKey(_ckptIdentity),
|
|
8786
8830
|
meta: globalKeyMeta(_ckptIdentity),
|
|
8787
|
-
|
|
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),
|
|
8788
8834
|
});
|
|
8789
8835
|
for (const r of resumeFindings(_ckpt)) { if (r && r.findings) _ckptPayloads.set(r.file, r.findings); }
|
|
8790
8836
|
_ckptDone = completedFiles(_ckpt);
|
|
@@ -9180,6 +9226,9 @@ function _deterministicFileTimings(timings) {
|
|
|
9180
9226
|
}
|
|
9181
9227
|
}
|
|
9182
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;}
|
|
9183
9232
|
aF.push(...(runDetector(_detectorErrors,'<project>','scanDbTaintCrossFile',()=>scanDbTaintCrossFile(fc))||[]));
|
|
9184
9233
|
aF.push(...(runDetector(_detectorErrors,'<project>','scanCsharpCrossFile',()=>scanCsharpCrossFile(fc))||[]));
|
|
9185
9234
|
// SARD_80_F1 W5.41/W5.42 — unlike the ADD-only cross-file passes above,
|
|
@@ -9281,10 +9330,17 @@ function _deterministicFileTimings(timings) {
|
|
|
9281
9330
|
// believing it enables deep mode — it never did, so those tests were
|
|
9282
9331
|
// exercising whatever coincidentally fires without the deep engine, not
|
|
9283
9332
|
// the interprocedural machinery they're named for.
|
|
9284
|
-
|
|
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;
|
|
9285
9341
|
const _inCi = !!(process.env.CI || process.env.GITHUB_ACTIONS || process.env.GITLAB_CI ||
|
|
9286
9342
|
process.env.BUILDKITE || process.env.CIRCLECI || process.env.JENKINS_URL);
|
|
9287
|
-
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;
|
|
9288
9344
|
const _deepEnabled = _deepRequested && (!_inCi || _deepInCiAllowed);
|
|
9289
9345
|
let _deepCallGraph = null;
|
|
9290
9346
|
let _deepFailure = null;
|
|
@@ -9333,6 +9389,7 @@ function _deterministicFileTimings(timings) {
|
|
|
9333
9389
|
f.unvalidated = true;
|
|
9334
9390
|
f.validator_verdict = 'unvalidated';
|
|
9335
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 */ }
|
|
9336
9393
|
aF.push(...irFindings);
|
|
9337
9394
|
} catch (e) {
|
|
9338
9395
|
// Deep mode is best-effort. A parser blowup in one file shouldn't kill
|
|
@@ -9496,6 +9553,13 @@ function _deterministicFileTimings(timings) {
|
|
|
9496
9553
|
}
|
|
9497
9554
|
} catch { /* Java SCA enrichment is best-effort */ }
|
|
9498
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(_){}
|
|
9499
9563
|
let finalFindings;try{finalFindings=dedupeFindingsWithEvidence(aF);}catch(_){finalFindings=dd(aF,f=>f.id);}
|
|
9500
9564
|
// Inline `agentic-security-ignore` pragmas, pass 1 of 2. This covers every
|
|
9501
9565
|
// finding that exists BY THIS POINT — the pattern detectors, the cross-file
|
|
@@ -9698,6 +9762,10 @@ function _deterministicFileTimings(timings) {
|
|
|
9698
9762
|
// reports fewer steps than the total and the progress bar does not
|
|
9699
9763
|
// necessarily reach it — that's the same "stale total, real motion" shape
|
|
9700
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 */ }
|
|
9701
9769
|
const _ANNOTATOR_TOTAL_ESTIMATE = 52;
|
|
9702
9770
|
let _annotatorIdx = 0;
|
|
9703
9771
|
const _runAnnotator = (phase, fn) => {
|
|
@@ -10197,6 +10265,8 @@ function _deterministicFileTimings(timings) {
|
|
|
10197
10265
|
// aF — aF was already snapshotted into finalFindings at dedupeFindingsWithEvidence
|
|
10198
10266
|
// above, so pushing into aF here silently discards every result.
|
|
10199
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(_){}
|
|
10200
10270
|
// Deployment-platform security checklist
|
|
10201
10271
|
try{const dpf=scanDeployPlatform(scanRoot);aLogic.push(...dpf);}catch(_){}
|
|
10202
10272
|
// Stack-specific security playbook
|
|
@@ -10412,7 +10482,7 @@ function _deterministicFileTimings(timings) {
|
|
|
10412
10482
|
// SBOM diff — drift detection across releases.
|
|
10413
10483
|
if (process.env.AGENTIC_SECURITY_NO_SBOM_DIFF !== '1') {
|
|
10414
10484
|
try {
|
|
10415
|
-
_sbomDiff = runSbomDiff(scanRoot, annotatedComponents || []);
|
|
10485
|
+
_sbomDiff = runSbomDiff(scanRoot, [...(annotatedComponents || []), ...((_languageBom && _languageBom.components) || [])]);
|
|
10416
10486
|
if (_sbomDiff && Array.isArray(_sbomDiff.findings)) finalFindings.push(..._sbomDiff.findings);
|
|
10417
10487
|
} catch (_) {}
|
|
10418
10488
|
}
|
|
@@ -10449,14 +10519,18 @@ function _deterministicFileTimings(timings) {
|
|
|
10449
10519
|
if (process.env.AGENTIC_SECURITY_NO_LICENSE_GRAPH !== '1') {
|
|
10450
10520
|
try {
|
|
10451
10521
|
const lgPolicy = loadLicenseGraphPolicy(scanRoot);
|
|
10452
|
-
|
|
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);
|
|
10453
10527
|
if (_licenseGraph && Array.isArray(_licenseGraph.findings)) finalFindings.push(..._licenseGraph.findings);
|
|
10454
10528
|
} catch (_) {}
|
|
10455
10529
|
}
|
|
10456
10530
|
// Attributions: emit ATTRIBUTIONS.md (and NOTICE if Apache deps present).
|
|
10457
10531
|
if (process.env.AGENTIC_SECURITY_NO_ATTRIBUTIONS !== '1') {
|
|
10458
10532
|
try {
|
|
10459
|
-
_attributions = generateAttributions(annotatedComponents || []);
|
|
10533
|
+
_attributions = generateAttributions([...(annotatedComponents || []), ...((_languageBom && _languageBom.components) || [])]);
|
|
10460
10534
|
if (_attributions && _attributions.componentCount) persistAttributions(scanRoot, _attributions);
|
|
10461
10535
|
} catch (_) {}
|
|
10462
10536
|
}
|
|
@@ -10467,7 +10541,7 @@ function _deterministicFileTimings(timings) {
|
|
|
10467
10541
|
// .json 03.04.10, nist-ai-600-1.json MG-4.1-001) could never clear.
|
|
10468
10542
|
if (process.env.AGENTIC_SECURITY_NO_AIBOM !== '1') {
|
|
10469
10543
|
try {
|
|
10470
|
-
const _aibom = buildAIBOM({ components: annotatedComponents || [] }, fc, {});
|
|
10544
|
+
const _aibom = buildAIBOM({ components: annotatedComponents || [] }, fc, { manifests: depFileContents });
|
|
10471
10545
|
if (_aibom && (_aibom.models.length || _aibom.promptTemplates.length || _aibom.frameworks.length)) {
|
|
10472
10546
|
persistAIBOM(scanRoot, _aibom);
|
|
10473
10547
|
}
|
|
@@ -10718,6 +10792,14 @@ function _deterministicFileTimings(timings) {
|
|
|
10718
10792
|
// exists on dependency COMPONENTS but was never carried onto the
|
|
10719
10793
|
// vulnerable_dep entries, so the negation was true for every entry and the
|
|
10720
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
|
+
}
|
|
10721
10803
|
const directDeps = (supplyChain || []).filter((s) => s && s.type === 'vulnerable_dep' && s.isDirect);
|
|
10722
10804
|
// `resolveDirectSCAOrigin`/`scaStableId` key on `filePath`; the vulnerable_dep
|
|
10723
10805
|
// entries built above carry the manifest path as `file` (report/index.js
|
|
@@ -10913,6 +10995,18 @@ function _deterministicFileTimings(timings) {
|
|
|
10913
10995
|
// (_rootCauseSweep/_proofCoverage/_coverageLedger/_scanHealth are hoisted
|
|
10914
10996
|
// above the `skipAnnotators` guard — FR-PROV-029.)
|
|
10915
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 */ }
|
|
10916
11010
|
// PRD F7.2: publish what CANNOT be proven alongside what can. A proof RATE
|
|
10917
11011
|
// computed over the provable subset makes a narrow subset look like strength;
|
|
10918
11012
|
// the three-bucket split (provable / declined-on-purpose / not-yet-classified)
|
|
@@ -10928,6 +11022,31 @@ function _deterministicFileTimings(timings) {
|
|
|
10928
11022
|
// summary, computed from signals the engine already collects.
|
|
10929
11023
|
// `analyzers` was `null` (see pipeline/scan-health.js's prior comment)
|
|
10930
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)}`] }; }
|
|
10931
11050
|
_scanHealth = computeScanHealth({
|
|
10932
11051
|
scanMeta: _scanMeta,
|
|
10933
11052
|
annotatorErrors: _annotatorErrors,
|
|
@@ -10935,6 +11054,8 @@ function _deterministicFileTimings(timings) {
|
|
|
10935
11054
|
deepStatus: _deepStatus,
|
|
10936
11055
|
analyzerCoverage: summarizeCoverageForScanHealth(_coverageLedger),
|
|
10937
11056
|
lineageStatus: _lineageStatus,
|
|
11057
|
+
languageCoverage: _languageCoverage,
|
|
11058
|
+
supplyChain,
|
|
10938
11059
|
});
|
|
10939
11060
|
// FR-207: stale vulnerability feeds, calibration data, and compliance
|
|
10940
11061
|
// evidence are real assurance gaps, not just findings the feed omits --
|
|
@@ -10950,7 +11071,7 @@ function _deterministicFileTimings(timings) {
|
|
|
10950
11071
|
compliance: _complianceReport ? { stale: _complianceReport.summary?.stale || 0 } : null,
|
|
10951
11072
|
});
|
|
10952
11073
|
} // end if (!skipAnnotators) — FR-PROV-029
|
|
10953
|
-
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};}
|
|
10954
11075
|
|
|
10955
11076
|
// Post-aggregation classification: every source becomes "unsafe"|"safe"; every sink becomes "confirmed"|"safe".
|
|
10956
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
|
+
}
|