@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
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { startOriginServer } from "./server.js";
|
|
4
|
+
import { cleanupSessionSync } from "../state/index.js";
|
|
5
|
+
import { COOLDOWN_EXEMPTIONS_ENV, loadCooldownExemptionsFile } from "../proxy/cooldown-exemptions-file.js";
|
|
6
|
+
import { isLoopbackHost } from "../gate/host-match.js";
|
|
7
|
+
import { redactSecrets } from "../launcher/output-redaction.js";
|
|
8
|
+
const PARENT_POLL_MS = 500;
|
|
9
|
+
const sessionPath = process.argv[2];
|
|
10
|
+
const apiBaseUrl = process.argv[3];
|
|
11
|
+
const classificationJson = process.env.DG_ORIGIN_CLASSIFICATION;
|
|
12
|
+
if (!sessionPath || !apiBaseUrl || !classificationJson) {
|
|
13
|
+
process.stderr.write("dg origin worker missing startup arguments\n");
|
|
14
|
+
process.exit(1);
|
|
15
|
+
}
|
|
16
|
+
const session = JSON.parse(readFileSync(sessionPath, "utf8"));
|
|
17
|
+
const classification = JSON.parse(classificationJson);
|
|
18
|
+
const cooldownExemptions = loadCooldownExemptionsFile(process.env[COOLDOWN_EXEMPTIONS_ENV]);
|
|
19
|
+
writeFileSync(session.files.pid, `${process.pid}\n`, { encoding: "utf8", mode: 0o600 });
|
|
20
|
+
function ecosystemFor(manager) {
|
|
21
|
+
if (manager === "pip" || manager === "pipx" || manager === "uv" || manager === "uvx") {
|
|
22
|
+
return "pypi";
|
|
23
|
+
}
|
|
24
|
+
if (manager === "cargo") {
|
|
25
|
+
return "cargo";
|
|
26
|
+
}
|
|
27
|
+
return "npm";
|
|
28
|
+
}
|
|
29
|
+
let handle = null;
|
|
30
|
+
let closed = false;
|
|
31
|
+
async function close() {
|
|
32
|
+
if (closed) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
closed = true;
|
|
36
|
+
await handle?.close();
|
|
37
|
+
cleanupSessionSync(session);
|
|
38
|
+
}
|
|
39
|
+
function shutdown() {
|
|
40
|
+
close().finally(() => process.exit(0));
|
|
41
|
+
}
|
|
42
|
+
function exitOnFatal(error) {
|
|
43
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
44
|
+
try {
|
|
45
|
+
process.stderr.write(`dg origin worker: unexpected error — ${redactSecrets(message)}\n`);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// stderr is gone; nothing left to report to.
|
|
49
|
+
}
|
|
50
|
+
close().finally(() => process.exit(1));
|
|
51
|
+
}
|
|
52
|
+
process.on("uncaughtException", exitOnFatal);
|
|
53
|
+
process.on("unhandledRejection", (reason) => exitOnFatal(reason));
|
|
54
|
+
process.stdin.resume();
|
|
55
|
+
process.stdin.on("end", shutdown);
|
|
56
|
+
process.stdin.on("error", shutdown);
|
|
57
|
+
process.on("SIGTERM", shutdown);
|
|
58
|
+
process.on("SIGINT", shutdown);
|
|
59
|
+
const parentWatch = setInterval(() => {
|
|
60
|
+
if (process.ppid === 1) {
|
|
61
|
+
shutdown();
|
|
62
|
+
}
|
|
63
|
+
}, PARENT_POLL_MS);
|
|
64
|
+
parentWatch.unref();
|
|
65
|
+
// DG_ORIGIN_UPSTREAMS lets a hermetic test point the origin at a fake registry
|
|
66
|
+
// instead of the public one; absent, the origin chains to the real upstream.
|
|
67
|
+
function parseUpstreams(raw) {
|
|
68
|
+
if (!raw) {
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
const parsed = JSON.parse(raw);
|
|
73
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
const record = parsed;
|
|
77
|
+
// Every override target must be loopback, so a NODE_ENV=test env can only
|
|
78
|
+
// chain the origin at a local mock — never at an external/internal host.
|
|
79
|
+
for (const value of Object.values(record)) {
|
|
80
|
+
if (typeof value !== "string" || !upstreamIsLoopback(value)) {
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return record;
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
function upstreamIsLoopback(value) {
|
|
91
|
+
try {
|
|
92
|
+
return isLoopbackHost(new URL(value).hostname);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// DG_ORIGIN_UPSTREAMS is a hermetic-test seam that redirects the upstream registry
|
|
99
|
+
// at a fake server; honouring it in production would let an ambient environment
|
|
100
|
+
// redirect every fetch, so it is read only under NODE_ENV=test.
|
|
101
|
+
const upstreamsOverride = process.env.NODE_ENV === "test" ? parseUpstreams(process.env.DG_ORIGIN_UPSTREAMS) : undefined;
|
|
102
|
+
handle = await startOriginServer({
|
|
103
|
+
ecosystem: ecosystemFor(classification.manager),
|
|
104
|
+
npmTarballStrategy: "loopback",
|
|
105
|
+
cacheDir: join(session.dir, "origin-cache"),
|
|
106
|
+
stateFile: session.files.proxy,
|
|
107
|
+
...(upstreamsOverride ? { upstreams: upstreamsOverride } : {}),
|
|
108
|
+
gate: {
|
|
109
|
+
session,
|
|
110
|
+
apiBaseUrl,
|
|
111
|
+
classification,
|
|
112
|
+
env: process.env,
|
|
113
|
+
...(cooldownExemptions.length > 0 ? { cooldownExemptions } : {})
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
process.stdout.write(`ready ${handle.port}\n`);
|
|
@@ -5,5 +5,25 @@ export function packagePageUrl(ecosystem, name) {
|
|
|
5
5
|
if (ecosystem !== "npm" && ecosystem !== "pypi") {
|
|
6
6
|
return null;
|
|
7
7
|
}
|
|
8
|
-
|
|
8
|
+
// Encode the name so a package whose name carries URL-significant characters
|
|
9
|
+
// (?, #, %, space, …) can't redirect or malform the link, while preserving the
|
|
10
|
+
// `@` and the single `/` of a scoped npm name that the site's /<path:name>
|
|
11
|
+
// route expects. Legit registry names have none of the encoded characters, so
|
|
12
|
+
// this only matters for a hostile lockfile-declared name.
|
|
13
|
+
if (ecosystem === "npm" && name.startsWith("@")) {
|
|
14
|
+
const slash = name.indexOf("/");
|
|
15
|
+
if (slash <= 1) {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
const scope = name.slice(1, slash);
|
|
19
|
+
const pkg = name.slice(slash + 1);
|
|
20
|
+
if (!pkg || pkg.includes("/")) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
return `${SITE}/${ecosystem}/@${encodeURIComponent(scope)}/${encodeURIComponent(pkg)}`;
|
|
24
|
+
}
|
|
25
|
+
if (name.includes("/")) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
return `${SITE}/${ecosystem}/${encodeURIComponent(name)}`;
|
|
9
29
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
function spec(name, version, ecosystem) {
|
|
2
|
+
return `${name}${ecosystem === "pypi" ? "==" : "@"}${version}`;
|
|
3
|
+
}
|
|
4
|
+
export function safeVersionLine(result, theme, ecosystem) {
|
|
5
|
+
const sv = result.safeVersion;
|
|
6
|
+
if (!sv)
|
|
7
|
+
return [];
|
|
8
|
+
const note = sv.crossesMajor ? "crosses major, may break" : "same major";
|
|
9
|
+
const text = `→ dg doesn't flag ${spec(result.name, sv.version, ecosystem ?? result.ecosystem)} (${sv.direction} · ${note})`;
|
|
10
|
+
return [` ${theme.paint(sv.crossesMajor ? "warn" : "pass", text)}`];
|
|
11
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
// Resolve a finding's opaque `location` string into SARIF coordinates so code
|
|
3
|
+
// scanning can map the alert to a real file (and annotate PRs):
|
|
4
|
+
// "package.json:dependencies.left-pad" -> { uri: "package.json", logicalName: "dependencies.left-pad" }
|
|
5
|
+
// "requirements.txt:42" -> { uri: "requirements.txt", line: 42 }
|
|
6
|
+
// "left-pad@1.3.0" -> { logicalName: "left-pad@1.3.0" } (no source file; caller may supply a manifest uri)
|
|
7
|
+
// "https://registry/x.tgz" -> { uri: "https://registry/x.tgz" }
|
|
8
|
+
export function resolveSarifLocation(location) {
|
|
9
|
+
if (/^https?:\/\//iu.test(location)) {
|
|
10
|
+
return { uri: location };
|
|
11
|
+
}
|
|
12
|
+
const numeric = /^(.*):(\d+)$/u.exec(location);
|
|
13
|
+
if (numeric && numeric[1] && numeric[2] !== undefined) {
|
|
14
|
+
const line = Number.parseInt(numeric[2], 10);
|
|
15
|
+
// SARIF 2.1.0 requires region.startLine >= 1; a 0 line degrades to a file ref.
|
|
16
|
+
return line >= 1 ? { uri: numeric[1], line } : { uri: numeric[1] };
|
|
17
|
+
}
|
|
18
|
+
const colon = location.lastIndexOf(":");
|
|
19
|
+
if (colon > 0) {
|
|
20
|
+
const suffix = location.slice(colon + 1);
|
|
21
|
+
// Split a `<manifest>:<section>.<name>` locator, but not a Windows drive
|
|
22
|
+
// letter or any colon whose suffix carries a path separator.
|
|
23
|
+
if (suffix.length > 0 && !/[/\\]/u.test(suffix)) {
|
|
24
|
+
return { uri: location.slice(0, colon), logicalName: suffix };
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
if (location.includes("@")) {
|
|
28
|
+
return { logicalName: location };
|
|
29
|
+
}
|
|
30
|
+
return { uri: location };
|
|
31
|
+
}
|
|
32
|
+
// Code Scanning de-duplicates alerts by this category, so a monorepo package or a
|
|
33
|
+
// matrix leg gets its own stable id instead of overwriting the other legs' alerts.
|
|
34
|
+
// `..` segments are rewritten to `up` rather than stripped so that two targets
|
|
35
|
+
// that differ only in how far they escape the cwd (../api vs ../../api) keep
|
|
36
|
+
// distinct categories and don't silently overwrite each other.
|
|
37
|
+
export function automationCategory(target) {
|
|
38
|
+
const normalized = target
|
|
39
|
+
.replace(/\\/gu, "/")
|
|
40
|
+
.replace(/\/+/gu, "/")
|
|
41
|
+
.replace(/^\.\//u, "")
|
|
42
|
+
.replace(/(^|\/)\.\.(?=\/|$)/gu, "$1up")
|
|
43
|
+
.replace(/^\/+/u, "")
|
|
44
|
+
.replace(/\/+$/u, "")
|
|
45
|
+
.replace(/\s/gu, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0")}`)
|
|
46
|
+
.replace(/^\.$/u, "");
|
|
47
|
+
return `dependency-guardian/${normalized || "root"}/`;
|
|
48
|
+
}
|
|
49
|
+
export function findingFingerprint(identity) {
|
|
50
|
+
return createHash("sha256").update(identity).digest("hex").slice(0, 16);
|
|
51
|
+
}
|
|
52
|
+
function firstNonEmpty(...values) {
|
|
53
|
+
for (const value of values) {
|
|
54
|
+
if (typeof value === "string" && value.length > 0) {
|
|
55
|
+
return value;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
function withPrefix(uri, pathPrefix) {
|
|
61
|
+
if (!uri || !pathPrefix || pathPrefix === ".") {
|
|
62
|
+
return uri;
|
|
63
|
+
}
|
|
64
|
+
if (/^([a-z][a-z0-9+.-]*:\/\/|\/)/iu.test(uri)) {
|
|
65
|
+
return uri;
|
|
66
|
+
}
|
|
67
|
+
// A target outside the code-scanning checkout root has no repo-relative uri, so
|
|
68
|
+
// emit the bare finding uri rather than fabricate a pseudo-relative path that
|
|
69
|
+
// resolves to no file. Only a single leading `./` is stripped; an absolute or
|
|
70
|
+
// `../`-escaping prefix bails.
|
|
71
|
+
const normalized = pathPrefix.replace(/^\.\/+/u, "").replace(/\/+$/u, "");
|
|
72
|
+
if (normalized === "" || normalized.startsWith("/") || normalized === ".." || normalized.startsWith("../")) {
|
|
73
|
+
return uri;
|
|
74
|
+
}
|
|
75
|
+
return `${normalized}/${uri}`;
|
|
76
|
+
}
|
|
77
|
+
function targetAnchor(pathPrefix) {
|
|
78
|
+
if (!pathPrefix) {
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
const normalized = pathPrefix.replace(/^\.\/+/u, "").replace(/\/+$/u, "");
|
|
82
|
+
if (normalized === "" || normalized.startsWith("/") || normalized === ".." || normalized.startsWith("../")) {
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
return normalized;
|
|
86
|
+
}
|
|
87
|
+
// Build a SARIF location entry from a finding's opaque location, falling back to a
|
|
88
|
+
// manifest uri (so a server verdict on a package still anchors to a real file).
|
|
89
|
+
// pathPrefix is the scan target's path relative to the working dir; relative uris
|
|
90
|
+
// are prefixed with it so they resolve from the repo root code scanning expects.
|
|
91
|
+
// A physicalLocation with a non-empty uri is an invariant: code scanning requires
|
|
92
|
+
// one to render an alert and rejects an empty uri, so when nothing resolves we
|
|
93
|
+
// anchor to the manifest/target fallback (or repo root) rather than emit a
|
|
94
|
+
// logical-only or empty-uri result.
|
|
95
|
+
export function sarifLocationEntry(location, fallbackUri, pathPrefix) {
|
|
96
|
+
const resolved = resolveSarifLocation(location);
|
|
97
|
+
const base = firstNonEmpty(resolved.uri, fallbackUri);
|
|
98
|
+
const prefixed = withPrefix(base, pathPrefix);
|
|
99
|
+
const uri = firstNonEmpty(prefixed, targetAnchor(pathPrefix)) ?? ".";
|
|
100
|
+
const physicalLocation = {
|
|
101
|
+
artifactLocation: { uri },
|
|
102
|
+
...(resolved.line !== undefined ? { region: { startLine: resolved.line } } : {})
|
|
103
|
+
};
|
|
104
|
+
const logicalLocations = resolved.logicalName ? [{ fullyQualifiedName: resolved.logicalName }] : undefined;
|
|
105
|
+
return {
|
|
106
|
+
physicalLocation,
|
|
107
|
+
...(logicalLocations ? { logicalLocations } : {})
|
|
108
|
+
};
|
|
109
|
+
}
|
package/dist/project/dgfile.js
CHANGED
|
@@ -7,6 +7,7 @@ import { writeJsonAtomic } from "../util/json-file.js";
|
|
|
7
7
|
import { canonicalCooldownName } from "../policy/pypi-name.js";
|
|
8
8
|
import { acquireLockSyncWithRetry, resolveDgPaths } from "../state/index.js";
|
|
9
9
|
import { stampAuthoredEntries } from "./override-trust.js";
|
|
10
|
+
import { sanitize, sanitizeLine } from "../security/sanitize.js";
|
|
10
11
|
const DG_FILE_LOCK_STALE_MS = 10_000;
|
|
11
12
|
const DG_FILE_LOCK_TIMEOUT_MS = 5_000;
|
|
12
13
|
export function withDgFileLock(root, env, action) {
|
|
@@ -133,14 +134,43 @@ export function warnUnreadableDgFile(file, write = (line) => process.stderr.writ
|
|
|
133
134
|
write(`dg: ignoring decisions in ${file.path} — ${file.failure ?? "unreadable"} (no warnings suppressed)\n`);
|
|
134
135
|
}
|
|
135
136
|
}
|
|
137
|
+
function decisionDedupeKey(d) {
|
|
138
|
+
const scope = d.scope.kind === "exact" ? `exact:${d.scope.version}` : "any";
|
|
139
|
+
const findings = Object.keys(d.findings)
|
|
140
|
+
.sort()
|
|
141
|
+
.map((key) => `${key}=${d.findings[key]}`)
|
|
142
|
+
.join(",");
|
|
143
|
+
return [d.ecosystem, d.name, scope, findings].join("\u0000");
|
|
144
|
+
}
|
|
145
|
+
// Enforce DECISION_ENTRY_CAP on the write path so re-remembering can never grow
|
|
146
|
+
// dg.json past the point where loadDgFile fails it closed (and bricks every later
|
|
147
|
+
// `dg decisions` invocation). Re-accepting the same warn replaces its prior entry
|
|
148
|
+
// rather than appending a duplicate, and a genuinely full list evicts oldest-first.
|
|
136
149
|
export function appendDecisions(file, additions, now = new Date()) {
|
|
137
150
|
const added = additions.map((decision) => ({
|
|
138
151
|
...decision,
|
|
139
152
|
id: randomUUID(),
|
|
140
|
-
|
|
141
|
-
|
|
153
|
+
// Sanitize the user-controlled fields at WRITE time exactly as the parser
|
|
154
|
+
// does at read time, so the override-trust tag (stamped over these bytes
|
|
155
|
+
// below) matches what verification recomputes after a parse — otherwise a
|
|
156
|
+
// reason containing a CR/control char silently desyncs and drops the waiver.
|
|
157
|
+
reason: sanitize(decision.reason).slice(0, DECISION_REASON_MAX),
|
|
158
|
+
acceptedBy: sanitizeLine(decision.acceptedBy),
|
|
159
|
+
acceptedAt: sanitizeLine(decision.acceptedAt ?? now.toISOString())
|
|
142
160
|
}));
|
|
143
|
-
|
|
161
|
+
const canonical = [...new Map(added.map((d) => [decisionDedupeKey(d), d])).values()];
|
|
162
|
+
const addedKeys = new Set(canonical.map(decisionDedupeKey));
|
|
163
|
+
const priorExtra = new Map(file.decisions.map((d) => [decisionDedupeKey(d), d.extra]));
|
|
164
|
+
const keep = file.decisions.filter((d) => !addedKeys.has(decisionDedupeKey(d)));
|
|
165
|
+
const merged = canonical.map((d) => {
|
|
166
|
+
const carried = priorExtra.get(decisionDedupeKey(d));
|
|
167
|
+
return carried !== undefined ? { ...d, extra: carried } : d;
|
|
168
|
+
});
|
|
169
|
+
const next = [...keep, ...merged];
|
|
170
|
+
return {
|
|
171
|
+
...file,
|
|
172
|
+
decisions: next.length > DECISION_ENTRY_CAP ? next.slice(next.length - DECISION_ENTRY_CAP) : next
|
|
173
|
+
};
|
|
144
174
|
}
|
|
145
175
|
export function removeDecisions(file, ids) {
|
|
146
176
|
return { ...file, decisions: file.decisions.filter((entry) => !ids.has(entry.id)) };
|
|
@@ -154,8 +184,9 @@ export function appendCooldownExemptions(file, additions, now = new Date()) {
|
|
|
154
184
|
const carried = priorExtra.get(`${a.ecosystem}:${a.name}`);
|
|
155
185
|
return {
|
|
156
186
|
...a,
|
|
157
|
-
reason: a.reason.slice(0, DECISION_REASON_MAX),
|
|
158
|
-
|
|
187
|
+
reason: sanitize(a.reason).slice(0, DECISION_REASON_MAX),
|
|
188
|
+
acceptedBy: sanitizeLine(a.acceptedBy),
|
|
189
|
+
acceptedAt: sanitizeLine(a.acceptedAt ?? now.toISOString()),
|
|
159
190
|
...(carried !== undefined ? { extra: carried } : {})
|
|
160
191
|
};
|
|
161
192
|
});
|
|
@@ -217,10 +248,10 @@ export function parseCooldownExemption(value) {
|
|
|
217
248
|
return {
|
|
218
249
|
ecosystem,
|
|
219
250
|
name,
|
|
220
|
-
reason: typeof value.reason === "string" ? value.reason.slice(0, DECISION_REASON_MAX) : "",
|
|
221
|
-
acceptedBy: typeof value.acceptedBy === "string" && value.acceptedBy.length > 0 ? value.acceptedBy : "unknown",
|
|
222
|
-
acceptedAt: typeof value.acceptedAt === "string" ? value.acceptedAt : "",
|
|
223
|
-
...(typeof expiresAt === "string" ? { expiresAt } : {}),
|
|
251
|
+
reason: typeof value.reason === "string" ? sanitize(value.reason).slice(0, DECISION_REASON_MAX) : "",
|
|
252
|
+
acceptedBy: typeof value.acceptedBy === "string" && value.acceptedBy.length > 0 ? sanitizeLine(value.acceptedBy) : "unknown",
|
|
253
|
+
acceptedAt: typeof value.acceptedAt === "string" ? sanitizeLine(value.acceptedAt) : "",
|
|
254
|
+
...(typeof expiresAt === "string" ? { expiresAt: sanitizeLine(expiresAt) } : {}),
|
|
224
255
|
...(Object.keys(extra).length > 0 ? { extra } : {})
|
|
225
256
|
};
|
|
226
257
|
}
|
|
@@ -313,16 +344,22 @@ function parseDecisionEntry(value) {
|
|
|
313
344
|
extra[key] = field;
|
|
314
345
|
}
|
|
315
346
|
}
|
|
347
|
+
// Sanitize at the trust boundary: a cloned repo's dg.json is attacker-controlled,
|
|
348
|
+
// and these fields reach several terminal renderers (dg decisions, dg cooldown,
|
|
349
|
+
// the install-preflight "previously accepted" notice). sanitizeLine strips
|
|
350
|
+
// ANSI/OSC/control/invisible bytes; sanitize keeps a legit multi-line reason's
|
|
351
|
+
// newlines. This is a no-op on legitimate values, so it does not perturb the
|
|
352
|
+
// override-trust HMAC (which binds these same fields and is verified post-parse).
|
|
316
353
|
return {
|
|
317
|
-
id: typeof value.id === "string" && value.id.length > 0 ? value.id : derivedEntryId(value),
|
|
354
|
+
id: typeof value.id === "string" && value.id.length > 0 ? sanitizeLine(value.id) : derivedEntryId(value),
|
|
318
355
|
ecosystem,
|
|
319
|
-
name,
|
|
356
|
+
name: sanitizeLine(name),
|
|
320
357
|
scope,
|
|
321
358
|
findings,
|
|
322
|
-
reason: typeof value.reason === "string" ? value.reason.slice(0, DECISION_REASON_MAX) : "",
|
|
323
|
-
acceptedBy: typeof value.acceptedBy === "string" && value.acceptedBy.length > 0 ? value.acceptedBy : "unknown",
|
|
324
|
-
acceptedAt: typeof value.acceptedAt === "string" ? value.acceptedAt : "",
|
|
325
|
-
...(typeof expiresAt === "string" ? { expiresAt } : {}),
|
|
359
|
+
reason: typeof value.reason === "string" ? sanitize(value.reason).slice(0, DECISION_REASON_MAX) : "",
|
|
360
|
+
acceptedBy: typeof value.acceptedBy === "string" && value.acceptedBy.length > 0 ? sanitizeLine(value.acceptedBy) : "unknown",
|
|
361
|
+
acceptedAt: typeof value.acceptedAt === "string" ? sanitizeLine(value.acceptedAt) : "",
|
|
362
|
+
...(typeof expiresAt === "string" ? { expiresAt: sanitizeLine(expiresAt) } : {}),
|
|
326
363
|
...(Object.keys(extra).length > 0 ? { extra } : {})
|
|
327
364
|
};
|
|
328
365
|
}
|
|
@@ -334,7 +371,7 @@ function parseScope(value) {
|
|
|
334
371
|
return { kind: "any" };
|
|
335
372
|
}
|
|
336
373
|
if (value.kind === "exact" && typeof value.version === "string" && value.version.length > 0) {
|
|
337
|
-
return { kind: "exact", version: value.version };
|
|
374
|
+
return { kind: "exact", version: sanitizeLine(value.version) };
|
|
338
375
|
}
|
|
339
376
|
return null;
|
|
340
377
|
}
|
|
@@ -351,7 +388,7 @@ function parseFindings(value) {
|
|
|
351
388
|
return null;
|
|
352
389
|
}
|
|
353
390
|
if (!UNSAFE_OBJECT_KEYS.has(category)) {
|
|
354
|
-
findings[category] = severity;
|
|
391
|
+
findings[sanitizeLine(category)] = severity;
|
|
355
392
|
}
|
|
356
393
|
}
|
|
357
394
|
return findings;
|
|
Binary file
|
|
@@ -59,8 +59,9 @@ export function enforceProtectedInstall(request) {
|
|
|
59
59
|
if (baseDecision.cause === "quota-exceeded" && proxyVerdict.quotaBehavior === "pass") {
|
|
60
60
|
return { ...baseDecision, action: "warn", reason: "over monthly quota — installed unverified per your dashboard setting" };
|
|
61
61
|
}
|
|
62
|
-
|
|
63
|
-
|
|
62
|
+
const decision = applyProvenanceDowngrade(baseDecision, proxyVerdict, userConfig.policy.provenanceDowngrade);
|
|
63
|
+
if (decision.action !== "block") {
|
|
64
|
+
return decision;
|
|
64
65
|
}
|
|
65
66
|
let force;
|
|
66
67
|
try {
|
|
@@ -82,7 +83,7 @@ export function enforceProtectedInstall(request) {
|
|
|
82
83
|
}
|
|
83
84
|
if (force.allowed) {
|
|
84
85
|
return {
|
|
85
|
-
...
|
|
86
|
+
...decision,
|
|
86
87
|
action: "warn",
|
|
87
88
|
reason: `force override allowed: ${force.reason}`,
|
|
88
89
|
forceOverride: {
|
|
@@ -95,7 +96,7 @@ export function enforceProtectedInstall(request) {
|
|
|
95
96
|
recordAuditEvent({
|
|
96
97
|
type: "install.blocked",
|
|
97
98
|
packageName,
|
|
98
|
-
reason:
|
|
99
|
+
reason: decision.reason,
|
|
99
100
|
policyMode: policy.mode,
|
|
100
101
|
createdAt: (request.now ?? new Date()).toISOString()
|
|
101
102
|
}, request.env);
|
|
@@ -104,16 +105,34 @@ export function enforceProtectedInstall(request) {
|
|
|
104
105
|
noteEnforcementSideEffectFailure(error);
|
|
105
106
|
}
|
|
106
107
|
if (!request.forceOverride?.force) {
|
|
107
|
-
return
|
|
108
|
+
return decision;
|
|
108
109
|
}
|
|
109
110
|
return {
|
|
110
|
-
...
|
|
111
|
+
...decision,
|
|
111
112
|
forceOverride: {
|
|
112
113
|
allowed: false,
|
|
113
114
|
reason: force.reason
|
|
114
115
|
}
|
|
115
116
|
};
|
|
116
117
|
}
|
|
118
|
+
// A provenance downgrade — a version that previously carried an attestation no
|
|
119
|
+
// longer does — is the actionable supply-chain signal. Escalation only: it never
|
|
120
|
+
// downgrades an existing block, and it is opt-in (policy.provenanceDowngrade,
|
|
121
|
+
// default off) so it cannot surprise-break installs of never-attested packages.
|
|
122
|
+
function applyProvenanceDowngrade(decision, verdict, mode) {
|
|
123
|
+
const downgrade = verdict.provenance?.downgrade;
|
|
124
|
+
if (!downgrade || mode === "off" || decision.action === "block") {
|
|
125
|
+
return decision;
|
|
126
|
+
}
|
|
127
|
+
const reason = `provenance downgrade: ${downgrade.fromVersion} was attested, this version is not`;
|
|
128
|
+
if (mode === "block") {
|
|
129
|
+
return { ...decision, action: "block", cause: "policy", reason };
|
|
130
|
+
}
|
|
131
|
+
if (decision.action === "pass") {
|
|
132
|
+
return { ...decision, action: "warn", cause: "policy", reason };
|
|
133
|
+
}
|
|
134
|
+
return decision;
|
|
135
|
+
}
|
|
117
136
|
function failClosedVerdict(classification) {
|
|
118
137
|
return {
|
|
119
138
|
verdict: "block",
|
|
@@ -180,6 +199,7 @@ function withOptionalDecisionFields(decision, verdict) {
|
|
|
180
199
|
...(verdict.unauthenticated ? { unauthenticated: true } : {}),
|
|
181
200
|
...(verdict.resetsAt ? { resetsAt: verdict.resetsAt } : {}),
|
|
182
201
|
...(verdict.quotaBehavior ? { quotaBehavior: verdict.quotaBehavior } : {}),
|
|
183
|
-
...(verdict.cooldown ? { cooldown: verdict.cooldown } : {})
|
|
202
|
+
...(verdict.cooldown ? { cooldown: verdict.cooldown } : {}),
|
|
203
|
+
...(verdict.provenance ? { provenance: verdict.provenance } : {})
|
|
184
204
|
};
|
|
185
205
|
}
|
|
@@ -3,7 +3,7 @@ import { brotliDecompressSync, gunzipSync, inflateRawSync, inflateSync } from "n
|
|
|
3
3
|
// decodeURIComponent throws on malformed percent-encoding (e.g. a stray % in a
|
|
4
4
|
// registry href); fall back to the raw value so one bad entry cannot throw out of
|
|
5
5
|
// the whole metadata-extraction pass and drop every identity.
|
|
6
|
-
function safeDecodeUriComponent(value) {
|
|
6
|
+
export function safeDecodeUriComponent(value) {
|
|
7
7
|
try {
|
|
8
8
|
return decodeURIComponent(value);
|
|
9
9
|
}
|
|
@@ -11,6 +11,16 @@ function safeDecodeUriComponent(value) {
|
|
|
11
11
|
return value;
|
|
12
12
|
}
|
|
13
13
|
}
|
|
14
|
+
// A registry-controlled href can be malformed; resolving it must not throw out of
|
|
15
|
+
// the extractor and wrongly block the install. Skip the bad entry instead.
|
|
16
|
+
function safeResolveUrl(href, base) {
|
|
17
|
+
try {
|
|
18
|
+
return new URL(href, base);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
14
24
|
export function extractRegistryMetadataIdentities(metadataUrl, response) {
|
|
15
25
|
const decoded = { headers: response.headers, body: decodeContentEncoding(response.headers, response.body) };
|
|
16
26
|
// PyPI Simple index (PEP 503 HTML or PEP 691 JSON) — the pip/uv/pipx flow.
|
|
@@ -203,7 +213,13 @@ export function artifactUrlKey(value) {
|
|
|
203
213
|
return url.toString();
|
|
204
214
|
}
|
|
205
215
|
function extractNpmIdentities(metadataUrl, parsed) {
|
|
206
|
-
|
|
216
|
+
// Bind the name to the requested URL path, not the registry-controlled JSON, so
|
|
217
|
+
// a hostile/compromised upstream can't relabel a package to dodge name-based
|
|
218
|
+
// policy (cooldown, known-bad). Fall back to JSON only when the path has no name.
|
|
219
|
+
const urlName = packageNameFromNpmMetadataPath(metadataUrl);
|
|
220
|
+
const packageName = urlName !== metadataUrl.hostname
|
|
221
|
+
? urlName
|
|
222
|
+
: (typeof parsed.name === "string" ? parsed.name : urlName);
|
|
207
223
|
const versions = isRecord(parsed.versions) ? parsed.versions : {};
|
|
208
224
|
const identities = [];
|
|
209
225
|
for (const [version, rawVersion] of Object.entries(versions)) {
|
|
@@ -214,12 +230,16 @@ function extractNpmIdentities(metadataUrl, parsed) {
|
|
|
214
230
|
if (typeof dist.tarball !== "string" || dist.tarball.length === 0) {
|
|
215
231
|
continue;
|
|
216
232
|
}
|
|
233
|
+
const tarball = safeResolveUrl(dist.tarball, metadataUrl);
|
|
234
|
+
if (!tarball) {
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
217
237
|
identities.push({
|
|
218
238
|
ecosystem: "npm",
|
|
219
239
|
name: packageName,
|
|
220
240
|
version,
|
|
221
241
|
registryHost: metadataUrl.hostname,
|
|
222
|
-
tarballUrl: artifactUrlKey(
|
|
242
|
+
tarballUrl: artifactUrlKey(tarball),
|
|
223
243
|
sourceKind: "registry-metadata"
|
|
224
244
|
});
|
|
225
245
|
}
|
|
@@ -227,9 +247,11 @@ function extractNpmIdentities(metadataUrl, parsed) {
|
|
|
227
247
|
}
|
|
228
248
|
function extractPypiIdentities(metadataUrl, parsed) {
|
|
229
249
|
const info = isRecord(parsed.info) ? parsed.info : {};
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
250
|
+
// Bind to the requested URL path over the registry-controlled JSON name.
|
|
251
|
+
const urlName = packageNameFromPypiMetadataPath(metadataUrl);
|
|
252
|
+
const packageName = urlName !== metadataUrl.hostname
|
|
253
|
+
? urlName
|
|
254
|
+
: (typeof info.name === "string" && info.name.length > 0 ? info.name : urlName);
|
|
233
255
|
const releases = isRecord(parsed.releases) ? parsed.releases : {};
|
|
234
256
|
const identities = [];
|
|
235
257
|
for (const [version, rawFiles] of Object.entries(releases)) {
|
|
@@ -240,12 +262,16 @@ function extractPypiIdentities(metadataUrl, parsed) {
|
|
|
240
262
|
if (!isRecord(rawFile) || typeof rawFile.url !== "string" || rawFile.url.length === 0) {
|
|
241
263
|
continue;
|
|
242
264
|
}
|
|
265
|
+
const fileUrl = safeResolveUrl(rawFile.url, metadataUrl);
|
|
266
|
+
if (!fileUrl) {
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
243
269
|
identities.push({
|
|
244
270
|
ecosystem: "pypi",
|
|
245
271
|
name: packageName,
|
|
246
272
|
version,
|
|
247
273
|
registryHost: metadataUrl.hostname,
|
|
248
|
-
tarballUrl: artifactUrlKey(
|
|
274
|
+
tarballUrl: artifactUrlKey(fileUrl),
|
|
249
275
|
sourceKind: "registry-metadata"
|
|
250
276
|
});
|
|
251
277
|
}
|
|
@@ -37,9 +37,6 @@ export function loadPreverifiedMap(sessionDir) {
|
|
|
37
37
|
loaded = { dir: sessionDir, map };
|
|
38
38
|
return map;
|
|
39
39
|
}
|
|
40
|
-
export function resetPreverifiedCache() {
|
|
41
|
-
loaded = null;
|
|
42
|
-
}
|
|
43
40
|
function isPreverifiedEntry(value) {
|
|
44
41
|
if (typeof value !== "object" || value === null) {
|
|
45
42
|
return false;
|