@xagents_in/unmask 0.1.3 → 0.1.4

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 (50) hide show
  1. package/README.md +86 -4
  2. package/dist/cli/annotations.mjs +145 -0
  3. package/dist/cli/audit.mjs +223 -0
  4. package/dist/cli/cache.mjs +488 -0
  5. package/dist/cli/compare.mjs +261 -0
  6. package/dist/cli/config.mjs +141 -0
  7. package/dist/cli/discovery.mjs +372 -0
  8. package/dist/cli/doctor.mjs +354 -0
  9. package/dist/cli/exceptions.mjs +170 -0
  10. package/dist/cli/file.mjs +271 -0
  11. package/dist/cli/files.mjs +141 -0
  12. package/dist/cli/history.mjs +232 -0
  13. package/dist/cli/hosted.mjs +251 -43
  14. package/dist/cli/info.mjs +125 -0
  15. package/dist/cli/init.mjs +57 -0
  16. package/dist/cli/install-guard.mjs +896 -0
  17. package/dist/cli/lockfile.mjs +330 -0
  18. package/dist/cli/main.mjs +97 -14
  19. package/dist/cli/preflight.mjs +388 -0
  20. package/dist/cli/sarif.mjs +100 -0
  21. package/dist/cli/shell-integration.mjs +122 -0
  22. package/dist/cli/terminal.mjs +396 -0
  23. package/dist/cli/watch.mjs +233 -0
  24. package/dist/cli/why.mjs +256 -0
  25. package/dist/core/advisories.mjs +101 -24
  26. package/dist/core/advisory-cache.mjs +295 -0
  27. package/dist/core/behavior-signals.mjs +1088 -0
  28. package/dist/core/dependency-graph.mjs +332 -0
  29. package/dist/core/evidence-exports.mjs +469 -0
  30. package/dist/core/guard-config.mjs +463 -0
  31. package/dist/core/guard-policy.mjs +308 -0
  32. package/dist/core/package-details.mjs +42 -6
  33. package/dist/core/report.mjs +7 -0
  34. package/dist/core/sarif.mjs +556 -0
  35. package/dist/core/syntax.mjs +296 -15
  36. package/dist/model/client.mjs +204 -56
  37. package/dist/model/diff-evidence.mjs +177 -0
  38. package/dist/model/summary.mjs +183 -0
  39. package/dist/server/advisory-result.mjs +137 -0
  40. package/dist/server/app.mjs +60 -2
  41. package/dist/server/dependency-screening.mjs +386 -0
  42. package/dist/server/exports.mjs +63 -0
  43. package/dist/server/guard-decision.mjs +37 -0
  44. package/dist/server/main.mjs +25 -7
  45. package/dist/server/queue.mjs +95 -53
  46. package/dist/server/store.mjs +21 -1
  47. package/dist/version.json +1 -1
  48. package/dist/viewer/render.mjs +10 -2
  49. package/dist/viewer/shell.mjs +1 -1
  50. package/package.json +1 -1
@@ -0,0 +1,386 @@
1
+ import {
2
+ LockfileError,
3
+ explainDependencyPaths,
4
+ explainLockfileLocation,
5
+ parseNpmLockfile,
6
+ } from "../core/dependency-graph.mjs";
7
+
8
+ export const SCREENING_LIMITS = Object.freeze({
9
+ bodyBytes: 2 * 1024 * 1024,
10
+ bodyTimeoutMs: 10000,
11
+ identities: 200,
12
+ concurrency: 4,
13
+ deadlineMs: 60000,
14
+ advisoriesPerDependency: 20,
15
+ explanations: 50,
16
+ omissionExplanations: 50,
17
+ admissions: 6,
18
+ attempts: 30,
19
+ readers: 2,
20
+ admissionWindowMs: 600000,
21
+ });
22
+ const STATUSES = new Set([
23
+ "found",
24
+ "none-reported",
25
+ "incomplete",
26
+ "unavailable",
27
+ ]);
28
+ const systemClock = { now: Date.now, setTimeout, clearTimeout };
29
+ const STATEMENT =
30
+ "Coverage describes exact registry identities only; the project root, local, rejected and unscreened entries were not checked. No reported advisory is not a safety verdict.";
31
+
32
+ // Keeps only fields the guard classifier needs, with affected severity limited to the screened package.
33
+ function advisorySummary(item, name) {
34
+ const affected = (Array.isArray(item.affected) ? item.affected : [])
35
+ .filter(
36
+ (entry) =>
37
+ entry?.package?.ecosystem === "npm" &&
38
+ entry.package.name === name &&
39
+ typeof entry.ecosystemSeverity === "string",
40
+ )
41
+ .slice(0, 8)
42
+ .map((entry) => ({
43
+ package: { ecosystem: "npm", name },
44
+ ecosystemSeverity: entry.ecosystemSeverity,
45
+ }));
46
+ return {
47
+ id: item.id,
48
+ aliases: Array.isArray(item.aliases) ? item.aliases.slice(0, 16) : [],
49
+ ...(typeof item.summary === "string" ? { summary: item.summary } : {}),
50
+ ...(typeof item.databaseSeverity === "string"
51
+ ? { databaseSeverity: item.databaseSeverity }
52
+ : {}),
53
+ ...(typeof item.withdrawn === "string"
54
+ ? { withdrawn: item.withdrawn }
55
+ : {}),
56
+ ...(affected.length ? { affected } : {}),
57
+ };
58
+ }
59
+
60
+ const unscreened = (identity, status, reason) => ({
61
+ ...identity,
62
+ status,
63
+ complete: false,
64
+ reasons: [reason],
65
+ advisories: [],
66
+ });
67
+
68
+ // Accepts a lookup only when it is bound to the exact identity and its status agrees with its advisories and completeness.
69
+ function normalize(identity, lookup, limits) {
70
+ if (
71
+ !lookup ||
72
+ typeof lookup !== "object" ||
73
+ !STATUSES.has(lookup.status) ||
74
+ !Array.isArray(lookup.advisories)
75
+ )
76
+ return unscreened(identity, "unavailable", "invalid-lookup-result");
77
+ if (
78
+ lookup.package?.name !== identity.name ||
79
+ lookup.package?.version !== identity.version
80
+ )
81
+ return unscreened(identity, "unavailable", "identity-mismatch");
82
+ const advisories = lookup.advisories
83
+ .filter((item) => item && typeof item.id === "string")
84
+ .map((item) => advisorySummary(item, identity.name));
85
+ const reasons = Array.isArray(lookup.reasons)
86
+ ? lookup.reasons.filter((reason) => typeof reason === "string")
87
+ : [];
88
+ let status = lookup.status;
89
+ const settled =
90
+ lookup.complete === true &&
91
+ reasons.length === 0 &&
92
+ advisories.length === lookup.advisories.length;
93
+ if (
94
+ (status === "found" && (!advisories.length || !settled)) ||
95
+ (status === "none-reported" && (lookup.advisories.length > 0 || !settled))
96
+ ) {
97
+ reasons.push("inconsistent-lookup-result");
98
+ status = advisories.length ? "incomplete" : "unavailable";
99
+ }
100
+ if (advisories.length > limits.advisoriesPerDependency)
101
+ reasons.push("advisory-output-limit");
102
+ return {
103
+ ...identity,
104
+ status,
105
+ complete:
106
+ ["found", "none-reported"].includes(status) && reasons.length === 0,
107
+ reasons,
108
+ advisories: advisories.slice(0, limits.advisoriesPerDependency),
109
+ };
110
+ }
111
+
112
+ // Screens unique exact registry identities with bounded concurrency, count and a deadline that also bounds in-flight lookups.
113
+ export async function screenDependencyGraph(
114
+ graph,
115
+ { lookup, limits: overrides = {}, clock = systemClock, signal } = {},
116
+ ) {
117
+ const limits = { ...SCREENING_LIMITS, ...overrides };
118
+ const started = clock.now();
119
+ const results = new Array(graph.identities.length);
120
+ const queue = graph.identities
121
+ .slice(0, limits.identities)
122
+ .map((identity, i) => [i, identity]);
123
+ graph.identities.slice(limits.identities).forEach((identity, offset) => {
124
+ results[limits.identities + offset] = unscreened(
125
+ identity,
126
+ "not-screened",
127
+ "identity-limit",
128
+ );
129
+ });
130
+ const deadline = new AbortController();
131
+ const combined = signal
132
+ ? AbortSignal.any([signal, deadline.signal])
133
+ : deadline.signal;
134
+ const timer = (clock.setTimeout ?? setTimeout)(
135
+ () => deadline.abort(),
136
+ limits.deadlineMs,
137
+ );
138
+ let stop;
139
+ const stopped = new Promise((resolve) => {
140
+ stop = () => resolve({ stopped: true });
141
+ combined.addEventListener("abort", stop, { once: true });
142
+ });
143
+ if (combined.aborted) stop();
144
+ const reason = () => (signal?.aborted ? "aborted" : "time-limit");
145
+ async function worker() {
146
+ for (let next = queue.shift(); next; next = queue.shift()) {
147
+ const [index, identity] = next;
148
+ if (combined.aborted || clock.now() - started >= limits.deadlineMs) {
149
+ results[index] = unscreened(identity, "not-screened", reason());
150
+ continue;
151
+ }
152
+ const pending = Promise.resolve()
153
+ .then(() => lookup(identity.spec, { signal: combined }))
154
+ .then(
155
+ (value) => ({ value }),
156
+ () => ({ failed: true }),
157
+ );
158
+ const outcome = await Promise.race([pending, stopped]);
159
+ results[index] = outcome.stopped
160
+ ? unscreened(identity, "unavailable", reason())
161
+ : outcome.failed
162
+ ? unscreened(identity, "unavailable", "lookup-failed")
163
+ : normalize(identity, outcome.value, limits);
164
+ }
165
+ }
166
+ try {
167
+ await Promise.all(
168
+ Array.from(
169
+ { length: Math.min(limits.concurrency, queue.length) },
170
+ worker,
171
+ ),
172
+ );
173
+ } finally {
174
+ (clock.clearTimeout ?? clearTimeout)(timer);
175
+ combined.removeEventListener("abort", stop);
176
+ }
177
+ let explained = 0;
178
+ for (const item of results) {
179
+ if (!item.advisories.length) continue;
180
+ item.explanation =
181
+ explained++ < limits.explanations
182
+ ? explainDependencyPaths(graph, item.spec)
183
+ : {
184
+ spec: item.spec,
185
+ locations: graph.index.bySpec.get(item.spec)?.length ?? 0,
186
+ direct: null,
187
+ paths: [],
188
+ complete: false,
189
+ reasons: ["explanation-limit"],
190
+ };
191
+ }
192
+ const count = (status) =>
193
+ results.filter((item) => item.status === status).length;
194
+ const advisoryBearing = results
195
+ .filter((item) => item.advisories.length)
196
+ .map((item) => item.spec);
197
+ const summary = {
198
+ entries: graph.entries,
199
+ identities: results.length,
200
+ advisoryBearing: advisoryBearing.length,
201
+ found: count("found"),
202
+ noneReported: count("none-reported"),
203
+ incomplete: count("incomplete"),
204
+ unavailable: count("unavailable"),
205
+ notScreened: count("not-screened"),
206
+ integrityConflicting: results.filter(
207
+ (item) => item.integrityStatus === "conflicting",
208
+ ).length,
209
+ rejected: graph.rejected.length,
210
+ local: graph.local.length,
211
+ };
212
+ const registryComplete =
213
+ graph.rejected.length === 0 &&
214
+ summary.integrityConflicting === 0 &&
215
+ results.every((item) => item.complete);
216
+ return {
217
+ schemaVersion: "1",
218
+ source: "OSV",
219
+ lockfileVersion: graph.lockfileVersion,
220
+ root: { ...graph.root, location: "", status: "not-screened" },
221
+ screenedAt: new Date(started).toISOString(),
222
+ status: advisoryBearing.length
223
+ ? "advisories-found"
224
+ : registryComplete
225
+ ? "none-reported"
226
+ : "incomplete",
227
+ coverage: {
228
+ scope: "exact-registry-identities",
229
+ registryComplete,
230
+ projectScreened: false,
231
+ },
232
+ summary,
233
+ advisoryBearing,
234
+ statement: STATEMENT,
235
+ dependencies: results,
236
+ rejected: graph.rejected.map((item, index) => ({
237
+ ...item,
238
+ status: "rejected",
239
+ explanation:
240
+ index < limits.omissionExplanations
241
+ ? explainLockfileLocation(graph, item.location)
242
+ : {
243
+ location: item.location,
244
+ locations: 1,
245
+ direct: null,
246
+ paths: [],
247
+ complete: false,
248
+ reasons: ["explanation-limit"],
249
+ },
250
+ })),
251
+ local: graph.local.map((item) => ({ ...item, status: "not-screened" })),
252
+ limits: {
253
+ identities: limits.identities,
254
+ concurrency: limits.concurrency,
255
+ deadlineMs: limits.deadlineMs,
256
+ },
257
+ };
258
+ }
259
+
260
+ function readJsonBody(req, limits) {
261
+ return new Promise((resolve, reject) => {
262
+ const chunks = [];
263
+ let size = 0,
264
+ done = false;
265
+ const timer = setTimeout(() => finish(408), limits.bodyTimeoutMs);
266
+ timer.unref?.();
267
+ function finish(status) {
268
+ if (done) return;
269
+ done = true;
270
+ clearTimeout(timer);
271
+ req.removeAllListeners("data");
272
+ if (status) {
273
+ req.on("error", () => {});
274
+ req.resume();
275
+ reject(Object.assign(new Error("body rejected"), { status }));
276
+ } else resolve(Buffer.concat(chunks, size));
277
+ }
278
+ if (Number(req.headers["content-length"]) > limits.bodyBytes)
279
+ return finish(413);
280
+ req.on("data", (chunk) => {
281
+ size += chunk.length;
282
+ if (size > limits.bodyBytes) finish(413);
283
+ else chunks.push(chunk);
284
+ });
285
+ req.on("end", () => finish());
286
+ req.on("error", () => finish(400));
287
+ req.on("aborted", () => finish(400));
288
+ });
289
+ }
290
+
291
+ export function createDependencyScreening({
292
+ lookup,
293
+ limits: overrides = {},
294
+ clock = systemClock,
295
+ } = {}) {
296
+ if (lookup !== undefined && typeof lookup !== "function")
297
+ throw new TypeError("advisoryLookup must be a function");
298
+ const limits = { ...SCREENING_LIMITS, ...overrides };
299
+ let active = false,
300
+ reading = 0,
301
+ admissions = [],
302
+ attempts = [];
303
+ const limited = (retryAfter) => [
304
+ 429,
305
+ { error: "screening-limit", retryAfter },
306
+ { "retry-after": String(retryAfter) },
307
+ ];
308
+ const wait = (list, max, now) =>
309
+ list.length >= max
310
+ ? Math.max(
311
+ 1,
312
+ Math.ceil((list[0] + limits.admissionWindowMs - now) / 1000),
313
+ )
314
+ : 0;
315
+ return async function handle(req, res, json) {
316
+ if (req.method !== "POST")
317
+ return json(405, { error: "method-not-allowed" }, { allow: "POST" });
318
+ if (
319
+ req.headers["sec-fetch-site"] === "cross-site" ||
320
+ (req.headers.origin &&
321
+ req.headers.origin !== `http://${req.headers.host}` &&
322
+ req.headers.origin !== `https://${req.headers.host}`)
323
+ )
324
+ return json(403, { error: "origin-rejected" });
325
+ if (
326
+ !/^application\/json(?:\s*;\s*charset=utf-8)?$/i.test(
327
+ req.headers["content-type"] || "",
328
+ )
329
+ )
330
+ return json(415, { error: "expected-json-lockfile" });
331
+ if (!lookup) return json(503, { error: "advisory-lookup-unavailable" });
332
+ let now = clock.now();
333
+ attempts = attempts.filter((at) => now - at < limits.admissionWindowMs);
334
+ // Every body attempt is counted and concurrent reads are capped before any screening slot is held.
335
+ const attemptWait = wait(attempts, limits.attempts, now);
336
+ if (attemptWait || reading >= limits.readers)
337
+ return json(...limited(attemptWait || 5));
338
+ attempts.push(now);
339
+ let body;
340
+ reading++;
341
+ try {
342
+ body = await readJsonBody(req, limits);
343
+ } catch (error) {
344
+ return json(error.status || 400, {
345
+ error: error.status === 413 ? "lockfile-too-large" : "body-unavailable",
346
+ });
347
+ } finally {
348
+ reading--;
349
+ }
350
+ let graph;
351
+ try {
352
+ graph = parseNpmLockfile(
353
+ new TextDecoder("utf-8", { fatal: true }).decode(body),
354
+ );
355
+ } catch (error) {
356
+ return json(400, {
357
+ error: "invalid-lockfile",
358
+ reason: error instanceof LockfileError ? error.reason : "invalid-utf8",
359
+ });
360
+ }
361
+ now = clock.now();
362
+ admissions = admissions.filter((at) => now - at < limits.admissionWindowMs);
363
+ if (active) return json(...limited(5));
364
+ const admissionWait = wait(admissions, limits.admissions, now);
365
+ if (admissionWait) return json(...limited(admissionWait));
366
+ active = true;
367
+ admissions.push(now);
368
+ const controller = new AbortController();
369
+ const abort = () => {
370
+ if (!res.writableEnded) controller.abort();
371
+ };
372
+ res.on("close", abort);
373
+ try {
374
+ const result = await screenDependencyGraph(graph, {
375
+ lookup,
376
+ limits,
377
+ clock,
378
+ signal: controller.signal,
379
+ });
380
+ return json(200, result);
381
+ } finally {
382
+ active = false;
383
+ res.removeListener("close", abort);
384
+ }
385
+ };
386
+ }
@@ -0,0 +1,63 @@
1
+ import {
2
+ buildCycloneDxSbom,
3
+ buildEvidenceManifest,
4
+ CYCLONEDX_MEDIA_TYPE,
5
+ } from "../core/evidence-exports.mjs";
6
+
7
+ const EXPORTS = {
8
+ "cdx.json": {
9
+ type: `${CYCLONEDX_MEDIA_TYPE}; charset=utf-8`,
10
+ filename: "unmask-sbom.cdx.json",
11
+ build: (report, versionInfo) =>
12
+ buildCycloneDxSbom(report, { toolVersion: versionInfo.version }),
13
+ },
14
+ "evidence.json": {
15
+ type: "application/json; charset=utf-8",
16
+ filename: "unmask-evidence-manifest.json",
17
+ build: (report, versionInfo, job) =>
18
+ buildEvidenceManifest(report, {
19
+ reportId: job.id,
20
+ completedAt: job.finishedAt,
21
+ versionInfo,
22
+ }),
23
+ },
24
+ };
25
+
26
+ export function matchExportPath(path) {
27
+ const match = /^\/r\/([a-f0-9]{32})\.([a-z]+\.json)$/.exec(path);
28
+ return match && Object.hasOwn(EXPORTS, match[2])
29
+ ? { id: match[1], kind: match[2] }
30
+ : null;
31
+ }
32
+
33
+ export function serveExport({ path, queue, versionInfo, send }) {
34
+ const route = matchExportPath(path);
35
+ if (!route) return false;
36
+ const json = (status, value, headers = {}, type = "application/json") =>
37
+ send(status, JSON.stringify(value), `${type}; charset=utf-8`, headers);
38
+ const job = queue.get(route.id);
39
+ if (!job) {
40
+ json(404, { error: "report-unavailable" });
41
+ return true;
42
+ }
43
+ if (["queued", "running"].includes(job.status)) {
44
+ json(202, { status: job.status }, { "retry-after": "3" });
45
+ return true;
46
+ }
47
+ if (!job.report) {
48
+ json(409, { error: "report-not-produced", status: job.status });
49
+ return true;
50
+ }
51
+ const spec = EXPORTS[route.kind];
52
+ let body;
53
+ try {
54
+ body = spec.build(job.report, versionInfo, job);
55
+ } catch {
56
+ json(422, { error: "report-not-exportable" });
57
+ return true;
58
+ }
59
+ send(200, JSON.stringify(body), spec.type, {
60
+ "content-disposition": `attachment; filename="${spec.filename}"`,
61
+ });
62
+ return true;
63
+ }
@@ -0,0 +1,37 @@
1
+ import {
2
+ evaluateGuardDecision,
3
+ getGuardProfile,
4
+ } from "../core/guard-policy.mjs";
5
+
6
+ export function guardDecisionResponse(job, { search = "" } = {}) {
7
+ const params = new URLSearchParams(search);
8
+ const profile = params.has("profile") ? params.get("profile") : "strict";
9
+ try {
10
+ if (params.size > 1 || [...params.keys()].some((key) => key !== "profile"))
11
+ throw new TypeError("Invalid query");
12
+ getGuardProfile(profile);
13
+ } catch {
14
+ return {
15
+ status: 400,
16
+ body: {
17
+ error: "invalid-guard-profile",
18
+ profiles: ["strict", "balanced", "audit"],
19
+ },
20
+ };
21
+ }
22
+ if (!job) return { status: 404, body: { error: "report-unavailable" } };
23
+ const pending = ["queued", "running"].includes(job.status);
24
+ return {
25
+ status: pending ? 202 : 200,
26
+ body: {
27
+ ...evaluateGuardDecision(job, { profile }),
28
+ reportId: job.id,
29
+ checkStatus: job.status,
30
+ assessedAt:
31
+ job.finishedAt === undefined
32
+ ? null
33
+ : new Date(job.finishedAt).toISOString(),
34
+ },
35
+ headers: pending ? { "retry-after": "3" } : {},
36
+ };
37
+ }
@@ -48,6 +48,7 @@ export async function startServer({
48
48
  versionInfo,
49
49
  modelClient,
50
50
  enrichJob,
51
+ advisoryCache,
51
52
  } = {}) {
52
53
  if (
53
54
  typeof host !== "string" ||
@@ -63,12 +64,20 @@ export async function startServer({
63
64
  : modelClient === undefined
64
65
  ? await modelClientFromEnv()
65
66
  : modelClient;
66
- const enrichment =
67
- enrichJob === null
68
- ? undefined
69
- : enrichJob === undefined
70
- ? (await import("../core/advisories.mjs")).lookupNpmAdvisories
71
- : enrichJob;
67
+ if (
68
+ advisoryCache !== undefined &&
69
+ (typeof advisoryCache?.lookup !== "function" || enrichJob !== undefined)
70
+ )
71
+ throw new TypeError("advisoryCache needs a lookup and no enrichJob");
72
+ // One cache per server: every advisory consumer derived from enrichJob shares it.
73
+ const cache =
74
+ enrichJob === undefined
75
+ ? (advisoryCache ??
76
+ (await import("../core/advisory-cache.mjs")).createAdvisoryCache({
77
+ exposeStats: process.env.UNMASK_ADVISORY_CACHE_STATS === "1",
78
+ }))
79
+ : undefined;
80
+ const enrichment = cache ? cache.lookup : (enrichJob ?? undefined);
72
81
  if (enrichment !== undefined && typeof enrichment !== "function")
73
82
  throw new TypeError("enrichJob must be a function or null");
74
83
  const run = await createRunner({
@@ -97,6 +106,15 @@ export async function startServer({
97
106
  enrichment(spec, {
98
107
  signal: AbortSignal.any([signal, shutdown.signal]),
99
108
  }),
109
+ advisoryLookup:
110
+ enrichment === undefined
111
+ ? undefined
112
+ : (spec, { signal } = {}) =>
113
+ enrichment(spec, {
114
+ signal: signal
115
+ ? AbortSignal.any([signal, shutdown.signal])
116
+ : shutdown.signal,
117
+ }),
100
118
  versionInfo: {
101
119
  ...(versionInfo || (await loadVersionInfo())),
102
120
  modelConfigDigest:
@@ -134,7 +152,7 @@ export async function startServer({
134
152
  await close();
135
153
  throw error;
136
154
  }
137
- return { server, close };
155
+ return { server, close, ...(cache ? { advisoryCache: cache } : {}) };
138
156
  }
139
157
 
140
158
  async function main() {