@westbayberry/dg 2.0.11 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/README.md +216 -226
  2. package/dist/agents/claude-code.js +113 -0
  3. package/dist/agents/codex.js +65 -0
  4. package/dist/agents/copilot-cli.js +115 -0
  5. package/dist/agents/cursor.js +113 -0
  6. package/dist/agents/gemini.js +107 -0
  7. package/dist/agents/persistence.js +285 -0
  8. package/dist/agents/registry.js +127 -0
  9. package/dist/agents/types.js +1 -0
  10. package/dist/agents/windsurf.js +93 -0
  11. package/dist/api/analyze.js +6 -4
  12. package/dist/audit/detectors.js +0 -11
  13. package/dist/audit/events.js +5 -21
  14. package/dist/audit-ui/AuditApp.js +2 -0
  15. package/dist/audit-ui/components/AuditResultsView.js +55 -92
  16. package/dist/audit-ui/export.js +0 -4
  17. package/dist/audit-ui/format.js +0 -3
  18. package/dist/audit-ui/launch.js +10 -1
  19. package/dist/auth/device-login.js +4 -5
  20. package/dist/auth/login-app.js +7 -7
  21. package/dist/auth/store.js +8 -3
  22. package/dist/bin/dg.js +59 -52
  23. package/dist/commands/agents.js +231 -0
  24. package/dist/commands/audit.js +22 -3
  25. package/dist/commands/config.js +26 -10
  26. package/dist/commands/cooldown.js +389 -0
  27. package/dist/commands/decisions.js +169 -0
  28. package/dist/commands/doctor.js +1 -1
  29. package/dist/commands/help.js +1 -1
  30. package/dist/commands/licenses.js +10 -22
  31. package/dist/commands/logout.js +4 -11
  32. package/dist/commands/router.js +8 -4
  33. package/dist/commands/sbom.js +206 -0
  34. package/dist/commands/scan.js +3 -2
  35. package/dist/commands/service.js +32 -13
  36. package/dist/commands/setup.js +197 -27
  37. package/dist/commands/status.js +5 -2
  38. package/dist/commands/types.js +1 -0
  39. package/dist/commands/update.js +17 -8
  40. package/dist/commands/verify.js +8 -5
  41. package/dist/config/settings.js +154 -65
  42. package/dist/decisions/apply.js +128 -0
  43. package/dist/decisions/remember-prompt.js +92 -0
  44. package/dist/export-ui/ExportDialog.js +198 -0
  45. package/dist/install-ui/LiveInstall.js +2 -2
  46. package/dist/install-ui/block-render.js +21 -4
  47. package/dist/install-ui/prep-spinner.js +32 -0
  48. package/dist/install-ui/prompt.js +14 -0
  49. package/dist/launcher/agent-check.js +466 -0
  50. package/dist/launcher/agent-hook-exec.js +70 -0
  51. package/dist/launcher/agent-hook-io.js +31 -0
  52. package/dist/launcher/cargo-cache.js +40 -0
  53. package/dist/launcher/classify.js +17 -6
  54. package/dist/launcher/env.js +71 -24
  55. package/dist/launcher/install-preflight.js +167 -17
  56. package/dist/launcher/live-install.js +25 -5
  57. package/dist/launcher/output-redaction.js +7 -4
  58. package/dist/launcher/preflight-prompt.js +43 -3
  59. package/dist/launcher/run.js +100 -86
  60. package/dist/launcher/spawn-invocation.js +21 -0
  61. package/dist/policy/cooldown.js +117 -0
  62. package/dist/policy/evaluate.js +5 -21
  63. package/dist/policy/pypi-name.js +17 -0
  64. package/dist/presentation/mode.js +3 -2
  65. package/dist/presentation/package-page.js +9 -0
  66. package/dist/presentation/provenance.js +23 -0
  67. package/dist/presentation/theme.js +7 -7
  68. package/dist/project/dgfile.js +446 -0
  69. package/dist/proxy/auth.js +42 -0
  70. package/dist/proxy/ca.js +29 -9
  71. package/dist/proxy/cooldown-exemptions-file.js +33 -0
  72. package/dist/proxy/enforcement.js +57 -17
  73. package/dist/proxy/metadata-map.js +66 -4
  74. package/dist/proxy/preverified.js +55 -0
  75. package/dist/proxy/server.js +473 -45
  76. package/dist/proxy/worker.js +16 -1
  77. package/dist/publish-set/collect.js +1 -4
  78. package/dist/publish-set/npm.js +8 -5
  79. package/dist/publish-set/pack.js +9 -3
  80. package/dist/runtime/cli.js +0 -4
  81. package/dist/runtime/fatal.js +31 -0
  82. package/dist/runtime/first-run.js +12 -11
  83. package/dist/runtime/node-version.js +43 -6
  84. package/dist/runtime/nudges.js +35 -2
  85. package/dist/sbom/cyclonedx.js +211 -0
  86. package/dist/sbom-ui/SbomApp.js +158 -0
  87. package/dist/sbom-ui/components/SbomHeader.js +32 -0
  88. package/dist/sbom-ui/components/SbomList.js +52 -0
  89. package/dist/sbom-ui/inventory.js +128 -0
  90. package/dist/sbom-ui/launch.js +51 -0
  91. package/dist/sbom-ui/run.js +55 -0
  92. package/dist/sbom-ui/store.js +26 -0
  93. package/dist/scan/collect.js +10 -6
  94. package/dist/scan/command.js +51 -17
  95. package/dist/scan/discovery.js +11 -2
  96. package/dist/scan/render.js +63 -8
  97. package/dist/scan/scanner-report.js +42 -9
  98. package/dist/scan/staged.js +71 -11
  99. package/dist/scan-ui/LegacyApp.js +12 -16
  100. package/dist/scan-ui/alt-screen.js +5 -8
  101. package/dist/scan-ui/components/InteractiveResultsView.js +193 -123
  102. package/dist/scan-ui/components/ProgressBar.js +3 -14
  103. package/dist/scan-ui/components/ProjectSelector.js +1 -1
  104. package/dist/scan-ui/components/ScoreHeader.js +2 -3
  105. package/dist/scan-ui/components/SetupBanner.js +0 -6
  106. package/dist/scan-ui/format-helpers.js +61 -5
  107. package/dist/scan-ui/hooks/useResizeRepaint.js +25 -0
  108. package/dist/scan-ui/hooks/useScan.js +46 -4
  109. package/dist/scan-ui/launch.js +7 -4
  110. package/dist/scan-ui/shims.js +14 -4
  111. package/dist/scripts/detect.js +158 -0
  112. package/dist/scripts/gate.js +170 -0
  113. package/dist/service/state.js +27 -8
  114. package/dist/service/trust-refresh.js +92 -0
  115. package/dist/service/worker.js +23 -1
  116. package/dist/setup/activate-shell.js +28 -0
  117. package/dist/setup/git-hook.js +49 -4
  118. package/dist/setup/plan.js +98 -29
  119. package/dist/setup-ui/gate.js +39 -0
  120. package/dist/setup-ui/offer.js +42 -0
  121. package/dist/setup-ui/selector.js +27 -0
  122. package/dist/setup-ui/tasks.js +56 -0
  123. package/dist/setup-ui/wizard.js +225 -0
  124. package/dist/state/cooldown-held.js +66 -0
  125. package/dist/state/index.js +1 -0
  126. package/dist/state/locks.js +4 -2
  127. package/dist/state/store.js +2 -1
  128. package/dist/util/external-tool.js +25 -0
  129. package/dist/util/git.js +10 -3
  130. package/dist/util/json-file.js +24 -0
  131. package/dist/util/report-writer.js +57 -0
  132. package/dist/util/tty-prompt.js +13 -6
  133. package/dist/verify/local.js +240 -42
  134. package/dist/verify/package-check.js +86 -18
  135. package/dist/verify/preflight.js +242 -49
  136. package/dist/verify/render.js +15 -1
  137. package/npm-shrinkwrap.json +2383 -0
  138. package/package.json +14 -8
  139. package/NOTICE +0 -5
  140. package/dist/commands/completion.js +0 -116
  141. package/dist/commands/explain.js +0 -232
  142. package/dist/commands/unavailable.js +0 -11
  143. package/dist/telemetry/events.js +0 -40
@@ -1,5 +1,5 @@
1
- import { existsSync, readFileSync } from "node:fs";
2
- import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
1
+ import { existsSync, readFileSync, statSync } from "node:fs";
2
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
3
3
  import { loadUserConfig } from "../config/settings.js";
4
4
  import { evaluatePackagePolicy, resolveEffectivePolicy } from "../policy/evaluate.js";
5
5
  const LOCKFILE_NAMES = new Set([
@@ -21,6 +21,28 @@ const REMOTE_SPEC_PREFIXES = [
21
21
  "ssh://",
22
22
  "github:"
23
23
  ];
24
+ const MAX_LOCKFILE_BYTES = 64 * 1024 * 1024;
25
+ const NPM_LOCKFILE_NAMES = new Set([
26
+ "npm-shrinkwrap.json",
27
+ "package-lock.json",
28
+ "pnpm-lock.yaml",
29
+ "yarn.lock"
30
+ ]);
31
+ const PYPI_LOCKFILE_NAMES = new Set([
32
+ "Pipfile.lock",
33
+ "poetry.lock",
34
+ "requirements.txt",
35
+ "uv.lock"
36
+ ]);
37
+ const DEFAULT_NPM_REGISTRY_HOSTS = ["registry.npmjs.org", "registry.yarnpkg.com"];
38
+ const DEFAULT_PYPI_REGISTRY_HOSTS = ["pypi.org", "files.pythonhosted.org"];
39
+ function readLockfileText(path) {
40
+ const sizeBytes = statSync(path).size;
41
+ if (sizeBytes > MAX_LOCKFILE_BYTES) {
42
+ throw new Error(`lockfile is ${sizeBytes} bytes, above the ${MAX_LOCKFILE_BYTES} byte parse limit`);
43
+ }
44
+ return readFileSync(path, "utf8");
45
+ }
24
46
  export function isSupportedLockfilePath(target) {
25
47
  return LOCKFILE_NAMES.has(basename(target));
26
48
  }
@@ -62,7 +84,7 @@ export function verifyLockfile(targetPath, options = {}) {
62
84
  }
63
85
  let text;
64
86
  try {
65
- text = readFileSync(absoluteTarget, "utf8");
87
+ text = readLockfileText(absoluteTarget);
66
88
  }
67
89
  catch (error) {
68
90
  return preflightReport({
@@ -88,7 +110,7 @@ export function parseLockfilePackages(targetPath) {
88
110
  const fileName = basename(absoluteTarget);
89
111
  let text;
90
112
  try {
91
- text = readFileSync(absoluteTarget, "utf8");
113
+ text = readLockfileText(absoluteTarget);
92
114
  }
93
115
  catch (error) {
94
116
  return {
@@ -114,10 +136,67 @@ function createParseContext(fileName, filePath) {
114
136
  return {
115
137
  fileName,
116
138
  filePath,
139
+ registryHosts: expectedRegistryHosts(fileName, filePath),
117
140
  skipped: [],
118
141
  errors: []
119
142
  };
120
143
  }
144
+ function expectedRegistryHosts(fileName, filePath) {
145
+ const hosts = new Set();
146
+ if (NPM_LOCKFILE_NAMES.has(fileName)) {
147
+ for (const host of DEFAULT_NPM_REGISTRY_HOSTS) {
148
+ hosts.add(host);
149
+ }
150
+ for (const host of npmrcRegistryHosts(filePath)) {
151
+ hosts.add(host);
152
+ }
153
+ }
154
+ else if (PYPI_LOCKFILE_NAMES.has(fileName)) {
155
+ for (const host of DEFAULT_PYPI_REGISTRY_HOSTS) {
156
+ hosts.add(host);
157
+ }
158
+ }
159
+ return hosts;
160
+ }
161
+ function npmrcRegistryHosts(lockfilePath) {
162
+ if (!lockfilePath) {
163
+ return [];
164
+ }
165
+ let text;
166
+ try {
167
+ text = readLockfileText(join(dirname(lockfilePath), ".npmrc"));
168
+ }
169
+ catch {
170
+ return [];
171
+ }
172
+ const hosts = [];
173
+ for (const line of text.split(/\r?\n/u)) {
174
+ const value = /^\s*(?:@[^=\s:]+:)?registry\s*=\s*(\S+)/u.exec(line)?.[1];
175
+ if (!value) {
176
+ continue;
177
+ }
178
+ try {
179
+ hosts.push(new URL(value).hostname.toLowerCase());
180
+ }
181
+ catch {
182
+ continue;
183
+ }
184
+ }
185
+ return hosts;
186
+ }
187
+ function untrustedResolvedHost(value, context) {
188
+ if (context.registryHosts.size === 0 || !/^https?:\/\//iu.test(value)) {
189
+ return null;
190
+ }
191
+ let host;
192
+ try {
193
+ host = new URL(value).hostname.toLowerCase();
194
+ }
195
+ catch {
196
+ return value;
197
+ }
198
+ return context.registryHosts.has(host) ? null : host;
199
+ }
121
200
  function recordSkip(context, name, reason, location) {
122
201
  context.skipped.push({ name, reason, location });
123
202
  }
@@ -136,7 +215,7 @@ function preflightReport(input) {
136
215
  reason: "dg verify command allowlist",
137
216
  trustedBy: "user"
138
217
  }));
139
- const deniedLicenses = new Set((input.options.denyLicenses ?? []).map(normalizeLicense));
218
+ const deniedLicenses = new Set((input.options.denyLicenses ?? []).map(denyListLicenseKey));
140
219
  const findings = [];
141
220
  for (const observation of input.observations) {
142
221
  const packageName = packageDisplayName(observation.identity);
@@ -212,10 +291,10 @@ function parseLockfile(text, context) {
212
291
  return parseRequirements(text, context);
213
292
  }
214
293
  if (name === "Cargo.lock") {
215
- return parseCargoLock(text);
294
+ return parseCargoLock(text, context);
216
295
  }
217
296
  if (name === "poetry.lock") {
218
- return parsePoetryLock(text);
297
+ return parsePoetryLock(text, context);
219
298
  }
220
299
  if (name === "uv.lock") {
221
300
  return parseUvLock(text, context);
@@ -304,14 +383,25 @@ function parsePackageLock(text, context) {
304
383
  continue;
305
384
  }
306
385
  const resolvedIsGit = resolved !== null && isUnsafeResolvedUrl(resolved);
386
+ const resolvedOffRegistry = resolved !== null && untrustedResolvedHost(resolved, context) !== null;
387
+ // A registry `resolved` URL is how npm actually fetched this entry; it must
388
+ // be scanned regardless of how a same-named sibling declares the dependency
389
+ // (a `file:`/`git:` spec elsewhere must not suppress a registry install).
390
+ // The name-keyed cross-package hint only applies when this entry has no
391
+ // registry resolved of its own.
392
+ const resolvedIsRegistry = !!resolved && !resolvedIsGit && !resolvedOffRegistry && !resolved.toLowerCase().startsWith("file:");
307
393
  const skipReason = resolvedIsGit
308
394
  ? "git"
309
395
  : resolved?.toLowerCase().startsWith("file:")
310
396
  ? "local"
311
- : specKinds.get(name) ?? null;
397
+ : resolvedOffRegistry
398
+ ? "direct-url"
399
+ : resolvedIsRegistry
400
+ ? null
401
+ : specKinds.get(name) ?? null;
312
402
  if (skipReason) {
313
403
  recordSkip(context, name, skipReason, path);
314
- if (!resolvedIsGit) {
404
+ if (!resolvedIsGit && !resolvedOffRegistry) {
315
405
  continue;
316
406
  }
317
407
  }
@@ -324,7 +414,7 @@ function parsePackageLock(text, context) {
324
414
  resolvedUrl: resolved,
325
415
  integrity: stringOrNull(rawPackage.integrity),
326
416
  license: stringOrNull(rawPackage.license)
327
- }));
417
+ }, context));
328
418
  }
329
419
  return observations;
330
420
  }
@@ -333,7 +423,11 @@ function parsePackageLock(text, context) {
333
423
  }
334
424
  return observations;
335
425
  }
336
- function walkLegacyDependencies(dependencies, observations, seen, context) {
426
+ const MAX_LEGACY_DEPENDENCY_DEPTH = 512;
427
+ function walkLegacyDependencies(dependencies, observations, seen, context, depth = 0) {
428
+ if (depth > MAX_LEGACY_DEPENDENCY_DEPTH) {
429
+ return;
430
+ }
337
431
  for (const [name, rawPackage] of Object.entries(dependencies)) {
338
432
  if (!isRecord(rawPackage) || rawPackage.bundled === true) {
339
433
  continue;
@@ -341,8 +435,13 @@ function walkLegacyDependencies(dependencies, observations, seen, context) {
341
435
  const rawVersion = stringOrNull(rawPackage.version);
342
436
  const resolved = stringOrNull(rawPackage.resolved);
343
437
  const resolvedIsGit = resolved !== null && isUnsafeResolvedUrl(resolved);
438
+ const resolvedOffRegistry = resolved !== null && untrustedResolvedHost(resolved, context) !== null;
344
439
  const versionKind = rawVersion && !rawVersion.startsWith("npm:") ? specSourceKind(rawVersion) : null;
345
- const skipReason = resolvedIsGit ? "git" : versionKind;
440
+ // Prefer the registry `resolved` URL over the version field: an entry npm
441
+ // fetched from the registry must be scanned even if its `version` reads
442
+ // file:/git:/workspace: (a contradiction an attacker can craft to hide it).
443
+ const resolvedIsRegistry = resolved !== null && !resolvedIsGit && !resolvedOffRegistry && !resolved.toLowerCase().startsWith("file:");
444
+ const skipReason = resolvedIsGit ? "git" : resolvedOffRegistry ? "direct-url" : resolvedIsRegistry ? null : versionKind;
346
445
  const alias = npmAliasVersion(rawVersion);
347
446
  const resolvedName = alias?.name ?? name;
348
447
  const version = alias?.version ?? rawVersion;
@@ -350,7 +449,7 @@ function walkLegacyDependencies(dependencies, observations, seen, context) {
350
449
  if (skipReason) {
351
450
  recordSkip(context, resolvedName, skipReason, name);
352
451
  }
353
- if (!seen.has(key) && (!skipReason || resolvedIsGit)) {
452
+ if (!seen.has(key) && (!skipReason || resolvedIsGit || resolvedOffRegistry)) {
354
453
  seen.add(key);
355
454
  observations.push(lockfileObservation({
356
455
  ecosystem: "npm",
@@ -361,10 +460,10 @@ function walkLegacyDependencies(dependencies, observations, seen, context) {
361
460
  resolvedUrl: resolved,
362
461
  integrity: stringOrNull(rawPackage.integrity),
363
462
  license: stringOrNull(rawPackage.license)
364
- }));
463
+ }, context));
365
464
  }
366
465
  if (isRecord(rawPackage.dependencies)) {
367
- walkLegacyDependencies(rawPackage.dependencies, observations, seen, context);
466
+ walkLegacyDependencies(rawPackage.dependencies, observations, seen, context, depth + 1);
368
467
  }
369
468
  }
370
469
  }
@@ -394,10 +493,14 @@ function parseYarnLock(text, context) {
394
493
  const requested = header.split(",")[0]?.trim().replace(/^"|"$/gu, "") ?? header;
395
494
  const name = packageNameFromYarnDescriptor(requested);
396
495
  const resolved = quotedValue(lines, "resolved");
397
- const skipReason = specSourceKind(yarnDescriptorSpec(requested));
496
+ const resolution = quotedValue(lines, "resolution");
497
+ const skipReason = specSourceKind(yarnDescriptorSpec(requested))
498
+ ?? (resolution ? specSourceKind(yarnDescriptorSpec(resolution)) : null);
499
+ const resolvedUnsafe = resolved !== null
500
+ && (isUnsafeResolvedUrl(resolved) || untrustedResolvedHost(resolved, context) !== null);
398
501
  if (skipReason) {
399
502
  recordSkip(context, name ?? requested, skipReason, requested);
400
- if (!(resolved && isUnsafeResolvedUrl(resolved))) {
503
+ if (!resolvedUnsafe) {
401
504
  continue;
402
505
  }
403
506
  }
@@ -414,7 +517,7 @@ function parseYarnLock(text, context) {
414
517
  resolvedUrl: resolved,
415
518
  integrity: quotedValue(lines, "integrity") ?? quotedValue(lines, "checksum"),
416
519
  license: null
417
- }));
520
+ }, context));
418
521
  }
419
522
  return observations;
420
523
  }
@@ -425,7 +528,7 @@ function parsePnpmLock(text, context) {
425
528
  let current = null;
426
529
  const flush = () => {
427
530
  if (current) {
428
- observations.push(lockfileObservation(current));
531
+ observations.push(lockfileObservation(current, context));
429
532
  current = null;
430
533
  }
431
534
  };
@@ -590,6 +693,33 @@ function collectRequirements(text, baseDir, state, context, observations) {
590
693
  }
591
694
  const hash = /--hash=([A-Za-z0-9:_-]+)/u.exec(line)?.[1] ?? null;
592
695
  const requirement = line.replace(/\s*--hash=[^\s]+/gu, "").trim();
696
+ const directReference = /^([A-Za-z0-9._-]+)(?:\[[^\]]*\])?\s*@\s*([^;\s]+)/u.exec(requirement);
697
+ if (directReference?.[1] && directReference[2]) {
698
+ const referenceName = directReference[1];
699
+ const referenceUrl = directReference[2];
700
+ const referenceKind = specSourceKind(referenceUrl);
701
+ if (referenceKind === "local" || /^\.{0,2}\//u.test(referenceUrl)) {
702
+ recordSkip(context, referenceName, "local", line);
703
+ continue;
704
+ }
705
+ recordSkip(context, referenceName, referenceKind ?? "direct-url", line);
706
+ observations.push(packageObservation({
707
+ ecosystem: "pypi",
708
+ name: referenceName,
709
+ version: null,
710
+ requested: line,
711
+ sourceKind: "lockfile-url-fallback",
712
+ resolvedUrl: referenceUrl,
713
+ integrity: hash,
714
+ license: null
715
+ }, "block", {
716
+ id: "unverified-lockfile-url",
717
+ title: "Unverified lockfile URL",
718
+ message: "lockfile resolved artifact uses a direct URL or git source that requires proxy hash verification",
719
+ location: line
720
+ }));
721
+ continue;
722
+ }
593
723
  const match = /^([A-Za-z0-9._-]+)(?:\[[^\]]*\])?(?:\s*={2,3}\s*([^;\s]+))?/u.exec(requirement);
594
724
  if (!match?.[1]) {
595
725
  observations.push(blockedUnknownSpec(line));
@@ -609,7 +739,7 @@ function collectRequirements(text, baseDir, state, context, observations) {
609
739
  resolvedUrl: null,
610
740
  integrity: hash,
611
741
  license: null
612
- }));
742
+ }, context));
613
743
  }
614
744
  }
615
745
  function followRequirementInclude(target, baseDir, state, context, observations) {
@@ -628,7 +758,7 @@ function followRequirementInclude(target, baseDir, state, context, observations)
628
758
  state.visited.add(includePath);
629
759
  let text;
630
760
  try {
631
- text = readFileSync(includePath, "utf8");
761
+ text = readLockfileText(includePath);
632
762
  }
633
763
  catch (error) {
634
764
  recordParseError(context, target, error);
@@ -652,27 +782,46 @@ function joinRequirementContinuations(lines) {
652
782
  }
653
783
  return joined;
654
784
  }
655
- function parseCargoLock(text) {
656
- return lockBlocks(text, "[[package]]").map((block) => {
657
- const name = tomlString(block, "name") ?? "unknown";
785
+ function parseCargoLock(text, context) {
786
+ const observations = [];
787
+ for (const block of lockBlocks(text, "[[package]]")) {
788
+ const name = tomlString(block, "name");
789
+ if (!name) {
790
+ continue;
791
+ }
658
792
  const version = tomlString(block, "version");
659
- return lockfileObservation({
793
+ const source = tomlString(block, "source");
794
+ if (source === null) {
795
+ recordSkip(context, name, "local", `${name}@${version ?? "unknown"}`);
796
+ continue;
797
+ }
798
+ observations.push(lockfileObservation({
660
799
  ecosystem: "cargo",
661
800
  name,
662
801
  version,
663
802
  requested: `${name}@${version ?? "unknown"}`,
664
803
  sourceKind: "lockfile",
665
- resolvedUrl: tomlString(block, "source"),
804
+ resolvedUrl: source,
666
805
  integrity: tomlString(block, "checksum"),
667
806
  license: null
668
- });
669
- }).filter((observation) => observation.identity.name !== "unknown");
807
+ }, context));
808
+ }
809
+ return observations;
670
810
  }
671
- function parsePoetryLock(text) {
672
- return lockBlocks(text, "[[package]]").map((block) => {
673
- const name = tomlString(block, "name") ?? "unknown";
811
+ function parsePoetryLock(text, context) {
812
+ const observations = [];
813
+ for (const block of lockBlocks(text, "[[package]]")) {
814
+ const name = tomlString(block, "name");
815
+ if (!name) {
816
+ continue;
817
+ }
674
818
  const version = tomlString(block, "version");
675
- return lockfileObservation({
819
+ const skipReason = poetrySourceSkipReason(block);
820
+ if (skipReason) {
821
+ recordSkip(context, name, skipReason, `${name}${version ? `==${version}` : ""}`);
822
+ continue;
823
+ }
824
+ observations.push(lockfileObservation({
676
825
  ecosystem: "pypi",
677
826
  name,
678
827
  version,
@@ -681,8 +830,26 @@ function parsePoetryLock(text) {
681
830
  resolvedUrl: null,
682
831
  integrity: /hash\s*=\s*"([^"]+)"/u.exec(block)?.[1] ?? null,
683
832
  license: tomlString(block, "license")
684
- });
685
- }).filter((observation) => observation.identity.name !== "unknown");
833
+ }, context));
834
+ }
835
+ return observations;
836
+ }
837
+ function poetrySourceSkipReason(block) {
838
+ const sourceTable = /\[package\.source\]([\s\S]*?)(?=\n\[|$)/u.exec(block)?.[1];
839
+ if (sourceTable === undefined) {
840
+ return null;
841
+ }
842
+ const type = /\btype\s*=\s*"([^"]+)"/u.exec(sourceTable)?.[1];
843
+ if (type === "git") {
844
+ return "git";
845
+ }
846
+ if (type === "url") {
847
+ return "direct-url";
848
+ }
849
+ if (type === "directory" || type === "file") {
850
+ return "local";
851
+ }
852
+ return null;
686
853
  }
687
854
  function parseUvLock(text, context) {
688
855
  const observations = [];
@@ -707,7 +874,7 @@ function parseUvLock(text, context) {
707
874
  resolvedUrl: null,
708
875
  integrity: /hash\s*=\s*"([^"]+)"/u.exec(block)?.[1] ?? null,
709
876
  license: null
710
- }));
877
+ }, context));
711
878
  }
712
879
  return observations;
713
880
  }
@@ -744,24 +911,41 @@ function parsePipfileLock(text, context) {
744
911
  }
745
912
  return ["default", "develop"].flatMap((section) => {
746
913
  const packages = isRecord(parsed[section]) ? parsed[section] : {};
747
- return Object.entries(packages).map(([name, rawPackage]) => {
914
+ return Object.entries(packages).flatMap(([name, rawPackage]) => {
748
915
  const record = isRecord(rawPackage) ? rawPackage : {};
749
916
  const version = stringOrNull(record.version)?.replace(/^==/u, "") ?? null;
917
+ const skipReason = pipfileSourceSkipReason(record);
918
+ if (skipReason) {
919
+ recordSkip(context, name, skipReason, `${name}${version ? `==${version}` : ""}`);
920
+ return [];
921
+ }
750
922
  const hashes = Array.isArray(record.hashes) ? record.hashes.filter((hash) => typeof hash === "string") : [];
751
- return lockfileObservation({
752
- ecosystem: "pypi",
753
- name,
754
- version,
755
- requested: `${name}${version ? `==${version}` : ""}`,
756
- sourceKind: "lockfile",
757
- resolvedUrl: null,
758
- integrity: hashes[0] ?? null,
759
- license: null
760
- });
923
+ return [lockfileObservation({
924
+ ecosystem: "pypi",
925
+ name,
926
+ version,
927
+ requested: `${name}${version ? `==${version}` : ""}`,
928
+ sourceKind: "lockfile",
929
+ resolvedUrl: null,
930
+ integrity: hashes[0] ?? null,
931
+ license: null
932
+ }, context)];
761
933
  });
762
934
  });
763
935
  }
764
- function lockfileObservation(identity) {
936
+ function pipfileSourceSkipReason(record) {
937
+ if (typeof record.git === "string") {
938
+ return "git";
939
+ }
940
+ if (typeof record.file === "string") {
941
+ return "direct-url";
942
+ }
943
+ if (record.editable === true || typeof record.path === "string") {
944
+ return "local";
945
+ }
946
+ return null;
947
+ }
948
+ function lockfileObservation(identity, context) {
765
949
  if (identity.resolvedUrl && isUnsafeResolvedUrl(identity.resolvedUrl)) {
766
950
  return packageObservation(identity, "block", {
767
951
  id: "unverified-lockfile-url",
@@ -770,6 +954,15 @@ function lockfileObservation(identity) {
770
954
  location: identity.requested
771
955
  });
772
956
  }
957
+ const untrustedHost = identity.resolvedUrl ? untrustedResolvedHost(identity.resolvedUrl, context) : null;
958
+ if (untrustedHost !== null) {
959
+ return packageObservation(identity, "block", {
960
+ id: "untrusted-registry-host",
961
+ title: "Untrusted lockfile registry host",
962
+ message: `${packageDisplayName(identity)} resolves from '${untrustedHost}', which is not an expected registry host for this lockfile`,
963
+ location: identity.requested
964
+ });
965
+ }
773
966
  const integrityFinding = integrityPolicyFinding(identity);
774
967
  if (integrityFinding) {
775
968
  return packageObservation(identity, "warn", integrityFinding);
@@ -788,7 +981,7 @@ function integrityPolicyFinding(identity) {
788
981
  };
789
982
  }
790
983
  function deniedLicenseFinding(identity, deniedLicenses) {
791
- if (!identity.license || !deniedLicenses.has(normalizeLicense(identity.license))) {
984
+ if (!identity.license || !deniedLicenses.has(denyListLicenseKey(identity.license))) {
792
985
  return null;
793
986
  }
794
987
  return {
@@ -956,7 +1149,7 @@ function stringOrNull(value) {
956
1149
  function stripQuotes(value) {
957
1150
  return value.replace(/^["']|["']$/gu, "");
958
1151
  }
959
- function normalizeLicense(value) {
1152
+ function denyListLicenseKey(value) {
960
1153
  return value.trim().toLowerCase();
961
1154
  }
962
1155
  function displayPath(root, path) {
@@ -1,4 +1,6 @@
1
1
  import { createTheme } from "../presentation/theme.js";
2
+ import { packagePageUrl } from "../presentation/package-page.js";
3
+ import { sanitizeDeep } from "../security/sanitize.js";
2
4
  const STATUS_ROLE = {
3
5
  pass: "pass",
4
6
  warn: "warn",
@@ -14,7 +16,8 @@ const STATUS_ACTION = {
14
16
  error: "block"
15
17
  };
16
18
  export function renderVerifyText(report, theme = createTheme(false), verbose = false) {
17
- return verbose ? renderVerifyVerbose(report, theme) : renderVerifyCompact(report, theme);
19
+ const safe = sanitizeDeep(report);
20
+ return verbose ? renderVerifyVerbose(safe, theme) : renderVerifyCompact(safe, theme);
18
21
  }
19
22
  function renderVerifyCompact(report, theme) {
20
23
  const badge = report.status === "error" ? theme.paint("block", "✘ ERROR") : theme.badge(STATUS_ACTION[report.status]);
@@ -30,8 +33,19 @@ function renderVerifyCompact(report, theme) {
30
33
  for (const error of report.errors) {
31
34
  lines.push(` ${theme.paint("block", "✘")} ${error}`);
32
35
  }
36
+ const page = packageReportUrl(report);
37
+ if (page) {
38
+ lines.push(theme.paint("muted", ` → ${page}`));
39
+ }
33
40
  return `${lines.join("\n")}\n`;
34
41
  }
42
+ function packageReportUrl(report) {
43
+ if (report.packages.length !== 1) {
44
+ return null;
45
+ }
46
+ const identity = report.packages[0];
47
+ return packagePageUrl(identity.ecosystem, identity.name);
48
+ }
35
49
  function targetLabel(report) {
36
50
  if (report.packages.length === 1) {
37
51
  const identity = report.packages[0];