@clear-capabilities/agentic-security-scanner 0.157.2 → 0.158.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.
@@ -1 +1 @@
1
- 56d856abb0b6dc7e1c61df986d0c52e0dddf7e196617f21a370f31f32b5f2977 agentic-security.mjs
1
+ 18b81da52b654c93c6240dfc2909e049da0accb60cd1b2a8dca439d05cca7fd9 agentic-security.mjs
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clear-capabilities/agentic-security-scanner",
3
- "version": "0.157.2",
3
+ "version": "0.158.0",
4
4
  "description": "Scanner engine for the agentic-security Claude Code plugin: SAST, SCA with reachability and CISA KEV, secrets, IaC, Haskell and Nix/NixOS, prompt-injection, MCP/agent-tool audit, authZ analysis, SBOM, compliance attestation, and more.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -79,9 +79,9 @@
79
79
  "test:lineage": "node --test test/lineage/schema.test.js test/lineage/ids.test.js test/lineage/protection.test.js test/lineage/classification.test.js test/lineage/validate.test.js test/lineage/json-schema-parity.test.js test/lineage/flagship-fixture.test.js test/lineage/flagship-fixture-semantics.test.js test/lineage/field-identity.test.js test/lineage/engine-expr-resolver.test.js test/lineage/engine-walker.test.js test/lineage/engine-integration.test.js test/lineage/engine-receiver-identity.test.js test/lineage/summaries.test.js test/lineage/driver.test.js test/lineage/engine-provenance.test.js test/lineage/engine-provenance-interprocedural.test.js test/lineage/path-store.test.js test/lineage/path-query.test.js test/lineage/flow-grade.test.js test/lineage/source-registry.test.js test/lineage/sink-registry.test.js test/lineage/transform-catalog.test.js test/lineage/handling-analyzer.test.js test/lineage/registry-real-code.test.js test/lineage/source-seeding.test.js test/lineage/graph-builder.test.js test/lineage/coverage.test.js test/lineage/resolve-destination.test.js test/lineage/queue-detail.test.js test/lineage/index.test.js test/lineage/obligation-mapping.test.js test/lineage/obligation-predicates.test.js test/lineage/obligation-predicates-walkthrough.test.js test/lineage/transit-protection.test.js test/lineage/at-rest-protection.test.js test/lineage/protection-summary.test.js test/lineage/ac01-multi-sink.test.js test/lineage/policy-verdict.test.js test/lineage/governance-refs.test.js test/lineage/export-privacy.test.js test/lineage/export-json.test.js test/lineage/export-csv.test.js test/lineage/decision-story.test.js test/lineage/export-briefing.test.js test/lineage/graph-snapshot.test.js test/lineage/graph-diff.test.js test/lineage/drift-policy.test.js test/lineage/recipient-profile.test.js test/lineage/recipient-registry.test.js test/lineage/recipient-wiring.test.js test/lineage/redact-graph.test.js test/lineage-scan-wiring.test.js test/lineage-artifact-write.test.js test/bench-data-lineage-runner.test.js test/bundle-frontend.test.js test/bundle-frontend-golden.test.js test/generate-html-report.test.js test/export-image.test.js test/lineage/scenario.test.js test/lineage/scenario-engine.test.js test/lineage/scenario-diff.test.js test/lineage/scenario-no-obligation-wiring.test.js test/lineage/impact-assessment.test.js test/lineage/impact-engine.test.js test/lineage/impact-no-obligation-wiring.test.js test/lineage/governance-edit.test.js test/lineage/remediation.test.js test/lineage/runtime-observation.test.js test/lineage/observation-correlation.test.js test/lineage/observation-adapters.test.js test/lineage/observation-store.test.js test/lineage/runtime-corroboration-wiring.test.js test/lineage/cross-repo-link.test.js test/lineage/federation-loader.test.js test/lineage/cross-repo-link-wiring.test.js",
80
80
  "test:bench-modules": "node --test test/phase4-harness.test.js test/pipeline.test.js test/proof-corpus-lib.test.js test/proof-corpus-runner.test.js",
81
81
  "test:server": "node --test test/server/graph-loader.test.js test/server/security.test.js test/server/routes.test.js test/server/http-server.test.js test/server/cmd-explore.test.js test/server/cmd-dataflow-export.test.js test/server/static-assets.test.js test/server/dataflow-scenario-cli.test.js test/server/dataflow-impact-cli.test.js",
82
- "test:haskell": "node --test test/haskell/haskell-injection.test.js test/haskell/haskell-ir.test.js test/haskell/haskell-manifests.test.js test/haskell/haskell-parser.test.js test/haskell/haskell-remediation.test.js test/haskell/haskell-resolved-graph.test.js test/haskell/haskell-sca.test.js test/haskell/haskell-security-rules.test.js test/haskell/haskell-taint.test.js",
83
- "test:nix": "node --test test/nix/nix-build-trust.test.js test/nix/nix-eval-isolation.test.js test/nix/nix-input-inventory.test.js test/nix/nix-parser-ir.test.js test/nix/nix-remediation.test.js test/nix/nix-resolved-closure.test.js test/nix/nix-sca-patches.test.js test/nix/nix-script-taint.test.js test/nix/nix-secrets-store.test.js test/nix/nixos-hardening.test.js test/nix/nixos-module-resolution.test.js",
84
- "test:language": "node --test test/language/capability-ledger.test.js test/language/language-accuracy-regression.test.js test/language/language-aibom.test.js test/language/language-assurance.test.js test/language/language-bom.test.js test/language/language-compliance.test.js test/language/language-contracts.test.js test/language/language-corpora-integrity.test.js test/language/language-discovery.test.js test/language/language-doc-coverage.test.js test/language/language-evidence-posture.test.js test/language/language-explorer.test.js test/language/language-gap-fixes.test.js test/language/language-governance.test.js test/language/language-integrations.test.js test/language/language-llm-agent.test.js test/language/language-local-ai.test.js test/language/language-polyglot-bridges.test.js test/language/language-privacy-lineage.test.js test/language/language-proof-remediation.test.js test/language/language-report-formats.test.js test/language/language-secrets.test.js test/language/language-trust-boundary.test.js",
82
+ "test:haskell": "node --test test/haskell/haskell-injection.test.js test/haskell/haskell-ir.test.js test/haskell/haskell-manifests.test.js test/haskell/haskell-parser.test.js test/haskell/haskell-remediation.test.js test/haskell/haskell-resolved-graph.test.js test/haskell/haskell-sca.test.js test/haskell/haskell-advisory-feed.test.js test/haskell/haskell-security-rules.test.js test/haskell/haskell-taint.test.js",
83
+ "test:nix": "node --test test/nix/nix-build-trust.test.js test/nix/nix-eval-isolation.test.js test/nix/nix-input-inventory.test.js test/nix/nix-parser-ir.test.js test/nix/nix-remediation.test.js test/nix/nix-resolved-closure.test.js test/nix/nix-sca-patches.test.js test/nix/nix-haskell-closure-names.test.js test/nix/nix-script-taint.test.js test/nix/nix-secrets-store.test.js test/nix/nixos-hardening.test.js test/nix/nixos-module-resolution.test.js",
84
+ "test:language": "node --test test/language/capability-ledger.test.js test/language/language-accuracy-regression.test.js test/language/language-aibom.test.js test/language/language-assurance.test.js test/language/language-bom.test.js test/language/language-compliance.test.js test/language/language-contracts.test.js test/language/language-corpora-integrity.test.js test/language/language-discovery.test.js test/language/language-doc-coverage.test.js test/language/language-evidence-posture.test.js test/language/language-explorer.test.js test/language/language-gap-fixes.test.js test/language/language-gap-fixes-2.test.js test/language/language-governance.test.js test/language/language-integrations.test.js test/language/language-llm-agent.test.js test/language/language-local-ai.test.js test/language/language-polyglot-bridges.test.js test/language/language-privacy-lineage.test.js test/language/language-proof-remediation.test.js test/language/language-report-formats.test.js test/language/language-secrets.test.js test/language/language-trust-boundary.test.js",
85
85
  "test:language-stress": "node --test --test-concurrency=1 test/language/language-stress-offline.test.js",
86
86
  "test:language-tools": "node --test test/haskell/haskell-web-auth.test.js test/haskell/haskell-support-gate.test.js",
87
87
  "bench:language-support:check": "node ../bench/language-support/check.mjs",
@@ -1148,6 +1148,7 @@ function _sinkFindingsForCall(calleeExpr, argExprs, cat, argTaints, state, callC
1148
1148
  // Haskell: two sinks of the same kind on one line (`if b then f (clean x) else f x`) are distinct
1149
1149
  // sites; without a site key they share an id and one silently replaces the other.
1150
1150
  ...(/\.(?:hs|lhs)$/i.test(_currentFile || '') ? { siteKey: _siteKey(taintedArgExpr) } : {}),
1151
+ ...(/\.(?:hs|lhs)$/i.test(_currentFile || '') && taintedArgExpr && taintedArgExpr.kind === 'call' ? { _argCallee: taintedArgExpr.callee } : {}),
1151
1152
  kind: 'taint',
1152
1153
  sinkId: e.id,
1153
1154
  vuln: e.vuln?.name || 'Tainted Sink',
@@ -1783,6 +1784,26 @@ function step(node, stateIn, callContext) {
1783
1784
  // state, never into the SummaryCache key (that stays exactly what the caller
1784
1785
  // supplied). Returns the ORIGINAL Set unchanged when there's nothing to add,
1785
1786
  // so callers that never touch annotation-shaped params pay zero extra cost.
1787
+ // Haskell: a sink whose tainted argument IS a call to another sink of the same CWE on the same line is one flaw reported twice
1788
+ // (`readCreateProcess (shell cmd)`: `shell` builds the command, `readCreateProcess` runs it). The INNER sink is kept, because it is
1789
+ // where the tainted text enters the command; the outer one is recorded on it, never dropped silently. Only a nested pair collapses:
1790
+ // two independent sinks on one line (`if b then f (clean x) else f x`) have different arguments and stay two findings, so a
1791
+ // discharged sink still cannot absorb its unprotected sibling.
1792
+ function _collapseNestedHsSinks(all) {
1793
+ const base = (c) => String(c && typeof c === 'object' ? (c.name || c.text || '') : (c ?? '')).split('.').pop();
1794
+ for (let i = all.length - 1; i >= 0; i--) {
1795
+ const f = all[i];
1796
+ if (f._argCallee === undefined || !/\.(?:hs|lhs)$/i.test(f.file || '')) continue;
1797
+ const want = base(f._argCallee);
1798
+ const inner = want && all.find((g) => g !== f && g.file === f.file && g.line === f.line && g.cwe === f.cwe && g.callee !== undefined && base(g.callee) === want);
1799
+ if (!inner) continue;
1800
+ inner.dedupedVulns = [...new Set([...(inner.dedupedVulns || []), f.vuln])];
1801
+ inner.alsoSink = [...new Set([...(inner.alsoSink || []), base(f.callee)])];
1802
+ all.splice(i, 1);
1803
+ }
1804
+ for (const f of all) if (f._argCallee !== undefined) delete f._argCallee;
1805
+ }
1806
+
1786
1807
  // Short stable fingerprint of an argument expression (ignores line numbers and IR bookkeeping).
1787
1808
  function _siteKey(expr) {
1788
1809
  let h = 5381;
@@ -2575,6 +2596,7 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
2575
2596
  if (dg[f.severity]) f.severity = dg[f.severity];
2576
2597
  }
2577
2598
  Object.defineProperty(all, '_summaryCache', { value: summaryCache, enumerable: false });
2599
+ _collapseNestedHsSinks(all);
2578
2600
  return all;
2579
2601
 
2580
2602
  // Dedup + map a raw findings array (from analyzeFunction's callContext)
@@ -2654,6 +2676,7 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
2654
2676
  // rebuilding the SMT path-feasibility mechanism (next-gen taint
2655
2677
  // capability #5) to depend on a REAL slice for a sound proof.
2656
2678
  ...(f.argIndex !== undefined ? { argIndex: f.argIndex } : {}),
2679
+ ...(f._argCallee !== undefined ? { _argCallee: f._argCallee } : {}),
2657
2680
  // callee: kept as plain `callee` (not underscore-prefixed) to match
2658
2681
  // backward.js's own contract, which reads `f.callee` on both real and
2659
2682
  // fake-fixture findings throughout its module and test suite. It is
package/src/engine.js CHANGED
@@ -9228,6 +9228,7 @@ function _deterministicFileTimings(timings) {
9228
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
9229
  // X-010: Hackage and Nix components, dependency edges and target provenance. Kept beside (not inside) the ordinary
9230
9230
  // component list so the existing consumers are untouched; the BOM emitters and the SBOM diff read it explicitly.
9231
+ try{if(process.env.AGENTIC_SECURITY_HACKAGE_ADVISORIES_LIVE==='1'){const{prefetchHackageFeed}=await import('./language/haskell-supply.js');const{resolvedHackageComponents,nixHaskellPackageNames}=await import('./language/resolved-pass.js');let _rc=[],_nx=[];try{_rc=resolvedHackageComponents(allFileContents).components||[];}catch(_){}try{_nx=nixHaskellPackageNames(allFileContents);}catch(_){}await prefetchHackageFeed(allFileContents,{resolvedComponents:_rc,extraNames:_nx});}}catch(_){/* the live feed is optional: a failure leaves the scan on whatever snapshot exists */}
9231
9232
  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&&!_nc.refused.length?{closure:_nc.closure}:{})});}}catch(_){_languageBom=null;}
9232
9233
  aF.push(...(runDetector(_detectorErrors,'<project>','scanDbTaintCrossFile',()=>scanDbTaintCrossFile(fc))||[]));
9233
9234
  aF.push(...(runDetector(_detectorErrors,'<project>','scanCsharpCrossFile',()=>scanCsharpCrossFile(fc))||[]));
@@ -26,6 +26,11 @@ or a capability.** Product-facing guides: `docs/guides/haskell.md`, `docs/guides
26
26
  outside the project root is refused (`fix-lifecycle.js` `isRootRelativePath`).
27
27
  - **Secrets never leave redacted.** `secrets.js` `redactLanguageSecrets` joins split literals before masking; egress and model
28
28
  prompts use it. Credential values are never written to any output or state file.
29
+ - **The advisory feed is opt-in and records coverage.** `haskell-advisory-feed.js` touches the network only when
30
+ `AGENTIC_SECURITY_HACKAGE_ADVISORIES_LIVE=1` and never under `AGENTIC_SECURITY_OFFLINE`. Its snapshot lists the packages it actually
31
+ queried (`covered`); a package it did not cover is `feed-incomplete` and a stale one `feed-stale`, in BOTH matchers
32
+ (`evaluateComponents` and `nix-sca.js`). Any new consumer of an `AdvisoryDb` must check `db.coverage(name)` before saying "no advisory".
33
+ The snapshot lives in the operator configuration directory, never the project (`trusted-inputs.js`).
29
34
  - **Pragma** (`pragma.js`): comment-aware, line-scoped, exact rule-id match, logged. A line-less finding cannot be suppressed.
30
35
 
31
36
  ## Module map
@@ -33,7 +38,7 @@ or a capability.** Product-facing guides: `docs/guides/haskell.md`, `docs/guides
33
38
  | Group | Files |
34
39
  |---|---|
35
40
  | 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` |
41
+ | 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-advisory-feed.js` (the opt-in live Hackage feed: OSV fetch, per-package coverage, operator-only snapshot), `haskell-supply.js`, `haskell-fix.js` |
37
42
  | 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
43
  | Measurement | `accuracy.js` (family-scoped scoring, per-layer), `support-registry.js` (status rules, promotion refusals, frozen-hash demotion) |
39
44
 
@@ -0,0 +1,179 @@
1
+ // Live Hackage advisory feed (HS-009 follow-on). The matching logic in haskell-sca.js takes records from its caller and never fetches;
2
+ // this module is the caller that can. It turns the OSV `Hackage` ecosystem (HSEC advisories and their aliases) into the same
3
+ // hash-recorded snapshot the loader already verifies, and writes it where only the operator can: the per-user configuration
4
+ // directory. A scanned project cannot supply or alter it (see trusted-inputs.js).
5
+ //
6
+ // Contract, in the order it matters:
7
+ // * Opt in. Nothing here touches the network unless AGENTIC_SECURITY_HACKAGE_ADVISORIES_LIVE=1, and never when
8
+ // AGENTIC_SECURITY_OFFLINE=1. An operator-pinned snapshot (AGENTIC_SECURITY_HACKAGE_ADVISORIES) always wins: it is not refreshed over.
9
+ // * Coverage is recorded, not assumed. The snapshot lists each package it actually queried and when. A package that was not queried,
10
+ // or whose query failed part-way, is NOT covered, and the matcher reports it `feed-incomplete` (unknown), never `no-advisories` (clean).
11
+ // * Failure degrades, it does not abort. An unreachable feed leaves the previous snapshot in place and the scan runs on it, with its age
12
+ // disclosed; with no previous snapshot the scan reports `feed-unavailable`, exactly as before this module existed.
13
+ // * Everything from the wire is untrusted: ids and package names are validated before they are placed in a URL or a path, sizes are
14
+ // bounded, and a record that does not parse as an OSV record is dropped and counted.
15
+
16
+ import { mkdirSync, readFileSync, writeFileSync, renameSync, chmodSync } from 'node:fs';
17
+ import { join } from 'node:path';
18
+ import { buildSnapshot, loadAdvisorySnapshot } from './haskell-sca.js';
19
+ import { operatorConfigDir } from './trusted-inputs.js';
20
+
21
+ export const FEED_ENV = 'AGENTIC_SECURITY_HACKAGE_ADVISORIES_LIVE';
22
+ export const PINNED_ENV = 'AGENTIC_SECURITY_HACKAGE_ADVISORIES';
23
+ export const SNAPSHOT_FILE = 'hackage-advisories.json';
24
+ export const OSV_BASE = 'https://api.osv.dev';
25
+
26
+ const DEFAULTS = Object.freeze({
27
+ ttlMs: 24 * 3600 * 1000, // a covered package is not queried again inside this window
28
+ timeoutMs: 8000,
29
+ concurrency: 10,
30
+ batchSize: 500,
31
+ maxPackages: 500,
32
+ maxRecordBytes: 1 << 20,
33
+ maxSnapshotRecords: 20000,
34
+ });
35
+
36
+ const NAME_OK = /^[A-Za-z0-9][A-Za-z0-9-]{0,127}$/; // Hackage package names
37
+ const ID_OK = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,95}$/; // HSEC-2023-0001, GHSA-..., CVE-...
38
+
39
+ let last = null;
40
+ /** What the most recent refresh in this process did, so a "no feed" reason can say why. */
41
+ export const getLastRefresh = () => last;
42
+ export const _resetLastRefresh = () => { last = null; };
43
+
44
+ export function liveFeedEnabled(env = process.env) {
45
+ return env[FEED_ENV] === '1' && env.AGENTIC_SECURITY_OFFLINE !== '1';
46
+ }
47
+
48
+ export function snapshotPath(env = process.env, dir = null) {
49
+ return join(dir || operatorConfigDir(env), SNAPSHOT_FILE);
50
+ }
51
+
52
+ function readCurrent(path, maxAgeDays) {
53
+ let raw;
54
+ try { raw = JSON.parse(readFileSync(path, 'utf8')); } catch { return { records: new Map(), covered: {}, problem: null }; }
55
+ // Integrity first: a cache whose records do not match their own hashes is discarded, not repaired.
56
+ const r = loadAdvisorySnapshot(raw, { maxAgeDays });
57
+ if (!r.ok) return { records: new Map(), covered: {}, problem: `the cached snapshot was discarded: ${r.reason}` };
58
+ const covered = raw.covered && typeof raw.covered === 'object' ? { ...raw.covered } : {};
59
+ return { records: new Map(raw.records.map((x) => [x.id, x])), covered, problem: null };
60
+ }
61
+
62
+ async function getJson(fetchImpl, url, init, timeoutMs, maxBytes) {
63
+ const resp = await fetchImpl(url, { ...init, signal: AbortSignal.timeout(timeoutMs) });
64
+ if (!resp || resp.status !== 200) throw new Error(`HTTP ${resp ? resp.status : 'none'}`);
65
+ const text = await resp.text();
66
+ if (Buffer.byteLength(text) > maxBytes) throw new Error('response too large');
67
+ return JSON.parse(text);
68
+ }
69
+
70
+ async function pool(items, limit, worker) {
71
+ const out = new Array(items.length);
72
+ let i = 0;
73
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async () => {
74
+ while (i < items.length) { const k = i++; out[k] = await worker(items[k], k); }
75
+ }));
76
+ return out;
77
+ }
78
+
79
+ /**
80
+ * Refresh the operator's Hackage advisory snapshot for the given package names.
81
+ * Never throws. Returns a plain result describing what happened; the snapshot file is only rewritten when something changed.
82
+ *
83
+ * @param {string[]} names Hackage package names the scan is about to evaluate
84
+ * @param {{env?: object, fetchImpl?: Function, now?: number, dir?: string, ttlMs?: number, timeoutMs?: number}} [opts]
85
+ */
86
+ export async function refreshHackageAdvisories(names, opts = {}) {
87
+ const env = opts.env || process.env;
88
+ const cfg = { ...DEFAULTS, ...Object.fromEntries(Object.entries(opts).filter(([k]) => k in DEFAULTS)) };
89
+ const now = opts.now ?? Date.now();
90
+ const done = (r) => { last = { ...r, at: new Date(now).toISOString() }; return last; };
91
+
92
+ if (env[PINNED_ENV]) return done({ status: 'pinned-snapshot-in-use', detail: `${PINNED_ENV} names an operator snapshot; it is used as given and is not refreshed` });
93
+ if (env[FEED_ENV] !== '1') return done({ status: 'disabled', detail: `set ${FEED_ENV}=1 to fetch Hackage advisories from the OSV feed` });
94
+ if (env.AGENTIC_SECURITY_OFFLINE === '1') return done({ status: 'offline', detail: 'AGENTIC_SECURITY_OFFLINE=1: no advisory data was fetched' });
95
+
96
+ const wanted = [...new Set((names || []).filter((n) => typeof n === 'string' && NAME_OK.test(n)))].sort().slice(0, cfg.maxPackages);
97
+ if (!wanted.length) return done({ status: 'nothing-to-do', detail: 'no Hackage packages to look up' });
98
+
99
+ const path = snapshotPath(env, opts.dir);
100
+ const cur = readCurrent(path, 36500);
101
+ const need = wanted.filter((n) => { const t = Date.parse(cur.covered[n]); return !Number.isFinite(t) || now - t > cfg.ttlMs || t > now + 3600000; });
102
+ if (!need.length) return done({ status: 'current', detail: `${wanted.length} package(s) covered within the last ${Math.round(cfg.ttlMs / 3600000)}h; no request made`, packages: wanted.length, requested: 0 });
103
+
104
+ const fetchImpl = opts.fetchImpl || globalThis.fetch;
105
+ if (typeof fetchImpl !== 'function') return done({ status: 'failed', detail: 'no fetch implementation is available in this runtime' });
106
+
107
+ const failures = [];
108
+ const ok = new Set(); // packages whose whole answer was obtained
109
+ const refs = new Map(); // package -> [{id, modified}]
110
+ // 1. which advisories does OSV hold for each package?
111
+ for (let i = 0; i < need.length; i += cfg.batchSize) {
112
+ const chunk = need.slice(i, i + cfg.batchSize);
113
+ try {
114
+ const data = await getJson(fetchImpl, `${OSV_BASE}/v1/querybatch`, {
115
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
116
+ body: JSON.stringify({ queries: chunk.map((name) => ({ package: { name, ecosystem: 'Hackage' } })) }),
117
+ }, cfg.timeoutMs, 8 << 20);
118
+ const results = Array.isArray(data && data.results) ? data.results : [];
119
+ chunk.forEach((name, k) => {
120
+ const res = results[k];
121
+ if (!res || typeof res !== 'object') { failures.push(`${name}: no result`); return; }
122
+ if (res.next_page_token) { failures.push(`${name}: more advisories than one page; not treated as covered`); return; }
123
+ refs.set(name, (Array.isArray(res.vulns) ? res.vulns : []).filter((v) => v && typeof v.id === 'string' && ID_OK.test(v.id)).map((v) => ({ id: v.id, modified: v.modified || null })));
124
+ });
125
+ } catch (e) { for (const n of chunk) failures.push(`${n}: ${String((e && e.message) || e).slice(0, 80)}`); }
126
+ }
127
+
128
+ // 2. fetch the records that are new or newer than the cached copy
129
+ const toFetch = new Map();
130
+ for (const list of refs.values()) for (const { id, modified } of list) {
131
+ const have = cur.records.get(id);
132
+ if (!have || (modified && have.modified && Date.parse(modified) > Date.parse(have.modified)) || (modified && !have.modified)) toFetch.set(id, modified);
133
+ }
134
+ const fetched = new Map();
135
+ const badIds = new Set();
136
+ await pool([...toFetch.keys()], cfg.concurrency, async (id) => {
137
+ try {
138
+ const rec = await getJson(fetchImpl, `${OSV_BASE}/v1/vulns/${encodeURIComponent(id)}`, {}, cfg.timeoutMs, cfg.maxRecordBytes);
139
+ if (!rec || typeof rec !== 'object' || rec.id !== id) throw new Error('not the requested record');
140
+ fetched.set(id, rec);
141
+ } catch (e) { badIds.add(id); failures.push(`${id}: ${String((e && e.message) || e).slice(0, 80)}`); }
142
+ });
143
+
144
+ // 3. a package is covered only if every advisory it references is in hand
145
+ const records = new Map(cur.records);
146
+ for (const [id, rec] of fetched) records.set(id, rec);
147
+ const covered = { ...cur.covered };
148
+ const iso = new Date(now).toISOString();
149
+ let newlyCovered = 0;
150
+ for (const [name, list] of refs) {
151
+ if (list.some((v) => badIds.has(v.id) || !records.has(v.id))) continue;
152
+ covered[name] = iso; ok.add(name); newlyCovered++;
153
+ }
154
+ const uncovered = need.filter((n) => !ok.has(n));
155
+
156
+ if (!ok.size && !fetched.size) {
157
+ return done({ status: 'failed', detail: `the OSV feed could not be read (${failures.slice(0, 2).join('; ') || 'no response'}); ${cur.records.size ? 'the previous snapshot is used and its age is disclosed' : 'no advisory data is available'}`, packages: wanted.length, requested: need.length, uncovered, failures: failures.slice(0, 10), cacheProblem: cur.problem });
158
+ }
159
+ if (records.size > cfg.maxSnapshotRecords) return done({ status: 'failed', detail: `the snapshot would exceed ${cfg.maxSnapshotRecords} records; refusing to write it`, packages: wanted.length, requested: need.length, uncovered });
160
+
161
+ // 4. write the snapshot atomically, readable by the operator only
162
+ const snap = buildSnapshot([...records.values()].sort((a, b) => (a.id < b.id ? -1 : 1)), iso);
163
+ snap.covered = Object.fromEntries(Object.entries(covered).sort(([a], [b]) => (a < b ? -1 : 1)));
164
+ try {
165
+ const dir = opts.dir || operatorConfigDir(env);
166
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
167
+ const tmp = `${path}.${process.pid}.tmp`;
168
+ writeFileSync(tmp, JSON.stringify(snap), { mode: 0o600 });
169
+ try { chmodSync(tmp, 0o600); } catch { /* best effort on filesystems without modes */ }
170
+ renameSync(tmp, path);
171
+ } catch (e) {
172
+ return done({ status: 'failed', detail: `the snapshot could not be written: ${e.code || e.message}`, packages: wanted.length, requested: need.length, uncovered });
173
+ }
174
+ return done({
175
+ status: uncovered.length ? 'partial' : 'refreshed',
176
+ detail: `${newlyCovered} package(s) covered, ${fetched.size} advisory record(s) fetched${uncovered.length ? `; ${uncovered.length} package(s) NOT covered (${uncovered.slice(0, 3).join(', ')}${uncovered.length > 3 ? ', ...' : ''}) and will be reported as unknown` : ''}`,
177
+ packages: wanted.length, requested: need.length, fetched: fetched.size, records: records.size, uncovered, failures: failures.slice(0, 10), path, cacheProblem: cur.problem,
178
+ });
179
+ }
@@ -40,7 +40,7 @@ export const HS_PACKAGES = Object.freeze({
40
40
  yesod: { modules: ['Yesod.Core', 'Yesod'], tested: ['1.6'] },
41
41
  'http-client': { modules: ['Network.HTTP.Client', 'Network.HTTP.Client.TLS'], tested: ['0.7'] },
42
42
  'http-conduit': { modules: ['Network.HTTP.Simple', 'Network.HTTP.Conduit'], tested: ['2.3'] },
43
- wreq: { modules: ['Network.Wreq'], tested: ['0.5'] },
43
+ wreq: { modules: ['Network.Wreq', 'Network.Wreq.Session'], tested: ['0.5'] },
44
44
  'blaze-html': { modules: ['Text.Blaze', 'Text.Blaze.Html', 'Text.Blaze.Html5', 'Text.Blaze.Internal'], tested: ['0.9'] },
45
45
  lucid: { modules: ['Lucid', 'Lucid.Base'], tested: ['2.11'] },
46
46
  shakespeare: { modules: ['Text.Hamlet', 'Text.Shakespeare'], tested: ['2.0'] },
@@ -127,7 +127,13 @@ sink('Network.HTTP.Simple', ['httpBS', 'httpLBS', 'httpJSON', 'httpNoBody', 'htt
127
127
  sink('Network.HTTP.Conduit', ['simpleHttp', 'parseRequest', 'parseUrlThrow', 'parseRequest_', 'httpLbs'], { ...SSRF, package: 'http-conduit' });
128
128
  sink('Network.HTTP.Client', ['parseRequest', 'parseUrlThrow', 'parseRequest_', 'requestFromURI'], { ...SSRF, package: 'http-client' });
129
129
  sink('Network.HTTP.Client', ['httpLbs', 'httpNoBody', 'withResponse', 'responseOpen'], { ...SSRF, argIndex: 0, package: 'http-client' });
130
- sink('Network.Wreq', ['get', 'post', 'put', 'delete', 'head', 'getWith', 'postWith', 'putWith'], { ...SSRF, argIndex: 0, package: 'wreq' });
130
+ // wreq: where the URL sits depends on the function. `get url`, but `getWith opts url` (the first argument is the Options, not the target),
131
+ // `customMethod method url`, `customMethodWith method opts url`; the Session forms take the session before the URL.
132
+ sink('Network.Wreq', ['get', 'post', 'put', 'delete', 'head', 'options', 'patch'], { ...SSRF, argIndex: 0, package: 'wreq' });
133
+ sink('Network.Wreq', ['getWith', 'postWith', 'putWith', 'deleteWith', 'headWith', 'optionsWith', 'patchWith', 'customMethod', 'customPayloadMethod'], { ...SSRF, argIndex: 1, package: 'wreq' });
134
+ sink('Network.Wreq', ['customMethodWith', 'customPayloadMethodWith'], { ...SSRF, argIndex: 2, package: 'wreq' });
135
+ sink('Network.Wreq.Session', ['get', 'post', 'put', 'delete', 'head', 'options', 'patch'], { ...SSRF, argIndex: 1, package: 'wreq' });
136
+ sink('Network.Wreq.Session', ['getWith', 'postWith', 'putWith', 'deleteWith', 'headWith', 'optionsWith', 'patchWith'], { ...SSRF, argIndex: 2, package: 'wreq' });
131
137
 
132
138
  // CWE-1427. Untrusted text placed in a model request body. The call is an ordinary HTTP body setter, so the
133
139
  // finding is kept only for a file that shows AI evidence (an AI endpoint, model literal or SDK import):
@@ -147,8 +147,12 @@ const fixedVersions = (ranges) => [...new Set((ranges || []).flatMap((r) => (r.e
147
147
 
148
148
  // ── advisory database (pinned snapshot / records) ────────────────────────────
149
149
  export class AdvisoryDb {
150
- constructor({ records = [], source = 'records', generatedAt = null, now = Date.now(), maxAgeDays = 30, integrity = null } = {}) {
150
+ constructor({ records = [], source = 'records', generatedAt = null, now = Date.now(), maxAgeDays = 30, integrity = null, covered = null } = {}) {
151
151
  this.source = source;
152
+ // `covered` is {package: ISO time it was last queried}. null means the snapshot makes no per-package claim (a hand-built or
153
+ // operator-pinned snapshot): every package is then treated as covered, as it always was.
154
+ this.covered = covered && typeof covered === 'object' ? covered : null;
155
+ this.now = now;
152
156
  this.generatedAt = generatedAt;
153
157
  this.integrity = integrity;
154
158
  this.problems = [];
@@ -167,6 +171,13 @@ export class AdvisoryDb {
167
171
  this.maxAgeDays = maxAgeDays;
168
172
  }
169
173
  forPackage(name) { return this.byPackage.get(name) || []; }
174
+ /** 'covered' | 'uncovered' | 'stale' for a package; always 'covered' when the snapshot makes no per-package claim. */
175
+ coverage(name) {
176
+ if (!this.covered) return 'covered';
177
+ const t = Date.parse(this.covered[name]);
178
+ if (!Number.isFinite(t)) return 'uncovered';
179
+ return (this.now - t) / 86400000 > this.maxAgeDays ? 'stale' : 'covered';
180
+ }
170
181
  }
171
182
 
172
183
  const sha256 = (b) => createHash('sha256').update(b).digest('hex');
@@ -185,7 +196,7 @@ export function loadAdvisorySnapshot(snapshot, { pinnedSha256 = null, signature
185
196
  if (!want) return refuse(`record ${rec.id} has no recorded hash`);
186
197
  if (sha256(JSON.stringify(rec)) !== want) return refuse(`record ${rec.id} does not match its recorded hash`);
187
198
  }
188
- const body = JSON.stringify({ schema: snapshot.schema, generatedAt: snapshot.generatedAt, records: snapshot.records, recordHashes: snapshot.recordHashes });
199
+ const body = snapshotBody(snapshot);
189
200
  const digest = sha256(body);
190
201
  if (pinnedSha256 && digest !== pinnedSha256) return refuse('snapshot hash does not match the pinned value');
191
202
  let signed = false;
@@ -197,7 +208,7 @@ export function loadAdvisorySnapshot(snapshot, { pinnedSha256 = null, signature
197
208
  signed = true;
198
209
  } catch (e) { return refuse(`signature check failed: ${e.message}`); }
199
210
  }
200
- const db = new AdvisoryDb({ records: snapshot.records, source: 'snapshot', generatedAt: snapshot.generatedAt, now, maxAgeDays, integrity: { sha256: digest, hashPinned: !!pinnedSha256, signed } });
211
+ const db = new AdvisoryDb({ records: snapshot.records, source: 'snapshot', generatedAt: snapshot.generatedAt, now, maxAgeDays, integrity: { sha256: digest, hashPinned: !!pinnedSha256, signed }, covered: snapshot.covered || null });
201
212
  return { ok: true, db, digest };
202
213
  }
203
214
 
@@ -205,7 +216,8 @@ export function buildSnapshot(records, generatedAt) {
205
216
  const recordHashes = Object.fromEntries(records.map((r) => [r.id, sha256(JSON.stringify(r))]));
206
217
  return { schema: 1, generatedAt, records, recordHashes };
207
218
  }
208
- export const snapshotBody = (s) => JSON.stringify({ schema: s.schema, generatedAt: s.generatedAt, records: s.records, recordHashes: s.recordHashes });
219
+ // `covered` joins the hashed body only when present, so a snapshot without it keeps the digest it always had.
220
+ export const snapshotBody = (s) => JSON.stringify({ schema: s.schema, generatedAt: s.generatedAt, records: s.records, recordHashes: s.recordHashes, ...(s.covered ? { covered: s.covered } : {}) });
209
221
 
210
222
  // ── matching ─────────────────────────────────────────────────────────────────
211
223
  /**
@@ -251,6 +263,8 @@ export function evaluateComponents(components, db, opts = {}) {
251
263
  for (const comp of components) {
252
264
  const base = { name: comp.name, version: comp.version || null, declaredRange: comp.declaredRange || null, scope: comp.scope || null, target: comp.target || null, purl: hackagePurl(comp.name, comp.version || null) };
253
265
  if (!db) { statuses.push({ ...base, status: 'feed-unavailable' }); continue; }
266
+ const cov = db.coverage(comp.name);
267
+ if (cov !== 'covered') { statuses.push({ ...base, status: cov === 'stale' ? 'feed-stale' : 'feed-incomplete', reason: cov === 'stale' ? 'this package was last looked up longer ago than the feed age limit; the absence of advisories is not evidence' : 'the advisory feed never covered this package; the absence of advisories is not evidence' }); continue; }
254
268
  const hits = db.forPackage(comp.name);
255
269
  if (!hits.length) { statuses.push({ ...base, status: 'no-advisories', feed: feed.status }); continue; }
256
270
  let any = false;
@@ -26,7 +26,7 @@ export const RULES = Object.freeze({
26
26
  'hs-ecb-mode': { cwe: 'CWE-327', severity: 'high', family: 'weak-crypto', vuln: 'ECB block cipher mode', remediation: 'ECB leaks plaintext structure. Use an AEAD mode (AES-GCM) with a fresh nonce.' },
27
27
  'hs-static-iv': { cwe: 'CWE-329', severity: 'high', family: 'weak-crypto', vuln: 'Static IV or nonce', remediation: 'Generate a fresh random IV/nonce per message with `getRandomBytes`; never reuse one with the same key.' },
28
28
  'hs-predictable-random': { cwe: 'CWE-338', severity: 'high', family: 'weak-randomness', vuln: 'Predictable random number generator used for a security value', remediation: 'Use `Crypto.Random.getRandomBytes` or `System.Entropy.getEntropy` (OS CSPRNG). `System.Random` is a deterministic PRNG.' },
29
- 'hs-unbounded-request-body': { cwe: 'CWE-400', severity: 'medium', family: 'resource-exhaustion', vuln: 'Request body read without a size limit', remediation: 'Apply `requestSizeLimitMiddleware` (wai-extra) or read a bounded number of chunks with `getRequestBodyChunk`.' },
29
+ 'hs-unbounded-request-body': { cwe: 'CWE-770', severity: 'medium', family: 'resource-exhaustion', vuln: 'Request body read without a size limit', remediation: 'Apply `requestSizeLimitMiddleware` (wai-extra) or read a bounded number of chunks with `getRequestBodyChunk`.' },
30
30
  'hs-xml-entity-substitution': { cwe: 'CWE-611', severity: 'medium', family: 'xxe', vuln: 'XML parser configured to substitute DTD entities', remediation: 'Disable DTD entity substitution (`withSubstDTDEntities no`) for untrusted XML.' },
31
31
  'hs-sensitive-logging': { cwe: 'CWE-532', severity: 'high', family: 'sensitive-logging', vuln: 'Sensitive value written to a log or trace', remediation: 'Do not log credentials or secrets. Log an identifier, or redact the value before it reaches the logger.' },
32
32
  'hs-insecure-cookie': { cwe: 'CWE-1004', severity: 'medium', family: 'cookie-attributes', vuln: 'Session cookie without HttpOnly or Secure', remediation: 'Set `setCookieHttpOnly = True`, `setCookieSecure = True` and `setCookieSameSite = Just sameSiteStrict` (or Lax) on a session or token cookie.' },
@@ -6,6 +6,7 @@ import { readFileSync } from 'node:fs';
6
6
  import { join } from 'node:path';
7
7
  import { analyzeHaskellManifests } from './haskell-manifests.js';
8
8
  import { buildHaskellIR } from './haskell-ir.js';
9
+ import { getLastRefresh, refreshHackageAdvisories, liveFeedEnabled, FEED_ENV } from './haskell-advisory-feed.js';
9
10
  import { AdvisoryDb, loadAdvisorySnapshot, evaluateComponents, reachability, nearNameCandidates, sourceIntegrity, lifecycle, licensePolicy, hackagePurl, GHC_BOOT_PACKAGES } from './haskell-sca.js';
10
11
 
11
12
  const MANIFEST = /(?:^|\/)(?:[^/]+\.cabal|cabal\.project(?:\.[a-z]+)?|package\.yaml|stack\.yaml|stack\.yaml\.lock)$/;
@@ -61,7 +62,18 @@ function mergeResolved(declared, resolved) {
61
62
  }
62
63
 
63
64
  /** Advisory data configured for this scan: a hash-pinned snapshot from the environment or the project's state dir. */
65
+ /** The live-feed sentence appended to a "no advisory data" reason: what the refresh did, or how to turn it on. */
66
+ function liveFeedNote(env) {
67
+ const r = getLastRefresh();
68
+ if (r && r.status !== 'disabled') return ` Live feed: ${r.status}, ${r.detail}.`;
69
+ return env[FEED_ENV] === '1' ? '' : ` To fetch advisories from the OSV Hackage feed instead, set ${FEED_ENV}=1 (network, opt-in).`;
70
+ }
71
+
64
72
  export function configuredAdvisoryDb(root, env = process.env) {
73
+ const c = _configuredAdvisoryDb(root, env);
74
+ return c.db ? c : { ...c, reason: `${c.reason}${liveFeedNote(env)}` };
75
+ }
76
+ function _configuredAdvisoryDb(root, env = process.env) {
65
77
  const sel = resolveOperatorSnapshot({ envVar: 'AGENTIC_SECURITY_HACKAGE_ADVISORIES', fileName: 'hackage-advisories.json', root, env });
66
78
  if (!sel.path) return { db: null, reason: `no advisory snapshot configured (set AGENTIC_SECURITY_HACKAGE_ADVISORIES, or place hackage-advisories.json in the operator configuration directory, agentic-security under XDG_CONFIG_HOME)${sel.projectLocalIgnored ? IGNORED_NOTE('hackage-advisories.json') : ''}`, projectLocalIgnored: !!sel.projectLocalIgnored };
67
79
  let snap;
@@ -70,6 +82,20 @@ export function configuredAdvisoryDb(root, env = process.env) {
70
82
  return r.ok ? { db: r.db, reason: null, source: sel.source } : { db: null, reason: `advisory snapshot refused: ${r.reason}` };
71
83
  }
72
84
 
85
+ /**
86
+ * The async step before a scan's synchronous passes: when the live feed is enabled, make sure the operator snapshot covers every
87
+ * Hackage package this scan will evaluate (declared, frozen, transitive when a plan is present, and Haskell packages inside an imported Nix closure). Does nothing, and costs nothing
88
+ * (no manifest parsing), unless the feed is enabled. Never throws.
89
+ */
90
+ export async function prefetchHackageFeed(files, { env = process.env, resolvedComponents = [], extraNames = [], ...opts } = {}) {
91
+ if (!liveFeedEnabled(env)) return null;
92
+ try {
93
+ const names = [...hackageComponents(files).components, ...resolvedComponents].map((c) => c.name).concat(extraNames);
94
+ if (!names.length) return null;
95
+ return await refreshHackageAdvisories(names, { env, ...opts });
96
+ } catch (e) { return { status: 'failed', detail: `the feed step failed: ${String((e && e.message) || e).slice(0, 120)}` }; }
97
+ }
98
+
73
99
  /** Imports and import-qualified callees across the project's Haskell sources. */
74
100
  export function collectUsage(files) {
75
101
  const hs = {};
@@ -101,6 +127,8 @@ export function analyzeHaskellSupply(files, { db = undefined, root = null, env =
101
127
  out.feed = ev.feed;
102
128
  if (!db) out.gaps.push({ kind: 'advisory-feed-unavailable', detail: `${feedReason || ev.feed.detail}. ${components.length} Hackage dependencies were NOT checked against any advisory: the absence of findings is not a clean result.` });
103
129
  else if (ev.feed.status === 'stale-cache') out.gaps.push({ kind: 'advisory-feed-stale', detail: ev.feed.detail });
130
+ const notCovered = ev.statuses.filter((x) => x.status === 'feed-incomplete' || x.status === 'feed-stale');
131
+ if (db && notCovered.length) out.gaps.push({ kind: 'advisory-feed-incomplete', detail: `${notCovered.length} Hackage package(s) were not covered by the advisory feed (${[...new Set(notCovered.map((x) => x.name))].slice(0, 5).join(', ')}${notCovered.length > 5 ? ', ...' : ''}): their status is unknown, and the absence of findings for them is not a clean result.` });
104
132
  const unresolved = components.filter((c) => !c.version);
105
133
  if (unresolved.length) out.gaps.push({ kind: 'unresolved-dependency-versions', detail: `${unresolved.length} dependency version(s) are only declared ranges (${unresolved.slice(0, 5).map((c) => c.name).join(', ')}${unresolved.length > 5 ? ', ...' : ''}); generate a Cabal plan or a Stack export to resolve them` });
106
134
 
@@ -221,6 +221,32 @@ function handlerFacts(ctx, fn) {
221
221
  };
222
222
  const guardIdx = ordered.findIndex((o) => isGuardCall(o.call));
223
223
  const sensIdx = ordered.findIndex((o) => isSensitive(o.call));
224
+ // An INLINE guard: the handler itself reads a credential and, before any sensitive operation, rejects. Scotty's `status` only sets the
225
+ // code and the handler keeps running, so there a halting call (`finish`, `raise`, ...) must follow, still before the sensitive step;
226
+ // a WAI response, a Servant throwError or a Yesod permissionDenied ends the handler by itself.
227
+ // `finish` is usually a bare operand (`status status401 >> finish`, `when bad finish`), which the IR records as an identifier
228
+ // rather than a call, so halting points are collected from both, by source line.
229
+ const HALT = /^Web\.Scotty(?:\.Trans)?\.(?:finish|raise|raiseStatus|redirect)$/;
230
+ const haltLines = [];
231
+ for (const n of nodes) for (const root of exprsOfNode(n)) for (const e of walkIr(root)) {
232
+ if ((e.kind === 'call' ? e.callee : e.kind === 'ident' ? e.name : null) && HALT.test(e.kind === 'call' ? e.callee : e.name)) haltLines.push(Number.isFinite(e.line) ? e.line : n.line);
233
+ }
234
+ const sensLine = sensIdx >= 0 ? ordered[sensIdx].line : Infinity;
235
+ const haltsAfter = (line) => haltLines.some((h) => Number.isFinite(h) && h >= line && h < sensLine);
236
+ // The complete call list (not the node walk, which does not descend into case alternatives), ordered by source line.
237
+ const allCalls = callsOf(fn).filter((c) => typeof c.callee === 'string' && Number.isFinite(c.line)).sort((x, y) => x.line - y.line);
238
+ let inlineIdx = -1, inlineKind = null, inlineLine = null, inlineCallee = null;
239
+ for (let i = 0; i < allCalls.length && inlineIdx < 0; i++) {
240
+ const k = credentialKind(allCalls[i]);
241
+ if (!k) continue;
242
+ for (let j = i + 1; j < allCalls.length && allCalls[j].line < sensLine; j++) {
243
+ const r = allCalls[j];
244
+ if (!isRejection(r)) continue;
245
+ const setsStatusOnly = /^Web\.Scotty(?:\.Trans)?\.status$/.test(r.callee);
246
+ if (setsStatusOnly && !haltsAfter(allCalls[i].line)) continue; // an identifier carries its enclosing node's line, so position is judged from the credential read
247
+ inlineIdx = j; inlineKind = k; inlineLine = r.line; inlineCallee = allCalls[i].callee; break;
248
+ }
249
+ }
224
250
  const principals = new Set();
225
251
  for (const a of assigns) if (a.source.kind === 'call' && isGuardCall(a.source)) principals.add(a.target);
226
252
  // ids read from the request
@@ -232,7 +258,7 @@ function handlerFacts(ctx, fn) {
232
258
  if (nm && /id$/i.test(nm)) idVars.add(a.target);
233
259
  }
234
260
  }
235
- return { ordered, guardIdx, sensIdx, principals, idVars, conds, assigns, isGuardCall, isSensitive, fn };
261
+ return { ordered, guardIdx, inlineIdx, inlineKind, inlineLine, inlineCallee, sensIdx, principals, idVars, conds, assigns, isGuardCall, isSensitive, fn };
236
262
  }
237
263
 
238
264
  function hasOwnership(ctx, facts) {
@@ -277,6 +303,8 @@ function analyzeRoute(ctx, route, handlerFn, opts) {
277
303
  R.auth = { status: 'authenticated', kind: (sub.kinds && sub.kinds[0]) || null, evidence: [{ kind: 'handler-guard', callee: g.callee, line: facts.ordered[facts.guardIdx].line }] };
278
304
  (sub.kinds || []).forEach((k) => R.credentialKinds.add(k));
279
305
  }
306
+ } else if (facts.inlineIdx >= 0) {
307
+ R.auth = { status: 'authenticated', kind: facts.inlineKind, evidence: [{ kind: 'inline-guard', callee: facts.inlineCallee, line: facts.inlineLine }] };
280
308
  } else R.auth = { status: 'none', kind: null, evidence: [] };
281
309
  // role
282
310
  const roleCall = facts.ordered.find((o) => ROLE_CALL.test(o.call.callee || ''));
@@ -102,7 +102,7 @@ export class NixAdvisoryData {
102
102
  }
103
103
 
104
104
  // ── upstream identity ────────────────────────────────────────────────────────
105
- const HASKELL_ENV_HINTS = ['libraryHaskellDepends', 'setupHaskellDepends', 'executableHaskellDepends', 'isLibrary', 'isExecutable', 'enableSeparateDataOutput', 'compilerName', 'enableLibraryProfiling'];
105
+ export const HASKELL_ENV_HINTS = ['libraryHaskellDepends', 'setupHaskellDepends', 'executableHaskellDepends', 'isLibrary', 'isExecutable', 'enableSeparateDataOutput', 'compilerName', 'enableLibraryProfiling'];
106
106
  function purlFromUrl(u) {
107
107
  const m = /^https?:\/\/(?:www\.)?github\.com\/([^/]+)\/([^/#?]+?)(?:\.git)?(?:\/|$)/.exec(u || '');
108
108
  if (m) return { purl: `pkg:github/${m[1].toLowerCase()}/${m[2].toLowerCase()}`, host: 'github' };
@@ -280,9 +280,14 @@ export function matchNixVulnerabilities(opts = {}) {
280
280
  if (!data) { push({ status: 'unknown', reason: 'no advisory feed was supplied: absence of a match is not a clean result' }); summary.unmapped++; continue; }
281
281
  if (ident.versionRejected && !ident.haskell && !(node.upstream && node.upstream.rev)) { push({ status: 'unknown', reason: `the version cannot be queried: ${ident.versionRejected}` }); summary.unmapped++; continue; }
282
282
 
283
- let matched = false; let mapped = false;
283
+ let matched = false; let mapped = false; let hackageGap = null;
284
284
  // Hackage (wrapped Haskell packages): reuse the PVP matcher
285
- if (ident.haskell && data.hackage && ident.version && parsePvp(ident.version)) {
285
+ if (ident.haskell && data.hackage && ident.version && parsePvp(ident.version) && data.hackage.coverage(node.pname) !== 'covered') {
286
+ // A feed that never looked up this package (or looked too long ago) says nothing about it: that is unknown, not "no advisory matches".
287
+ hackageGap = data.hackage.coverage(node.pname) === 'stale'
288
+ ? 'the Hackage advisory feed last covered this package longer ago than its age limit: the absence of a match is not a clean result'
289
+ : 'the Hackage advisory feed never covered this package: the absence of a match is not a clean result';
290
+ } else if (ident.haskell && data.hackage && ident.version && parsePvp(ident.version)) {
286
291
  mapped = true;
287
292
  for (const { adv, aff } of data.hackage.forPackage(node.pname)) {
288
293
  if (adv.withdrawn) continue;
@@ -316,6 +321,7 @@ export function matchNixVulnerabilities(opts = {}) {
316
321
  }
317
322
  }
318
323
  if (matched) summary.matched++;
324
+ else if (hackageGap) { summary.unmapped++; push({ status: 'unknown', reason: hackageGap }, { identityMapped: false, feedCoverage: 'incomplete' }); }
319
325
  else if (mapped) { summary.mappedNoMatch++; push({ status: 'not-affected', reason: 'mapped to an upstream identity and no advisory in the supplied feed matches this version' }, { identityMapped: true }); }
320
326
  else { summary.unmapped++; push({ status: 'unknown', reason: ident.ambiguous ? 'the upstream identity is ambiguous: no verdict' : 'the upstream identity could not be mapped to anything an advisory is keyed by' }, { identityMapped: false }); }
321
327
  }