@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.
- package/README.md +216 -226
- package/dist/agents/claude-code.js +113 -0
- package/dist/agents/codex.js +65 -0
- package/dist/agents/copilot-cli.js +115 -0
- package/dist/agents/cursor.js +113 -0
- package/dist/agents/gemini.js +107 -0
- package/dist/agents/persistence.js +285 -0
- package/dist/agents/registry.js +127 -0
- package/dist/agents/types.js +1 -0
- package/dist/agents/windsurf.js +93 -0
- package/dist/api/analyze.js +6 -4
- package/dist/audit/detectors.js +0 -11
- package/dist/audit/events.js +5 -21
- package/dist/audit-ui/AuditApp.js +2 -0
- package/dist/audit-ui/components/AuditResultsView.js +55 -92
- package/dist/audit-ui/export.js +0 -4
- package/dist/audit-ui/format.js +0 -3
- package/dist/audit-ui/launch.js +10 -1
- package/dist/auth/device-login.js +4 -5
- package/dist/auth/login-app.js +7 -7
- package/dist/auth/store.js +8 -3
- package/dist/bin/dg.js +59 -52
- package/dist/commands/agents.js +231 -0
- package/dist/commands/audit.js +22 -3
- package/dist/commands/config.js +26 -10
- package/dist/commands/cooldown.js +389 -0
- package/dist/commands/decisions.js +169 -0
- package/dist/commands/doctor.js +1 -1
- package/dist/commands/help.js +1 -1
- package/dist/commands/licenses.js +10 -22
- package/dist/commands/logout.js +4 -11
- package/dist/commands/router.js +8 -4
- package/dist/commands/sbom.js +206 -0
- package/dist/commands/scan.js +3 -2
- package/dist/commands/service.js +32 -13
- package/dist/commands/setup.js +197 -27
- package/dist/commands/status.js +5 -2
- package/dist/commands/types.js +1 -0
- package/dist/commands/update.js +17 -8
- package/dist/commands/verify.js +8 -5
- package/dist/config/settings.js +154 -65
- package/dist/decisions/apply.js +128 -0
- package/dist/decisions/remember-prompt.js +92 -0
- package/dist/export-ui/ExportDialog.js +198 -0
- package/dist/install-ui/LiveInstall.js +2 -2
- package/dist/install-ui/block-render.js +21 -4
- package/dist/install-ui/prep-spinner.js +32 -0
- package/dist/install-ui/prompt.js +14 -0
- package/dist/launcher/agent-check.js +466 -0
- package/dist/launcher/agent-hook-exec.js +70 -0
- package/dist/launcher/agent-hook-io.js +31 -0
- package/dist/launcher/cargo-cache.js +40 -0
- package/dist/launcher/classify.js +17 -6
- package/dist/launcher/env.js +71 -24
- package/dist/launcher/install-preflight.js +167 -17
- package/dist/launcher/live-install.js +25 -5
- package/dist/launcher/output-redaction.js +7 -4
- package/dist/launcher/preflight-prompt.js +43 -3
- package/dist/launcher/run.js +100 -86
- package/dist/launcher/spawn-invocation.js +21 -0
- package/dist/policy/cooldown.js +117 -0
- package/dist/policy/evaluate.js +5 -21
- package/dist/policy/pypi-name.js +17 -0
- package/dist/presentation/mode.js +3 -2
- package/dist/presentation/package-page.js +9 -0
- package/dist/presentation/provenance.js +23 -0
- package/dist/presentation/theme.js +7 -7
- package/dist/project/dgfile.js +446 -0
- package/dist/proxy/auth.js +42 -0
- package/dist/proxy/ca.js +29 -9
- package/dist/proxy/cooldown-exemptions-file.js +33 -0
- package/dist/proxy/enforcement.js +57 -17
- package/dist/proxy/metadata-map.js +66 -4
- package/dist/proxy/preverified.js +55 -0
- package/dist/proxy/server.js +473 -45
- package/dist/proxy/worker.js +16 -1
- package/dist/publish-set/collect.js +1 -4
- package/dist/publish-set/npm.js +8 -5
- package/dist/publish-set/pack.js +9 -3
- package/dist/runtime/cli.js +0 -4
- package/dist/runtime/fatal.js +31 -0
- package/dist/runtime/first-run.js +12 -11
- package/dist/runtime/node-version.js +43 -6
- package/dist/runtime/nudges.js +35 -2
- package/dist/sbom/cyclonedx.js +211 -0
- package/dist/sbom-ui/SbomApp.js +158 -0
- package/dist/sbom-ui/components/SbomHeader.js +32 -0
- package/dist/sbom-ui/components/SbomList.js +52 -0
- package/dist/sbom-ui/inventory.js +128 -0
- package/dist/sbom-ui/launch.js +51 -0
- package/dist/sbom-ui/run.js +55 -0
- package/dist/sbom-ui/store.js +26 -0
- package/dist/scan/collect.js +10 -6
- package/dist/scan/command.js +51 -17
- package/dist/scan/discovery.js +11 -2
- package/dist/scan/render.js +63 -8
- package/dist/scan/scanner-report.js +42 -9
- package/dist/scan/staged.js +71 -11
- package/dist/scan-ui/LegacyApp.js +12 -16
- package/dist/scan-ui/alt-screen.js +5 -8
- package/dist/scan-ui/components/InteractiveResultsView.js +193 -123
- package/dist/scan-ui/components/ProgressBar.js +3 -14
- package/dist/scan-ui/components/ProjectSelector.js +1 -1
- package/dist/scan-ui/components/ScoreHeader.js +2 -3
- package/dist/scan-ui/components/SetupBanner.js +0 -6
- package/dist/scan-ui/format-helpers.js +61 -5
- package/dist/scan-ui/hooks/useResizeRepaint.js +25 -0
- package/dist/scan-ui/hooks/useScan.js +46 -4
- package/dist/scan-ui/launch.js +7 -4
- package/dist/scan-ui/shims.js +14 -4
- package/dist/scripts/detect.js +158 -0
- package/dist/scripts/gate.js +170 -0
- package/dist/service/state.js +27 -8
- package/dist/service/trust-refresh.js +92 -0
- package/dist/service/worker.js +23 -1
- package/dist/setup/activate-shell.js +28 -0
- package/dist/setup/git-hook.js +49 -4
- package/dist/setup/plan.js +98 -29
- package/dist/setup-ui/gate.js +39 -0
- package/dist/setup-ui/offer.js +42 -0
- package/dist/setup-ui/selector.js +27 -0
- package/dist/setup-ui/tasks.js +56 -0
- package/dist/setup-ui/wizard.js +225 -0
- package/dist/state/cooldown-held.js +66 -0
- package/dist/state/index.js +1 -0
- package/dist/state/locks.js +4 -2
- package/dist/state/store.js +2 -1
- package/dist/util/external-tool.js +25 -0
- package/dist/util/git.js +10 -3
- package/dist/util/json-file.js +24 -0
- package/dist/util/report-writer.js +57 -0
- package/dist/util/tty-prompt.js +13 -6
- package/dist/verify/local.js +240 -42
- package/dist/verify/package-check.js +86 -18
- package/dist/verify/preflight.js +242 -49
- package/dist/verify/render.js +15 -1
- package/npm-shrinkwrap.json +2383 -0
- package/package.json +14 -8
- package/NOTICE +0 -5
- package/dist/commands/completion.js +0 -116
- package/dist/commands/explain.js +0 -232
- package/dist/commands/unavailable.js +0 -11
- package/dist/telemetry/events.js +0 -40
package/dist/util/tty-prompt.js
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
import { closeSync, openSync, readSync } from "node:fs";
|
|
2
2
|
export function promptYesNo(question, defaultYes, out = process.stderr) {
|
|
3
|
+
const answer = promptLine(`${question} ${defaultYes ? "[Y/n]" : "[y/N]"} `, out);
|
|
4
|
+
if (answer === null) {
|
|
5
|
+
return null;
|
|
6
|
+
}
|
|
7
|
+
const normalized = answer.trim().toLowerCase();
|
|
8
|
+
if (normalized === "") {
|
|
9
|
+
return defaultYes;
|
|
10
|
+
}
|
|
11
|
+
return normalized === "y" || normalized === "yes";
|
|
12
|
+
}
|
|
13
|
+
export function promptLine(question, out = process.stderr) {
|
|
3
14
|
let tty;
|
|
4
15
|
try {
|
|
5
16
|
tty = openSync("/dev/tty", "rs");
|
|
@@ -8,7 +19,7 @@ export function promptYesNo(question, defaultYes, out = process.stderr) {
|
|
|
8
19
|
return null;
|
|
9
20
|
}
|
|
10
21
|
try {
|
|
11
|
-
out.write(
|
|
22
|
+
out.write(question);
|
|
12
23
|
const byte = Buffer.alloc(1);
|
|
13
24
|
let answer = "";
|
|
14
25
|
for (;;) {
|
|
@@ -31,11 +42,7 @@ export function promptYesNo(question, defaultYes, out = process.stderr) {
|
|
|
31
42
|
}
|
|
32
43
|
answer += char;
|
|
33
44
|
}
|
|
34
|
-
|
|
35
|
-
if (normalized === "") {
|
|
36
|
-
return defaultYes;
|
|
37
|
-
}
|
|
38
|
-
return normalized === "y" || normalized === "yes";
|
|
45
|
+
return answer;
|
|
39
46
|
}
|
|
40
47
|
finally {
|
|
41
48
|
closeSync(tty);
|
package/dist/verify/local.js
CHANGED
|
@@ -8,6 +8,10 @@ const MAX_UNPACKED_BYTES = 250 * 1024 * 1024;
|
|
|
8
8
|
const MAX_ARCHIVE_ENTRIES = 20000;
|
|
9
9
|
const MAX_ARCHIVE_PATH_LENGTH = 240;
|
|
10
10
|
const TAR_BLOCK_SIZE = 512;
|
|
11
|
+
const GNU_LONGNAME_TYPE = 0x4c;
|
|
12
|
+
const GNU_LONGLINK_TYPE = 0x4b;
|
|
13
|
+
const PAX_EXTENDED_TYPE = 0x78;
|
|
14
|
+
const PAX_GLOBAL_TYPE = 0x67;
|
|
11
15
|
export function verifyLocalTarget(targetPath, cwd = process.cwd()) {
|
|
12
16
|
const absoluteTarget = resolve(cwd, targetPath);
|
|
13
17
|
if (!existsSync(absoluteTarget)) {
|
|
@@ -115,23 +119,73 @@ function scanTarball(bytes, path) {
|
|
|
115
119
|
const entries = [];
|
|
116
120
|
let offset = 0;
|
|
117
121
|
let unpackedSizeBytes = 0;
|
|
122
|
+
let pendingLongName = null;
|
|
123
|
+
let pendingPax = null;
|
|
124
|
+
let globalPax = null;
|
|
118
125
|
while (offset + TAR_BLOCK_SIZE <= tarBytes.length) {
|
|
119
126
|
const header = tarBytes.subarray(offset, offset + TAR_BLOCK_SIZE);
|
|
120
127
|
if (header.every((byte) => byte === 0)) {
|
|
121
128
|
break;
|
|
122
129
|
}
|
|
123
|
-
const
|
|
130
|
+
const rawName = tarEntryName(header);
|
|
124
131
|
const size = tarEntrySize(header);
|
|
132
|
+
if (size === null) {
|
|
133
|
+
errors.push("tar archive has an unparseable entry size");
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
125
136
|
const bodyOffset = offset + TAR_BLOCK_SIZE;
|
|
126
137
|
const nextOffset = bodyOffset + Math.ceil(size / TAR_BLOCK_SIZE) * TAR_BLOCK_SIZE;
|
|
127
|
-
if (
|
|
138
|
+
if (rawName.length === 0 || nextOffset > tarBytes.length) {
|
|
128
139
|
errors.push("tar archive has a malformed entry header");
|
|
129
140
|
break;
|
|
130
141
|
}
|
|
142
|
+
const body = tarBytes.subarray(bodyOffset, bodyOffset + size);
|
|
143
|
+
const typeflag = header.readUInt8(156);
|
|
144
|
+
if (typeflag === GNU_LONGNAME_TYPE) {
|
|
145
|
+
pendingLongName = readNullTerminated(body, 0, body.length);
|
|
146
|
+
if (pendingLongName.length === 0) {
|
|
147
|
+
errors.push("tar archive has an empty long-name header");
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
offset = nextOffset;
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (typeflag === GNU_LONGLINK_TYPE) {
|
|
154
|
+
offset = nextOffset;
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (typeflag === PAX_EXTENDED_TYPE || typeflag === PAX_GLOBAL_TYPE) {
|
|
158
|
+
const records = parsePaxRecords(body);
|
|
159
|
+
if (records === null) {
|
|
160
|
+
errors.push("tar archive has an unparseable PAX extended header");
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
if (typeflag === PAX_EXTENDED_TYPE) {
|
|
164
|
+
pendingPax = mergePaxRecords(pendingPax, records);
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
globalPax = mergePaxRecords(globalPax, records);
|
|
168
|
+
}
|
|
169
|
+
offset = nextOffset;
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
const paxPath = pendingPax?.get("path") ?? globalPax?.get("path") ?? null;
|
|
173
|
+
if (paxPath !== null && pendingLongName !== null && paxPath !== pendingLongName) {
|
|
174
|
+
errors.push("tar entry name metadata is contradictory");
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
const paxSize = pendingPax?.get("size") ?? null;
|
|
178
|
+
if (paxSize !== null && Number.parseInt(paxSize, 10) !== size) {
|
|
179
|
+
errors.push("tar entry size metadata is contradictory");
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
const name = paxPath ?? pendingLongName ?? rawName;
|
|
183
|
+
pendingLongName = null;
|
|
184
|
+
pendingPax = null;
|
|
131
185
|
unpackedSizeBytes += size;
|
|
132
186
|
entries.push({
|
|
133
187
|
name,
|
|
134
|
-
body
|
|
188
|
+
body
|
|
135
189
|
});
|
|
136
190
|
findings.push(...pathSafetyFindings(name));
|
|
137
191
|
if (entries.length > MAX_ARCHIVE_ENTRIES) {
|
|
@@ -144,6 +198,9 @@ function scanTarball(bytes, path) {
|
|
|
144
198
|
}
|
|
145
199
|
offset = nextOffset;
|
|
146
200
|
}
|
|
201
|
+
if (errors.length === 0 && (pendingLongName !== null || pendingPax !== null)) {
|
|
202
|
+
errors.push("tar archive ends with an unapplied extended header");
|
|
203
|
+
}
|
|
147
204
|
findings.push(...packageManifestFindings(entries));
|
|
148
205
|
return {
|
|
149
206
|
findings,
|
|
@@ -155,56 +212,41 @@ function scanTarball(bytes, path) {
|
|
|
155
212
|
}
|
|
156
213
|
};
|
|
157
214
|
}
|
|
215
|
+
const ZIP_LOCAL_HEADER_SIG = 0x04034b50;
|
|
216
|
+
const ZIP_CENTRAL_HEADER_SIG = 0x02014b50;
|
|
217
|
+
const ZIP_EOCD_SIG = 0x06054b50;
|
|
218
|
+
const ZIP_DATA_DESCRIPTOR_SIG = 0x08074b50;
|
|
219
|
+
const ZIP_EOCD_MIN_BYTES = 22;
|
|
220
|
+
const ZIP_EOCD_SEARCH_BYTES = ZIP_EOCD_MIN_BYTES + 0xffff;
|
|
158
221
|
function scanZipLike(bytes, path) {
|
|
222
|
+
const central = readZipCentralDirectory(bytes);
|
|
223
|
+
if ("error" in central) {
|
|
224
|
+
return archiveError(`${basename(path)}: ${central.error}`);
|
|
225
|
+
}
|
|
226
|
+
const mismatch = zipLocalHeaderMismatch(bytes, central);
|
|
227
|
+
if (mismatch) {
|
|
228
|
+
return archiveError(`${basename(path)}: ${mismatch}`);
|
|
229
|
+
}
|
|
159
230
|
const findings = [];
|
|
160
231
|
const errors = [];
|
|
161
232
|
const entries = [];
|
|
162
|
-
let offset = 0;
|
|
163
233
|
let unpackedSizeBytes = 0;
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
if (
|
|
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) {
|
|
234
|
+
for (const entry of central.entries) {
|
|
235
|
+
findings.push(...pathSafetyFindings(entry.name));
|
|
236
|
+
if ((entry.flags & 0x1) !== 0) {
|
|
185
237
|
findings.push({
|
|
186
238
|
id: "encrypted-archive-entry",
|
|
187
239
|
severity: "block",
|
|
188
240
|
title: "Encrypted archive entry",
|
|
189
241
|
message: "encrypted archive entries cannot be inspected locally",
|
|
190
|
-
location: name
|
|
242
|
+
location: entry.name
|
|
191
243
|
});
|
|
192
244
|
}
|
|
193
|
-
|
|
194
|
-
unpackedSizeBytes += uncompressedSize;
|
|
245
|
+
unpackedSizeBytes += entry.uncompressedSize;
|
|
195
246
|
entries.push({
|
|
196
|
-
name,
|
|
197
|
-
body
|
|
247
|
+
name: entry.name,
|
|
248
|
+
body: readZipEntryBody(bytes, entry, errors)
|
|
198
249
|
});
|
|
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
250
|
if (entries.length > MAX_ARCHIVE_ENTRIES) {
|
|
209
251
|
findings.push(limitFinding("archive has too many entries"));
|
|
210
252
|
break;
|
|
@@ -213,7 +255,6 @@ function scanZipLike(bytes, path) {
|
|
|
213
255
|
findings.push(limitFinding("archive expands beyond the local verification limit"));
|
|
214
256
|
break;
|
|
215
257
|
}
|
|
216
|
-
offset = dataEnd;
|
|
217
258
|
}
|
|
218
259
|
findings.push(...packageManifestFindings(entries));
|
|
219
260
|
return {
|
|
@@ -226,6 +267,121 @@ function scanZipLike(bytes, path) {
|
|
|
226
267
|
}
|
|
227
268
|
};
|
|
228
269
|
}
|
|
270
|
+
function readZipCentralDirectory(bytes) {
|
|
271
|
+
const eocdOffset = findZipEndOfCentralDirectory(bytes);
|
|
272
|
+
if (eocdOffset === null) {
|
|
273
|
+
return { error: "zip end of central directory record not found" };
|
|
274
|
+
}
|
|
275
|
+
const entryCount = bytes.readUInt16LE(eocdOffset + 10);
|
|
276
|
+
const centralDirectorySize = bytes.readUInt32LE(eocdOffset + 12);
|
|
277
|
+
const centralDirectoryOffset = bytes.readUInt32LE(eocdOffset + 16);
|
|
278
|
+
if (entryCount === 0xffff || centralDirectorySize === 0xffffffff || centralDirectoryOffset === 0xffffffff) {
|
|
279
|
+
return { error: "zip64 archives are not supported by local verification" };
|
|
280
|
+
}
|
|
281
|
+
if (centralDirectoryOffset + centralDirectorySize > eocdOffset) {
|
|
282
|
+
return { error: "zip central directory extends past its end record" };
|
|
283
|
+
}
|
|
284
|
+
const entries = [];
|
|
285
|
+
let offset = centralDirectoryOffset;
|
|
286
|
+
for (let index = 0; index < entryCount; index += 1) {
|
|
287
|
+
if (offset + 46 > eocdOffset || bytes.readUInt32LE(offset) !== ZIP_CENTRAL_HEADER_SIG) {
|
|
288
|
+
return { error: "zip central directory is truncated or malformed" };
|
|
289
|
+
}
|
|
290
|
+
const nameLength = bytes.readUInt16LE(offset + 28);
|
|
291
|
+
const extraLength = bytes.readUInt16LE(offset + 30);
|
|
292
|
+
const commentLength = bytes.readUInt16LE(offset + 32);
|
|
293
|
+
if (offset + 46 + nameLength + extraLength + commentLength > eocdOffset) {
|
|
294
|
+
return { error: "zip central directory is truncated or malformed" };
|
|
295
|
+
}
|
|
296
|
+
entries.push({
|
|
297
|
+
name: bytes.subarray(offset + 46, offset + 46 + nameLength).toString("utf8"),
|
|
298
|
+
flags: bytes.readUInt16LE(offset + 8),
|
|
299
|
+
method: bytes.readUInt16LE(offset + 10),
|
|
300
|
+
compressedSize: bytes.readUInt32LE(offset + 20),
|
|
301
|
+
uncompressedSize: bytes.readUInt32LE(offset + 24),
|
|
302
|
+
localHeaderOffset: bytes.readUInt32LE(offset + 42)
|
|
303
|
+
});
|
|
304
|
+
offset += 46 + nameLength + extraLength + commentLength;
|
|
305
|
+
}
|
|
306
|
+
if (offset !== centralDirectoryOffset + centralDirectorySize) {
|
|
307
|
+
return { error: "zip central directory size disagrees with its entries" };
|
|
308
|
+
}
|
|
309
|
+
return { entries, centralDirectoryOffset };
|
|
310
|
+
}
|
|
311
|
+
function findZipEndOfCentralDirectory(bytes) {
|
|
312
|
+
const searchStart = Math.max(0, bytes.length - ZIP_EOCD_SEARCH_BYTES);
|
|
313
|
+
for (let offset = bytes.length - ZIP_EOCD_MIN_BYTES; offset >= searchStart; offset -= 1) {
|
|
314
|
+
if (bytes.readUInt32LE(offset) === ZIP_EOCD_SIG) {
|
|
315
|
+
const commentLength = bytes.readUInt16LE(offset + 20);
|
|
316
|
+
if (offset + ZIP_EOCD_MIN_BYTES + commentLength === bytes.length) {
|
|
317
|
+
return offset;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
function zipLocalHeaderMismatch(bytes, central) {
|
|
324
|
+
const { entries, centralDirectoryOffset } = central;
|
|
325
|
+
const claimed = new Map();
|
|
326
|
+
for (const entry of entries) {
|
|
327
|
+
if (claimed.has(entry.localHeaderOffset)) {
|
|
328
|
+
return "zip central directory lists overlapping local entries";
|
|
329
|
+
}
|
|
330
|
+
claimed.set(entry.localHeaderOffset, entry);
|
|
331
|
+
}
|
|
332
|
+
for (const entry of entries) {
|
|
333
|
+
const offset = entry.localHeaderOffset;
|
|
334
|
+
if (offset + 30 > centralDirectoryOffset || bytes.readUInt32LE(offset) !== ZIP_LOCAL_HEADER_SIG) {
|
|
335
|
+
return "zip central directory points at a missing local file header";
|
|
336
|
+
}
|
|
337
|
+
const flags = bytes.readUInt16LE(offset + 6);
|
|
338
|
+
const compressedSize = bytes.readUInt32LE(offset + 18);
|
|
339
|
+
const nameLength = bytes.readUInt16LE(offset + 26);
|
|
340
|
+
const name = bytes.subarray(offset + 30, offset + 30 + nameLength).toString("utf8");
|
|
341
|
+
if (name !== entry.name) {
|
|
342
|
+
return "zip local file header name disagrees with the central directory";
|
|
343
|
+
}
|
|
344
|
+
if ((flags & 0x8) === 0 && compressedSize !== entry.compressedSize) {
|
|
345
|
+
return "zip local file header size disagrees with the central directory";
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
let offset = bytes.length >= 4 && bytes.readUInt32LE(0) === ZIP_LOCAL_HEADER_SIG
|
|
349
|
+
? 0
|
|
350
|
+
: entries.length > 0
|
|
351
|
+
? Math.min(...entries.map((entry) => entry.localHeaderOffset))
|
|
352
|
+
: centralDirectoryOffset;
|
|
353
|
+
while (offset + 4 <= centralDirectoryOffset && bytes.readUInt32LE(offset) === ZIP_LOCAL_HEADER_SIG) {
|
|
354
|
+
const entry = claimed.get(offset);
|
|
355
|
+
if (!entry) {
|
|
356
|
+
return "zip has a local file entry the central directory does not list";
|
|
357
|
+
}
|
|
358
|
+
const nameLength = bytes.readUInt16LE(offset + 26);
|
|
359
|
+
const extraLength = bytes.readUInt16LE(offset + 28);
|
|
360
|
+
let next = offset + 30 + nameLength + extraLength + entry.compressedSize;
|
|
361
|
+
if ((entry.flags & 0x8) !== 0) {
|
|
362
|
+
next += next + 4 <= centralDirectoryOffset && bytes.readUInt32LE(next) === ZIP_DATA_DESCRIPTOR_SIG ? 16 : 12;
|
|
363
|
+
}
|
|
364
|
+
if (next > centralDirectoryOffset) {
|
|
365
|
+
return "zip local entry data extends into the central directory";
|
|
366
|
+
}
|
|
367
|
+
offset = next;
|
|
368
|
+
}
|
|
369
|
+
return null;
|
|
370
|
+
}
|
|
371
|
+
function readZipEntryBody(bytes, entry, errors) {
|
|
372
|
+
if ((entry.flags & 0x1) !== 0) {
|
|
373
|
+
return Buffer.alloc(0);
|
|
374
|
+
}
|
|
375
|
+
const headerOffset = entry.localHeaderOffset;
|
|
376
|
+
const nameLength = bytes.readUInt16LE(headerOffset + 26);
|
|
377
|
+
const extraLength = bytes.readUInt16LE(headerOffset + 28);
|
|
378
|
+
const dataStart = headerOffset + 30 + nameLength + extraLength;
|
|
379
|
+
if (dataStart + entry.compressedSize > bytes.length) {
|
|
380
|
+
errors.push(`${entry.name}: zip entry data is truncated`);
|
|
381
|
+
return Buffer.alloc(0);
|
|
382
|
+
}
|
|
383
|
+
return readZipBody(bytes.subarray(dataStart, dataStart + entry.compressedSize), entry.method, entry.name, errors);
|
|
384
|
+
}
|
|
229
385
|
function readZipBody(data, method, name, errors) {
|
|
230
386
|
if (method === 0) {
|
|
231
387
|
return data;
|
|
@@ -375,9 +531,51 @@ function tarEntryName(header) {
|
|
|
375
531
|
return prefix.length > 0 ? `${prefix}/${name}` : name;
|
|
376
532
|
}
|
|
377
533
|
function tarEntrySize(header) {
|
|
534
|
+
if ((header.readUInt8(124) & 0x80) !== 0) {
|
|
535
|
+
return null;
|
|
536
|
+
}
|
|
378
537
|
const value = readNullTerminated(header, 124, 12).trim();
|
|
379
|
-
|
|
380
|
-
|
|
538
|
+
if (value.length === 0) {
|
|
539
|
+
return 0;
|
|
540
|
+
}
|
|
541
|
+
if (!/^[0-7]+$/u.test(value)) {
|
|
542
|
+
return null;
|
|
543
|
+
}
|
|
544
|
+
return Number.parseInt(value, 8);
|
|
545
|
+
}
|
|
546
|
+
function parsePaxRecords(body) {
|
|
547
|
+
const records = new Map();
|
|
548
|
+
let offset = 0;
|
|
549
|
+
while (offset < body.length) {
|
|
550
|
+
const space = body.indexOf(0x20, offset);
|
|
551
|
+
if (space === -1 || space === offset) {
|
|
552
|
+
return null;
|
|
553
|
+
}
|
|
554
|
+
const lengthText = body.subarray(offset, space).toString("ascii");
|
|
555
|
+
if (!/^\d+$/u.test(lengthText)) {
|
|
556
|
+
return null;
|
|
557
|
+
}
|
|
558
|
+
const length = Number.parseInt(lengthText, 10);
|
|
559
|
+
const recordEnd = offset + length;
|
|
560
|
+
if (length <= space - offset + 1 || recordEnd > body.length || body.readUInt8(recordEnd - 1) !== 0x0a) {
|
|
561
|
+
return null;
|
|
562
|
+
}
|
|
563
|
+
const text = body.subarray(space + 1, recordEnd - 1).toString("utf8");
|
|
564
|
+
const equals = text.indexOf("=");
|
|
565
|
+
if (equals <= 0) {
|
|
566
|
+
return null;
|
|
567
|
+
}
|
|
568
|
+
records.set(text.slice(0, equals), text.slice(equals + 1));
|
|
569
|
+
offset = recordEnd;
|
|
570
|
+
}
|
|
571
|
+
return records;
|
|
572
|
+
}
|
|
573
|
+
function mergePaxRecords(existing, incoming) {
|
|
574
|
+
const merged = new Map(existing ?? []);
|
|
575
|
+
for (const [key, value] of incoming) {
|
|
576
|
+
merged.set(key, value);
|
|
577
|
+
}
|
|
578
|
+
return merged;
|
|
381
579
|
}
|
|
382
580
|
function readNullTerminated(buffer, start, length) {
|
|
383
581
|
const slice = buffer.subarray(start, start + length);
|
|
@@ -2,10 +2,12 @@ import { existsSync, writeFileSync } from "node:fs";
|
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
3
|
import { analyzePackages, AnalyzeError } from "../api/analyze.js";
|
|
4
4
|
import { createTheme } from "../presentation/theme.js";
|
|
5
|
+
import { provenanceLabel, provenanceDowngradeLine } from "../presentation/provenance.js";
|
|
6
|
+
import { packagePageUrl } from "../presentation/package-page.js";
|
|
5
7
|
import { resolvePresentation } from "../presentation/mode.js";
|
|
6
8
|
import { isRemotePackageSpec, isSupportedLockfilePath } from "./preflight.js";
|
|
7
9
|
import { authStatus } from "../auth/store.js";
|
|
8
|
-
import { EXIT_TOOL_ERROR, EXIT_UNAVAILABLE } from "../commands/types.js";
|
|
10
|
+
import { EXIT_TOOL_ERROR, EXIT_UNAVAILABLE, EXIT_USAGE_VERDICT } from "../commands/types.js";
|
|
9
11
|
const REGISTRIES = { npm: "npm", pypi: "pypi" };
|
|
10
12
|
const DEEP_VERIFY_HINT = "deep verify supports npm and pypi: dg verify npm:<package> or pypi:<package>";
|
|
11
13
|
function parseSpec(target) {
|
|
@@ -37,10 +39,10 @@ function parseSpec(target) {
|
|
|
37
39
|
const version = rest.slice(at + separator.length).trim() || null;
|
|
38
40
|
return { ecosystem, name, version };
|
|
39
41
|
}
|
|
40
|
-
async function resolveLatest(ecosystem, name, fetchImpl) {
|
|
42
|
+
export async function resolveLatest(ecosystem, name, fetchImpl) {
|
|
41
43
|
try {
|
|
42
44
|
if (ecosystem === "npm") {
|
|
43
|
-
const response = await fetchImpl(`https://registry.npmjs.org/${name
|
|
45
|
+
const response = await fetchImpl(`https://registry.npmjs.org/${encodeNpmPackagePath(name)}`);
|
|
44
46
|
if (!response.ok) {
|
|
45
47
|
return null;
|
|
46
48
|
}
|
|
@@ -58,14 +60,31 @@ async function resolveLatest(ecosystem, name, fetchImpl) {
|
|
|
58
60
|
return null;
|
|
59
61
|
}
|
|
60
62
|
}
|
|
63
|
+
function provenanceLines(version, result, theme, withPredicate) {
|
|
64
|
+
if (!result.provenance) {
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
const label = withPredicate && result.provenance.predicateType
|
|
68
|
+
? `provenance ${provenanceLabel(result.provenance)} · ${result.provenance.predicateType}`
|
|
69
|
+
: `provenance ${provenanceLabel(result.provenance)}`;
|
|
70
|
+
const lines = [` ${theme.paint("muted", label)}`];
|
|
71
|
+
const downgrade = provenanceDowngradeLine(version, result.provenance);
|
|
72
|
+
if (downgrade) {
|
|
73
|
+
lines.push(` ${theme.paint("warn", `⚠ ${downgrade}`)}`);
|
|
74
|
+
}
|
|
75
|
+
return lines;
|
|
76
|
+
}
|
|
77
|
+
function reasonGlyph(action, theme) {
|
|
78
|
+
return action === "block" ? theme.paint("block", "✘") : action === "warn" ? theme.paint("warn", "⚠") : theme.paint("muted", "·");
|
|
79
|
+
}
|
|
61
80
|
function renderResult(spec, version, result, theme, verbose) {
|
|
62
81
|
const action = result.action ?? "pass";
|
|
63
82
|
const badge = theme.badge(action);
|
|
64
83
|
const lines = [`${badge} ${result.name}@${version} (${spec.ecosystem}) ${theme.paint("muted", `score ${result.score}`)}`];
|
|
84
|
+
lines.push(...provenanceLines(version, result, theme, verbose));
|
|
65
85
|
const reasons = verbose ? result.reasons : result.reasons.slice(0, 6);
|
|
66
86
|
for (const reason of reasons) {
|
|
67
|
-
|
|
68
|
-
lines.push(` ${glyph} ${reason}`);
|
|
87
|
+
lines.push(` ${reasonGlyph(action, theme)} ${reason}`);
|
|
69
88
|
}
|
|
70
89
|
if (!verbose && result.reasons.length > reasons.length) {
|
|
71
90
|
lines.push(` ${theme.paint("muted", `… ${result.reasons.length - reasons.length} more — rerun with --verbose`)}`);
|
|
@@ -81,8 +100,43 @@ function renderResult(spec, version, result, theme, verbose) {
|
|
|
81
100
|
if (result.recommendation) {
|
|
82
101
|
lines.push(` ${theme.paint("muted", result.recommendation)}`);
|
|
83
102
|
}
|
|
103
|
+
const page = packagePageUrl(spec.ecosystem, result.name);
|
|
104
|
+
if (page) {
|
|
105
|
+
lines.push(` ${theme.paint("muted", `→ ${page}`)}`);
|
|
106
|
+
}
|
|
107
|
+
return `${lines.join("\n")}\n`;
|
|
108
|
+
}
|
|
109
|
+
const FREE_REASON_CAP = 3;
|
|
110
|
+
const FREE_SCANS_FOOTER_THRESHOLD = 5_000;
|
|
111
|
+
function renderFreeResult(spec, version, result, theme, freeScansRemaining) {
|
|
112
|
+
const action = result.action ?? "pass";
|
|
113
|
+
const lines = [`${theme.badge(action)} ${result.name}@${version} (${spec.ecosystem})`];
|
|
114
|
+
lines.push(...provenanceLines(version, result, theme, false));
|
|
115
|
+
if (action === "pass") {
|
|
116
|
+
lines.push(` ${theme.paint("muted", result.reasons[0] ?? "no risk signals")}`);
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
const reasons = result.reasons.slice(0, FREE_REASON_CAP);
|
|
120
|
+
for (const reason of reasons) {
|
|
121
|
+
lines.push(` ${reasonGlyph(action, theme)} ${reason}`);
|
|
122
|
+
}
|
|
123
|
+
if (result.reasons.length > reasons.length) {
|
|
124
|
+
lines.push(` ${theme.paint("muted", `… ${result.reasons.length - reasons.length} more — sign in to see all:`)} ${theme.paint("accent", "dg login")}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const page = packagePageUrl(spec.ecosystem, result.name);
|
|
128
|
+
if (page) {
|
|
129
|
+
lines.push(` ${theme.paint("muted", `→ ${page}`)}`);
|
|
130
|
+
}
|
|
131
|
+
lines.push(` ${theme.paint("muted", "full findings, license, JSON output:")} ${theme.paint("accent", "dg login")}`);
|
|
132
|
+
if (freeScansRemaining !== undefined && freeScansRemaining < FREE_SCANS_FOOTER_THRESHOLD) {
|
|
133
|
+
lines.push(` ${theme.paint("muted", `${freeScansRemaining.toLocaleString()} free package checks left this month`)}`);
|
|
134
|
+
}
|
|
84
135
|
return `${lines.join("\n")}\n`;
|
|
85
136
|
}
|
|
137
|
+
function encodeNpmPackagePath(name) {
|
|
138
|
+
return name.split("/").map((segment) => encodeURIComponent(segment)).join("%2f");
|
|
139
|
+
}
|
|
86
140
|
function exitCodeFor(action) {
|
|
87
141
|
if (action === "block") {
|
|
88
142
|
return 2;
|
|
@@ -98,39 +152,41 @@ function exitCodeFor(action) {
|
|
|
98
152
|
export async function runPackageCheck(target, io = {}, options = {}) {
|
|
99
153
|
const fetchImpl = io.fetchImpl ?? fetch;
|
|
100
154
|
const theme = createTheme(resolvePresentation().color);
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
155
|
+
const accent = (text) => theme.paint("accent", text);
|
|
156
|
+
const muted = (text) => theme.paint("muted", text);
|
|
157
|
+
const authenticated = authStatus(io.env).authenticated;
|
|
158
|
+
if (!authenticated && (options.format === "json" || options.outputPath)) {
|
|
104
159
|
return {
|
|
105
160
|
exitCode: EXIT_UNAVAILABLE,
|
|
106
161
|
stdout: "",
|
|
107
|
-
stderr: `\n ${theme.paint("warn", "⚠")}
|
|
162
|
+
stderr: `\n ${theme.paint("warn", "⚠")} ${muted("--json and --output for registry checks require sign-in.")}\n` +
|
|
108
163
|
` ${accent("dg login")}${muted(" · see plans at")} ${accent("westbayberry.com/pricing")}\n\n`
|
|
109
164
|
};
|
|
110
165
|
}
|
|
111
166
|
const parsed = parseSpec(target);
|
|
112
167
|
if (parsed === null) {
|
|
113
168
|
return {
|
|
114
|
-
exitCode:
|
|
169
|
+
exitCode: EXIT_USAGE_VERDICT,
|
|
115
170
|
stdout: "",
|
|
116
171
|
stderr: `dg verify: add a registry, e.g. dg verify npm:${target} or dg verify pypi:${target}\n`
|
|
117
172
|
};
|
|
118
173
|
}
|
|
119
174
|
if ("error" in parsed) {
|
|
120
|
-
return { exitCode:
|
|
175
|
+
return { exitCode: EXIT_USAGE_VERDICT, stdout: "", stderr: `dg verify: ${parsed.error}\n` };
|
|
121
176
|
}
|
|
122
177
|
let version = parsed.version;
|
|
123
178
|
if (!version) {
|
|
124
179
|
version = await resolveLatest(parsed.ecosystem, parsed.name, fetchImpl);
|
|
125
180
|
if (!version) {
|
|
126
181
|
return {
|
|
127
|
-
exitCode:
|
|
182
|
+
exitCode: exitCodeFor("analysis_incomplete"),
|
|
128
183
|
stdout: "",
|
|
129
184
|
stderr: `dg verify: could not resolve the latest version of ${parsed.name} on ${parsed.ecosystem}\n`
|
|
130
185
|
};
|
|
131
186
|
}
|
|
132
187
|
}
|
|
133
188
|
let result;
|
|
189
|
+
let freeScansRemaining;
|
|
134
190
|
try {
|
|
135
191
|
const response = await analyzePackages([{ name: parsed.name, version }], {
|
|
136
192
|
ecosystem: parsed.ecosystem,
|
|
@@ -138,13 +194,22 @@ export async function runPackageCheck(target, io = {}, options = {}) {
|
|
|
138
194
|
...(io.env ? { env: io.env } : {})
|
|
139
195
|
});
|
|
140
196
|
result = response.packages.find((entry) => entry.name === parsed.name) ?? response.packages[0];
|
|
197
|
+
freeScansRemaining = response.freeScansRemaining;
|
|
141
198
|
}
|
|
142
199
|
catch (error) {
|
|
200
|
+
if (error instanceof AnalyzeError && error.code === "quota_exceeded") {
|
|
201
|
+
const loginLine = authenticated ? "" : ` ${muted("Run")} ${accent("dg login")} ${muted("to connect your account.")}\n`;
|
|
202
|
+
return {
|
|
203
|
+
exitCode: exitCodeFor("analysis_incomplete"),
|
|
204
|
+
stdout: "",
|
|
205
|
+
stderr: `dg verify: ${error.message}\n${loginLine} ${muted("see plans at")} ${accent("westbayberry.com/pricing")}\n`
|
|
206
|
+
};
|
|
207
|
+
}
|
|
143
208
|
const message = error instanceof AnalyzeError ? error.message : error instanceof Error ? error.message : "could not reach the scanner";
|
|
144
|
-
return { exitCode:
|
|
209
|
+
return { exitCode: exitCodeFor("analysis_incomplete"), stdout: "", stderr: `dg verify: ${message}\n` };
|
|
145
210
|
}
|
|
146
211
|
if (!result) {
|
|
147
|
-
return { exitCode:
|
|
212
|
+
return { exitCode: exitCodeFor("analysis_incomplete"), stdout: "", stderr: `dg verify: scanner returned no result for ${parsed.name}\n` };
|
|
148
213
|
}
|
|
149
214
|
const action = result.action ?? "pass";
|
|
150
215
|
const rendered = options.format === "json"
|
|
@@ -157,9 +222,12 @@ export async function runPackageCheck(target, io = {}, options = {}) {
|
|
|
157
222
|
score: result.score,
|
|
158
223
|
reasons: result.reasons,
|
|
159
224
|
findings: result.findings,
|
|
225
|
+
...(result.provenance ? { provenance: result.provenance } : {}),
|
|
160
226
|
...(result.recommendation ? { recommendation: result.recommendation } : {})
|
|
161
227
|
}, null, 2)}\n`
|
|
162
|
-
:
|
|
228
|
+
: authenticated
|
|
229
|
+
? renderResult(parsed, version, result, theme, options.verbose ?? false)
|
|
230
|
+
: renderFreeResult(parsed, version, result, theme, freeScansRemaining);
|
|
163
231
|
if (options.outputPath) {
|
|
164
232
|
try {
|
|
165
233
|
writeFileSync(resolve(options.outputPath), rendered, "utf8");
|
|
@@ -236,20 +304,20 @@ export async function maybeVerifyPackage(args) {
|
|
|
236
304
|
if (outputFlagMissingPath) {
|
|
237
305
|
return {
|
|
238
306
|
handled: true,
|
|
239
|
-
result: { exitCode:
|
|
307
|
+
result: { exitCode: EXIT_USAGE_VERDICT, stdout: "", stderr: `dg verify: ${outputFlagMissingPath} requires a path. Run 'dg verify --help'.\n` }
|
|
240
308
|
};
|
|
241
309
|
}
|
|
242
310
|
if (unknownFlag) {
|
|
243
311
|
return {
|
|
244
312
|
handled: true,
|
|
245
|
-
result: { exitCode:
|
|
313
|
+
result: { exitCode: EXIT_USAGE_VERDICT, stdout: "", stderr: `dg verify: unknown option '${unknownFlag}'. Run 'dg verify --help'.\n` }
|
|
246
314
|
};
|
|
247
315
|
}
|
|
248
316
|
if (sarif) {
|
|
249
317
|
return {
|
|
250
318
|
handled: true,
|
|
251
319
|
result: {
|
|
252
|
-
exitCode:
|
|
320
|
+
exitCode: EXIT_USAGE_VERDICT,
|
|
253
321
|
stdout: "",
|
|
254
322
|
stderr: "dg verify: --sarif applies to local artifacts and lockfiles; registry package checks support --json.\n"
|
|
255
323
|
}
|