@westbayberry/dg 2.1.0 → 2.3.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 (148) hide show
  1. package/README.md +216 -226
  2. package/dist/agents/claude-code.js +123 -0
  3. package/dist/agents/codex.js +65 -0
  4. package/dist/agents/copilot-cli.js +115 -0
  5. package/dist/agents/cursor.js +118 -0
  6. package/dist/agents/gate-posture.js +21 -0
  7. package/dist/agents/gemini.js +107 -0
  8. package/dist/agents/persistence.js +351 -0
  9. package/dist/agents/registry.js +128 -0
  10. package/dist/agents/routing.js +118 -0
  11. package/dist/agents/types.js +1 -0
  12. package/dist/agents/windsurf.js +93 -0
  13. package/dist/api/analyze.js +1 -1
  14. package/dist/audit/detectors.js +0 -11
  15. package/dist/audit/events.js +5 -21
  16. package/dist/audit/rules.js +6 -2
  17. package/dist/audit-ui/AuditApp.js +2 -0
  18. package/dist/audit-ui/components/AuditResultsView.js +55 -92
  19. package/dist/audit-ui/export.js +0 -4
  20. package/dist/audit-ui/format.js +0 -3
  21. package/dist/audit-ui/launch.js +10 -1
  22. package/dist/auth/device-login.js +4 -5
  23. package/dist/auth/login-app.js +7 -7
  24. package/dist/auth/store.js +17 -5
  25. package/dist/bin/dg.js +59 -52
  26. package/dist/commands/agents.js +275 -0
  27. package/dist/commands/audit.js +28 -3
  28. package/dist/commands/config.js +15 -7
  29. package/dist/commands/cooldown.js +389 -0
  30. package/dist/commands/decisions.js +23 -9
  31. package/dist/commands/doctor.js +1 -1
  32. package/dist/commands/help.js +1 -1
  33. package/dist/commands/licenses.js +10 -22
  34. package/dist/commands/logout.js +4 -11
  35. package/dist/commands/router.js +6 -4
  36. package/dist/commands/sbom.js +206 -0
  37. package/dist/commands/scan.js +1 -1
  38. package/dist/commands/service.js +32 -13
  39. package/dist/commands/setup.js +196 -27
  40. package/dist/commands/types.js +1 -0
  41. package/dist/commands/uninstall.js +2 -1
  42. package/dist/commands/update.js +17 -8
  43. package/dist/commands/verify.js +8 -5
  44. package/dist/config/settings.js +49 -48
  45. package/dist/decisions/remember-prompt.js +5 -10
  46. package/dist/export-ui/ExportDialog.js +198 -0
  47. package/dist/install-ui/LiveInstall.js +5 -4
  48. package/dist/install-ui/block-render.js +18 -14
  49. package/dist/install-ui/prep-spinner.js +32 -0
  50. package/dist/launcher/agent-check.js +896 -0
  51. package/dist/launcher/agent-hook-exec.js +70 -0
  52. package/dist/launcher/agent-hook-io.js +39 -0
  53. package/dist/launcher/cargo-cache.js +40 -0
  54. package/dist/launcher/classify.js +44 -7
  55. package/dist/launcher/env.js +105 -26
  56. package/dist/launcher/install-preflight.js +64 -18
  57. package/dist/launcher/live-install.js +29 -8
  58. package/dist/launcher/manifest-screen.js +171 -0
  59. package/dist/launcher/output-redaction.js +10 -4
  60. package/dist/launcher/preflight-prompt.js +18 -4
  61. package/dist/launcher/run.js +178 -103
  62. package/dist/launcher/spawn-invocation.js +21 -0
  63. package/dist/policy/cooldown.js +18 -5
  64. package/dist/policy/evaluate.js +5 -6
  65. package/dist/policy/pypi-name.js +17 -0
  66. package/dist/presentation/mode.js +3 -2
  67. package/dist/presentation/package-page.js +9 -0
  68. package/dist/presentation/theme.js +7 -7
  69. package/dist/project/dgfile.js +146 -6
  70. package/dist/project/override-trust.js +0 -0
  71. package/dist/proxy/auth.js +42 -0
  72. package/dist/proxy/ca.js +29 -9
  73. package/dist/proxy/cooldown-exemptions-file.js +33 -0
  74. package/dist/proxy/enforcement.js +55 -16
  75. package/dist/proxy/metadata-map.js +66 -12
  76. package/dist/proxy/preverified.js +55 -0
  77. package/dist/proxy/server.js +568 -53
  78. package/dist/proxy/worker.js +16 -1
  79. package/dist/publish-set/collect.js +1 -4
  80. package/dist/publish-set/npm.js +8 -5
  81. package/dist/publish-set/pack.js +9 -3
  82. package/dist/runtime/cli.js +0 -4
  83. package/dist/runtime/fatal.js +31 -0
  84. package/dist/runtime/first-run.js +14 -12
  85. package/dist/runtime/node-version.js +43 -6
  86. package/dist/runtime/nudges.js +35 -2
  87. package/dist/sbom/cyclonedx.js +211 -0
  88. package/dist/sbom-ui/SbomApp.js +158 -0
  89. package/dist/sbom-ui/components/SbomHeader.js +32 -0
  90. package/dist/sbom-ui/components/SbomList.js +52 -0
  91. package/dist/sbom-ui/inventory.js +132 -0
  92. package/dist/sbom-ui/launch.js +51 -0
  93. package/dist/sbom-ui/run.js +55 -0
  94. package/dist/sbom-ui/store.js +26 -0
  95. package/dist/scan/collect.js +10 -6
  96. package/dist/scan/command.js +17 -10
  97. package/dist/scan/discovery.js +11 -2
  98. package/dist/scan/render.js +28 -4
  99. package/dist/scan/scanner-report.js +15 -9
  100. package/dist/scan/staged.js +33 -9
  101. package/dist/scan-ui/LegacyApp.js +9 -13
  102. package/dist/scan-ui/alt-screen.js +5 -8
  103. package/dist/scan-ui/components/InteractiveResultsView.js +133 -120
  104. package/dist/scan-ui/components/ProgressBar.js +3 -14
  105. package/dist/scan-ui/components/ProjectSelector.js +1 -1
  106. package/dist/scan-ui/components/ScoreHeader.js +2 -3
  107. package/dist/scan-ui/components/SetupBanner.js +0 -6
  108. package/dist/scan-ui/format-helpers.js +61 -5
  109. package/dist/scan-ui/hooks/useResizeRepaint.js +25 -0
  110. package/dist/scan-ui/hooks/useScan.js +19 -2
  111. package/dist/scan-ui/launch.js +7 -4
  112. package/dist/scan-ui/shims.js +11 -4
  113. package/dist/scripts/detect.js +7 -2
  114. package/dist/scripts/gate.js +1 -1
  115. package/dist/security/sanitize.js +8 -4
  116. package/dist/service/state.js +28 -9
  117. package/dist/service/trust-refresh.js +92 -0
  118. package/dist/service/trust-store.js +5 -9
  119. package/dist/service/worker.js +23 -1
  120. package/dist/setup/activate-shell.js +28 -0
  121. package/dist/setup/git-hook.js +49 -4
  122. package/dist/setup/plan.js +227 -49
  123. package/dist/setup/uninstall-standalone.js +25 -0
  124. package/dist/setup-ui/gate.js +39 -0
  125. package/dist/setup-ui/offer.js +42 -0
  126. package/dist/setup-ui/selector.js +27 -0
  127. package/dist/setup-ui/tasks.js +56 -0
  128. package/dist/setup-ui/wizard.js +233 -0
  129. package/dist/standalone/uninstall.mjs +2123 -0
  130. package/dist/state/cooldown-held.js +66 -0
  131. package/dist/state/index.js +1 -0
  132. package/dist/state/locks.js +4 -2
  133. package/dist/state/store.js +2 -1
  134. package/dist/util/external-tool.js +25 -0
  135. package/dist/util/git.js +10 -3
  136. package/dist/util/report-writer.js +57 -0
  137. package/dist/verify/local.js +242 -44
  138. package/dist/verify/package-check.js +87 -29
  139. package/dist/verify/preflight.js +242 -49
  140. package/dist/verify/render.js +15 -1
  141. package/npm-shrinkwrap.json +2383 -0
  142. package/package.json +7 -8
  143. package/NOTICE +0 -5
  144. package/dist/commands/completion.js +0 -117
  145. package/dist/commands/explain.js +0 -236
  146. package/dist/commands/unavailable.js +0 -11
  147. package/dist/scripts/rebuild.js +0 -28
  148. package/dist/telemetry/events.js +0 -40
@@ -0,0 +1,66 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { writeJsonAtomic } from "../util/json-file.js";
4
+ import { resolveDgPaths } from "./paths.js";
5
+ export const HELD_PACKAGES_CAP = 500;
6
+ export const UNKNOWN_ELIGIBILITY_TTL_MS = 30 * 24 * 60 * 60 * 1000;
7
+ export function heldPackagesPath(env = process.env) {
8
+ return join(resolveDgPaths(env).stateDir, "cooldown-held.json");
9
+ }
10
+ function heldKey(entry) {
11
+ return `${entry.ecosystem}:${entry.name}@${entry.version}`;
12
+ }
13
+ function readDocument(path) {
14
+ let raw;
15
+ try {
16
+ raw = readFileSync(path, "utf8");
17
+ }
18
+ catch {
19
+ return [];
20
+ }
21
+ try {
22
+ const parsed = JSON.parse(raw);
23
+ if (!parsed || parsed.schemaVersion !== 1 || !Array.isArray(parsed.entries)) {
24
+ return [];
25
+ }
26
+ return parsed.entries.filter((entry) => !!entry
27
+ && typeof entry.ecosystem === "string"
28
+ && typeof entry.name === "string"
29
+ && typeof entry.version === "string"
30
+ && typeof entry.requiredDays === "number"
31
+ && typeof entry.firstSeenAt === "string"
32
+ && typeof entry.lastSeenAt === "string");
33
+ }
34
+ catch {
35
+ return [];
36
+ }
37
+ }
38
+ function prune(entries, now) {
39
+ return entries.filter((entry) => {
40
+ const eligible = entry.eligibleAt ? Date.parse(entry.eligibleAt) : Number.NaN;
41
+ if (Number.isFinite(eligible)) {
42
+ return eligible > now.getTime();
43
+ }
44
+ const lastSeen = Date.parse(entry.lastSeenAt);
45
+ return Number.isFinite(lastSeen) && now.getTime() - lastSeen <= UNKNOWN_ELIGIBILITY_TTL_MS;
46
+ });
47
+ }
48
+ export function readHeldPackages(env = process.env, now = new Date()) {
49
+ return prune(readDocument(heldPackagesPath(env)), now);
50
+ }
51
+ export function recordHeldPackage(entry, env = process.env, now = new Date()) {
52
+ const path = heldPackagesPath(env);
53
+ const nowIso = now.toISOString();
54
+ const existing = prune(readDocument(path), now);
55
+ const key = heldKey(entry);
56
+ const previous = existing.find((candidate) => heldKey(candidate) === key);
57
+ const updated = {
58
+ ...entry,
59
+ firstSeenAt: previous?.firstSeenAt ?? nowIso,
60
+ lastSeenAt: nowIso
61
+ };
62
+ const merged = [...existing.filter((candidate) => heldKey(candidate) !== key), updated]
63
+ .sort((left, right) => right.lastSeenAt.localeCompare(left.lastSeenAt))
64
+ .slice(0, HELD_PACKAGES_CAP);
65
+ writeJsonAtomic(path, { schemaVersion: 1, entries: merged });
66
+ }
@@ -1,4 +1,5 @@
1
1
  export * from "./cleanup-registry.js";
2
+ export * from "./cooldown-held.js";
2
3
  export * from "./locks.js";
3
4
  export * from "./paths.js";
4
5
  export * from "./sessions.js";
@@ -17,7 +17,8 @@ export class LockBusyError extends Error {
17
17
  export async function acquireLock(paths, name, options = {}) {
18
18
  assertLockName(name);
19
19
  await mkdir(paths.locksDir, {
20
- recursive: true
20
+ recursive: true,
21
+ mode: 0o700
21
22
  });
22
23
  const path = join(paths.locksDir, `${name}.lock`);
23
24
  await removeStaleLock(path, options);
@@ -54,7 +55,8 @@ export async function acquireLock(paths, name, options = {}) {
54
55
  export function acquireLockSync(paths, name, options = {}) {
55
56
  assertLockName(name);
56
57
  mkdirSync(paths.locksDir, {
57
- recursive: true
58
+ recursive: true,
59
+ mode: 0o700
58
60
  });
59
61
  const path = join(paths.locksDir, `${name}.lock`);
60
62
  removeStaleLockSync(path, options);
@@ -26,7 +26,8 @@ export async function readJsonFile(path, fallback) {
26
26
  }
27
27
  export async function writeJsonFileAtomic(path, value) {
28
28
  await mkdir(dirname(path), {
29
- recursive: true
29
+ recursive: true,
30
+ mode: 0o700
30
31
  });
31
32
  const tempPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
32
33
  const payload = `${JSON.stringify(value, null, 2)}\n`;
@@ -0,0 +1,25 @@
1
+ import { resolveRealBinary } from "../launcher/resolve-real-binary.js";
2
+ import { resolveSpawnInvocation } from "../launcher/spawn-invocation.js";
3
+ const toolPathCache = new Map();
4
+ // Windows resolves bare command names against the child cwd before PATH,
5
+ // and dg routinely runs with cwd inside untrusted trees; external tools
6
+ // must always spawn from an absolute PATH-resolved location. Callers may
7
+ // pass a minimal child env; resolution then falls back to the parent PATH.
8
+ export function resolveToolPath(name, env = process.env) {
9
+ const searchEnv = env.PATH ? env : { ...env, PATH: process.env.PATH };
10
+ const key = `${name} ${searchEnv.PATH ?? ""}`;
11
+ const cached = toolPathCache.get(key);
12
+ if (cached !== undefined) {
13
+ return cached;
14
+ }
15
+ const path = resolveRealBinary({ name, env: searchEnv }).path;
16
+ toolPathCache.set(key, path);
17
+ return path;
18
+ }
19
+ export function toolInvocation(name, args, env = process.env) {
20
+ const path = resolveToolPath(name, env);
21
+ if (!path) {
22
+ return null;
23
+ }
24
+ return resolveSpawnInvocation(path, args);
25
+ }
package/dist/util/git.js CHANGED
@@ -1,11 +1,18 @@
1
1
  import { spawnSync } from "node:child_process";
2
+ import { toolInvocation } from "./external-tool.js";
2
3
  const DEFAULT_MAX_BUFFER = 256 * 1024 * 1024;
3
4
  export function gitSync(args, options) {
4
- const result = spawnSync("git", [...args], {
5
+ const env = options.env ?? process.env;
6
+ const invocation = toolInvocation("git", args, env);
7
+ if (!invocation) {
8
+ return { ok: false, code: null, stdout: "", stderr: "git executable not found on PATH" };
9
+ }
10
+ const result = spawnSync(invocation.command, [...invocation.args], {
5
11
  cwd: options.cwd,
6
- env: options.env ?? process.env,
12
+ env,
7
13
  encoding: "utf8",
8
- maxBuffer: options.maxBuffer ?? DEFAULT_MAX_BUFFER
14
+ maxBuffer: options.maxBuffer ?? DEFAULT_MAX_BUFFER,
15
+ windowsVerbatimArguments: invocation.windowsVerbatimArguments
9
16
  });
10
17
  return {
11
18
  ok: result.status === 0,
@@ -0,0 +1,57 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { dirname, isAbsolute, join, resolve } from "node:path";
5
+ export function writeReportAtomic(outputPath, contents) {
6
+ const directory = dirname(outputPath);
7
+ mkdirSync(directory, { recursive: true });
8
+ const temporaryPath = `${outputPath}.${randomUUID()}.tmp`;
9
+ try {
10
+ writeFileSync(temporaryPath, contents, { encoding: "utf8", flag: "wx" });
11
+ renameSync(temporaryPath, outputPath);
12
+ }
13
+ catch (error) {
14
+ rmSync(temporaryPath, { force: true });
15
+ throw error;
16
+ }
17
+ }
18
+ export function resolveExportPath(input, cwd) {
19
+ const trimmed = input.trim();
20
+ if (trimmed === "~" || trimmed.startsWith("~/")) {
21
+ return resolve(homedir(), trimmed.slice(2));
22
+ }
23
+ return resolve(cwd, trimmed);
24
+ }
25
+ function isDirectory(path) {
26
+ try {
27
+ return statSync(path, { throwIfNoEntry: false })?.isDirectory() ?? false;
28
+ }
29
+ catch {
30
+ return false;
31
+ }
32
+ }
33
+ export function userHomeDir(env = process.env) {
34
+ return env.HOME && isAbsolute(env.HOME) ? env.HOME : homedir();
35
+ }
36
+ export function exportDestinations(cwd, env = process.env) {
37
+ const home = userHomeDir(env);
38
+ const candidates = [{ label: "This folder", dir: cwd }];
39
+ for (const name of ["Downloads", "Desktop"]) {
40
+ const dir = join(home, name);
41
+ if (isDirectory(dir)) {
42
+ candidates.push({ label: name, dir });
43
+ }
44
+ }
45
+ candidates.push({ label: "Home", dir: home });
46
+ const seen = new Set();
47
+ const out = [];
48
+ for (const candidate of candidates) {
49
+ const key = resolve(candidate.dir);
50
+ if (seen.has(key)) {
51
+ continue;
52
+ }
53
+ seen.add(key);
54
+ out.push(candidate);
55
+ }
56
+ return out;
57
+ }
@@ -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)) {
@@ -101,7 +105,7 @@ function scanTarball(bytes, path) {
101
105
  let tarBytes = bytes;
102
106
  if (path.endsWith(".tgz") || path.endsWith(".tar.gz")) {
103
107
  try {
104
- tarBytes = gunzipSync(bytes);
108
+ tarBytes = gunzipSync(bytes, { maxOutputLength: MAX_UNPACKED_BYTES });
105
109
  }
106
110
  catch (error) {
107
111
  return archiveError(`could not decompress tarball: ${error instanceof Error ? error.message : "unknown gzip error"}`);
@@ -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 name = tarEntryName(header);
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 (name.length === 0 || nextOffset > tarBytes.length) {
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: tarBytes.subarray(bodyOffset, bodyOffset + size)
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
- 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) {
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
- const body = readZipBody(bytes.subarray(dataStart, dataEnd), method, name, errors);
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,13 +267,128 @@ 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;
232
388
  }
233
389
  if (method === 8) {
234
390
  try {
235
- return inflateRawSync(data);
391
+ return inflateRawSync(data, { maxOutputLength: MAX_UNPACKED_BYTES });
236
392
  }
237
393
  catch (error) {
238
394
  errors.push(`${name}: could not inflate zip entry: ${error instanceof Error ? error.message : "unknown inflate error"}`);
@@ -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
- const parsed = Number.parseInt(value || "0", 8);
380
- return Number.isFinite(parsed) ? parsed : 0;
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);