@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.
- package/README.md +216 -226
- package/dist/agents/claude-code.js +123 -0
- package/dist/agents/codex.js +65 -0
- package/dist/agents/copilot-cli.js +115 -0
- package/dist/agents/cursor.js +118 -0
- package/dist/agents/gate-posture.js +21 -0
- package/dist/agents/gemini.js +107 -0
- package/dist/agents/persistence.js +351 -0
- package/dist/agents/registry.js +128 -0
- package/dist/agents/routing.js +118 -0
- package/dist/agents/types.js +1 -0
- package/dist/agents/windsurf.js +93 -0
- package/dist/api/analyze.js +1 -1
- package/dist/audit/detectors.js +0 -11
- package/dist/audit/events.js +5 -21
- package/dist/audit/rules.js +6 -2
- 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 +17 -5
- package/dist/bin/dg.js +59 -52
- package/dist/commands/agents.js +275 -0
- package/dist/commands/audit.js +28 -3
- package/dist/commands/config.js +15 -7
- package/dist/commands/cooldown.js +389 -0
- package/dist/commands/decisions.js +23 -9
- 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 +6 -4
- package/dist/commands/sbom.js +206 -0
- package/dist/commands/scan.js +1 -1
- package/dist/commands/service.js +32 -13
- package/dist/commands/setup.js +196 -27
- package/dist/commands/types.js +1 -0
- package/dist/commands/uninstall.js +2 -1
- package/dist/commands/update.js +17 -8
- package/dist/commands/verify.js +8 -5
- package/dist/config/settings.js +49 -48
- package/dist/decisions/remember-prompt.js +5 -10
- package/dist/export-ui/ExportDialog.js +198 -0
- package/dist/install-ui/LiveInstall.js +5 -4
- package/dist/install-ui/block-render.js +18 -14
- package/dist/install-ui/prep-spinner.js +32 -0
- package/dist/launcher/agent-check.js +896 -0
- package/dist/launcher/agent-hook-exec.js +70 -0
- package/dist/launcher/agent-hook-io.js +39 -0
- package/dist/launcher/cargo-cache.js +40 -0
- package/dist/launcher/classify.js +44 -7
- package/dist/launcher/env.js +105 -26
- package/dist/launcher/install-preflight.js +64 -18
- package/dist/launcher/live-install.js +29 -8
- package/dist/launcher/manifest-screen.js +171 -0
- package/dist/launcher/output-redaction.js +10 -4
- package/dist/launcher/preflight-prompt.js +18 -4
- package/dist/launcher/run.js +178 -103
- package/dist/launcher/spawn-invocation.js +21 -0
- package/dist/policy/cooldown.js +18 -5
- package/dist/policy/evaluate.js +5 -6
- 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/theme.js +7 -7
- package/dist/project/dgfile.js +146 -6
- package/dist/project/override-trust.js +0 -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 +55 -16
- package/dist/proxy/metadata-map.js +66 -12
- package/dist/proxy/preverified.js +55 -0
- package/dist/proxy/server.js +568 -53
- 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 +14 -12
- 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 +132 -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 +17 -10
- package/dist/scan/discovery.js +11 -2
- package/dist/scan/render.js +28 -4
- package/dist/scan/scanner-report.js +15 -9
- package/dist/scan/staged.js +33 -9
- package/dist/scan-ui/LegacyApp.js +9 -13
- package/dist/scan-ui/alt-screen.js +5 -8
- package/dist/scan-ui/components/InteractiveResultsView.js +133 -120
- 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 +19 -2
- package/dist/scan-ui/launch.js +7 -4
- package/dist/scan-ui/shims.js +11 -4
- package/dist/scripts/detect.js +7 -2
- package/dist/scripts/gate.js +1 -1
- package/dist/security/sanitize.js +8 -4
- package/dist/service/state.js +28 -9
- package/dist/service/trust-refresh.js +92 -0
- package/dist/service/trust-store.js +5 -9
- 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 +227 -49
- package/dist/setup/uninstall-standalone.js +25 -0
- 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 +233 -0
- package/dist/standalone/uninstall.mjs +2123 -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/report-writer.js +57 -0
- package/dist/verify/local.js +242 -44
- package/dist/verify/package-check.js +87 -29
- package/dist/verify/preflight.js +242 -49
- package/dist/verify/render.js +15 -1
- package/npm-shrinkwrap.json +2383 -0
- package/package.json +7 -8
- package/NOTICE +0 -5
- package/dist/commands/completion.js +0 -117
- package/dist/commands/explain.js +0 -236
- package/dist/commands/unavailable.js +0 -11
- package/dist/scripts/rebuild.js +0 -28
- 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
|
+
}
|
package/dist/state/index.js
CHANGED
package/dist/state/locks.js
CHANGED
|
@@ -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);
|
package/dist/state/store.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
+
}
|
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)) {
|
|
@@ -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
|
|
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,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
|
-
|
|
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);
|