@westbayberry/dg 1.3.2 → 2.0.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 (126) hide show
  1. package/LICENSE +1 -201
  2. package/NOTICE +1 -4
  3. package/README.md +293 -0
  4. package/dist/api/analyze.js +210 -0
  5. package/dist/audit/deep.js +180 -0
  6. package/dist/audit/detectors.js +247 -0
  7. package/dist/audit/events.js +41 -0
  8. package/dist/audit/rules.js +426 -0
  9. package/dist/audit-ui/AuditApp.js +39 -0
  10. package/dist/audit-ui/components/AuditHeader.js +24 -0
  11. package/dist/audit-ui/components/AuditResultsView.js +307 -0
  12. package/dist/audit-ui/components/DeepStatusRow.js +11 -0
  13. package/dist/audit-ui/export.js +85 -0
  14. package/dist/audit-ui/format.js +34 -0
  15. package/dist/audit-ui/launch.js +34 -0
  16. package/dist/auth/device-login.js +271 -0
  17. package/dist/auth/env-token.js +6 -0
  18. package/dist/auth/login-app.js +156 -0
  19. package/dist/auth/store.js +147 -0
  20. package/dist/bin/dg.js +71 -0
  21. package/dist/commands/audit.js +357 -0
  22. package/dist/commands/completion.js +116 -0
  23. package/dist/commands/config.js +99 -0
  24. package/dist/commands/doctor.js +39 -0
  25. package/dist/commands/explain.js +100 -0
  26. package/dist/commands/guard-commit.js +158 -0
  27. package/dist/commands/help.js +74 -0
  28. package/dist/commands/licenses.js +435 -0
  29. package/dist/commands/login.js +81 -0
  30. package/dist/commands/logout.js +37 -0
  31. package/dist/commands/router.js +98 -0
  32. package/dist/commands/scan.js +18 -0
  33. package/dist/commands/service.js +475 -0
  34. package/dist/commands/setup.js +302 -0
  35. package/dist/commands/status.js +115 -0
  36. package/dist/commands/suggest.js +35 -0
  37. package/dist/commands/types.js +4 -0
  38. package/dist/commands/unavailable.js +11 -0
  39. package/dist/commands/uninstall.js +111 -0
  40. package/dist/commands/update.js +210 -0
  41. package/dist/commands/verify.js +151 -0
  42. package/dist/commands/version.js +22 -0
  43. package/dist/commands/wrap.js +55 -0
  44. package/dist/config/settings.js +302 -0
  45. package/dist/install-ui/LiveInstall.js +24 -0
  46. package/dist/install-ui/block-render.js +83 -0
  47. package/dist/install-ui/live-install-app.js +48 -0
  48. package/dist/install-ui/prompt.js +24 -0
  49. package/dist/launcher/classify.js +116 -0
  50. package/dist/launcher/env.js +53 -0
  51. package/dist/launcher/live-install.js +50 -0
  52. package/dist/launcher/output-redaction.js +77 -0
  53. package/dist/launcher/preflight-prompt.js +139 -0
  54. package/dist/launcher/resolve-real-binary.js +73 -0
  55. package/dist/launcher/run.js +417 -0
  56. package/dist/policy/evaluate.js +128 -0
  57. package/dist/presentation/mode.js +52 -0
  58. package/dist/presentation/theme.js +29 -0
  59. package/dist/proxy/buffer-budget.js +64 -0
  60. package/dist/proxy/ca.js +126 -0
  61. package/dist/proxy/classify-host.js +26 -0
  62. package/dist/proxy/enforcement.js +102 -0
  63. package/dist/proxy/metadata-map.js +336 -0
  64. package/dist/proxy/server.js +909 -0
  65. package/dist/proxy/upstream-proxy.js +102 -0
  66. package/dist/proxy/worker.js +39 -0
  67. package/dist/publish-set/collect.js +51 -0
  68. package/dist/publish-set/no-exec-shell.js +19 -0
  69. package/dist/publish-set/npm.js +109 -0
  70. package/dist/publish-set/pack.js +36 -0
  71. package/dist/publish-set/pypi.js +59 -0
  72. package/dist/runtime/cli.js +17 -0
  73. package/dist/runtime/first-run.js +60 -0
  74. package/dist/runtime/node-version.js +58 -0
  75. package/dist/runtime/nudges.js +105 -0
  76. package/dist/scan/analyze-worker.js +21 -0
  77. package/dist/scan/collect.js +153 -0
  78. package/dist/scan/command.js +159 -0
  79. package/dist/scan/discovery.js +209 -0
  80. package/dist/scan/render.js +240 -0
  81. package/dist/scan/scanner-report.js +82 -0
  82. package/dist/scan/staged.js +173 -0
  83. package/dist/scan/types.js +1 -0
  84. package/dist/scan-ui/LegacyApp.js +156 -0
  85. package/dist/scan-ui/alt-screen.js +84 -0
  86. package/dist/scan-ui/api-aliases.js +1 -0
  87. package/dist/scan-ui/components/ErrorView.js +23 -0
  88. package/dist/scan-ui/components/InteractiveResultsView.js +1166 -0
  89. package/dist/scan-ui/components/ProgressBar.js +89 -0
  90. package/dist/scan-ui/components/ProjectSelector.js +62 -0
  91. package/dist/scan-ui/components/ScoreHeader.js +20 -0
  92. package/dist/scan-ui/components/SetupBanner.js +13 -0
  93. package/dist/scan-ui/components/Spinner.js +4 -0
  94. package/dist/scan-ui/format-helpers.js +40 -0
  95. package/dist/scan-ui/hooks/useExpandAnimation.js +40 -0
  96. package/dist/scan-ui/hooks/useScan.js +113 -0
  97. package/dist/scan-ui/hooks/useTerminalSize.js +24 -0
  98. package/dist/scan-ui/launch.js +27 -0
  99. package/dist/scan-ui/logo.js +91 -0
  100. package/dist/scan-ui/shims.js +30 -0
  101. package/dist/security/sanitize.js +28 -0
  102. package/dist/service/state.js +837 -0
  103. package/dist/service/trust-store.js +234 -0
  104. package/dist/service/worker.js +88 -0
  105. package/dist/setup/git-hook.js +244 -0
  106. package/dist/setup/optional-support.js +58 -0
  107. package/dist/setup/plan.js +899 -0
  108. package/dist/state/cleanup-registry.js +60 -0
  109. package/dist/state/index.js +5 -0
  110. package/dist/state/locks.js +161 -0
  111. package/dist/state/paths.js +24 -0
  112. package/dist/state/sessions.js +170 -0
  113. package/dist/state/store.js +50 -0
  114. package/dist/telemetry/events.js +40 -0
  115. package/dist/util/git.js +20 -0
  116. package/dist/util/tty-prompt.js +43 -0
  117. package/dist/verify/local.js +400 -0
  118. package/dist/verify/package-check.js +240 -0
  119. package/dist/verify/preflight.js +698 -0
  120. package/dist/verify/render.js +184 -0
  121. package/dist/verify/types.js +1 -0
  122. package/package.json +33 -50
  123. package/dist/index.mjs +0 -54141
  124. package/dist/postinstall.mjs +0 -731
  125. package/dist/python-hook/dg_pip_hook.pth +0 -1
  126. package/dist/python-hook/dg_pip_hook.py +0 -130
@@ -0,0 +1,400 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, readFileSync, statSync } from "node:fs";
3
+ import { basename, relative, resolve, sep } from "node:path";
4
+ import { gunzipSync, inflateRawSync } from "node:zlib";
5
+ import { scanProject } from "../scan/discovery.js";
6
+ const MAX_ARTIFACT_BYTES = 100 * 1024 * 1024;
7
+ const MAX_UNPACKED_BYTES = 250 * 1024 * 1024;
8
+ const MAX_ARCHIVE_ENTRIES = 20000;
9
+ const MAX_ARCHIVE_PATH_LENGTH = 240;
10
+ const TAR_BLOCK_SIZE = 512;
11
+ export function verifyLocalTarget(targetPath, cwd = process.cwd()) {
12
+ const absoluteTarget = resolve(cwd, targetPath);
13
+ if (!existsSync(absoluteTarget)) {
14
+ throw new Error(`path does not exist: ${targetPath}`);
15
+ }
16
+ const targetInfo = statSync(absoluteTarget);
17
+ if (targetInfo.isDirectory()) {
18
+ return verifyPackageProjectTarget(absoluteTarget, cwd);
19
+ }
20
+ if (!targetInfo.isFile()) {
21
+ throw new Error(`path is neither a file nor a directory: ${targetPath}`);
22
+ }
23
+ if (basename(absoluteTarget) === "package.json") {
24
+ return verifyPackageProjectTarget(absoluteTarget, cwd);
25
+ }
26
+ const inputKind = archiveInputKind(absoluteTarget);
27
+ if (!inputKind) {
28
+ throw new Error(`unsupported local verify input: ${targetPath}`);
29
+ }
30
+ if (targetInfo.size > MAX_ARTIFACT_BYTES) {
31
+ return archiveReport({
32
+ absoluteTarget,
33
+ archive: {
34
+ errors: [`artifact is ${targetInfo.size} bytes, above the ${MAX_ARTIFACT_BYTES} byte local verification limit`],
35
+ findings: [],
36
+ summary: {
37
+ entryCount: 0,
38
+ packageManifestCount: 0,
39
+ unpackedSizeBytes: null
40
+ }
41
+ },
42
+ cwd,
43
+ inputKind,
44
+ sha256: sha256File(absoluteTarget),
45
+ sizeBytes: targetInfo.size
46
+ });
47
+ }
48
+ const bytes = readFileSync(absoluteTarget);
49
+ const archive = inputKind === "tarball"
50
+ ? scanTarball(bytes, absoluteTarget)
51
+ : scanZipLike(bytes, absoluteTarget);
52
+ return archiveReport({
53
+ absoluteTarget,
54
+ archive,
55
+ cwd,
56
+ inputKind,
57
+ sha256: sha256Buffer(bytes),
58
+ sizeBytes: targetInfo.size
59
+ });
60
+ }
61
+ function verifyPackageProjectTarget(absoluteTarget, cwd) {
62
+ const workspaceScan = scanProject({
63
+ cwd,
64
+ targetPath: absoluteTarget
65
+ });
66
+ const findings = workspaceScan.findings.map(scanFindingToVerifyFinding);
67
+ const errors = workspaceScan.errors.map((error) => `${error.location}: ${error.message}`);
68
+ const inputKind = workspaceScan.summary.projectCount > 1 ? "workspace" : "package-directory";
69
+ return {
70
+ target: displayPath(cwd, absoluteTarget),
71
+ inputKind,
72
+ status: workspaceScan.status,
73
+ sha256: null,
74
+ sizeBytes: null,
75
+ archive: null,
76
+ workspaceScan,
77
+ preflight: null,
78
+ packages: [],
79
+ findings,
80
+ errors,
81
+ summary: summarize(findings, errors)
82
+ };
83
+ }
84
+ function archiveReport(options) {
85
+ return {
86
+ target: displayPath(options.cwd, options.absoluteTarget),
87
+ inputKind: options.inputKind,
88
+ status: statusFor(options.archive.findings, options.archive.errors),
89
+ sha256: options.sha256,
90
+ sizeBytes: options.sizeBytes,
91
+ archive: options.archive.summary,
92
+ workspaceScan: null,
93
+ preflight: null,
94
+ packages: [],
95
+ findings: options.archive.findings,
96
+ errors: options.archive.errors,
97
+ summary: summarize(options.archive.findings, options.archive.errors)
98
+ };
99
+ }
100
+ function scanTarball(bytes, path) {
101
+ let tarBytes = bytes;
102
+ if (path.endsWith(".tgz") || path.endsWith(".tar.gz")) {
103
+ try {
104
+ tarBytes = gunzipSync(bytes);
105
+ }
106
+ catch (error) {
107
+ return archiveError(`could not decompress tarball: ${error instanceof Error ? error.message : "unknown gzip error"}`);
108
+ }
109
+ }
110
+ if (tarBytes.length > MAX_UNPACKED_BYTES) {
111
+ return archiveError(`unpacked tarball is above the ${MAX_UNPACKED_BYTES} byte local verification limit`);
112
+ }
113
+ const findings = [];
114
+ const errors = [];
115
+ const entries = [];
116
+ let offset = 0;
117
+ let unpackedSizeBytes = 0;
118
+ while (offset + TAR_BLOCK_SIZE <= tarBytes.length) {
119
+ const header = tarBytes.subarray(offset, offset + TAR_BLOCK_SIZE);
120
+ if (header.every((byte) => byte === 0)) {
121
+ break;
122
+ }
123
+ const name = tarEntryName(header);
124
+ const size = tarEntrySize(header);
125
+ const bodyOffset = offset + TAR_BLOCK_SIZE;
126
+ const nextOffset = bodyOffset + Math.ceil(size / TAR_BLOCK_SIZE) * TAR_BLOCK_SIZE;
127
+ if (name.length === 0 || nextOffset > tarBytes.length) {
128
+ errors.push("tar archive has a malformed entry header");
129
+ break;
130
+ }
131
+ unpackedSizeBytes += size;
132
+ entries.push({
133
+ name,
134
+ body: tarBytes.subarray(bodyOffset, bodyOffset + size)
135
+ });
136
+ findings.push(...pathSafetyFindings(name));
137
+ if (entries.length > MAX_ARCHIVE_ENTRIES) {
138
+ findings.push(limitFinding("archive has too many entries"));
139
+ break;
140
+ }
141
+ if (unpackedSizeBytes > MAX_UNPACKED_BYTES) {
142
+ findings.push(limitFinding("archive expands beyond the local verification limit"));
143
+ break;
144
+ }
145
+ offset = nextOffset;
146
+ }
147
+ findings.push(...packageManifestFindings(entries));
148
+ return {
149
+ findings,
150
+ errors,
151
+ summary: {
152
+ entryCount: entries.length,
153
+ packageManifestCount: packageManifestCount(entries.map((entry) => entry.name)),
154
+ unpackedSizeBytes
155
+ }
156
+ };
157
+ }
158
+ function scanZipLike(bytes, path) {
159
+ const findings = [];
160
+ const errors = [];
161
+ const entries = [];
162
+ let offset = 0;
163
+ let unpackedSizeBytes = 0;
164
+ while (offset + 30 <= bytes.length) {
165
+ const signature = bytes.readUInt32LE(offset);
166
+ if (signature !== 0x04034b50) {
167
+ break;
168
+ }
169
+ const flags = bytes.readUInt16LE(offset + 6);
170
+ const method = bytes.readUInt16LE(offset + 8);
171
+ const compressedSize = bytes.readUInt32LE(offset + 18);
172
+ const uncompressedSize = bytes.readUInt32LE(offset + 22);
173
+ const fileNameLength = bytes.readUInt16LE(offset + 26);
174
+ const extraLength = bytes.readUInt16LE(offset + 28);
175
+ const nameStart = offset + 30;
176
+ const dataStart = nameStart + fileNameLength + extraLength;
177
+ const dataEnd = dataStart + compressedSize;
178
+ if (dataEnd > bytes.length) {
179
+ errors.push(`${basename(path)} has a malformed zip entry`);
180
+ break;
181
+ }
182
+ const name = bytes.subarray(nameStart, nameStart + fileNameLength).toString("utf8");
183
+ findings.push(...pathSafetyFindings(name));
184
+ if ((flags & 0x1) !== 0) {
185
+ findings.push({
186
+ id: "encrypted-archive-entry",
187
+ severity: "block",
188
+ title: "Encrypted archive entry",
189
+ message: "encrypted archive entries cannot be inspected locally",
190
+ location: name
191
+ });
192
+ }
193
+ const body = readZipBody(bytes.subarray(dataStart, dataEnd), method, name, errors);
194
+ unpackedSizeBytes += uncompressedSize;
195
+ entries.push({
196
+ name,
197
+ body
198
+ });
199
+ if ((flags & 0x8) !== 0) {
200
+ findings.push({
201
+ id: "zip-data-descriptor",
202
+ severity: "warn",
203
+ title: "Zip data descriptor",
204
+ message: "zip entry uses a data descriptor, so byte-size validation is conservative",
205
+ location: name
206
+ });
207
+ }
208
+ if (entries.length > MAX_ARCHIVE_ENTRIES) {
209
+ findings.push(limitFinding("archive has too many entries"));
210
+ break;
211
+ }
212
+ if (unpackedSizeBytes > MAX_UNPACKED_BYTES) {
213
+ findings.push(limitFinding("archive expands beyond the local verification limit"));
214
+ break;
215
+ }
216
+ offset = dataEnd;
217
+ }
218
+ findings.push(...packageManifestFindings(entries));
219
+ return {
220
+ findings,
221
+ errors,
222
+ summary: {
223
+ entryCount: entries.length,
224
+ packageManifestCount: packageManifestCount(entries.map((entry) => entry.name)),
225
+ unpackedSizeBytes
226
+ }
227
+ };
228
+ }
229
+ function readZipBody(data, method, name, errors) {
230
+ if (method === 0) {
231
+ return data;
232
+ }
233
+ if (method === 8) {
234
+ try {
235
+ return inflateRawSync(data);
236
+ }
237
+ catch (error) {
238
+ errors.push(`${name}: could not inflate zip entry: ${error instanceof Error ? error.message : "unknown inflate error"}`);
239
+ return Buffer.alloc(0);
240
+ }
241
+ }
242
+ errors.push(`${name}: unsupported zip compression method ${method}`);
243
+ return Buffer.alloc(0);
244
+ }
245
+ function packageManifestFindings(entries) {
246
+ const findings = [];
247
+ for (const entry of entries) {
248
+ if (!entry.name.endsWith("package.json")) {
249
+ continue;
250
+ }
251
+ try {
252
+ const parsed = JSON.parse(entry.body.toString("utf8"));
253
+ if (isRecord(parsed) && isRecord(parsed.scripts)) {
254
+ for (const scriptName of Object.keys(parsed.scripts).sort()) {
255
+ if (["preinstall", "install", "postinstall", "prepare"].includes(scriptName)) {
256
+ findings.push({
257
+ id: "npm-lifecycle-script",
258
+ severity: "warn",
259
+ title: "Install lifecycle script present",
260
+ message: `script '${scriptName}' can execute during package manager installs`,
261
+ location: `${entry.name}:scripts.${scriptName}`
262
+ });
263
+ }
264
+ }
265
+ }
266
+ }
267
+ catch (error) {
268
+ findings.push({
269
+ id: "malformed-package-manifest",
270
+ severity: "block",
271
+ title: "Malformed package manifest",
272
+ message: error instanceof Error ? error.message : "package.json could not be parsed",
273
+ location: entry.name
274
+ });
275
+ }
276
+ }
277
+ return findings;
278
+ }
279
+ function pathSafetyFindings(entryPath) {
280
+ const findings = [];
281
+ if (!isSafeArchivePath(entryPath)) {
282
+ findings.push({
283
+ id: "archive-path-traversal",
284
+ severity: "block",
285
+ title: "Unsafe archive path",
286
+ message: "archive entry escapes the extraction root or uses an unsafe absolute path",
287
+ location: entryPath
288
+ });
289
+ }
290
+ if (entryPath.length > MAX_ARCHIVE_PATH_LENGTH) {
291
+ findings.push({
292
+ id: "archive-path-too-long",
293
+ severity: "block",
294
+ title: "Archive path too long",
295
+ message: `archive entry path is longer than ${MAX_ARCHIVE_PATH_LENGTH} characters`,
296
+ location: entryPath
297
+ });
298
+ }
299
+ return findings;
300
+ }
301
+ function isSafeArchivePath(entryPath) {
302
+ if (entryPath.length === 0 || entryPath.includes("\\") || entryPath.startsWith("/") || /^[a-zA-Z]:/.test(entryPath)) {
303
+ return false;
304
+ }
305
+ const parts = entryPath.split("/");
306
+ return !parts.some((part) => part === "..");
307
+ }
308
+ function archiveInputKind(path) {
309
+ if (path.endsWith(".whl")) {
310
+ return "wheel";
311
+ }
312
+ if (path.endsWith(".zip")) {
313
+ return "zip";
314
+ }
315
+ if (path.endsWith(".tgz") || path.endsWith(".tar.gz") || path.endsWith(".tar")) {
316
+ return "tarball";
317
+ }
318
+ return null;
319
+ }
320
+ function archiveError(message) {
321
+ return {
322
+ findings: [],
323
+ errors: [message],
324
+ summary: {
325
+ entryCount: 0,
326
+ packageManifestCount: 0,
327
+ unpackedSizeBytes: null
328
+ }
329
+ };
330
+ }
331
+ function scanFindingToVerifyFinding(finding) {
332
+ return {
333
+ id: finding.id,
334
+ severity: finding.severity,
335
+ title: finding.title,
336
+ message: finding.message,
337
+ location: finding.location
338
+ };
339
+ }
340
+ function packageManifestCount(paths) {
341
+ return paths.filter((path) => path.endsWith("package.json")).length;
342
+ }
343
+ function limitFinding(message) {
344
+ return {
345
+ id: "archive-size-limit",
346
+ severity: "block",
347
+ title: "Archive safety limit exceeded",
348
+ message,
349
+ location: "archive"
350
+ };
351
+ }
352
+ function statusFor(findings, errors) {
353
+ if (errors.length > 0) {
354
+ return "error";
355
+ }
356
+ if (findings.some((finding) => finding.severity === "block")) {
357
+ return "block";
358
+ }
359
+ if (findings.some((finding) => finding.severity === "warn")) {
360
+ return "warn";
361
+ }
362
+ return "pass";
363
+ }
364
+ function summarize(findings, errors) {
365
+ return {
366
+ findingCount: findings.length,
367
+ warnCount: findings.filter((finding) => finding.severity === "warn").length,
368
+ blockCount: findings.filter((finding) => finding.severity === "block").length,
369
+ errorCount: errors.length
370
+ };
371
+ }
372
+ function tarEntryName(header) {
373
+ const name = readNullTerminated(header, 0, 100);
374
+ const prefix = readNullTerminated(header, 345, 155);
375
+ return prefix.length > 0 ? `${prefix}/${name}` : name;
376
+ }
377
+ function tarEntrySize(header) {
378
+ const value = readNullTerminated(header, 124, 12).trim();
379
+ const parsed = Number.parseInt(value || "0", 8);
380
+ return Number.isFinite(parsed) ? parsed : 0;
381
+ }
382
+ function readNullTerminated(buffer, start, length) {
383
+ const slice = buffer.subarray(start, start + length);
384
+ const end = slice.indexOf(0);
385
+ return slice.subarray(0, end === -1 ? slice.length : end).toString("utf8");
386
+ }
387
+ function sha256File(path) {
388
+ return sha256Buffer(readFileSync(path));
389
+ }
390
+ function sha256Buffer(buffer) {
391
+ return createHash("sha256").update(buffer).digest("hex");
392
+ }
393
+ function displayPath(root, path) {
394
+ const relativePath = relative(resolve(root), resolve(path));
395
+ const display = relativePath.length === 0 ? "." : relativePath;
396
+ return display.split(sep).join("/");
397
+ }
398
+ function isRecord(value) {
399
+ return typeof value === "object" && value !== null && !Array.isArray(value);
400
+ }
@@ -0,0 +1,240 @@
1
+ import { existsSync, writeFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { analyzePackages, AnalyzeError } from "../api/analyze.js";
4
+ import { createTheme } from "../presentation/theme.js";
5
+ import { resolvePresentation } from "../presentation/mode.js";
6
+ import { isSupportedLockfilePath } from "./preflight.js";
7
+ import { authStatus } from "../auth/store.js";
8
+ import { EXIT_TOOL_ERROR, EXIT_UNAVAILABLE } from "../commands/types.js";
9
+ const REGISTRIES = { npm: "npm", pypi: "pypi" };
10
+ const DEEP_VERIFY_HINT = "deep verify supports npm and pypi: dg verify npm:<package> or pypi:<package>";
11
+ function parseSpec(target) {
12
+ const colon = target.indexOf(":");
13
+ if (colon < 0) {
14
+ return null;
15
+ }
16
+ const registry = target.slice(0, colon).toLowerCase();
17
+ const ecosystem = REGISTRIES[registry];
18
+ if (!ecosystem) {
19
+ return { error: `unknown registry '${registry}'. ${DEEP_VERIFY_HINT}` };
20
+ }
21
+ const rest = target.slice(colon + 1).trim();
22
+ if (!rest) {
23
+ return { error: `missing package name. ${DEEP_VERIFY_HINT}` };
24
+ }
25
+ const separator = ecosystem === "pypi" ? "==" : "@";
26
+ const at = ecosystem === "npm" ? rest.lastIndexOf("@") : rest.indexOf("==");
27
+ if (ecosystem === "npm" && (at <= 0)) {
28
+ return { ecosystem, name: rest, version: null };
29
+ }
30
+ if (ecosystem === "pypi" && at < 0) {
31
+ return { ecosystem, name: rest, version: null };
32
+ }
33
+ const name = rest.slice(0, at);
34
+ const version = rest.slice(at + separator.length).trim() || null;
35
+ return { ecosystem, name, version };
36
+ }
37
+ async function resolveLatest(ecosystem, name, fetchImpl) {
38
+ try {
39
+ if (ecosystem === "npm") {
40
+ const response = await fetchImpl(`https://registry.npmjs.org/${name.replace("/", "%2F")}`);
41
+ if (!response.ok) {
42
+ return null;
43
+ }
44
+ const body = (await response.json());
45
+ return body["dist-tags"]?.latest ?? null;
46
+ }
47
+ const response = await fetchImpl(`https://pypi.org/pypi/${encodeURIComponent(name)}/json`);
48
+ if (!response.ok) {
49
+ return null;
50
+ }
51
+ const body = (await response.json());
52
+ return body.info?.version ?? null;
53
+ }
54
+ catch {
55
+ return null;
56
+ }
57
+ }
58
+ function renderResult(spec, version, result, theme, verbose) {
59
+ const action = result.action ?? "pass";
60
+ const badge = theme.badge(action);
61
+ const lines = [`${badge} ${result.name}@${version} (${spec.ecosystem}) ${theme.paint("muted", `score ${result.score}`)}`];
62
+ const reasons = verbose ? result.reasons : result.reasons.slice(0, 6);
63
+ for (const reason of reasons) {
64
+ const glyph = action === "block" ? theme.paint("block", "✘") : action === "warn" ? theme.paint("warn", "⚠") : theme.paint("muted", "·");
65
+ lines.push(` ${glyph} ${reason}`);
66
+ }
67
+ if (!verbose && result.reasons.length > reasons.length) {
68
+ lines.push(` ${theme.paint("muted", `… ${result.reasons.length - reasons.length} more — rerun with --verbose`)}`);
69
+ }
70
+ if (verbose) {
71
+ for (const finding of result.findings) {
72
+ lines.push(` ${theme.paint("muted", `finding: ${finding.title}`)}`);
73
+ }
74
+ }
75
+ if (reasons.length === 0 && action === "pass") {
76
+ lines.push(` ${theme.paint("muted", "no risk signals")}`);
77
+ }
78
+ if (result.recommendation) {
79
+ lines.push(` ${theme.paint("muted", result.recommendation)}`);
80
+ }
81
+ return `${lines.join("\n")}\n`;
82
+ }
83
+ function exitCodeFor(action) {
84
+ if (action === "block") {
85
+ return 2;
86
+ }
87
+ if (action === "warn") {
88
+ return 1;
89
+ }
90
+ if (action === "analysis_incomplete") {
91
+ return 4;
92
+ }
93
+ return 0;
94
+ }
95
+ export async function runPackageCheck(target, io = {}, options = {}) {
96
+ const fetchImpl = io.fetchImpl ?? fetch;
97
+ const theme = createTheme(resolvePresentation().color);
98
+ if (!authStatus(io.env).authenticated) {
99
+ const accent = (text) => theme.paint("accent", text);
100
+ const muted = (text) => theme.paint("muted", text);
101
+ return {
102
+ exitCode: EXIT_UNAVAILABLE,
103
+ stdout: "",
104
+ stderr: `\n ${theme.paint("warn", "⚠")} Checking a package before you install it requires ${accent("sign-in")}.\n` +
105
+ ` ${accent("dg login")}${muted(" · see plans at")} ${accent("westbayberry.com/pricing")}\n\n`
106
+ };
107
+ }
108
+ const parsed = parseSpec(target);
109
+ if (parsed === null) {
110
+ return {
111
+ exitCode: 2,
112
+ stdout: "",
113
+ stderr: `dg verify: add a registry, e.g. dg verify npm:${target} or dg verify pypi:${target}\n`
114
+ };
115
+ }
116
+ if ("error" in parsed) {
117
+ return { exitCode: 2, stdout: "", stderr: `dg verify: ${parsed.error}\n` };
118
+ }
119
+ let version = parsed.version;
120
+ if (!version) {
121
+ version = await resolveLatest(parsed.ecosystem, parsed.name, fetchImpl);
122
+ if (!version) {
123
+ return {
124
+ exitCode: 1,
125
+ stdout: "",
126
+ stderr: `dg verify: could not resolve the latest version of ${parsed.name} on ${parsed.ecosystem}\n`
127
+ };
128
+ }
129
+ }
130
+ let result;
131
+ try {
132
+ const response = await analyzePackages([{ name: parsed.name, version }], {
133
+ ecosystem: parsed.ecosystem,
134
+ fetchImpl,
135
+ ...(io.env ? { env: io.env } : {})
136
+ });
137
+ result = response.packages.find((entry) => entry.name === parsed.name) ?? response.packages[0];
138
+ }
139
+ catch (error) {
140
+ const message = error instanceof AnalyzeError ? error.message : error instanceof Error ? error.message : "could not reach the scanner";
141
+ return { exitCode: 1, stdout: "", stderr: `dg verify: ${message}\n` };
142
+ }
143
+ if (!result) {
144
+ return { exitCode: 1, stdout: "", stderr: `dg verify: scanner returned no result for ${parsed.name}\n` };
145
+ }
146
+ const action = result.action ?? "pass";
147
+ const rendered = options.format === "json"
148
+ ? `${JSON.stringify({
149
+ target,
150
+ ecosystem: parsed.ecosystem,
151
+ name: result.name,
152
+ version,
153
+ action,
154
+ score: result.score,
155
+ reasons: result.reasons,
156
+ findings: result.findings,
157
+ ...(result.recommendation ? { recommendation: result.recommendation } : {})
158
+ }, null, 2)}\n`
159
+ : renderResult(parsed, version, result, theme, options.verbose ?? false);
160
+ if (options.outputPath) {
161
+ try {
162
+ writeFileSync(resolve(options.outputPath), rendered, "utf8");
163
+ }
164
+ catch (error) {
165
+ return {
166
+ exitCode: EXIT_TOOL_ERROR,
167
+ stdout: "",
168
+ stderr: `dg verify could not write ${options.outputPath}: ${error instanceof Error ? error.message : "write error"}\n`
169
+ };
170
+ }
171
+ return { exitCode: exitCodeFor(action), stdout: `Wrote verify report to ${options.outputPath}\n`, stderr: "" };
172
+ }
173
+ return { exitCode: exitCodeFor(action), stdout: rendered, stderr: "" };
174
+ }
175
+ // Registry specs run the real scanner check here; local paths/lockfiles fall
176
+ // through to the advisory handler. --sarif errors rather than silently downgrading.
177
+ export async function maybeVerifyPackage(args) {
178
+ const noop = { exitCode: 0, stdout: "", stderr: "" };
179
+ if (args[0] !== "verify") {
180
+ return { handled: false, result: noop };
181
+ }
182
+ const rest = args.slice(1);
183
+ if (rest.some((arg) => ["--help", "-h", "help"].includes(arg))) {
184
+ return { handled: false, result: noop };
185
+ }
186
+ let format = "text";
187
+ let verbose = false;
188
+ let sarif = false;
189
+ let outputPath = null;
190
+ let target;
191
+ let unknownFlag;
192
+ for (let index = 0; index < rest.length; index += 1) {
193
+ const arg = rest[index];
194
+ if (!arg) {
195
+ continue;
196
+ }
197
+ if (arg === "--json") {
198
+ format = "json";
199
+ }
200
+ else if (arg === "--verbose" || arg === "-v") {
201
+ verbose = true;
202
+ }
203
+ else if (arg === "--sarif") {
204
+ sarif = true;
205
+ }
206
+ else if (arg === "--output" || arg === "-o") {
207
+ outputPath = rest[index + 1] ?? null;
208
+ index += 1;
209
+ }
210
+ else if (arg.startsWith("-")) {
211
+ unknownFlag = unknownFlag ?? arg;
212
+ }
213
+ else {
214
+ target = target ?? arg;
215
+ }
216
+ }
217
+ if (!target) {
218
+ return { handled: false, result: noop };
219
+ }
220
+ if (isSupportedLockfilePath(target) || existsSync(resolve(target))) {
221
+ return { handled: false, result: noop };
222
+ }
223
+ if (unknownFlag) {
224
+ return {
225
+ handled: true,
226
+ result: { exitCode: 2, stdout: "", stderr: `dg verify: unknown option '${unknownFlag}'. Run 'dg verify --help'.\n` }
227
+ };
228
+ }
229
+ if (sarif) {
230
+ return {
231
+ handled: true,
232
+ result: {
233
+ exitCode: 2,
234
+ stdout: "",
235
+ stderr: "dg verify: --sarif applies to local artifacts and lockfiles; registry package checks support --json.\n"
236
+ }
237
+ };
238
+ }
239
+ return { handled: true, result: await runPackageCheck(target, {}, { format, verbose, outputPath }) };
240
+ }