@westbayberry/dg 2.0.4 → 2.0.5

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.
@@ -6,6 +6,7 @@ import { envAuthToken } from "../auth/env-token.js";
6
6
  import { loadUserConfig } from "../config/settings.js";
7
7
  import { sanitize, sanitizeResponse } from "../security/sanitize.js";
8
8
  import { resolveDgPaths } from "../state/index.js";
9
+ import { dgVersion } from "../commands/version.js";
9
10
  export class AnalyzeError extends Error {
10
11
  statusCode;
11
12
  body;
@@ -29,13 +30,15 @@ export async function analyzePackages(packages, options) {
29
30
  const token = resolveToken(env);
30
31
  const deviceId = getOrCreateDeviceId(env);
31
32
  const url = `${baseUrl}${ANALYZE_PATHS[options.ecosystem]}`;
32
- options.onProgress?.(0, packages.length, []);
33
+ const batchCount = Math.max(1, Math.ceil(packages.length / BATCH_SIZE));
34
+ options.onProgress?.({ done: 0, total: packages.length, batchIndex: 0, batchCount });
33
35
  const responses = [];
34
36
  for (let index = 0; index < packages.length; index += BATCH_SIZE) {
35
37
  const batch = packages.slice(index, index + BATCH_SIZE);
36
- options.onProgress?.(index, packages.length, batch.map((entry) => entry.name));
38
+ const batchIndex = Math.floor(index / BATCH_SIZE) + 1;
39
+ options.onProgress?.({ done: index, total: packages.length, batchIndex, batchCount });
37
40
  responses.push(await analyzeBatchWithRetry(url, batch, token, deviceId, fetchImpl, options.timeoutMs ?? DEFAULT_TIMEOUT_MS));
38
- options.onProgress?.(Math.min(index + batch.length, packages.length), packages.length, batch.map((entry) => entry.name));
41
+ options.onProgress?.({ done: Math.min(index + batch.length, packages.length), total: packages.length, batchIndex, batchCount });
39
42
  }
40
43
  return mergeAnalyzeResponses(responses);
41
44
  }
@@ -86,6 +89,7 @@ async function analyzeBatch(url, batch, token, deviceId, fetchImpl, timeoutMs) {
86
89
  headers: {
87
90
  "Content-Type": "application/json",
88
91
  "X-Device-Id": deviceId,
92
+ "X-Dg-Version": dgVersion(),
89
93
  ...(token ? { Authorization: `Bearer ${token}` } : {})
90
94
  },
91
95
  body: JSON.stringify({
@@ -128,8 +128,8 @@ export const App = ({ config, userStatus, scanUsage, setupIssues = [], initialVi
128
128
  case "selecting":
129
129
  return (_jsx(ProjectSelector, { projects: state.projects, onConfirm: scanSelectedProjects, onCancel: () => { process.exitCode = 0; leaveAltScreen(); exit(); }, userStatus: userStatus }));
130
130
  case "scanning":
131
- return (_jsx(ProgressBar, { value: state.done, total: state.total, label: state.currentBatch.length > 0
132
- ? state.currentBatch[state.currentBatch.length - 1]
131
+ return (_jsx(ProgressBar, { value: state.done, total: state.total, label: state.batchCount > 1 && state.batchIndex >= 1
132
+ ? `batch ${state.batchIndex}/${state.batchCount}`
133
133
  : undefined }));
134
134
  case "results":
135
135
  return (_jsx(InteractiveResultsView, { result: state.result, config: config, durationMs: state.durationMs, onExit: handleResultsExit, onBack: restartSelection ?? undefined, discoveredTotal: state.discoveredTotal, userStatus: userStatus, scanUsage: scanUsage, initialView: initialView }));
@@ -47,7 +47,7 @@ function isYankedIncomplete(pkg) {
47
47
  }
48
48
  export function packageBadge(pkg) {
49
49
  if (isYankedIncomplete(pkg))
50
- return { label: "Removed", color: chalk.yellow };
50
+ return { label: "Unverified", color: chalk.yellow };
51
51
  return actionBadge(pkg.action);
52
52
  }
53
53
  const EVIDENCE_LIMIT = 2;
@@ -21,9 +21,10 @@ export function truncate(s, max) {
21
21
  export function groupPackages(packages, keyBy = "name") {
22
22
  const map = new Map();
23
23
  for (const pkg of packages) {
24
+ const action = pkg.action ?? "pass";
24
25
  const fingerprint = pkg.findings.length === 0
25
- ? `__clean_${pkg.score}`
26
- : pkg.findings
26
+ ? `${action}|${pkg.name}@${pkg.version ?? ""}|score:${pkg.score}`
27
+ : `${action}|` + pkg.findings
27
28
  .map((f) => `${f.category ?? ""}:${f.severity}`)
28
29
  .sort()
29
30
  .join("|") + `|score:${pkg.score}`;
@@ -9,11 +9,11 @@ function reducer(_state, action) {
9
9
  case "DISCOVERY_PROGRESS":
10
10
  return { phase: "discovering", path: action.path, found: action.found };
11
11
  case "DISCOVERY_COMPLETE":
12
- return { phase: "scanning", done: 0, total: action.total, currentBatch: [] };
12
+ return { phase: "scanning", done: 0, total: action.total, batchIndex: 0, batchCount: 1 };
13
13
  case "DISCOVERY_EMPTY":
14
14
  return { phase: "empty", message: action.message };
15
15
  case "SCAN_PROGRESS":
16
- return { phase: "scanning", done: action.done, total: action.total, currentBatch: action.currentBatch };
16
+ return { phase: "scanning", done: action.done, total: action.total, batchIndex: action.batchIndex, batchCount: action.batchCount };
17
17
  case "SCAN_COMPLETE":
18
18
  return {
19
19
  phase: "results",
@@ -83,8 +83,8 @@ async function scanProjects(projects, dispatch) {
83
83
  const base = completed;
84
84
  responses.push(await analyzePackages(packages, {
85
85
  ecosystem,
86
- onProgress: (done, _ecosystemTotal, currentBatch) => {
87
- dispatch({ type: "SCAN_PROGRESS", done: base + done, total, currentBatch: [...currentBatch] });
86
+ onProgress: (progress) => {
87
+ dispatch({ type: "SCAN_PROGRESS", done: base + progress.done, total, batchIndex: progress.batchIndex, batchCount: progress.batchCount });
88
88
  }
89
89
  }));
90
90
  completed = base + packages.length;
@@ -245,44 +245,73 @@ function parsePackageLock(text) {
245
245
  return [malformedLockfileObservation("package-lock.json", new Error("root must be an object"))];
246
246
  }
247
247
  const observations = [];
248
- const packages = isRecord(parsed.packages) ? parsed.packages : {};
249
- for (const [path, rawPackage] of Object.entries(packages)) {
250
- if (path.length === 0 || !isRecord(rawPackage)) {
251
- continue;
252
- }
253
- const name = typeof rawPackage.name === "string" ? rawPackage.name : packageNameFromNodeModulesPath(path);
254
- const version = typeof rawPackage.version === "string" ? rawPackage.version : null;
255
- if (!name) {
256
- continue;
248
+ if (isRecord(parsed.packages)) {
249
+ for (const [path, rawPackage] of Object.entries(parsed.packages)) {
250
+ if (path.length === 0 || !isRecord(rawPackage) || rawPackage.link === true) {
251
+ continue;
252
+ }
253
+ const declaredName = typeof rawPackage.name === "string" ? rawPackage.name : packageNameFromNodeModulesPath(path);
254
+ const alias = npmAliasVersion(stringOrNull(rawPackage.version));
255
+ const name = alias?.name ?? declaredName;
256
+ const version = alias?.version ?? (typeof rawPackage.version === "string" ? rawPackage.version : null);
257
+ if (!name) {
258
+ continue;
259
+ }
260
+ observations.push(lockfileObservation({
261
+ ecosystem: "npm",
262
+ name,
263
+ version,
264
+ requested: path,
265
+ sourceKind: "lockfile",
266
+ resolvedUrl: stringOrNull(rawPackage.resolved),
267
+ integrity: stringOrNull(rawPackage.integrity),
268
+ license: stringOrNull(rawPackage.license)
269
+ }));
257
270
  }
258
- observations.push(lockfileObservation({
259
- ecosystem: "npm",
260
- name,
261
- version,
262
- requested: path,
263
- sourceKind: "lockfile",
264
- resolvedUrl: stringOrNull(rawPackage.resolved),
265
- integrity: stringOrNull(rawPackage.integrity),
266
- license: stringOrNull(rawPackage.license)
267
- }));
271
+ return observations;
268
272
  }
269
- const dependencies = isRecord(parsed.dependencies) ? parsed.dependencies : {};
273
+ if (isRecord(parsed.dependencies)) {
274
+ walkLegacyDependencies(parsed.dependencies, observations, new Set());
275
+ }
276
+ return observations;
277
+ }
278
+ function walkLegacyDependencies(dependencies, observations, seen) {
270
279
  for (const [name, rawPackage] of Object.entries(dependencies)) {
271
- if (!isRecord(rawPackage) || observations.some((observation) => observation.identity.name === name)) {
280
+ if (!isRecord(rawPackage) || rawPackage.bundled === true) {
272
281
  continue;
273
282
  }
274
- observations.push(lockfileObservation({
275
- ecosystem: "npm",
276
- name,
277
- version: stringOrNull(rawPackage.version),
278
- requested: name,
279
- sourceKind: "lockfile",
280
- resolvedUrl: stringOrNull(rawPackage.resolved),
281
- integrity: stringOrNull(rawPackage.integrity),
282
- license: stringOrNull(rawPackage.license)
283
- }));
283
+ const alias = npmAliasVersion(stringOrNull(rawPackage.version));
284
+ const resolvedName = alias?.name ?? name;
285
+ const version = alias?.version ?? stringOrNull(rawPackage.version);
286
+ const key = `${resolvedName}@${version ?? ""}`;
287
+ if (!seen.has(key)) {
288
+ seen.add(key);
289
+ observations.push(lockfileObservation({
290
+ ecosystem: "npm",
291
+ name: resolvedName,
292
+ version,
293
+ requested: name,
294
+ sourceKind: "lockfile",
295
+ resolvedUrl: stringOrNull(rawPackage.resolved),
296
+ integrity: stringOrNull(rawPackage.integrity),
297
+ license: stringOrNull(rawPackage.license)
298
+ }));
299
+ }
300
+ if (isRecord(rawPackage.dependencies)) {
301
+ walkLegacyDependencies(rawPackage.dependencies, observations, seen);
302
+ }
284
303
  }
285
- return observations;
304
+ }
305
+ function npmAliasVersion(version) {
306
+ if (!version || !version.startsWith("npm:")) {
307
+ return null;
308
+ }
309
+ const spec = version.slice(4);
310
+ const at = spec.startsWith("@") ? spec.indexOf("@", 1) : spec.indexOf("@");
311
+ if (at <= 0) {
312
+ return { name: spec, version: null };
313
+ }
314
+ return { name: spec.slice(0, at), version: spec.slice(at + 1) || null };
286
315
  }
287
316
  function parseYarnLock(text) {
288
317
  const observations = [];
@@ -293,22 +322,23 @@ function parseYarnLock(text) {
293
322
  if (!header) {
294
323
  continue;
295
324
  }
325
+ if (header.startsWith("#") || header === "__metadata") {
326
+ continue;
327
+ }
296
328
  const requested = header.split(",")[0]?.trim().replace(/^"|"$/gu, "") ?? header;
297
329
  const name = packageNameFromYarnDescriptor(requested);
298
- if (!name) {
330
+ const version = quotedValue(lines, "version");
331
+ if (!name || !version) {
299
332
  continue;
300
333
  }
301
- const version = quotedValue(lines, "version");
302
- const resolvedUrl = quotedValue(lines, "resolved");
303
- const integrity = quotedValue(lines, "integrity");
304
334
  observations.push(lockfileObservation({
305
335
  ecosystem: "npm",
306
336
  name,
307
337
  version,
308
338
  requested,
309
339
  sourceKind: "lockfile",
310
- resolvedUrl,
311
- integrity,
340
+ resolvedUrl: quotedValue(lines, "resolved"),
341
+ integrity: quotedValue(lines, "integrity") ?? quotedValue(lines, "checksum"),
312
342
  license: null
313
343
  }));
314
344
  }
@@ -317,23 +347,33 @@ function parseYarnLock(text) {
317
347
  function parsePnpmLock(text) {
318
348
  const observations = [];
319
349
  const lines = text.split(/\r?\n/u);
350
+ let inPackages = false;
320
351
  let current = null;
352
+ const flush = () => {
353
+ if (current) {
354
+ observations.push(lockfileObservation(current));
355
+ current = null;
356
+ }
357
+ };
321
358
  for (const line of lines) {
322
- const packageMatch = /^\s{2}['"]?(?:\/)?((?:@[^/\s]+\/)?[^@\s:'"]+)@([^:\s'"]+)['"]?:\s*$/u.exec(line);
323
- if (packageMatch?.[1] && packageMatch[2]) {
324
- if (current) {
325
- observations.push(lockfileObservation(current));
326
- }
327
- current = {
328
- ecosystem: "npm",
329
- name: packageMatch[1],
330
- version: packageMatch[2],
331
- requested: `${packageMatch[1]}@${packageMatch[2]}`,
332
- sourceKind: "lockfile",
333
- resolvedUrl: null,
334
- integrity: null,
335
- license: null
336
- };
359
+ const sectionMatch = /^([A-Za-z][\w-]*):\s*$/u.exec(line);
360
+ if (sectionMatch) {
361
+ flush();
362
+ // The `snapshots:` section keys the resolved peer graph, e.g.
363
+ // `eslint-utils@4.9.1(eslint@9.39.4)` — the peer suffix is not a real
364
+ // registry version, so scanning it yields a false "removed from
365
+ // registry" verdict. `packages:` is the canonical inventory (carries
366
+ // integrity); take identity only from there.
367
+ inPackages = sectionMatch[1] === "packages";
368
+ continue;
369
+ }
370
+ if (!inPackages) {
371
+ continue;
372
+ }
373
+ const keyMatch = /^\s{2}(\S.*?):\s*$/u.exec(line);
374
+ if (keyMatch?.[1]) {
375
+ flush();
376
+ current = parsePnpmPackageKey(keyMatch[1]);
337
377
  continue;
338
378
  }
339
379
  if (!current) {
@@ -354,24 +394,69 @@ function parsePnpmLock(text) {
354
394
  };
355
395
  }
356
396
  }
357
- if (current) {
358
- observations.push(lockfileObservation(current));
359
- }
397
+ flush();
360
398
  return observations;
361
399
  }
400
+ function parsePnpmPackageKey(rawKey) {
401
+ const key = stripQuotes(rawKey.trim());
402
+ if (/(?:file|link|workspace|git\+|git:|https?):/u.test(key)) {
403
+ return null;
404
+ }
405
+ const hadSlash = key.startsWith("/");
406
+ const body = hadSlash ? key.slice(1) : key;
407
+ let name = null;
408
+ let version = null;
409
+ if (hadSlash) {
410
+ // lockfileVersion 5.x keys are /<name>/<version>[_peer]; the version
411
+ // follows the final slash and the name may itself be scoped (two slashes).
412
+ const lastSlash = body.lastIndexOf("/");
413
+ if (lastSlash > 0) {
414
+ name = body.slice(0, lastSlash);
415
+ version = stripPnpmPeerSuffix(body.slice(lastSlash + 1));
416
+ }
417
+ }
418
+ else {
419
+ const atForm = /^((?:@[^/\s]+\/)?[^@\s]+)@(.+)$/u.exec(body);
420
+ if (atForm?.[1] && atForm[2]) {
421
+ name = atForm[1];
422
+ version = stripPnpmPeerSuffix(atForm[2]);
423
+ }
424
+ }
425
+ if (!name || !version) {
426
+ return null;
427
+ }
428
+ return {
429
+ ecosystem: "npm",
430
+ name,
431
+ version,
432
+ requested: `${name}@${version}`,
433
+ sourceKind: "lockfile",
434
+ resolvedUrl: null,
435
+ integrity: null,
436
+ license: null
437
+ };
438
+ }
439
+ function stripPnpmPeerSuffix(version) {
440
+ const peerStart = version.search(/[(_]/u);
441
+ return peerStart === -1 ? version : version.slice(0, peerStart);
442
+ }
362
443
  function parseRequirements(text) {
363
- return text.split(/\r?\n/u)
364
- .map((line) => line.trim())
365
- .filter((line) => line.length > 0 && !line.startsWith("#"))
366
- .map((line) => {
367
- if (REMOTE_SPEC_PREFIXES.some((prefix) => line.toLowerCase().startsWith(prefix))) {
368
- return packageObservation({
444
+ const observations = [];
445
+ for (const logical of joinRequirementContinuations(text.split(/\r?\n/u))) {
446
+ const line = logical.trim();
447
+ if (line.length === 0 || line.startsWith("#")) {
448
+ continue;
449
+ }
450
+ const editable = /^(?:-e|--editable)[=\s]+(.+)$/u.exec(line);
451
+ const target = (editable?.[1] ?? line).trim();
452
+ if (REMOTE_SPEC_PREFIXES.some((prefix) => target.toLowerCase().startsWith(prefix))) {
453
+ observations.push(packageObservation({
369
454
  ecosystem: "pypi",
370
- name: line,
455
+ name: target,
371
456
  version: null,
372
457
  requested: line,
373
458
  sourceKind: "lockfile-url-fallback",
374
- resolvedUrl: line,
459
+ resolvedUrl: target,
375
460
  integrity: null,
376
461
  license: null
377
462
  }, "block", {
@@ -379,15 +464,20 @@ function parseRequirements(text) {
379
464
  title: "Lockfile URL fallback identity",
380
465
  message: "lockfile entry uses a URL without package identity or hash metadata",
381
466
  location: line
382
- });
467
+ }));
468
+ continue;
383
469
  }
384
- const hash = /--hash=([A-Za-z0-9:-]+)/u.exec(line)?.[1] ?? null;
385
- const cleanLine = line.replace(/\s+--hash=[^\s]+/gu, "");
386
- const match = /^([A-Za-z0-9_.-]+)(?:==([^;\s]+))?/u.exec(cleanLine);
470
+ if (line.startsWith("-") || /^\.{0,2}\//u.test(target)) {
471
+ continue;
472
+ }
473
+ const hash = /--hash=([A-Za-z0-9:_-]+)/u.exec(line)?.[1] ?? null;
474
+ const requirement = line.replace(/\s*--hash=[^\s]+/gu, "").trim();
475
+ const match = /^([A-Za-z0-9._-]+)(?:\[[^\]]*\])?(?:\s*==\s*([^;\s]+))?/u.exec(requirement);
387
476
  if (!match?.[1]) {
388
- return blockedUnknownSpec(line);
477
+ observations.push(blockedUnknownSpec(line));
478
+ continue;
389
479
  }
390
- return lockfileObservation({
480
+ observations.push(lockfileObservation({
391
481
  ecosystem: "pypi",
392
482
  name: match[1],
393
483
  version: match[2] ?? null,
@@ -396,8 +486,25 @@ function parseRequirements(text) {
396
486
  resolvedUrl: null,
397
487
  integrity: hash,
398
488
  license: null
399
- });
400
- });
489
+ }));
490
+ }
491
+ return observations;
492
+ }
493
+ function joinRequirementContinuations(lines) {
494
+ const joined = [];
495
+ let buffer = "";
496
+ for (const line of lines) {
497
+ if (line.endsWith("\\")) {
498
+ buffer += `${line.slice(0, -1)} `;
499
+ continue;
500
+ }
501
+ joined.push(buffer + line);
502
+ buffer = "";
503
+ }
504
+ if (buffer.length > 0) {
505
+ joined.push(buffer);
506
+ }
507
+ return joined;
401
508
  }
402
509
  function parseCargoLock(text) {
403
510
  return lockBlocks(text, "[[package]]").map((block) => {
@@ -426,7 +533,7 @@ function parsePoetryLock(text) {
426
533
  requested: `${name}==${version ?? "unknown"}`,
427
534
  sourceKind: "lockfile",
428
535
  resolvedUrl: null,
429
- integrity: null,
536
+ integrity: /hash\s*=\s*"([^"]+)"/u.exec(block)?.[1] ?? null,
430
537
  license: tomlString(block, "license")
431
538
  });
432
539
  }).filter((observation) => observation.identity.name !== "unknown");
@@ -588,7 +695,8 @@ function isExactVersion(version) {
588
695
  }
589
696
  function isSupportedIntegrity(value) {
590
697
  return /^(?:sha256|sha384|sha512)-[A-Za-z0-9+/=]+$/u.test(value)
591
- || /^(?:sha256:)?[a-f0-9]{64}$/iu.test(value);
698
+ || /^(?:sha256:)?[a-f0-9]{64}$/iu.test(value)
699
+ || /^[0-9a-f]+\/[0-9a-f]{64,}$/iu.test(value);
592
700
  }
593
701
  function isUnsafeResolvedUrl(value) {
594
702
  const lower = value.toLowerCase();
@@ -657,13 +765,18 @@ function packageNameFromNodeModulesPath(path) {
657
765
  return first;
658
766
  }
659
767
  function packageNameFromYarnDescriptor(descriptor) {
660
- const value = descriptor.startsWith("@") ? descriptor.slice(1) : descriptor;
661
- const index = value.lastIndexOf("@");
662
- const name = descriptor.startsWith("@") ? `@${value.slice(0, index)}` : value.slice(0, index);
663
- return name || null;
768
+ const scoped = descriptor.startsWith("@");
769
+ const body = scoped ? descriptor.slice(1) : descriptor;
770
+ const at = body.indexOf("@");
771
+ const name = at === -1 ? descriptor : scoped ? `@${body.slice(0, at)}` : body.slice(0, at);
772
+ const spec = at === -1 ? "" : body.slice(at + 1);
773
+ // npm: alias descriptors (`alias@npm:real-pkg@range`) resolve to the alias
774
+ // target — that is the registry artifact actually fetched and scanned.
775
+ const alias = /^npm:((?:@[^/\s]+\/)?[^@\s]+)@/u.exec(spec);
776
+ return alias?.[1] ?? (name || null);
664
777
  }
665
778
  function quotedValue(lines, key) {
666
- const pattern = new RegExp(`^\\s*${key}\\s+\"?([^\"\\n]+)\"?\\s*$`, "u");
779
+ const pattern = new RegExp(`^\\s*${key}:?\\s+"?([^"\\n]+?)"?\\s*$`, "u");
667
780
  for (const line of lines) {
668
781
  const match = pattern.exec(line);
669
782
  if (match?.[1]) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@westbayberry/dg",
3
- "version": "2.0.4",
3
+ "version": "2.0.5",
4
4
  "description": "Dependency Guardian supply-chain firewall CLI",
5
5
  "type": "module",
6
6
  "bin": {