@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.
- package/CHANGELOG.md +49 -0
- package/dist/1919.index.js +5 -2
- package/dist/2862.index.js +29 -9
- package/dist/4171.index.js +2 -2
- package/dist/{8551.index.js → 7033.index.js} +430 -22
- package/dist/9560.index.js +3 -1
- package/dist/agentic-security.mjs +15 -15
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +7 -5
- package/src/dataflow/engine.js +23 -0
- package/src/engine.js +7 -1
- package/src/language/CLAUDE.md +53 -1
- package/src/language/assurance.js +21 -4
- package/src/language/context.js +2 -0
- package/src/language/cvss.js +91 -0
- package/src/language/fix-lifecycle.js +114 -21
- package/src/language/haskell-adapter.js +4 -3
- package/src/language/haskell-advisory-feed.js +194 -0
- package/src/language/haskell-boundaries.js +220 -0
- package/src/language/haskell-cpp.js +460 -0
- package/src/language/haskell-ir.js +6 -3
- package/src/language/haskell-models.js +12 -5
- package/src/language/haskell-parser.js +122 -38
- package/src/language/haskell-sca.js +28 -10
- package/src/language/haskell-security-rules.js +1 -1
- package/src/language/haskell-supply.js +70 -8
- package/src/language/haskell-web.js +143 -10
- package/src/language/nix-advisory-feed.js +275 -0
- package/src/language/nix-fix.js +162 -24
- package/src/language/nix-ir.js +16 -12
- package/src/language/nix-sca.js +80 -15
- package/src/language/nixos-eval-forms.js +162 -0
- package/src/language/nixos-hardening.js +37 -2
- package/src/language/nixos-module-resolver.js +359 -39
- package/src/language/nixos-option-catalog.js +1 -0
- package/src/language/resolved-pass.js +65 -3
- package/src/lsp/server.js +5 -2
- package/src/mcp/tools.js +3 -1
- package/src/posture/fix-history.js +30 -3
- package/src/report/index.js +4 -1
- package/dist/1310.index.js +0 -3161
- package/dist/2376.index.js +0 -3151
- package/dist/3526.index.js +0 -1019
- package/dist/7310.index.js +0 -520
- package/dist/8520.index.js +0 -13
- 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
|
-
|
|
141
|
-
if (/^
|
|
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 =
|
|
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) => {
|
|
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
|
+
}
|