@westbayberry/dg 2.3.2 → 2.3.4
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 +95 -208
- package/dist/agents/copilot-cli.js +7 -2
- package/dist/agents/persistence.js +83 -8
- package/dist/agents/registry.js +5 -2
- package/dist/agents/routing.js +31 -14
- package/dist/api/analyze.js +138 -41
- package/dist/audit/deep.js +2 -1
- package/dist/audit/detectors.js +23 -2
- package/dist/audit/rules.js +4 -1
- package/dist/audit-ui/export.js +5 -4
- package/dist/auth/device-login.js +33 -11
- package/dist/auth/login-app.js +17 -12
- package/dist/auth/store.js +65 -9
- package/dist/bin/dg.js +1 -1
- package/dist/commands/audit.js +12 -20
- package/dist/commands/{guard-commit.js → commit-guard.js} +12 -12
- package/dist/commands/cooldown.js +2 -1
- package/dist/commands/decisions.js +8 -7
- package/dist/commands/help.js +11 -3
- package/dist/commands/licenses.js +17 -8
- package/dist/commands/login.js +58 -21
- package/dist/commands/router.js +12 -2
- package/dist/commands/sbom.js +20 -5
- package/dist/commands/scan.js +5 -4
- package/dist/commands/service.js +1 -1
- package/dist/commands/setup.js +12 -12
- package/dist/commands/status.js +4 -4
- package/dist/commands/uninstall.js +1 -1
- package/dist/commands/update.js +20 -0
- package/dist/commands/verify.js +4 -4
- package/dist/config/settings.js +81 -12
- package/dist/export-ui/ExportDialog.js +1 -1
- package/dist/gate/cooldown-request.js +22 -0
- package/dist/gate/egress-guard.js +185 -0
- package/dist/gate/host-match.js +48 -0
- package/dist/gate/verdict-core.js +101 -0
- package/dist/gate/verdict-fetch.js +214 -0
- package/dist/launcher/agent-check.js +1096 -42
- package/dist/launcher/agent-hook-exec.js +8 -1
- package/dist/launcher/agent-hook-io.js +5 -3
- package/dist/launcher/classify.js +110 -26
- package/dist/launcher/env.js +84 -18
- package/dist/launcher/install-preflight.js +26 -3
- package/dist/launcher/live-install.js +4 -2
- package/dist/launcher/manifest-screen.js +88 -7
- package/dist/launcher/output-redaction.js +153 -15
- package/dist/launcher/preflight-prompt.js +1 -1
- package/dist/launcher/resolve-real-binary.js +8 -3
- package/dist/launcher/run.js +237 -40
- package/dist/origin/artifact-cache.js +55 -0
- package/dist/origin/cargo-config.js +12 -0
- package/dist/origin/gate-mode.js +7 -0
- package/dist/origin/platform-paths.js +14 -0
- package/dist/origin/precedence.js +345 -0
- package/dist/origin/server.js +611 -0
- package/dist/origin/spike-worker.js +35 -0
- package/dist/origin/worker.js +116 -0
- package/dist/presentation/package-page.js +21 -1
- package/dist/presentation/safe-version.js +11 -0
- package/dist/presentation/sarif.js +109 -0
- package/dist/project/dgfile.js +54 -17
- package/dist/project/override-trust.js +0 -0
- package/dist/proxy/enforcement.js +27 -7
- package/dist/proxy/metadata-map.js +33 -7
- package/dist/proxy/preverified.js +0 -3
- package/dist/proxy/server.js +104 -446
- package/dist/proxy/upstream-proxy.js +23 -4
- package/dist/proxy/worker.js +3 -2
- package/dist/publish-set/collect.js +6 -0
- package/dist/runtime/fatal.js +2 -1
- package/dist/runtime/first-run.js +5 -1
- package/dist/runtime/node-version.js +51 -0
- package/dist/runtime/nudges.js +27 -1
- package/dist/sbom/cyclonedx.js +120 -33
- package/dist/sbom/graph.js +236 -0
- package/dist/sbom/spdx.js +53 -0
- package/dist/sbom-ui/SbomApp.js +5 -3
- package/dist/sbom-ui/inventory.js +8 -10
- package/dist/scan/analyze-worker.js +3 -1
- package/dist/scan/collect.js +38 -8
- package/dist/scan/command.js +77 -27
- package/dist/scan/discovery.js +18 -7
- package/dist/scan/installed.js +202 -0
- package/dist/scan/manifest-coverage.js +128 -0
- package/dist/scan/render.js +125 -40
- package/dist/scan/scanner-report.js +92 -22
- package/dist/scan/staged.js +68 -15
- package/dist/scan/types.js +7 -1
- package/dist/scan-ui/LegacyApp.js +3 -3
- package/dist/scan-ui/components/InteractiveResultsView.js +17 -14
- package/dist/scan-ui/hooks/useScan.js +37 -11
- package/dist/scan-ui/launch.js +2 -2
- package/dist/scan-ui/logo.js +6 -2
- package/dist/scan-ui/shims.js +6 -6
- package/dist/scripts/gate.js +53 -35
- package/dist/security/csv.js +9 -0
- package/dist/security/sanitize.js +11 -2
- package/dist/service/state.js +120 -37
- package/dist/service/trust-refresh.js +12 -1
- package/dist/service/trust-store.js +36 -5
- package/dist/service/worker.js +5 -7
- package/dist/setup/git-hook.js +2 -2
- package/dist/setup/install-method.js +36 -0
- package/dist/setup/plan.js +119 -57
- package/dist/setup/uninstall-standalone.js +7 -2
- package/dist/setup-ui/gate.js +1 -8
- package/dist/setup-ui/offer.js +14 -7
- package/dist/setup-ui/selector.js +1 -1
- package/dist/setup-ui/wizard.js +73 -78
- package/dist/standalone/uninstall.mjs +1114 -386
- package/dist/state/cleanup-registry.js +14 -3
- package/dist/state/cooldown-held.js +36 -13
- package/dist/state/locks.js +239 -34
- package/dist/state/sessions.js +2 -2
- package/dist/util/sh-escape.js +6 -0
- package/dist/util/tty-prompt.js +13 -4
- package/dist/verify/local.js +60 -10
- package/dist/verify/package-check.js +35 -49
- package/dist/verify/preflight.js +59 -33
- package/dist/verify/render.js +11 -12
- package/npm-shrinkwrap.json +714 -213
- package/package.json +4 -2
package/dist/commands/setup.js
CHANGED
|
@@ -15,19 +15,19 @@ import { markSecurityNotesShown, securityNotesShown } from "../setup-ui/gate.js"
|
|
|
15
15
|
export const setupCommand = {
|
|
16
16
|
name: "setup",
|
|
17
17
|
summary: "Protect this machine — shell installs, AI-agent installs, and a repo's commits.",
|
|
18
|
-
usage: "dg setup [--print] [--yes] [--agents] [--guard
|
|
18
|
+
usage: "dg setup [--print] [--yes] [--agents] [--commit-guard] [--shell <auto|zsh|bash|fish>] [--service]",
|
|
19
19
|
flags: [
|
|
20
20
|
{ flag: "--print", summary: "Preview the exact write plan and change nothing." },
|
|
21
|
-
{ flag: "--yes", summary: "Apply the shell firewall only, without prompts (add --agents / --guard
|
|
21
|
+
{ flag: "--yes", summary: "Apply the shell firewall only, without prompts (add --agents / --commit-guard to opt in the rest)." },
|
|
22
22
|
{ flag: "--agents", summary: "With --yes: also route every detected AI agent's installs through dg." },
|
|
23
|
-
{ flag: "--guard
|
|
23
|
+
{ flag: "--commit-guard", summary: "With --yes: also scan this repo's commits (per-repo)." },
|
|
24
24
|
{ flag: "--shell", value: "<auto|zsh|bash|fish>", summary: "Target shell rc to write (default: auto-detect)." },
|
|
25
|
-
{ flag: "--service", summary: "Set up service mode (Pro/Team;
|
|
25
|
+
{ flag: "--service", summary: "Set up service mode (Pro/Team; long-running local proxy for this login session + managed CA)." }
|
|
26
26
|
],
|
|
27
27
|
examples: ["dg setup", "dg setup --print", "dg setup --yes --agents", "dg setup --shell zsh --yes"],
|
|
28
28
|
details: [
|
|
29
29
|
"In a terminal it shows one consent screen listing everything it will protect — your shell's installs plus every detected AI agent, with the exact files it writes — and applies them on a single yes. Commits in the current repo are a separate per-repo question.",
|
|
30
|
-
"Non-interactively, --yes applies just the shell firewall; add --agents and/or --guard
|
|
30
|
+
"Non-interactively, --yes applies just the shell firewall; add --agents and/or --commit-guard to opt those in. --print previews. Writes only dg-owned reversible files; undo it all with dg uninstall."
|
|
31
31
|
],
|
|
32
32
|
handler: (context) => setupHandler(context.args)
|
|
33
33
|
};
|
|
@@ -234,7 +234,7 @@ async function applyRequestedSurfaces(parsed) {
|
|
|
234
234
|
}
|
|
235
235
|
}
|
|
236
236
|
}
|
|
237
|
-
if (parsed.
|
|
237
|
+
if (parsed.commitGuard) {
|
|
238
238
|
const repo = commitGuardOffer();
|
|
239
239
|
if (repo) {
|
|
240
240
|
try {
|
|
@@ -254,7 +254,7 @@ function printableSurfaceOffers() {
|
|
|
254
254
|
lines.push(`would offer: route ${offer.label} installs through dg (--agents)`);
|
|
255
255
|
}
|
|
256
256
|
if (commitGuardOffer()) {
|
|
257
|
-
lines.push("would offer: scan this repo's commits (--guard
|
|
257
|
+
lines.push("would offer: scan this repo's commits (--commit-guard)");
|
|
258
258
|
}
|
|
259
259
|
return lines.length > 0 ? `\n${lines.join("\n")}\n` : "";
|
|
260
260
|
}
|
|
@@ -335,7 +335,7 @@ function parseSetupArgs(args) {
|
|
|
335
335
|
let shell = "auto";
|
|
336
336
|
let service = false;
|
|
337
337
|
let agents = false;
|
|
338
|
-
let
|
|
338
|
+
let commitGuard = false;
|
|
339
339
|
for (let index = 0; index < args.length; index += 1) {
|
|
340
340
|
const arg = args[index];
|
|
341
341
|
if (arg === "--print") {
|
|
@@ -350,8 +350,8 @@ function parseSetupArgs(args) {
|
|
|
350
350
|
else if (arg === "--agents") {
|
|
351
351
|
agents = true;
|
|
352
352
|
}
|
|
353
|
-
else if (arg === "--guard
|
|
354
|
-
|
|
353
|
+
else if (arg === "--commit-guard") {
|
|
354
|
+
commitGuard = true;
|
|
355
355
|
}
|
|
356
356
|
else if (arg === "--python-hook" || arg?.startsWith("--python-hook=")) {
|
|
357
357
|
return {
|
|
@@ -362,7 +362,7 @@ function parseSetupArgs(args) {
|
|
|
362
362
|
else if (arg === "--git-hooks") {
|
|
363
363
|
return {
|
|
364
364
|
exitCode: EXIT_USAGE,
|
|
365
|
-
error: "dg setup --git-hooks moved — run 'dg guard
|
|
365
|
+
error: "dg setup --git-hooks moved — run 'dg commit-guard' inside the repo you want to protect.\n"
|
|
366
366
|
};
|
|
367
367
|
}
|
|
368
368
|
else if (arg === "--shell") {
|
|
@@ -419,7 +419,7 @@ function parseSetupArgs(args) {
|
|
|
419
419
|
shell,
|
|
420
420
|
service,
|
|
421
421
|
agents,
|
|
422
|
-
|
|
422
|
+
commitGuard
|
|
423
423
|
};
|
|
424
424
|
}
|
|
425
425
|
function parseShell(value) {
|
package/dist/commands/status.js
CHANGED
|
@@ -64,7 +64,7 @@ async function buildStatusReport(io) {
|
|
|
64
64
|
async function fetchStatusUsage(connected, io) {
|
|
65
65
|
const token = connected ? resolveStatusToken() : undefined;
|
|
66
66
|
if (!token) {
|
|
67
|
-
return { state: "
|
|
67
|
+
return { state: "signed-out" };
|
|
68
68
|
}
|
|
69
69
|
const account = await fetchAccountStatus(token, process.env, io.fetchImpl ?? fetch, io.usageTimeoutMs ?? STATUS_USAGE_TIMEOUT_MS);
|
|
70
70
|
if (!account || account.scansUsed === null) {
|
|
@@ -131,7 +131,7 @@ function renderStatus(report, theme) {
|
|
|
131
131
|
return `${lines.join("\n")}\n`;
|
|
132
132
|
}
|
|
133
133
|
function usageLine(report, theme) {
|
|
134
|
-
if (report.usage.state === "
|
|
134
|
+
if (report.usage.state === "signed-out") {
|
|
135
135
|
return theme.paint("muted", "sign in to see usage");
|
|
136
136
|
}
|
|
137
137
|
if (report.usage.state === "unavailable") {
|
|
@@ -147,7 +147,7 @@ function commitGuardLine(state, theme) {
|
|
|
147
147
|
return `${theme.paint("pass", "✓")} this repo's commits are scanned`;
|
|
148
148
|
}
|
|
149
149
|
if (state === "dead") {
|
|
150
|
-
return `${theme.paint("warn", "⚠")} installed but git isn't using it — run ${theme.paint("accent", "dg guard
|
|
150
|
+
return `${theme.paint("warn", "⚠")} installed but git isn't using it — run ${theme.paint("accent", "dg commit-guard --check")}`;
|
|
151
151
|
}
|
|
152
|
-
return `${theme.paint("warn", "⚠")} off — run ${theme.paint("accent", "dg guard
|
|
152
|
+
return `${theme.paint("warn", "⚠")} off — run ${theme.paint("accent", "dg commit-guard")}`;
|
|
153
153
|
}
|
|
@@ -8,7 +8,7 @@ export const uninstallCommand = {
|
|
|
8
8
|
usage: "dg uninstall [--yes] [--service] [--all] [--keep-config]",
|
|
9
9
|
flags: [
|
|
10
10
|
{ flag: "--yes", summary: "Remove without the confirmation prompt." },
|
|
11
|
-
{ flag: "--keep-config", summary: "Keep
|
|
11
|
+
{ flag: "--keep-config", summary: "Keep the saved login too (the default removes it; config.json is kept either way unless --all)." },
|
|
12
12
|
{ flag: "--all", summary: "Also remove the config directory — a full wipe." },
|
|
13
13
|
{ flag: "--service", summary: "Reverse only service-mode writes." }
|
|
14
14
|
],
|
package/dist/commands/update.js
CHANGED
|
@@ -1,7 +1,24 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
2
4
|
import { toolInvocation } from "../util/external-tool.js";
|
|
5
|
+
import { resolveDgPaths } from "../state/index.js";
|
|
3
6
|
import { EXIT_USAGE } from "./types.js";
|
|
4
7
|
import { dgVersion } from "./version.js";
|
|
8
|
+
// `dg update` only prints the npm command; the user runs the upgrade manually,
|
|
9
|
+
// during which dg is briefly unavailable. Drop a freshness-stamped sentinel the
|
|
10
|
+
// shim's fail-open self-cleanup honors (15-min window) so a slow `npm install -g`
|
|
11
|
+
// is not mistaken for an uninstall and self-cleaned mid-upgrade.
|
|
12
|
+
function markUpgradeWindow() {
|
|
13
|
+
try {
|
|
14
|
+
const dgHome = join(resolveDgPaths().homeDir, ".dg");
|
|
15
|
+
mkdirSync(dgHome, { recursive: true, mode: 0o700 });
|
|
16
|
+
writeFileSync(join(dgHome, ".upgrading"), "", { encoding: "utf8", mode: 0o600 });
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
// best-effort: a missing sentinel only restores the prior 60s-debounce behavior
|
|
20
|
+
}
|
|
21
|
+
}
|
|
5
22
|
const PACKAGE_NAME = "@westbayberry/dg";
|
|
6
23
|
const VERSION_PATTERN = /^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/u;
|
|
7
24
|
export const updateCommand = {
|
|
@@ -24,6 +41,9 @@ function runUpdateCommand(args, commandName) {
|
|
|
24
41
|
}
|
|
25
42
|
const latestVersion = readLatestVersion();
|
|
26
43
|
const report = buildUpdateReport(latestVersion);
|
|
44
|
+
if (report.updateCommand) {
|
|
45
|
+
markUpgradeWindow();
|
|
46
|
+
}
|
|
27
47
|
if (parsed.format === "json") {
|
|
28
48
|
return {
|
|
29
49
|
exitCode: report.status === "unknown" ? 1 : 0,
|
package/dist/commands/verify.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { EXIT_ANALYSIS_INCOMPLETE, EXIT_UNAVAILABLE, EXIT_USAGE_VERDICT } from "./types.js";
|
|
1
|
+
import { EXIT_ANALYSIS_INCOMPLETE, EXIT_TOOL_ERROR, EXIT_UNAVAILABLE, EXIT_USAGE_VERDICT } from "./types.js";
|
|
2
2
|
import { writeFileSync } from "node:fs";
|
|
3
3
|
import { existsSync } from "node:fs";
|
|
4
4
|
import { resolve } from "node:path";
|
|
@@ -22,8 +22,8 @@ export const verifyCommand = {
|
|
|
22
22
|
],
|
|
23
23
|
examples: ["dg verify npm:react", "dg verify pypi:requests@2.31.0", "dg verify ./pkg.tgz --verbose", "dg verify package-lock.json --json"],
|
|
24
24
|
details: [
|
|
25
|
-
"dg verify npm:react (or pypi:requests, with an optional @version — defaults to latest) runs a real scanner check on a published package before you install it.
|
|
26
|
-
"Local paths, workspaces, tgz/zip/wheel artifacts, and lockfiles are verified offline as advisory preflight (
|
|
25
|
+
"dg verify npm:react (or pypi:requests, with an optional @version — defaults to latest) runs a real scanner check on a published package before you install it. Registry checks require sign-in — run dg login.",
|
|
26
|
+
"Local paths, workspaces, tgz/zip/wheel artifacts, and lockfiles are verified offline as advisory preflight (no account needed); proxy enforcement remains authoritative for network artifact fetches."
|
|
27
27
|
],
|
|
28
28
|
handler: (context) => {
|
|
29
29
|
const parsed = parseVerifyArgs(context.args);
|
|
@@ -52,7 +52,7 @@ export const verifyCommand = {
|
|
|
52
52
|
}
|
|
53
53
|
catch (error) {
|
|
54
54
|
return {
|
|
55
|
-
exitCode:
|
|
55
|
+
exitCode: EXIT_TOOL_ERROR,
|
|
56
56
|
stdout: "",
|
|
57
57
|
stderr: `dg verify could not write ${parsed.outputPath}: ${error instanceof Error ? error.message : "unknown write error"}\n`
|
|
58
58
|
};
|
package/dist/config/settings.js
CHANGED
|
@@ -11,8 +11,10 @@ export const CONFIG_KEYS = Object.freeze([
|
|
|
11
11
|
"policy.scriptHardening",
|
|
12
12
|
"policy.shimFailClosed",
|
|
13
13
|
"policy.strictEgress",
|
|
14
|
+
"policy.allowNonVendorApiHost",
|
|
15
|
+
"policy.provenanceDowngrade",
|
|
14
16
|
"scriptGate.mode",
|
|
15
|
-
"scriptGate.
|
|
17
|
+
"scriptGate.persistObservations",
|
|
16
18
|
"gitHook.onWarn",
|
|
17
19
|
"gitHook.onIncomplete",
|
|
18
20
|
"cooldown.age",
|
|
@@ -37,11 +39,13 @@ export const DEFAULT_CONFIG = Object.freeze({
|
|
|
37
39
|
allowForceOverride: true,
|
|
38
40
|
scriptHardening: false,
|
|
39
41
|
shimFailClosed: false,
|
|
40
|
-
strictEgress: false
|
|
42
|
+
strictEgress: false,
|
|
43
|
+
allowNonVendorApiHost: false,
|
|
44
|
+
provenanceDowngrade: "off"
|
|
41
45
|
},
|
|
42
46
|
scriptGate: {
|
|
43
47
|
mode: "observe",
|
|
44
|
-
|
|
48
|
+
persistObservations: false
|
|
45
49
|
},
|
|
46
50
|
gitHook: {
|
|
47
51
|
onWarn: "prompt",
|
|
@@ -146,11 +150,17 @@ export function getConfigValue(config, key) {
|
|
|
146
150
|
if (key === "policy.strictEgress") {
|
|
147
151
|
return String(config.policy.strictEgress);
|
|
148
152
|
}
|
|
153
|
+
if (key === "policy.allowNonVendorApiHost") {
|
|
154
|
+
return String(config.policy.allowNonVendorApiHost);
|
|
155
|
+
}
|
|
156
|
+
if (key === "policy.provenanceDowngrade") {
|
|
157
|
+
return config.policy.provenanceDowngrade;
|
|
158
|
+
}
|
|
149
159
|
if (key === "scriptGate.mode") {
|
|
150
160
|
return config.scriptGate.mode;
|
|
151
161
|
}
|
|
152
|
-
if (key === "scriptGate.
|
|
153
|
-
return String(config.scriptGate.
|
|
162
|
+
if (key === "scriptGate.persistObservations") {
|
|
163
|
+
return String(config.scriptGate.persistObservations);
|
|
154
164
|
}
|
|
155
165
|
if (key === "gitHook.onWarn") {
|
|
156
166
|
return config.gitHook.onWarn;
|
|
@@ -183,7 +193,8 @@ export const ADVANCED_CONFIG_KEYS = new Set([
|
|
|
183
193
|
"policy.scriptHardening",
|
|
184
194
|
"policy.shimFailClosed",
|
|
185
195
|
"policy.strictEgress",
|
|
186
|
-
"
|
|
196
|
+
"policy.provenanceDowngrade",
|
|
197
|
+
"scriptGate.persistObservations",
|
|
187
198
|
"cooldown.npm.age",
|
|
188
199
|
"cooldown.pypi.age",
|
|
189
200
|
"cooldown.cargo.age"
|
|
@@ -199,7 +210,7 @@ export function setConfigValue(config, key, rawValue) {
|
|
|
199
210
|
return {
|
|
200
211
|
...config,
|
|
201
212
|
api: {
|
|
202
|
-
baseUrl: parseUrl(rawValue)
|
|
213
|
+
baseUrl: assertVendorApiBaseUrl(parseUrl(rawValue), config.policy.allowNonVendorApiHost)
|
|
203
214
|
}
|
|
204
215
|
};
|
|
205
216
|
}
|
|
@@ -235,6 +246,25 @@ export function setConfigValue(config, key, rawValue) {
|
|
|
235
246
|
if (key === "policy.strictEgress") {
|
|
236
247
|
return withPolicyBoolean(config, "strictEgress", rawValue);
|
|
237
248
|
}
|
|
249
|
+
if (key === "policy.allowNonVendorApiHost") {
|
|
250
|
+
const next = withPolicyBoolean(config, "allowNonVendorApiHost", rawValue);
|
|
251
|
+
if (!next.policy.allowNonVendorApiHost) {
|
|
252
|
+
const host = new URL(next.api.baseUrl).hostname;
|
|
253
|
+
if (!isVendorApiHost(host)) {
|
|
254
|
+
throw new ConfigError(`cannot disable policy.allowNonVendorApiHost while api.baseUrl points at the self-hosted host ${host}; reset api.baseUrl to a vendor host (or unset it) first`);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return next;
|
|
258
|
+
}
|
|
259
|
+
if (key === "policy.provenanceDowngrade") {
|
|
260
|
+
return {
|
|
261
|
+
...config,
|
|
262
|
+
policy: {
|
|
263
|
+
...config.policy,
|
|
264
|
+
provenanceDowngrade: parseProvenanceDowngradeMode(rawValue)
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
}
|
|
238
268
|
if (key === "scriptGate.mode") {
|
|
239
269
|
return {
|
|
240
270
|
...config,
|
|
@@ -244,12 +274,12 @@ export function setConfigValue(config, key, rawValue) {
|
|
|
244
274
|
}
|
|
245
275
|
};
|
|
246
276
|
}
|
|
247
|
-
if (key === "scriptGate.
|
|
277
|
+
if (key === "scriptGate.persistObservations") {
|
|
248
278
|
return {
|
|
249
279
|
...config,
|
|
250
280
|
scriptGate: {
|
|
251
281
|
...config.scriptGate,
|
|
252
|
-
|
|
282
|
+
persistObservations: parseBoolean(rawValue, key)
|
|
253
283
|
}
|
|
254
284
|
};
|
|
255
285
|
}
|
|
@@ -317,7 +347,7 @@ function normalizeConfig(raw) {
|
|
|
317
347
|
const cooldown = fieldObject(raw, "cooldown");
|
|
318
348
|
const audit = fieldObject(raw, "audit");
|
|
319
349
|
const scriptHardening = fieldBoolean(policy, "policy.scriptHardening", "scriptHardening") ?? DEFAULT_CONFIG.policy.scriptHardening;
|
|
320
|
-
|
|
350
|
+
const normalized = {
|
|
321
351
|
version: 1,
|
|
322
352
|
api: {
|
|
323
353
|
baseUrl: parseUrl(fieldString(api, "api.baseUrl", "baseUrl") ?? DEFAULT_CONFIG.api.baseUrl)
|
|
@@ -331,11 +361,15 @@ function normalizeConfig(raw) {
|
|
|
331
361
|
allowForceOverride: fieldBoolean(policy, "policy.allowForceOverride", "allowForceOverride") ?? DEFAULT_CONFIG.policy.allowForceOverride,
|
|
332
362
|
scriptHardening,
|
|
333
363
|
shimFailClosed: fieldBoolean(policy, "policy.shimFailClosed", "shimFailClosed") ?? DEFAULT_CONFIG.policy.shimFailClosed,
|
|
334
|
-
strictEgress: fieldBoolean(policy, "policy.strictEgress", "strictEgress") ?? DEFAULT_CONFIG.policy.strictEgress
|
|
364
|
+
strictEgress: fieldBoolean(policy, "policy.strictEgress", "strictEgress") ?? DEFAULT_CONFIG.policy.strictEgress,
|
|
365
|
+
allowNonVendorApiHost: fieldBoolean(policy, "policy.allowNonVendorApiHost", "allowNonVendorApiHost") ?? DEFAULT_CONFIG.policy.allowNonVendorApiHost,
|
|
366
|
+
provenanceDowngrade: parseProvenanceDowngradeMode(fieldString(policy, "policy.provenanceDowngrade", "provenanceDowngrade") ?? DEFAULT_CONFIG.policy.provenanceDowngrade)
|
|
335
367
|
},
|
|
336
368
|
scriptGate: {
|
|
337
369
|
mode: parseScriptGateMode(fieldString(scriptGate, "scriptGate.mode", "mode") ?? (scriptHardening ? "enforce" : DEFAULT_CONFIG.scriptGate.mode)),
|
|
338
|
-
|
|
370
|
+
persistObservations: fieldBoolean(scriptGate, "scriptGate.persistObservations", "persistObservations") ??
|
|
371
|
+
fieldBoolean(scriptGate, "scriptGate.observe", "observe") ??
|
|
372
|
+
DEFAULT_CONFIG.scriptGate.persistObservations
|
|
339
373
|
},
|
|
340
374
|
gitHook: {
|
|
341
375
|
onWarn: parseOnWarn(fieldString(gitHook, "gitHook.onWarn", "onWarn") ?? DEFAULT_CONFIG.gitHook.onWarn),
|
|
@@ -353,6 +387,8 @@ function normalizeConfig(raw) {
|
|
|
353
387
|
upload: fieldBoolean(audit, "audit.upload", "upload") ?? DEFAULT_CONFIG.audit.upload
|
|
354
388
|
}
|
|
355
389
|
};
|
|
390
|
+
assertVendorApiBaseUrl(normalized.api.baseUrl, normalized.policy.allowNonVendorApiHost);
|
|
391
|
+
return normalized;
|
|
356
392
|
}
|
|
357
393
|
function fieldObject(root, field) {
|
|
358
394
|
const value = root[field];
|
|
@@ -417,6 +453,12 @@ function parsePolicyMode(value) {
|
|
|
417
453
|
}
|
|
418
454
|
throw new ConfigError("policy.mode must be one of: off, warn, block, strict");
|
|
419
455
|
}
|
|
456
|
+
function parseProvenanceDowngradeMode(value) {
|
|
457
|
+
if (value === "off" || value === "warn" || value === "block") {
|
|
458
|
+
return value;
|
|
459
|
+
}
|
|
460
|
+
throw new ConfigError("policy.provenanceDowngrade must be one of: off, warn, block");
|
|
461
|
+
}
|
|
420
462
|
function parseScriptGateMode(value) {
|
|
421
463
|
if (value === "observe" || value === "enforce" || value === "off") {
|
|
422
464
|
return value;
|
|
@@ -500,3 +542,30 @@ export function parseUrl(value) {
|
|
|
500
542
|
throw new ConfigError("api.baseUrl must be an absolute URL");
|
|
501
543
|
}
|
|
502
544
|
}
|
|
545
|
+
// The vendor API host the CLI ships to — derived from the default so there is one
|
|
546
|
+
// source of truth, never a duplicated literal. localhost stays usable for dev.
|
|
547
|
+
export const VENDOR_API_HOST = new URL(DEFAULT_CONFIG.api.baseUrl).hostname;
|
|
548
|
+
export function isVendorApiHost(host) {
|
|
549
|
+
return host === VENDOR_API_HOST || host === "localhost" || host === "127.0.0.1";
|
|
550
|
+
}
|
|
551
|
+
// The CLI attaches a Bearer token to and trusts scan verdicts from this base URL,
|
|
552
|
+
// so a non-vendor host (a tampered config / auth file, an env-set login base, an
|
|
553
|
+
// unpinned MITM) would leak the token and could return action:pass for malware.
|
|
554
|
+
// Refuse it unless the operator opted into a self-hosted API in their own global
|
|
555
|
+
// config (policy.allowNonVendorApiHost) — a project/repo cannot set that field.
|
|
556
|
+
export function assertVendorApiBaseUrl(baseUrl, allowNonVendor) {
|
|
557
|
+
if (allowNonVendor) {
|
|
558
|
+
return baseUrl;
|
|
559
|
+
}
|
|
560
|
+
let host;
|
|
561
|
+
try {
|
|
562
|
+
host = new URL(baseUrl).hostname;
|
|
563
|
+
}
|
|
564
|
+
catch {
|
|
565
|
+
throw new ConfigError("api.baseUrl must be an absolute URL");
|
|
566
|
+
}
|
|
567
|
+
if (!isVendorApiHost(host)) {
|
|
568
|
+
throw new ConfigError(`api.baseUrl host ${host} is not a Dependency Guardian vendor host; set policy.allowNonVendorApiHost=true to use a self-hosted API`);
|
|
569
|
+
}
|
|
570
|
+
return baseUrl;
|
|
571
|
+
}
|
|
@@ -6,7 +6,7 @@ import { existsSync, readdirSync } from "node:fs";
|
|
|
6
6
|
import { basename, join, resolve, sep } from "node:path";
|
|
7
7
|
import { exportDestinations, resolveExportPath, userHomeDir, writeReportAtomic } from "../util/report-writer.js";
|
|
8
8
|
export function loginRequiredToast() {
|
|
9
|
-
return `${chalk.yellow("Sign in to export:")} ${chalk.cyan.bold("dg login")}
|
|
9
|
+
return `${chalk.yellow("Sign in to export:")} ${chalk.cyan.bold("dg login")}`;
|
|
10
10
|
}
|
|
11
11
|
const PATH_DISPLAY_MAX = 48;
|
|
12
12
|
function cursorBeforeExtension(name) {
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { cooldownRequestParam, isCooldownExemptByDgFile } from "../policy/cooldown.js";
|
|
2
|
+
import { DEFAULT_CONFIG, loadUserConfig } from "../config/settings.js";
|
|
3
|
+
export function resolveCooldownRequest(options, identity) {
|
|
4
|
+
if (identity.ecosystem === "unknown" || identity.version === "unknown") {
|
|
5
|
+
return undefined;
|
|
6
|
+
}
|
|
7
|
+
if (isCooldownExemptByDgFile(identity.name, identity.ecosystem, options.cooldownExemptions ?? [])) {
|
|
8
|
+
return undefined;
|
|
9
|
+
}
|
|
10
|
+
// A corrupt user config must not silently disable the cooldown gate — fall back
|
|
11
|
+
// to defaults (24h age + onUnknown=block) so the gate still applies, consistent
|
|
12
|
+
// with the rest of the policy stack. undefined stays reserved for the legitimate
|
|
13
|
+
// disabled/exempt cases cooldownRequestParam itself returns.
|
|
14
|
+
let config;
|
|
15
|
+
try {
|
|
16
|
+
config = loadUserConfig(options.env);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
config = DEFAULT_CONFIG;
|
|
20
|
+
}
|
|
21
|
+
return cooldownRequestParam(config, options.env, identity.ecosystem, identity.name);
|
|
22
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { lookup as dnsLookup } from "node:dns";
|
|
2
|
+
import { hostMatchesList, testUpstreamHostMap } from "./host-match.js";
|
|
3
|
+
// Node's WHATWG URL parser canonicalizes an IPv4-mapped IPv6 literal to the hex
|
|
4
|
+
// hextet form ([::ffff:169.254.169.254] -> [::ffff:a9fe:a9fe]), so a guard that
|
|
5
|
+
// only string-matches the dotted-decimal form silently lets the cloud-metadata
|
|
6
|
+
// IP through as "public". Recover the embedded IPv4 (both forms) before
|
|
7
|
+
// classifying so the IPv4 ranges below cover the mapped address too.
|
|
8
|
+
function hextetsToIpv4(hi, lo) {
|
|
9
|
+
const h = parseInt(hi, 16);
|
|
10
|
+
const l = parseInt(lo, 16);
|
|
11
|
+
return `${(h >> 8) & 0xff}.${h & 0xff}.${(l >> 8) & 0xff}.${l & 0xff}`;
|
|
12
|
+
}
|
|
13
|
+
// Recover an IPv4 address embedded in an IPv6 literal so the IPv4 ranges below
|
|
14
|
+
// also cover it. Covers IPv4-mapped (::ffff:), IPv4-compatible (::), 6to4
|
|
15
|
+
// (2002::/16), and NAT64 (64:ff9b::/96) — each a way to smuggle 169.254.169.254
|
|
16
|
+
// past a guard that only string-matches the dotted-decimal form.
|
|
17
|
+
function recoverIpv4MappedHost(host) {
|
|
18
|
+
const mappedDotted = /^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i.exec(host);
|
|
19
|
+
if (mappedDotted?.[1])
|
|
20
|
+
return mappedDotted[1];
|
|
21
|
+
const mappedHex = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(host);
|
|
22
|
+
if (mappedHex?.[1] && mappedHex[2])
|
|
23
|
+
return hextetsToIpv4(mappedHex[1], mappedHex[2]);
|
|
24
|
+
const compatDotted = /^::(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i.exec(host);
|
|
25
|
+
if (compatDotted?.[1])
|
|
26
|
+
return compatDotted[1];
|
|
27
|
+
const compatHex = /^::([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(host);
|
|
28
|
+
if (compatHex?.[1] && compatHex[2])
|
|
29
|
+
return hextetsToIpv4(compatHex[1], compatHex[2]);
|
|
30
|
+
const sixToFour = /^2002:([0-9a-f]{1,4}):([0-9a-f]{1,4}):/i.exec(host);
|
|
31
|
+
if (sixToFour?.[1] && sixToFour[2])
|
|
32
|
+
return hextetsToIpv4(sixToFour[1], sixToFour[2]);
|
|
33
|
+
const nat64Dotted = /^64:ff9b::(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i.exec(host);
|
|
34
|
+
if (nat64Dotted?.[1])
|
|
35
|
+
return nat64Dotted[1];
|
|
36
|
+
const nat64Hex = /^64:ff9b::([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(host);
|
|
37
|
+
if (nat64Hex?.[1] && nat64Hex[2])
|
|
38
|
+
return hextetsToIpv4(nat64Hex[1], nat64Hex[2]);
|
|
39
|
+
return host;
|
|
40
|
+
}
|
|
41
|
+
// Block a RESOLVED address (the actual connect target), so a public hostname
|
|
42
|
+
// whose A/AAAA record points at an internal/metadata IP — DNS-rebinding SSRF —
|
|
43
|
+
// is refused at connect time, not just literal-IP targets.
|
|
44
|
+
export function isBlockedResolvedIp(ip) {
|
|
45
|
+
const host = recoverIpv4MappedHost(ip.toLowerCase().replace(/^\[/, "").replace(/\]$/, ""));
|
|
46
|
+
if (host === "0.0.0.0" || host === "::" || host === "::1") {
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
if (isPrivateIpv4(host)) {
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
return /^(fe80|f[cd][0-9a-f]{2}|ff[0-9a-f]{2}):/.test(host);
|
|
53
|
+
}
|
|
54
|
+
function guardedLookup(hostname, options, callback) {
|
|
55
|
+
const opts = typeof options === "object" && options !== null ? options : {};
|
|
56
|
+
dnsLookup(hostname, { ...opts, all: true }, (err, addresses) => {
|
|
57
|
+
if (err) {
|
|
58
|
+
callback(err, "", 0);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const list = addresses;
|
|
62
|
+
const blocked = list.find((entry) => isBlockedResolvedIp(entry.address));
|
|
63
|
+
if (blocked) {
|
|
64
|
+
callback(new Error(`refusing to connect to ${hostname}: resolves to blocked address ${blocked.address}. If this is a private/tailnet registry you trust, add its host to DG_PRIVATE_REGISTRY_HOSTS.`), "", 0);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (opts.all === true) {
|
|
68
|
+
callback(null, list);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const first = list[0];
|
|
72
|
+
if (!first) {
|
|
73
|
+
callback(new Error(`no address found for ${hostname}`), "", 0);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
callback(null, first.address, first.family);
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
// The resolved-IP guard exists for DNS rebinding — a PUBLIC hostname whose A/AAAA
|
|
80
|
+
// record points at an internal IP. It must not fire for: an explicitly-configured
|
|
81
|
+
// private registry (DG_PRIVATE_REGISTRY_HOSTS) whose name legitimately resolves to
|
|
82
|
+
// a private IP; a test host-map target; or a target that is ALREADY a private/
|
|
83
|
+
// loopback literal (the user/test pointed there directly — not rebinding, and the
|
|
84
|
+
// link-local/metadata literal is still blocked at the artifact path).
|
|
85
|
+
export function lookupForTarget(target, env) {
|
|
86
|
+
const host = target.hostname.replace(/^\[(.*)\]$/, "$1");
|
|
87
|
+
if (hostMatchesList(target.hostname, env.DG_PRIVATE_REGISTRY_HOSTS ?? "")) {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
if (testUpstreamHostMap(env).has(host)) {
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
if (isPrivateNetworkHost(target)) {
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
return guardedLookup;
|
|
97
|
+
}
|
|
98
|
+
// The direct connect paths run the resolve-and-classify rebind guard via
|
|
99
|
+
// lookupForTarget's custom lookup. When an upstream HTTP/HTTPS proxy is used the
|
|
100
|
+
// proxy resolves the target itself, so that guard never runs — pre-resolve here
|
|
101
|
+
// and refuse a public target that resolves into a blocked range, mirroring the
|
|
102
|
+
// same exemptions (private registry / test host-map / already-private literal).
|
|
103
|
+
// Best-effort: a resolution failure surfaces later as the real connect error.
|
|
104
|
+
export async function assertTargetEgressAllowed(target, env) {
|
|
105
|
+
if (lookupForTarget(target, env) === undefined) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const host = target.hostname.replace(/^\[(.*)\]$/, "$1");
|
|
109
|
+
const addresses = await new Promise((resolve) => {
|
|
110
|
+
dnsLookup(host, { all: true }, (err, list) => {
|
|
111
|
+
resolve(err ? [] : list);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
const blocked = addresses.find((entry) => isBlockedResolvedIp(entry.address));
|
|
115
|
+
if (blocked) {
|
|
116
|
+
throw new Error(`refusing to connect to ${host} via the upstream proxy: resolves to blocked address ${blocked.address}. If this is a private/tailnet registry you trust, add its host to DG_PRIVATE_REGISTRY_HOSTS.`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// Non-globally-routable / special-purpose IPv4 ranges. None is ever a legitimate
|
|
120
|
+
// public registry, so a public hostname resolving into one is SSRF, and a direct
|
|
121
|
+
// literal target there is the user's own choice (handled by the callers). Covers
|
|
122
|
+
// RFC1918 + loopback + link-local + CGNAT, plus the special-purpose ranges that
|
|
123
|
+
// are still connectable: 198.18/15 benchmarking, 192.0.0/24 IETF, the TEST-NET
|
|
124
|
+
// documentation blocks, and multicast / reserved 224.0.0.0/3 (224-255).
|
|
125
|
+
function isPrivateIpv4(host) {
|
|
126
|
+
return (/^(127|10|0)\./.test(host) ||
|
|
127
|
+
/^192\.168\./.test(host) ||
|
|
128
|
+
/^169\.254\./.test(host) ||
|
|
129
|
+
/^172\.(1[6-9]|2[0-9]|3[01])\./.test(host) ||
|
|
130
|
+
/^100\.(6[4-9]|[7-9][0-9]|1[01][0-9]|12[0-7])\./.test(host) ||
|
|
131
|
+
/^198\.1[89]\./.test(host) ||
|
|
132
|
+
/^192\.0\.0\./.test(host) ||
|
|
133
|
+
/^192\.0\.2\./.test(host) ||
|
|
134
|
+
/^198\.51\.100\./.test(host) ||
|
|
135
|
+
/^203\.0\.113\./.test(host) ||
|
|
136
|
+
/^(22[4-9]|23[0-9]|24[0-9]|25[0-5])\./.test(host));
|
|
137
|
+
}
|
|
138
|
+
export function isPrivateNetworkHost(url) {
|
|
139
|
+
const raw = url.hostname.toLowerCase().replace(/^\[/, "").replace(/\]$/, "");
|
|
140
|
+
const host = recoverIpv4MappedHost(raw);
|
|
141
|
+
if (host === "localhost" || host.endsWith(".localhost") || host === "0.0.0.0" || host === "::" || host === "::1") {
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
if (isPrivateIpv4(host)) {
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
return /^(fe80|f[cd][0-9a-f]{2}|ff[0-9a-f]{2}):/.test(host);
|
|
148
|
+
}
|
|
149
|
+
// Zero-expand a compressed IPv6 literal to its eight bare-hextet form so an
|
|
150
|
+
// exact metadata-address compare matches regardless of "::" placement. Returns
|
|
151
|
+
// the input unchanged for anything that is not a plain IPv6 literal.
|
|
152
|
+
function normalizeIpv6(host) {
|
|
153
|
+
if (host.includes(".") || !host.includes(":") || !/^[0-9a-f:]+$/i.test(host)) {
|
|
154
|
+
return host;
|
|
155
|
+
}
|
|
156
|
+
const sides = host.split("::");
|
|
157
|
+
if (sides.length > 2) {
|
|
158
|
+
return host;
|
|
159
|
+
}
|
|
160
|
+
const head = sides[0] ? sides[0].split(":") : [];
|
|
161
|
+
const tail = sides.length === 2 && sides[1] ? sides[1].split(":") : [];
|
|
162
|
+
let parts;
|
|
163
|
+
if (sides.length === 2) {
|
|
164
|
+
const missing = 8 - head.length - tail.length;
|
|
165
|
+
if (missing < 0)
|
|
166
|
+
return host;
|
|
167
|
+
parts = [...head, ...Array(missing).fill("0"), ...tail];
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
parts = head;
|
|
171
|
+
}
|
|
172
|
+
if (parts.length !== 8) {
|
|
173
|
+
return host;
|
|
174
|
+
}
|
|
175
|
+
return parts.map((part) => parseInt(part, 16).toString(16)).join(":");
|
|
176
|
+
}
|
|
177
|
+
// The AWS IPv6 IMDS endpoint is a ULA (fd00::/8), so it is not caught by the
|
|
178
|
+
// fe80 link-local check; match the exact metadata literal only — broadening to
|
|
179
|
+
// all of fd00::/8 would refuse legitimate private/tailnet registries.
|
|
180
|
+
const IPV6_METADATA_HOST = "fd00:ec2:0:0:0:0:0:254";
|
|
181
|
+
export function isLinkLocalHost(url) {
|
|
182
|
+
const raw = url.hostname.toLowerCase().replace(/^\[/, "").replace(/\]$/, "");
|
|
183
|
+
const host = recoverIpv4MappedHost(raw);
|
|
184
|
+
return /^169\.254\./.test(host) || /^fe80:/.test(host) || normalizeIpv6(host) === IPV6_METADATA_HOST;
|
|
185
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export function hostMatchesList(host, rawList) {
|
|
2
|
+
const normalized = normalizeHost(host);
|
|
3
|
+
return rawList.split(",").map((entry) => entry.trim()).filter(Boolean).some((pattern) => hostMatchesPattern(normalized, pattern));
|
|
4
|
+
}
|
|
5
|
+
function hostMatchesPattern(host, pattern) {
|
|
6
|
+
const normalized = normalizeHost(pattern);
|
|
7
|
+
if (normalized.startsWith("*.")) {
|
|
8
|
+
const suffix = normalized.slice(1);
|
|
9
|
+
return host.endsWith(suffix) && host.length > suffix.length;
|
|
10
|
+
}
|
|
11
|
+
return host === normalized;
|
|
12
|
+
}
|
|
13
|
+
function normalizeHost(host) {
|
|
14
|
+
return host.replace(/^\[/, "").replace(/\]$/, "").replace(/\.$/, "").toLowerCase();
|
|
15
|
+
}
|
|
16
|
+
export function testUpstreamHostMap(env) {
|
|
17
|
+
if (env.NODE_ENV !== "test") {
|
|
18
|
+
return new Map();
|
|
19
|
+
}
|
|
20
|
+
const entries = (env.DG_TEST_UPSTREAM_HOST_MAP ?? "")
|
|
21
|
+
.split(",")
|
|
22
|
+
.map((entry) => entry.trim())
|
|
23
|
+
.filter(Boolean)
|
|
24
|
+
.map((entry) => {
|
|
25
|
+
const separator = entry.indexOf("=");
|
|
26
|
+
if (separator <= 0) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
const target = entry.slice(separator + 1);
|
|
30
|
+
// The seam only exists to point a public host at a LOOPBACK mock in tests.
|
|
31
|
+
// Refusing any non-loopback target stops an attacker who can set
|
|
32
|
+
// NODE_ENV=test from redirecting a registry fetch to an external/internal
|
|
33
|
+
// host (SSRF) and from disabling the egress DNS guard for it.
|
|
34
|
+
if (!isLoopbackHost(target)) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
return [entry.slice(0, separator), target];
|
|
38
|
+
})
|
|
39
|
+
.filter((entry) => entry !== null);
|
|
40
|
+
return new Map(entries);
|
|
41
|
+
}
|
|
42
|
+
export function isLoopbackHost(value) {
|
|
43
|
+
const host = normalizeHost(value);
|
|
44
|
+
// Only the 127.0.0.0/8 IPv4 literal range is loopback — anchor to a full dotted
|
|
45
|
+
// quad so a hostname like 127.evil.com (which resolves anywhere) is NOT treated
|
|
46
|
+
// as loopback and cannot slip past the SSRF confinement.
|
|
47
|
+
return host === "localhost" || host === "::1" || /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}(?::\d+)?$/.test(host);
|
|
48
|
+
}
|