@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,256 @@
1
+ import { LocalInputError } from "../core/pipeline.mjs";
2
+ import {
3
+ isExactVersion,
4
+ isPackageName,
5
+ nameFromKey,
6
+ readLockfile,
7
+ resolveEdge,
8
+ } from "./lockfile.mjs";
9
+
10
+ export const WHY_LIMITS = Object.freeze({
11
+ defaultPaths: 10,
12
+ maxPaths: 100,
13
+ maxDepth: 64,
14
+ maxSteps: 50000,
15
+ });
16
+
17
+ export function parseQuery(value) {
18
+ const separator = typeof value === "string" ? value.lastIndexOf("@") : -1;
19
+ const name = separator > 0 ? value.slice(0, separator) : value;
20
+ const version = separator > 0 ? value.slice(separator + 1) : null;
21
+ if (!isPackageName(name) || (version !== null && !isExactVersion(version)))
22
+ throw new LocalInputError(
23
+ "invalid-package",
24
+ "Use why name or why name@exact-version",
25
+ );
26
+ return { name, version };
27
+ }
28
+
29
+ export function parseWhyArgs(argv) {
30
+ const options = {
31
+ lockfile: "package-lock.json",
32
+ maxPaths: WHY_LIMITS.defaultPaths,
33
+ json: false,
34
+ };
35
+ if (!argv[0] || argv[0].startsWith("--"))
36
+ throw new LocalInputError(
37
+ "usage",
38
+ "Use why name[@version] [--lockfile PATH] [--max-paths N] [--json]",
39
+ );
40
+ options.query = parseQuery(argv[0]);
41
+ const seen = new Set();
42
+ for (let index = 1; index < argv.length; index++) {
43
+ const key = argv[index];
44
+ if (seen.has(key))
45
+ throw new LocalInputError(
46
+ "duplicate-option",
47
+ "Each option may be supplied only once",
48
+ );
49
+ seen.add(key);
50
+ if (key === "--json") {
51
+ options.json = true;
52
+ continue;
53
+ }
54
+ const value = argv[++index];
55
+ if (key === "--lockfile" && value && !value.startsWith("--"))
56
+ options.lockfile = value;
57
+ else if (
58
+ key === "--max-paths" &&
59
+ /^[1-9]\d*$/.test(value ?? "") &&
60
+ Number(value) <= WHY_LIMITS.maxPaths
61
+ )
62
+ options.maxPaths = Number(value);
63
+ else
64
+ throw new LocalInputError(
65
+ "invalid-option",
66
+ `Use --lockfile PATH, --max-paths 1..${WHY_LIMITS.maxPaths} or --json`,
67
+ );
68
+ }
69
+ return options;
70
+ }
71
+
72
+ function graph(lock) {
73
+ const dependents = new Map();
74
+ const unresolved = [];
75
+ for (const entry of lock.entries.values()) {
76
+ for (const edge of entry.edges) {
77
+ const target = resolveEdge(lock, entry.key, edge.name);
78
+ if (!target) {
79
+ if (edge.type !== "dev" || ["root", "workspace"].includes(entry.kind))
80
+ unresolved.push({ from: entry.key, ...edge });
81
+ continue;
82
+ }
83
+ const list = dependents.get(target.key) ?? [];
84
+ list.push({ from: entry.key, type: edge.type, range: edge.range });
85
+ dependents.set(target.key, list);
86
+ }
87
+ }
88
+ return { dependents, unresolved };
89
+ }
90
+
91
+ const isRoot = (entry) => ["root", "workspace"].includes(entry?.kind);
92
+ const label = (entry) =>
93
+ entry.kind === "root"
94
+ ? `${entry.name ?? "(root)"} (root)`
95
+ : `${entry.name}@${entry.version ?? "?"}${entry.kind === "workspace" ? " (workspace)" : ""}`;
96
+
97
+ function pathsTo(lock, dependents, key, limits) {
98
+ const paths = [];
99
+ let steps = 0;
100
+ let truncated = false;
101
+ const onPath = new Set([key]);
102
+ // Depth-first walk to roots over dependents; onPath breaks cycles.
103
+ function walk(current, trail) {
104
+ if (paths.length >= limits.maxPaths || ++steps > limits.maxSteps) {
105
+ truncated = true;
106
+ return;
107
+ }
108
+ const entry = lock.entries.get(current);
109
+ if (isRoot(entry) && current !== key) {
110
+ paths.push(trail.slice().reverse());
111
+ return;
112
+ }
113
+ if (trail.length > limits.maxDepth) {
114
+ truncated = true;
115
+ return;
116
+ }
117
+ for (const link of dependents.get(current) ?? []) {
118
+ if (onPath.has(link.from)) continue;
119
+ onPath.add(link.from);
120
+ trail.push({ key: link.from, type: link.type, range: link.range });
121
+ walk(link.from, trail);
122
+ trail.pop();
123
+ onPath.delete(link.from);
124
+ }
125
+ }
126
+ walk(key, [{ key, type: null, range: null }]);
127
+ paths.sort((a, b) => a.length - b.length);
128
+ return {
129
+ paths: paths.map((trail) =>
130
+ trail.map((step) => {
131
+ const entry = lock.entries.get(step.key);
132
+ return {
133
+ key: step.key,
134
+ label: label(entry),
135
+ dependencyType: step.type,
136
+ range: step.range,
137
+ };
138
+ }),
139
+ ),
140
+ truncated,
141
+ };
142
+ }
143
+
144
+ export function explainWhy(lock, query, { maxPaths = 10 } = {}) {
145
+ const limits = { ...WHY_LIMITS, maxPaths };
146
+ const { dependents, unresolved } = graph(lock);
147
+ const matches = [];
148
+ for (const entry of lock.entries.values()) {
149
+ if (entry.kind === "root" || entry.kind === "link") continue;
150
+ const alias = nameFromKey(entry.key);
151
+ if (entry.name !== query.name && alias !== query.name) continue;
152
+ if (query.version && entry.version !== query.version) continue;
153
+ const { paths, truncated } = pathsTo(lock, dependents, entry.key, limits);
154
+ matches.push({
155
+ key: entry.key,
156
+ name: entry.name,
157
+ alias: alias !== entry.name ? alias : null,
158
+ version: entry.version,
159
+ kind: entry.kind,
160
+ dev: entry.dev,
161
+ optional: entry.optional,
162
+ bundled: entry.bundled,
163
+ direct: paths.some((trail) => trail.length === 2),
164
+ workspace: entry.kind === "workspace",
165
+ paths,
166
+ truncated,
167
+ });
168
+ }
169
+ const versions = new Set(matches.map((match) => match.version));
170
+ const limitations = [
171
+ "Paths follow lockfile placement and npm node_modules resolution, not the installed tree on disk.",
172
+ ];
173
+ if (matches.length > 1)
174
+ limitations.push(
175
+ `${matches.length} locations (${versions.size} versions) are locked; each is explained separately.`,
176
+ );
177
+ if (matches.some((match) => match.truncated))
178
+ limitations.push("Some path lists were truncated by traversal limits.");
179
+ if (matches.some((match) => !match.workspace && match.paths.length === 0))
180
+ limitations.push(
181
+ "Entries with no path are extraneous, bundled or reached only through unresolved links.",
182
+ );
183
+ if (matches.some((match) => match.workspace))
184
+ limitations.push(
185
+ "A workspace is part of the project itself; paths show only other workspaces that depend on it.",
186
+ );
187
+ if ([...lock.entries.values()].some((entry) => entry.kind === "workspace"))
188
+ limitations.push(
189
+ "Workspaces are treated as their own roots; root-to-workspace membership is not shown as an edge.",
190
+ );
191
+ return {
192
+ schemaVersion: "1",
193
+ query,
194
+ found: matches.length > 0,
195
+ matches,
196
+ unresolvedEdges: unresolved
197
+ .filter((edge) => edge.name === query.name)
198
+ .slice(0, limits.maxPaths),
199
+ limitations,
200
+ };
201
+ }
202
+
203
+ function formatText(result) {
204
+ const lines = [];
205
+ for (const match of result.matches) {
206
+ const flags = [
207
+ match.workspace ? null : match.direct ? "direct" : "transitive",
208
+ match.dev && "dev",
209
+ match.optional && "optional",
210
+ match.bundled && "bundled",
211
+ !["registry", "bundled"].includes(match.kind) && match.kind,
212
+ ].filter(Boolean);
213
+ lines.push(
214
+ `${match.name}@${match.version ?? "?"} at ${match.key} (${flags.join(", ")}): ${match.paths.length}${match.truncated ? "+" : ""} path(s)`,
215
+ );
216
+ for (const trail of match.paths)
217
+ lines.push(
218
+ " " +
219
+ trail
220
+ .map((step) =>
221
+ step.dependencyType && step.dependencyType !== "prod"
222
+ ? `${step.label} -[${step.dependencyType}]->`
223
+ : step.dependencyType
224
+ ? `${step.label} ->`
225
+ : step.label,
226
+ )
227
+ .join(" "),
228
+ );
229
+ }
230
+ for (const note of result.limitations) lines.push(`Note: ${note}`);
231
+ return lines.join("\n") + "\n";
232
+ }
233
+
234
+ export async function runWhy(argv) {
235
+ const options = parseWhyArgs(argv);
236
+ const lock = await readLockfile(options.lockfile);
237
+ const result = explainWhy(lock, options.query, options);
238
+ if (!result.found && result.unresolvedEdges.length > 0) {
239
+ const from = result.unresolvedEdges
240
+ .map((edge) => `${edge.from || "the root"} (${edge.type})`)
241
+ .join(", ");
242
+ throw new LocalInputError(
243
+ "package-not-locked",
244
+ `Declared by ${from} but has no locked entry; npm would resolve it at install time, so it is unscreened.`,
245
+ );
246
+ }
247
+ if (!result.found)
248
+ throw new LocalInputError(
249
+ "package-not-in-lockfile",
250
+ "No locked entry matches that package; it is not part of this lockfile.",
251
+ );
252
+ process.stdout.write(
253
+ options.json ? JSON.stringify(result, null, 2) + "\n" : formatText(result),
254
+ );
255
+ return 0;
256
+ }
@@ -6,16 +6,21 @@ export const ADVISORY_LIMITS = Object.freeze({
6
6
  responseBytes: 1024 * 1024,
7
7
  pages: 3,
8
8
  advisories: 100,
9
+ retries: 1,
10
+ retryDelayMs: 250,
9
11
  });
12
+ const TRANSIENT_STATUSES = [429, 500, 502, 503, 504];
10
13
  const systemClock = { now: Date.now, setTimeout, clearTimeout };
11
14
  class LookupFailure extends Error {
12
- constructor(reason) {
15
+ constructor(reason, detail = {}) {
13
16
  super(reason);
14
17
  this.reason = reason;
18
+ this.httpStatus = detail.httpStatus;
19
+ this.retryable = detail.retryable === true;
15
20
  }
16
21
  }
17
- const fail = (reason) => {
18
- throw new LookupFailure(reason);
22
+ const fail = (reason, detail) => {
23
+ throw new LookupFailure(reason, detail);
19
24
  };
20
25
  const object = (value) =>
21
26
  value !== null && typeof value === "object" && !Array.isArray(value);
@@ -248,7 +253,41 @@ export async function lookupNpmAdvisories(
248
253
  const reasons = new Set();
249
254
  const records = new Map();
250
255
  const mark = (reason) => reasons.add(reason);
256
+ const provider = { requests: 0, retries: 0, failures: [] };
257
+ let timedOut = false,
258
+ active = true;
259
+ const recorded = new WeakSet();
260
+ function note(error) {
261
+ if (!active || recorded.has(error)) return;
262
+ recorded.add(error);
263
+ const reason =
264
+ error instanceof LookupFailure ? error.reason : "request-failed";
265
+ const last = provider.failures.at(-1);
266
+ if (last?.reason === reason && last.attempt === provider.requests) return;
267
+ provider.failures.push({
268
+ attempt: provider.requests,
269
+ reason,
270
+ ...(error?.httpStatus ? { httpStatus: error.httpStatus } : {}),
271
+ });
272
+ }
251
273
  function finish() {
274
+ result.diagnostics = {
275
+ provider: {
276
+ name: "OSV",
277
+ availability: !provider.requests
278
+ ? "not-queried"
279
+ : !result.pagesFetched
280
+ ? "unavailable"
281
+ : provider.failures.length
282
+ ? "degraded"
283
+ : "available",
284
+ requests: provider.requests,
285
+ retries: provider.retries,
286
+ timeouts: timedOut ? 1 : 0,
287
+ failures: provider.failures.map((failure) => ({ ...failure })),
288
+ durationMs: Math.max(0, clock.now() - started),
289
+ },
290
+ };
252
291
  result.advisories = [...records.values()].sort((a, b) =>
253
292
  a.id < b.id ? -1 : a.id > b.id ? 1 : 0,
254
293
  );
@@ -277,7 +316,7 @@ export async function lookupNpmAdvisories(
277
316
  (key) =>
278
317
  !Object.hasOwn(ADVISORY_LIMITS, key) ||
279
318
  !Number.isSafeInteger(limits[key]) ||
280
- limits[key] < 1 ||
319
+ limits[key] < (key === "retries" ? 0 : 1) ||
281
320
  limits[key] > ADVISORY_LIMITS[key],
282
321
  )
283
322
  ) {
@@ -293,13 +332,12 @@ export async function lookupNpmAdvisories(
293
332
  const combined = signal
294
333
  ? AbortSignal.any([signal, controller.signal])
295
334
  : controller.signal;
296
- let timedOut = false,
297
- active = true;
298
335
  const readers = new Set();
299
336
  const timer = clock.setTimeout(() => {
300
337
  timedOut = true;
301
338
  controller.abort();
302
339
  }, limits.timeoutMs);
340
+ const pauses = new Set();
303
341
  let interrupt;
304
342
  const interrupted = new Promise((_, reject) => {
305
343
  interrupt = () => {
@@ -318,22 +356,29 @@ export async function lookupNpmAdvisories(
318
356
  }
319
357
  async function page(token) {
320
358
  alive();
321
- const response = await fetchImpl(ENDPOINT, {
322
- method: "POST",
323
- redirect: "manual",
324
- credentials: "omit",
325
- referrerPolicy: "no-referrer",
326
- signal: combined,
327
- headers: {
328
- "content-type": "application/json",
329
- accept: "application/json",
330
- },
331
- body: JSON.stringify({
332
- package: { name: claim.name, ecosystem: "npm" },
333
- version: claim.version,
334
- ...(token ? { page_token: token } : {}),
335
- }),
336
- });
359
+ provider.requests++;
360
+ let response;
361
+ try {
362
+ response = await fetchImpl(ENDPOINT, {
363
+ method: "POST",
364
+ redirect: "manual",
365
+ credentials: "omit",
366
+ referrerPolicy: "no-referrer",
367
+ signal: combined,
368
+ headers: {
369
+ "content-type": "application/json",
370
+ accept: "application/json",
371
+ },
372
+ body: JSON.stringify({
373
+ package: { name: claim.name, ecosystem: "npm" },
374
+ version: claim.version,
375
+ ...(token ? { page_token: token } : {}),
376
+ }),
377
+ });
378
+ } catch {
379
+ alive();
380
+ fail("request-failed", { retryable: true });
381
+ }
337
382
  if (combined.aborted || !active) response.body?.cancel().catch(() => {});
338
383
  alive();
339
384
  if (
@@ -346,7 +391,16 @@ export async function lookupNpmAdvisories(
346
391
  }
347
392
  if (response.status !== 200) {
348
393
  response.body?.cancel().catch(() => {});
349
- fail("http-error");
394
+ const status = Number.isSafeInteger(response.status)
395
+ ? response.status
396
+ : undefined;
397
+ const wait = response.headers.get("retry-after");
398
+ fail("http-error", {
399
+ httpStatus: status,
400
+ retryable:
401
+ TRANSIENT_STATUSES.includes(status) &&
402
+ (wait === null || /^[01]$/.test(wait.trim())),
403
+ });
350
404
  }
351
405
  if (
352
406
  !/^application\/json(?:\s*;\s*charset=utf-8)?$/i.test(
@@ -412,11 +466,32 @@ export async function lookupNpmAdvisories(
412
466
  fail("invalid-response");
413
467
  return parsed;
414
468
  }
469
+ async function attempt(token) {
470
+ for (;;) {
471
+ try {
472
+ return await page(token);
473
+ } catch (error) {
474
+ note(error);
475
+ if (!error?.retryable || provider.retries >= limits.retries)
476
+ throw error;
477
+ provider.retries++;
478
+ // Back off once for transient provider failures, still inside the lookup deadline.
479
+ await new Promise((resolve) => {
480
+ const id = clock.setTimeout(() => {
481
+ pauses.delete(id);
482
+ resolve();
483
+ }, limits.retryDelayMs);
484
+ pauses.add(id);
485
+ });
486
+ alive();
487
+ }
488
+ }
489
+ }
415
490
  async function query() {
416
491
  const tokens = new Set();
417
492
  let token;
418
493
  for (let i = 0; i < limits.pages; i++) {
419
- const value = await page(token);
494
+ const value = await attempt(token);
420
495
  alive();
421
496
  result.pagesFetched++;
422
497
  for (const raw of value.vulns ?? []) {
@@ -446,10 +521,12 @@ export async function lookupNpmAdvisories(
446
521
  try {
447
522
  await Promise.race([query(), interrupted]);
448
523
  } catch (error) {
524
+ note(error);
449
525
  mark(error instanceof LookupFailure ? error.reason : "request-failed");
450
526
  } finally {
451
527
  active = false;
452
528
  clock.clearTimeout(timer);
529
+ for (const id of pauses) clock.clearTimeout(id);
453
530
  combined.removeEventListener("abort", interrupt);
454
531
  for (const reader of readers) reader.cancel().catch(() => {});
455
532
  }