@clear-capabilities/agentic-security-scanner 0.157.2 → 0.159.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.
Files changed (46) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/dist/1919.index.js +5 -2
  3. package/dist/2862.index.js +29 -9
  4. package/dist/4171.index.js +2 -2
  5. package/dist/{8551.index.js → 7033.index.js} +430 -22
  6. package/dist/9560.index.js +3 -1
  7. package/dist/agentic-security.mjs +15 -15
  8. package/dist/agentic-security.mjs.sha256 +1 -1
  9. package/package.json +7 -5
  10. package/src/dataflow/engine.js +23 -0
  11. package/src/engine.js +7 -1
  12. package/src/language/CLAUDE.md +53 -1
  13. package/src/language/assurance.js +21 -4
  14. package/src/language/context.js +2 -0
  15. package/src/language/cvss.js +91 -0
  16. package/src/language/fix-lifecycle.js +114 -21
  17. package/src/language/haskell-adapter.js +4 -3
  18. package/src/language/haskell-advisory-feed.js +194 -0
  19. package/src/language/haskell-boundaries.js +220 -0
  20. package/src/language/haskell-cpp.js +460 -0
  21. package/src/language/haskell-ir.js +6 -3
  22. package/src/language/haskell-models.js +12 -5
  23. package/src/language/haskell-parser.js +122 -38
  24. package/src/language/haskell-sca.js +28 -10
  25. package/src/language/haskell-security-rules.js +1 -1
  26. package/src/language/haskell-supply.js +70 -8
  27. package/src/language/haskell-web.js +143 -10
  28. package/src/language/nix-advisory-feed.js +275 -0
  29. package/src/language/nix-fix.js +162 -24
  30. package/src/language/nix-ir.js +16 -12
  31. package/src/language/nix-sca.js +80 -15
  32. package/src/language/nixos-eval-forms.js +162 -0
  33. package/src/language/nixos-hardening.js +37 -2
  34. package/src/language/nixos-module-resolver.js +359 -39
  35. package/src/language/nixos-option-catalog.js +1 -0
  36. package/src/language/resolved-pass.js +65 -3
  37. package/src/lsp/server.js +5 -2
  38. package/src/mcp/tools.js +3 -1
  39. package/src/posture/fix-history.js +30 -3
  40. package/src/report/index.js +4 -1
  41. package/dist/1310.index.js +0 -3161
  42. package/dist/2376.index.js +0 -3151
  43. package/dist/3526.index.js +0 -1019
  44. package/dist/7310.index.js +0 -520
  45. package/dist/8520.index.js +0 -13
  46. package/dist/9091.index.js +0 -3135
@@ -36,7 +36,7 @@ const WRITE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
36
36
  const SCOTTY_METHODS = { get: 'GET', post: 'POST', put: 'PUT', delete: 'DELETE', patch: 'PATCH', options: 'OPTIONS', matchAny: 'ANY', addroute: 'ANY' };
37
37
  const SENSITIVE_PREFIX = ['Database.', 'System.Process.', 'System.Directory.', 'Prelude.readFile', 'Prelude.writeFile', 'Prelude.appendFile', 'System.IO.', 'Network.HTTP.Simple.', 'Network.HTTP.Client.'];
38
38
  const PERSIST_FNS = new Set(['get', 'getBy', 'selectFirst', 'selectList', 'insert', 'insert_', 'update', 'updateWhere', 'delete', 'deleteWhere', 'replace', 'runDB', 'getJust', 'upsert']);
39
- const GUARD_PRIMITIVES = new Set(['Yesod.Core.requireAuthId', 'Yesod.Core.requireAuth', 'Yesod.Auth.requireAuthId', 'Yesod.Auth.requireAuth', 'Network.Wai.Middleware.HttpAuth.basicAuth']);
39
+ const GUARD_PRIMITIVES = new Set(['Yesod.Core.requireAuthId', 'Yesod.Core.requireAuth', 'Yesod.Auth.requireAuthId', 'Yesod.Auth.requireAuth', 'Yesod.requireAuthId', 'Yesod.requireAuth', 'Network.Wai.Middleware.HttpAuth.basicAuth']);
40
40
  const CREDENTIAL_HEADER = /^(?:authorization|cookie|x-api-key|x-auth-token|x-access-token)$/i;
41
41
  const ADMIN_PATH = /(?:^|\/)(?:admin|internal|manage|superuser)(?:\/|$|:)/i;
42
42
  const ROLE_CALL = /(?:^|\.)(?:isAdmin|hasRole|requireAdmin|checkRole|hasPermission|requireRole|requirePermission|isSuperuser)$/i;
@@ -128,31 +128,111 @@ function credentialKind(c) {
128
128
  if (c.callee === 'Web.Cookie.parseCookies') return 'cookie';
129
129
  if (c.callee === 'Network.Wai.requestHeaders') return 'token';
130
130
  if (c.callee.startsWith('Web.JWT.')) return 'token';
131
+ // Yesod: the session principal, or a credential header, read by the handler itself (the caller still has to reject on it)
132
+ if (/^Yesod(?:\.Core|\.Auth)?\.(?:maybeAuthId|maybeAuth)$/.test(c.callee)) return 'session';
133
+ if (/^Yesod(?:\.Core)?\.lookupHeader$/.test(c.callee)) { const h = headerArg(c); if (h && CREDENTIAL_HEADER.test(h)) return /cookie/i.test(h) ? 'cookie' : 'token'; }
134
+ if (/^Yesod(?:\.Core)?\.lookupBearerAuth$/.test(c.callee)) return 'token';
135
+ if (/^Yesod(?:\.Core)?\.lookupBasicAuth$/.test(c.callee)) return 'basic';
131
136
  if (GUARD_PRIMITIVES.has(c.callee)) return c.callee.includes('HttpAuth') ? 'basic' : 'session';
132
137
  return null;
133
138
  }
134
139
 
140
+ /**
141
+ * Credential reads of one function, by call. A WAI `requestHeaders` read is classified by the key it is looked up with: a lookup of a
142
+ * header that is not a credential (`lookup "X-Request-Id" (requestHeaders req)`) is not a credential read. An unrecognised use of
143
+ * `requestHeaders` keeps its old meaning (a token read), so only the provable non-credential lookup is excluded.
144
+ * The IR lowers `lookup k t` to a call on `t` with the key kept as `hs.selector`.
145
+ */
146
+ function credentialReadsOf(fn) {
147
+ const nodes = Object.values(fn.cfg.nodes);
148
+ const hdrTargets = new Map(); // temp name -> line of the node that read requestHeaders
149
+ for (const n of nodes) if (n.kind === 'assign' && n.source && n.source.kind === 'call' && n.source.callee === 'Network.Wai.requestHeaders') hdrTargets.set(n.target, n.line);
150
+ const lineKind = new Map(); // line of a requestHeaders read -> 'token' | 'cookie' | null (a header that is not a credential)
151
+ for (const n of nodes) for (const root of exprsOfNode(n)) for (const e of walkIr(root)) {
152
+ if (e.kind !== 'call' || !/(?:^|\.)lookup$/.test(e.callee || '') || !e.hs || !e.hs.selector) continue;
153
+ const sel = e.hs.selector;
154
+ let name = null;
155
+ if (sel.kind === 'literal' && typeof sel.value === 'string') name = sel.value;
156
+ else if (sel.kind === 'ident' && /(?:^|\.)hAuthorization$/.test(sel.name || '')) name = 'Authorization';
157
+ else if (sel.kind === 'ident' && /(?:^|\.)hCookie$/.test(sel.name || '')) name = 'Cookie';
158
+ if (name === null) continue;
159
+ const a0 = (e.args || [])[0];
160
+ const line = a0 && a0.kind === 'ident' && hdrTargets.has(a0.name) ? hdrTargets.get(a0.name) : (a0 && a0.kind === 'call' && a0.callee === 'Network.Wai.requestHeaders' ? n.line : null);
161
+ if (line === null) continue;
162
+ lineKind.set(line, CREDENTIAL_HEADER.test(name) ? (/cookie/i.test(name) ? 'cookie' : 'token') : null);
163
+ }
164
+ const out = new Map();
165
+ for (const c of callsOf(fn)) {
166
+ let k;
167
+ if (c.callee === 'Network.Wai.requestHeaders' && lineKind.has(c.line)) k = lineKind.get(c.line);
168
+ else k = credentialKind(c);
169
+ if (k) out.set(c, k);
170
+ }
171
+ return out;
172
+ }
173
+
174
+ /**
175
+ * `notAuthenticated` takes no argument, so it is an identifier in the IR, not a call (like Scotty's `finish`). It ends the handler by itself,
176
+ * so each occurrence is a rejection, reported as a pseudo-call at its enclosing node's line.
177
+ */
178
+ function nullaryRejectionsOf(fn) {
179
+ const out = [];
180
+ for (const n of Object.values(fn.cfg.nodes)) for (const root of exprsOfNode(n)) for (const e of walkIr(root)) {
181
+ if (e.kind === 'ident' && /^Yesod(?:\.Core)?\.notAuthenticated$/.test(e.name || '')) out.push({ kind: 'call', callee: e.name, args: [], line: Number.isFinite(e.line) ? e.line : n.line, hs: { nullary: true } });
182
+ }
183
+ return out;
184
+ }
185
+
186
+ // A WAI rejection is a response VALUE: it ends the handler only when it is handed back (`respond (responseLBS 401 ..)`, `return (..)`).
187
+ // Built and dropped, it rejects nothing. Servant's throwError, Yesod's permissionDenied/notAuthenticated and Scotty's raiseStatus end the
188
+ // handler by themselves.
189
+ function stopsHandler(c, allCalls) {
190
+ if (c.callee !== 'Network.Wai.responseLBS') return true;
191
+ return allCalls.some((p) => p !== c && (p.callee === 'return' || p.callee === 'pure' || (p.hs && p.hs.status === 'param') || p.callee === 'respond')
192
+ && (p.args || []).some((a) => [...walkIr(a)].some((x) => x.kind === 'call' && x.args === c.args)));
193
+ }
194
+
135
195
  function isRejection(c) {
136
196
  const name = c.callee;
137
197
  const argNames = (c.args || []).flatMap((a) => (a && a.kind === 'ident' ? [a.name] : []));
138
198
  if ((name === 'Web.Scotty.status' || name === 'Web.Scotty.Trans.status') && argNames.some((n) => /(?:status40[13]|unauthorized401|forbidden403)$/.test(n))) return true;
199
+ if (/^Web\.Scotty(?:\.Trans)?\.raiseStatus$/.test(name) && argNames.some((n) => /(?:status40[13]|unauthorized401|forbidden403)$/.test(n))) return true; // halts by itself
139
200
  if (name === 'Network.Wai.responseLBS' && argNames.some((n) => /(?:status40[13]|unauthorized401|forbidden403)$/.test(n))) return true;
140
- if (/^(?:Servant|Servant\.Server|Control\.Monad\.Except)\.throwError$/.test(name) && (c.args || []).some((a) => a && ((a.kind === 'ident' && /err40[13]$/.test(a.name)) || (a.kind === 'call' && /err40[13]$/.test(a.callee))))) return true;
141
- if (/^Yesod\.Core\.(?:permissionDenied|notAuthenticated)$/.test(name)) return true;
201
+ // `throwError err401 { errBody = .. }` is a record update: the status constructor is inside it
202
+ if (/^(?:Servant|Servant\.Server|Control\.Monad\.Except)\.throwError$/.test(name) && (c.args || []).some((a) => a && [...walkIr(a)].some((x) => (x.kind === 'ident' && /err40[13]$/.test(x.name || '')) || (x.kind === 'call' && /err40[13]$/.test(x.callee || ''))))) return true;
203
+ if (/^Yesod(?:\.Core)?\.(?:permissionDenied|notAuthenticated)$/.test(name)) return true;
142
204
  return false;
143
205
  }
144
206
 
207
+ // Scotty's `status` only sets the response code: the handler keeps running unless something halts it.
208
+ const SCOTTY_STATUS = /^Web\.Scotty(?:\.Trans)?\.status$/;
209
+ const SCOTTY_HALT = /^Web\.Scotty(?:\.Trans)?\.(?:finish|raise|raiseStatus|redirect)$/;
210
+ /**
211
+ * Source lines of every Scotty halting point in one function IR. `finish` is usually a bare operand (`status status401 >> finish`,
212
+ * `when bad finish`), which the IR records as an identifier rather than a call, and an identifier has no line of its own: it inherits the
213
+ * enclosing node's line. So both calls and identifiers are collected, by line.
214
+ */
215
+ function haltLinesOf(fn) {
216
+ const lines = [];
217
+ for (const n of Object.values(fn.cfg.nodes)) for (const root of exprsOfNode(n)) for (const e of walkIr(root)) {
218
+ const nm = e.kind === 'call' ? e.callee : e.kind === 'ident' ? e.name : null;
219
+ if (nm && SCOTTY_HALT.test(nm)) lines.push(Number.isFinite(e.line) ? e.line : n.line);
220
+ }
221
+ return lines;
222
+ }
223
+
145
224
  function guardSummary(ctx, fn, depth = 0, seen = new Set()) {
146
225
  if (!fn) return { auth: false, kinds: [], role: false, owner: false, csrf: false };
147
226
  if (ctx.guardMemo.has(fn.qid)) return ctx.guardMemo.get(fn.qid);
148
227
  if (seen.has(fn.qid) || depth > 3) return { auth: false, kinds: [], role: false, owner: false, csrf: false };
149
228
  seen.add(fn.qid);
150
229
  const kinds = new Set();
151
- let reads = false, rejects = false, role = false, owner = false, csrf = false, primitive = false;
230
+ let reads = false, rejects = false, statusRejects = [], role = false, owner = false, csrf = false, primitive = false;
231
+ const credReads = credentialReadsOf(fn);
152
232
  for (const c of callsOf(fn)) {
153
- const k = credentialKind(c);
233
+ const k = credReads.get(c) || null;
154
234
  if (k) { kinds.add(k); reads = true; if (GUARD_PRIMITIVES.has(c.callee)) primitive = true; }
155
- if (isRejection(c)) rejects = true;
235
+ if (isRejection(c)) { if (SCOTTY_STATUS.test(c.callee)) statusRejects.push(c.line); else rejects = true; }
156
236
  if (ROLE_CALL.test(c.callee || '')) role = true;
157
237
  if (OWNER_CALL.test(c.callee || '')) owner = true;
158
238
  if (CSRF_CALL.test(c.callee || '')) csrf = true;
@@ -162,6 +242,11 @@ function guardSummary(ctx, fn, depth = 0, seen = new Set()) {
162
242
  if (sub.role) role = true; if (sub.owner) owner = true; if (sub.csrf) csrf = true;
163
243
  }
164
244
  }
245
+ if (!rejects && nullaryRejectionsOf(fn).length) rejects = true;
246
+ // A Scotty `status 40x` is a rejection only if the function also halts the handler (finish, raise, raiseStatus, redirect). Position is not
247
+ // judged here: a bare `finish` operand carries its enclosing node's line (often the function head), so a line comparison would reject
248
+ // the ordinary `Nothing -> status status401 >> finish`. The inline-guard path, which can order against the first sensitive call, does.
249
+ if (!rejects && statusRejects.length && haltLinesOf(fn).length) rejects = true;
165
250
  // literal comparisons against an admin role count as a role check
166
251
  for (const n of Object.values(fn.cfg.nodes)) for (const e of exprsOfNode(n)) for (const x of walkIr(e)) {
167
252
  if (x.kind === 'binary' && (x.op === '==' || x.op === '===' || x.op === '!=')) {
@@ -188,7 +273,7 @@ const identsIr = (e) => { const out = new Set(); for (const x of walkIr(e)) { if
188
273
 
189
274
  // ── handler summary ─────────────────────────────────────────────────────────
190
275
  // Ordered call list (program order) + principal / id / ownership facts for one handler function IR.
191
- function handlerFacts(ctx, fn) {
276
+ function handlerFacts(ctx, fn, opts = {}) {
192
277
  const ordered = [];
193
278
  const nodes = Object.entries(fn.cfg.nodes).map(([id, n]) => ({ id, ...n })).sort((a, b) => Number(a.id.slice(1)) - Number(b.id.slice(1)));
194
279
  const assigns = [];
@@ -221,6 +306,46 @@ function handlerFacts(ctx, fn) {
221
306
  };
222
307
  const guardIdx = ordered.findIndex((o) => isGuardCall(o.call));
223
308
  const sensIdx = ordered.findIndex((o) => isSensitive(o.call));
309
+ // An INLINE guard: the handler itself reads a credential and, before any sensitive operation, rejects. Scotty's `status` only sets the
310
+ // code and the handler keeps running, so there a halting call (`finish`, `raise`, ...) must follow, still before the sensitive step;
311
+ // a WAI response, a Servant throwError or a Yesod permissionDenied ends the handler by itself.
312
+ // `finish` is usually a bare operand (`status status401 >> finish`, `when bad finish`), which the IR records as an identifier
313
+ // rather than a call, so halting points are collected from both, by source line.
314
+ const haltLines = haltLinesOf(fn);
315
+ const sensLine = sensIdx >= 0 ? ordered[sensIdx].line : Infinity;
316
+ const haltsAfter = (line) => haltLines.some((h) => Number.isFinite(h) && h >= line && h < sensLine);
317
+ // The complete call list (not the node walk, which does not descend into case alternatives), ordered by source line.
318
+ const allCalls = [...callsOf(fn), ...nullaryRejectionsOf(fn)].filter((c) => typeof c.callee === 'string' && Number.isFinite(c.line)).sort((x, y) => x.line - y.line);
319
+ const credReads = credentialReadsOf(fn);
320
+ // Credential reads in source order: calls that read one, and handler parameters bound to a credential header (Servant `Header "Authorization"`),
321
+ // whose "read" is their first use in the body.
322
+ const reads = [];
323
+ for (const c of allCalls) if (credReads.has(c)) reads.push({ line: c.line, kind: credReads.get(c), callee: c.callee, call: c });
324
+ for (const cp of opts.credentialParams || []) {
325
+ let first = null;
326
+ for (const n of nodes) for (const root of exprsOfNode(n)) for (const e of walkIr(root)) if (e.kind === 'ident' && e.name === cp.name && Number.isFinite(n.line) && (first === null || n.line < first)) first = n.line;
327
+ if (first !== null) reads.push({ line: first, kind: cp.kind, callee: `param:${cp.name}`, call: null });
328
+ }
329
+ reads.sort((x, y) => x.line - y.line);
330
+ let inlineIdx = -1, inlineKind = null, inlineLine = null, inlineCallee = null;
331
+ for (let i = 0; i < reads.length && inlineIdx < 0; i++) {
332
+ const rd = reads[i];
333
+ if (!(rd.line <= sensLine)) break;
334
+ // (a) a rejection at or after the read and before the first sensitive operation, by line. Scotty's `status` only sets the code, so there a
335
+ // halting call must follow too.
336
+ for (let j = 0; j < allCalls.length && allCalls[j].line < sensLine; j++) {
337
+ const r = allCalls[j];
338
+ if (r === rd.call || r.line < rd.line || !isRejection(r) || !stopsHandler(r, allCalls)) continue;
339
+ if (SCOTTY_STATUS.test(r.callee) && !haltsAfter(rd.line)) continue; // an identifier carries its enclosing node's line, so position is judged from the credential read
340
+ inlineIdx = j; inlineKind = rd.kind; inlineLine = r.line; inlineCallee = rd.callee; break;
341
+ }
342
+ if (inlineIdx >= 0) break;
343
+ // (b) a rejection that ends the handler by itself (WAI response handed back, throwError, permissionDenied, raiseStatus). A call in tail position
344
+ // (the last statement, a case alternative) reports the enclosing line, not its own, so its line cannot be ordered against the sensitive
345
+ // operation; the ordering that CAN be trusted is the credential read against the first sensitive operation, tested above.
346
+ const hard = allCalls.findIndex((r) => r !== rd.call && isRejection(r) && !SCOTTY_STATUS.test(r.callee) && stopsHandler(r, allCalls));
347
+ if (hard >= 0) { inlineIdx = hard; inlineKind = rd.kind; inlineLine = allCalls[hard].line; inlineCallee = rd.callee; break; }
348
+ }
224
349
  const principals = new Set();
225
350
  for (const a of assigns) if (a.source.kind === 'call' && isGuardCall(a.source)) principals.add(a.target);
226
351
  // ids read from the request
@@ -232,7 +357,7 @@ function handlerFacts(ctx, fn) {
232
357
  if (nm && /id$/i.test(nm)) idVars.add(a.target);
233
358
  }
234
359
  }
235
- return { ordered, guardIdx, sensIdx, principals, idVars, conds, assigns, isGuardCall, isSensitive, fn };
360
+ return { ordered, guardIdx, inlineIdx, inlineKind, inlineLine, inlineCallee, sensIdx, principals, idVars, conds, assigns, isGuardCall, isSensitive, fn };
236
361
  }
237
362
 
238
363
  function hasOwnership(ctx, facts) {
@@ -259,7 +384,7 @@ function newRoute(r) { return { auth: { status: 'unknown', kind: null, evidence:
259
384
  function analyzeRoute(ctx, route, handlerFn, opts) {
260
385
  const R = route;
261
386
  if (!handlerFn) { R.handler = R.handler || { kind: 'unknown' }; R.auth = { status: 'unknown', kind: null, evidence: [], reason: 'handler not resolved' }; return; }
262
- const facts = handlerFacts(ctx, handlerFn);
387
+ const facts = handlerFacts(ctx, handlerFn, opts);
263
388
  for (const v of opts.principalVars || []) facts.principals.add(v);
264
389
  for (const v of opts.idVars || []) facts.idVars.add(v);
265
390
  const gs = guardSummary(ctx, handlerFn);
@@ -277,6 +402,8 @@ function analyzeRoute(ctx, route, handlerFn, opts) {
277
402
  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
403
  (sub.kinds || []).forEach((k) => R.credentialKinds.add(k));
279
404
  }
405
+ } else if (facts.inlineIdx >= 0) {
406
+ R.auth = { status: 'authenticated', kind: facts.inlineKind, evidence: [{ kind: 'inline-guard', callee: facts.inlineCallee, line: facts.inlineLine }] };
280
407
  } else R.auth = { status: 'none', kind: null, evidence: [] };
281
408
  // role
282
409
  const roleCall = facts.ordered.find((o) => ROLE_CALL.test(o.call.callee || ''));
@@ -432,6 +559,7 @@ function expandChain(chain, acc) {
432
559
  const name = first.t === 'con' ? first.v : null;
433
560
  if (name === 'Capture' || name === 'Capture\'') { const nm = head.find((t) => t.t === 'str'); a.path.push(`:${nm ? nm.v : 'param'}`); a.captures.push(nm ? nm.v : 'param'); a.order.push(`capture:${nm ? nm.v : 'param'}`); }
434
561
  else if (name === 'QueryParam' || name === 'QueryParam\'' || name === 'QueryParams') { const nm = head.find((t) => t.t === 'str'); a.params.push(nm ? nm.v : '?'); a.order.push(`query:${nm ? nm.v : '?'}`); }
562
+ else if (name === 'Header' || name === 'Header\'') { const nm = head.find((t) => t.t === 'str'); a.order.push(`header:${nm ? nm.v : '?'}`); }
435
563
  else if (name === 'ReqBody') a.order.push('body');
436
564
  else if (name === 'BasicAuth') { a.auth = { kind: 'basic', tag: 'BasicAuth' }; a.order.push('auth'); }
437
565
  else if (name === 'AuthProtect') { const nm = head.find((t) => t.t === 'str'); a.auth = { kind: 'token', tag: nm ? nm.v : 'AuthProtect' }; a.order.push('auth'); if (nm && /admin|role|perm/i.test(nm.v)) a.role = true; }
@@ -626,11 +754,16 @@ export function analyzeHaskellWeb(files, opts = {}) {
626
754
  (p.servant.order || []).forEach((kind, i) => {
627
755
  const nm = paramNames[i]; if (!nm) return;
628
756
  if (kind === 'auth') o.principalVars.push(nm);
757
+ else if (/^header:/.test(kind) && CREDENTIAL_HEADER.test(kind.slice(7))) (o.credentialParams ||= []).push({ name: nm, kind: /cookie/i.test(kind) ? 'cookie' : 'token' });
629
758
  else if (/^(?:capture|query):.*id$/i.test(kind)) o.idVars.push(nm);
630
759
  });
631
760
  } else if (p.framework === 'servant') {
632
761
  o.idVars = [];
633
- (p.servant.order || []).forEach((kind, i) => { const nm = paramNames[i]; if (nm && /^(?:capture|query):.*id$/i.test(kind)) o.idVars.push(nm); });
762
+ (p.servant.order || []).forEach((kind, i) => {
763
+ const nm = paramNames[i]; if (!nm) return;
764
+ if (/^(?:capture|query):.*id$/i.test(kind)) o.idVars.push(nm);
765
+ else if (/^header:/.test(kind) && CREDENTIAL_HEADER.test(kind.slice(7))) (o.credentialParams ||= []).push({ name: nm, kind: /cookie/i.test(kind) ? 'cookie' : 'token' });
766
+ });
634
767
  }
635
768
  if (p.framework === 'yesod') {
636
769
  o.idVars = paramNames.filter(Boolean);
@@ -0,0 +1,275 @@
1
+ // Live advisory feed for the upstream software inside a Nix closure (NIX-009 follow-on).
2
+ //
3
+ // OSV has no nixpkgs ecosystem, so the Hackage approach (ask OSV by ecosystem and package name) does not carry over. The source used
4
+ // here is the NVD CVE API 2.0 (https://services.nvd.nist.gov/rest/json/cves/2.0), queried by CPE vendor:product, because a nixpkgs
5
+ // package declares its upstream identity as a CPE in meta.identifiers and NVD is the one public, machine-readable feed keyed by it.
6
+ // What it can and cannot answer is stated in docs/guides/nix-nixos.md: an identity that has no CPE (a purl or a source URL alone) is
7
+ // not something this feed is keyed by, so it is reported unknown, never clean.
8
+ //
9
+ // Contract (the same as haskell-advisory-feed.js, in the order it matters):
10
+ // * Opt in. Nothing here touches the network unless AGENTIC_SECURITY_NIX_ADVISORIES_LIVE=1, and never when AGENTIC_SECURITY_OFFLINE=1.
11
+ // An operator-pinned snapshot (AGENTIC_SECURITY_NIX_ADVISORIES) is used as given and is never refreshed over. A hand-written
12
+ // nix-advisories.json in the operator configuration directory is never overwritten either.
13
+ // * Coverage is recorded, not assumed. The snapshot lists every vendor:product it fully read and when. One it did not read, or read
14
+ // only in part, is NOT covered and the matcher reports it unknown.
15
+ // * Failure degrades. An unreachable or throttling feed leaves the previous snapshot in place; the scan runs on it with its age stated.
16
+ // * Everything from the wire is untrusted: the identity is validated before it reaches a URL, responses are size-capped, a record
17
+ // that does not name the requested product is dropped, and a cache whose records fail their own hashes is discarded.
18
+ // * Rate limits. NVD allows 5 requests per rolling 30 seconds without an API key and 50 with one (a key is free; set
19
+ // AGENTIC_SECURITY_NVD_API_KEY). Requests are spaced to stay under that, a throttling response stops the run instead of retrying,
20
+ // and the number of requests per scan is capped, so a large closure fills in over several scans rather than hammering the service.
21
+
22
+ import { mkdirSync, readFileSync, writeFileSync, renameSync, chmodSync } from 'node:fs';
23
+ import { join } from 'node:path';
24
+ import { createHash } from 'node:crypto';
25
+ import { operatorConfigDir } from './trusted-inputs.js';
26
+
27
+ export const FEED_ENV = 'AGENTIC_SECURITY_NIX_ADVISORIES_LIVE';
28
+ export const PINNED_ENV = 'AGENTIC_SECURITY_NIX_ADVISORIES';
29
+ export const KEY_ENV = 'AGENTIC_SECURITY_NVD_API_KEY';
30
+ export const SNAPSHOT_FILE = 'nix-advisories.json';
31
+ export const SNAPSHOT_SCHEMA = 'nix-advisories-live/1';
32
+ export const NVD_BASE = 'https://services.nvd.nist.gov/rest/json/cves/2.0';
33
+
34
+ const DEFAULTS = Object.freeze({
35
+ ttlMs: 24 * 3600 * 1000, // a covered identity is not queried again inside this window
36
+ timeoutMs: 20000,
37
+ maxProducts: 60, // identities considered per scan
38
+ maxRequestsKeyless: 20, // about 2 minutes at the keyless spacing
39
+ maxRequestsKeyed: 120,
40
+ intervalKeylessMs: 6500, // 5 per 30 s, with margin
41
+ intervalKeyedMs: 700, // 50 per 30 s, with margin
42
+ pageSize: 2000, // the NVD maximum
43
+ maxPages: 3,
44
+ maxResponseBytes: 32 << 20,
45
+ maxSnapshotRecords: 30000,
46
+ });
47
+
48
+ const PART = /^[a-z0-9][a-z0-9_.+~-]{0,63}$/; // a CPE vendor or product, in the lowercase form NVD uses; no colon, slash, quote or escape
49
+ const CVE_ID = /^CVE-\d{4}-\d{4,12}$/;
50
+ const VERSION_OK = /^[A-Za-z0-9][A-Za-z0-9._+~-]{0,63}$/;
51
+ const KEY_OK = /^[0-9a-fA-F-]{20,64}$/;
52
+
53
+ /** 'vendor:product' from a full CPE 2.3 string, a {vendor, product} / {cpe} object, or a key already in that form; null if it cannot be made safely. */
54
+ export function cpeKey(x) {
55
+ let v = null; let p = null;
56
+ if (x && typeof x === 'object') {
57
+ if (typeof x.vendor === 'string' && typeof x.product === 'string') { v = x.vendor; p = x.product; }
58
+ else if (typeof x.cpe === 'string') return cpeKey(x.cpe);
59
+ else return null;
60
+ } else if (typeof x === 'string') {
61
+ if (x.startsWith('cpe:2.3:')) { const parts = x.split(':'); if (parts.length < 5) return null; v = parts[3]; p = parts[4]; }
62
+ else { const parts = x.split(':'); if (parts.length !== 2) return null; [v, p] = parts; }
63
+ } else return null;
64
+ v = v.toLowerCase(); p = p.toLowerCase();
65
+ return PART.test(v) && PART.test(p) ? `${v}:${p}` : null;
66
+ }
67
+ export const validCpeKey = (k) => typeof k === 'string' && cpeKey(k) === k;
68
+
69
+ let last = null;
70
+ /** What the most recent refresh in this process did, so a "no feed" reason can say why. */
71
+ export const getLastRefresh = () => last;
72
+ export const _resetLastRefresh = () => { last = null; };
73
+
74
+ export function liveFeedEnabled(env = process.env) {
75
+ return env[FEED_ENV] === '1' && env.AGENTIC_SECURITY_OFFLINE !== '1';
76
+ }
77
+ export function snapshotPath(env = process.env, dir = null) { return join(dir || operatorConfigDir(env), SNAPSHOT_FILE); }
78
+
79
+ const sha256 = (s) => createHash('sha256').update(s).digest('hex');
80
+
81
+ /**
82
+ * Validate a snapshot written by this module. Returns {ok:true, records, covered, generatedAt} or {ok:false, reason}.
83
+ * `foreign` marks a parseable file that is not ours (a hand-written operator snapshot): not an error, and never overwritten.
84
+ */
85
+ export function loadLiveSnapshot(raw) {
86
+ if (!raw || typeof raw !== 'object' || raw.schema !== SNAPSHOT_SCHEMA) return { ok: false, foreign: true, reason: 'not a live-feed snapshot' };
87
+ if (!Array.isArray(raw.records) || !raw.recordHashes || typeof raw.recordHashes !== 'object') return { ok: false, reason: 'the snapshot has no record list or no record hashes' };
88
+ for (const rec of raw.records) {
89
+ if (!rec || typeof rec.id !== 'string' || !CVE_ID.test(rec.id)) return { ok: false, reason: 'a record has no valid identifier' };
90
+ if (raw.recordHashes[rec.id] !== sha256(JSON.stringify(rec))) return { ok: false, reason: `record ${rec.id} does not match its recorded hash` };
91
+ }
92
+ const covered = {};
93
+ if (!raw.covered || typeof raw.covered !== 'object' || Array.isArray(raw.covered)) return { ok: false, reason: 'the coverage table is missing or malformed' };
94
+ for (const [k, t] of Object.entries(raw.covered)) { if (!validCpeKey(k) || !Number.isFinite(Date.parse(t))) return { ok: false, reason: 'the coverage table has a malformed entry' }; covered[k] = t; }
95
+ return { ok: true, records: raw.records, covered, generatedAt: typeof raw.generatedAt === 'string' ? raw.generatedAt : null };
96
+ }
97
+
98
+ function readCurrent(path) {
99
+ let raw;
100
+ try { raw = JSON.parse(readFileSync(path, 'utf8')); } catch (e) { return { records: new Map(), covered: {}, problem: e && e.code === 'ENOENT' ? null : 'the cached snapshot was unreadable and was discarded', foreign: false }; }
101
+ const r = loadLiveSnapshot(raw);
102
+ if (r.foreign) return { records: new Map(), covered: {}, problem: null, foreign: true };
103
+ if (!r.ok) return { records: new Map(), covered: {}, problem: `the cached snapshot was discarded: ${r.reason}`, foreign: false };
104
+ return { records: new Map(r.records.map((x) => [x.id, x])), covered: r.covered, problem: null, foreign: false };
105
+ }
106
+
107
+ const splitCpe = (s) => s.split(/(?<!\\):/);
108
+ const isoOrNull = (t) => {
109
+ if (typeof t !== 'string') return null;
110
+ const ms = Date.parse(/[zZ]|[+-]\d\d:?\d\d$/.test(t) ? t : `${t}Z`); // NVD timestamps carry no zone and are UTC
111
+ return Number.isFinite(ms) ? new Date(ms).toISOString() : null;
112
+ };
113
+
114
+ /**
115
+ * One NVD CVE object -> an OSV-style record limited to the requested vendor:product, or null when the record does not name it
116
+ * (or is rejected, or carries nothing usable). Only `vulnerable` matches of the requested product are read; operating-system or
117
+ * hardware conditions on a combined configuration are NOT evaluated (see the guide).
118
+ * A start bound that is exclusive is carried as an `introduced_excluding` event, which the Nix matcher understands.
119
+ */
120
+ export function nvdToOsv(cve, key) {
121
+ if (!cve || typeof cve !== 'object' || typeof cve.id !== 'string' || !CVE_ID.test(cve.id)) return null;
122
+ if (typeof cve.vulnStatus === 'string' && /^rejected$/i.test(cve.vulnStatus)) return null;
123
+ const ranges = []; const versions = [];
124
+ for (const cfg of Array.isArray(cve.configurations) ? cve.configurations : []) {
125
+ for (const node of Array.isArray(cfg && cfg.nodes) ? cfg.nodes : []) {
126
+ if (node && node.negate === true) continue;
127
+ for (const m of Array.isArray(node && node.cpeMatch) ? node.cpeMatch : []) {
128
+ if (!m || m.vulnerable !== true || typeof m.criteria !== 'string' || !m.criteria.startsWith('cpe:2.3:')) continue;
129
+ const parts = splitCpe(m.criteria);
130
+ if (parts.length < 6 || !['a', 'o', '*'].includes(parts[2]) || `${parts[3]}:${parts[4]}` !== key) continue;
131
+ const ver = parts[5]; const upd = parts[6];
132
+ if (ver === '*') {
133
+ const s1 = m.versionStartIncluding; const s2 = m.versionStartExcluding; const e1 = m.versionEndIncluding; const e2 = m.versionEndExcluding;
134
+ if ([s1, s2, e1, e2].some((x) => x !== undefined && (typeof x !== 'string' || !VERSION_OK.test(x)))) continue;
135
+ const events = [];
136
+ if (s1) events.push({ introduced: s1 }); else if (s2) events.push({ introduced_excluding: s2 }); else events.push({ introduced: '0' });
137
+ if (e2) events.push({ fixed: e2 }); else if (e1) events.push({ last_affected: e1 });
138
+ ranges.push({ type: 'ECOSYSTEM', events });
139
+ } else if (ver !== '-' && ver !== '?') {
140
+ const v = upd && upd !== '*' && upd !== '-' ? `${ver}-${upd}` : ver;
141
+ if (VERSION_OK.test(v)) versions.push(v);
142
+ }
143
+ }
144
+ }
145
+ }
146
+ if (!ranges.length && !versions.length) return null;
147
+ const desc = (Array.isArray(cve.descriptions) ? cve.descriptions : []).find((d) => d && d.lang === 'en' && typeof d.value === 'string');
148
+ const refs = (Array.isArray(cve.references) ? cve.references : []).map((r) => r && r.url).filter((u) => typeof u === 'string' && /^https?:\/\//.test(u) && u.length <= 300).slice(0, 5);
149
+ return {
150
+ id: cve.id, aliases: [], summary: desc ? desc.value.slice(0, 400) : '', published: isoOrNull(cve.published), modified: isoOrNull(cve.lastModified),
151
+ affected: [{ package: { ecosystem: 'NVD-CPE', name: key.split(':')[1], cpe: key }, ranges, versions }],
152
+ references: refs.map((url) => ({ type: 'WEB', url })), database_specific: { source: 'nvd-cve-2.0' },
153
+ };
154
+ }
155
+
156
+ const sleepReal = (ms) => new Promise((r) => setTimeout(r, ms));
157
+
158
+ async function getJson(fetchImpl, url, headers, timeoutMs, maxBytes) {
159
+ const resp = await fetchImpl(url, { headers, signal: AbortSignal.timeout(timeoutMs) });
160
+ if (!resp) throw Object.assign(new Error('no response'), { http: 0 });
161
+ if (resp.status !== 200) throw Object.assign(new Error(`HTTP ${resp.status}`), { http: resp.status });
162
+ const text = await resp.text();
163
+ if (Buffer.byteLength(text) > maxBytes) throw new Error('response too large');
164
+ return JSON.parse(text);
165
+ }
166
+
167
+ /**
168
+ * Refresh the operator's Nix advisory snapshot for the given CPE identities ('vendor:product' keys or anything cpeKey accepts).
169
+ * Never throws. Returns a plain result; the snapshot file is rewritten only when something was read.
170
+ *
171
+ * @param {Array<string|object>} identities
172
+ * @param {{env?: object, fetchImpl?: Function, now?: number, dir?: string, sleep?: Function}} [opts]
173
+ */
174
+ export async function refreshNixAdvisories(identities, opts = {}) {
175
+ const env = opts.env || process.env;
176
+ const cfg = { ...DEFAULTS, ...Object.fromEntries(Object.entries(opts).filter(([k]) => k in DEFAULTS)) };
177
+ const now = opts.now ?? Date.now();
178
+ const done = (r) => { last = { ...r, at: new Date(now).toISOString() }; return last; };
179
+
180
+ 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` });
181
+ if (env[FEED_ENV] !== '1') return done({ status: 'disabled', detail: `set ${FEED_ENV}=1 to fetch advisories for the closure's upstream software from the NVD` });
182
+ if (env.AGENTIC_SECURITY_OFFLINE === '1') return done({ status: 'offline', detail: 'AGENTIC_SECURITY_OFFLINE=1: no advisory data was fetched' });
183
+
184
+ const all = (identities || []).map(cpeKey);
185
+ const invalid = all.filter((k) => !k).length;
186
+ const wanted = [...new Set(all.filter(Boolean))].sort().slice(0, cfg.maxProducts);
187
+ if (!wanted.length) return done({ status: 'nothing-to-do', detail: invalid ? `${invalid} identity value(s) were not valid CPE vendor:product names and were not queried` : 'the closure declares no CPE identity to look up', invalid });
188
+
189
+ const path = snapshotPath(env, opts.dir);
190
+ const cur = readCurrent(path);
191
+ if (cur.foreign) return done({ status: 'operator-snapshot-in-use', detail: `${path} is an operator-written snapshot; it is used as given and the live feed does not overwrite it` });
192
+ const need = wanted.filter((k) => { const t = Date.parse(cur.covered[k]); return !Number.isFinite(t) || now - t > cfg.ttlMs || t > now + 3600000; });
193
+ if (!need.length) return done({ status: 'current', detail: `${wanted.length} identity(ies) covered within the last ${Math.round(cfg.ttlMs / 3600000)}h; no request made`, identities: wanted.length, requested: 0 });
194
+
195
+ const fetchImpl = opts.fetchImpl || globalThis.fetch;
196
+ if (typeof fetchImpl !== 'function') return done({ status: 'failed', detail: 'no fetch implementation is available in this runtime' });
197
+ const sleep = opts.sleep || sleepReal;
198
+ const apiKey = typeof env[KEY_ENV] === 'string' && KEY_OK.test(env[KEY_ENV]) ? env[KEY_ENV] : null;
199
+ const keyNote = env[KEY_ENV] && !apiKey ? ` (${KEY_ENV} is set but is not a valid key; it was ignored)` : '';
200
+ const headers = { 'User-Agent': 'agentic-security-advisory-feed', Accept: 'application/json', ...(apiKey ? { apiKey } : {}) };
201
+ const interval = apiKey ? cfg.intervalKeyedMs : cfg.intervalKeylessMs;
202
+ const maxRequests = apiKey ? cfg.maxRequestsKeyed : cfg.maxRequestsKeyless;
203
+
204
+ let requests = 0; let throttled = false; let dropped = 0;
205
+ const failures = []; const fresh = new Map(); // key -> Map(cveId -> record)
206
+ const request = async (url) => { if (requests > 0) await sleep(interval); requests++; return getJson(fetchImpl, url, headers, cfg.timeoutMs, cfg.maxResponseBytes); };
207
+
208
+ for (const key of need) {
209
+ if (throttled) { failures.push(`${key}: not queried (the service signalled a rate limit)`); continue; }
210
+ if (requests >= maxRequests) { failures.push(`${key}: not queried (per-scan request cap of ${maxRequests} reached; a later scan will cover it)`); continue; }
211
+ const recs = new Map();
212
+ try {
213
+ let start = 0; let total = null; let seen = 0;
214
+ for (let page = 0; page < cfg.maxPages; page++) {
215
+ if (page > 0 && requests >= maxRequests) throw new Error('per-scan request cap reached part-way through this identity');
216
+ const url = `${NVD_BASE}?virtualMatchString=${encodeURIComponent(`cpe:2.3:*:${key}`)}&resultsPerPage=${cfg.pageSize}&startIndex=${start}`;
217
+ const data = await request(url);
218
+ if (!data || typeof data !== 'object' || !Array.isArray(data.vulnerabilities) || !Number.isInteger(data.totalResults) || data.totalResults < 0 || data.startIndex !== start) throw new Error('the response is not a CVE 2.0 page for this request');
219
+ if (total === null) { total = data.totalResults; if (total > cfg.pageSize * cfg.maxPages) throw new Error(`${total} CVEs name this product, more than the ${cfg.pageSize * cfg.maxPages} this feed reads; not treated as covered`); }
220
+ else if (data.totalResults !== total) throw new Error('the result set changed while it was being read');
221
+ for (const v of data.vulnerabilities) {
222
+ const rec = nvdToOsv(v && v.cve, key);
223
+ if (rec) recs.set(rec.id, rec); else dropped++;
224
+ }
225
+ seen += data.vulnerabilities.length; start += data.vulnerabilities.length;
226
+ if (seen >= total || !data.vulnerabilities.length) break;
227
+ }
228
+ if (seen < total) throw new Error('the result set was not read to the end');
229
+ fresh.set(key, recs);
230
+ } catch (e) {
231
+ const http = e && e.http;
232
+ if (http === 403 || http === 429 || http === 503) throttled = true;
233
+ failures.push(`${key}: ${String((e && e.message) || e).slice(0, 100)}`);
234
+ }
235
+ }
236
+
237
+ const uncovered = need.filter((k) => !fresh.has(k));
238
+ if (!fresh.size) return done({ status: 'failed', detail: `the NVD 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'}${keyNote}`, identities: wanted.length, requested: need.length, requests, uncovered, failures: failures.slice(0, 10), throttled, cacheProblem: cur.problem });
239
+
240
+ // merge: for each fully read identity, replace what the cache held for it and keep everything else
241
+ const records = new Map([...cur.records].map(([id, r]) => [id, { ...r, affected: [...(r.affected || [])] }]));
242
+ for (const [key, recs] of fresh) {
243
+ for (const [id, r] of [...records]) { r.affected = r.affected.filter((a) => !(a.package && a.package.cpe === key)); if (!r.affected.length) records.delete(id); }
244
+ for (const [id, rec] of recs) {
245
+ const have = records.get(id);
246
+ if (have) have.affected.push(...rec.affected); else records.set(id, rec);
247
+ }
248
+ }
249
+ if (records.size > cfg.maxSnapshotRecords) return done({ status: 'failed', detail: `the snapshot would exceed ${cfg.maxSnapshotRecords} records; refusing to write it`, identities: wanted.length, requested: need.length, uncovered });
250
+ const covered = { ...cur.covered };
251
+ const iso = new Date(now).toISOString();
252
+ for (const k of fresh.keys()) covered[k] = iso;
253
+
254
+ const sorted = [...records.values()].sort((a, b) => (a.id < b.id ? -1 : 1));
255
+ const snap = {
256
+ schema: SNAPSHOT_SCHEMA, source: 'nvd-cve-2.0', generatedAt: iso, records: sorted,
257
+ recordHashes: Object.fromEntries(sorted.map((r) => [r.id, sha256(JSON.stringify(r))])),
258
+ covered: Object.fromEntries(Object.entries(covered).sort(([a], [b]) => (a < b ? -1 : 1))),
259
+ };
260
+ try {
261
+ const dir = opts.dir || operatorConfigDir(env);
262
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
263
+ const tmp = `${path}.${process.pid}.tmp`;
264
+ writeFileSync(tmp, JSON.stringify(snap), { mode: 0o600 });
265
+ try { chmodSync(tmp, 0o600); } catch { /* best effort on filesystems without modes */ }
266
+ renameSync(tmp, path);
267
+ } catch (e) {
268
+ return done({ status: 'failed', detail: `the snapshot could not be written: ${e.code || e.message}`, identities: wanted.length, requested: need.length, uncovered });
269
+ }
270
+ return done({
271
+ status: uncovered.length ? 'partial' : 'refreshed',
272
+ detail: `${fresh.size} identity(ies) covered in ${requests} request(s)${uncovered.length ? `; ${uncovered.length} NOT covered (${uncovered.slice(0, 3).join(', ')}${uncovered.length > 3 ? ', ...' : ''}) and will be reported as unknown` : ''}${throttled ? '; the service signalled a rate limit and the run stopped' : ''}${apiKey ? '' : `; no ${KEY_ENV} set (keyless limit: 5 requests per 30 s)`}${keyNote}`,
273
+ identities: wanted.length, requested: need.length, requests, records: records.size, dropped, uncovered, failures: failures.slice(0, 10), throttled, path, cacheProblem: cur.problem,
274
+ });
275
+ }