@westbayberry/dg 2.3.3 → 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.
Files changed (122) hide show
  1. package/README.md +94 -217
  2. package/dist/agents/copilot-cli.js +7 -2
  3. package/dist/agents/persistence.js +83 -8
  4. package/dist/agents/registry.js +5 -2
  5. package/dist/agents/routing.js +31 -14
  6. package/dist/api/analyze.js +138 -41
  7. package/dist/audit/deep.js +2 -1
  8. package/dist/audit/detectors.js +23 -2
  9. package/dist/audit/rules.js +4 -1
  10. package/dist/audit-ui/export.js +5 -4
  11. package/dist/auth/device-login.js +33 -11
  12. package/dist/auth/login-app.js +17 -12
  13. package/dist/auth/store.js +65 -9
  14. package/dist/bin/dg.js +1 -1
  15. package/dist/commands/audit.js +12 -20
  16. package/dist/commands/{guard-commit.js → commit-guard.js} +12 -12
  17. package/dist/commands/cooldown.js +2 -1
  18. package/dist/commands/decisions.js +8 -7
  19. package/dist/commands/help.js +11 -3
  20. package/dist/commands/licenses.js +17 -8
  21. package/dist/commands/login.js +58 -21
  22. package/dist/commands/router.js +12 -2
  23. package/dist/commands/sbom.js +20 -5
  24. package/dist/commands/scan.js +5 -4
  25. package/dist/commands/service.js +1 -1
  26. package/dist/commands/setup.js +12 -12
  27. package/dist/commands/status.js +4 -4
  28. package/dist/commands/uninstall.js +1 -1
  29. package/dist/commands/update.js +20 -0
  30. package/dist/commands/verify.js +4 -4
  31. package/dist/config/settings.js +81 -12
  32. package/dist/export-ui/ExportDialog.js +1 -1
  33. package/dist/gate/cooldown-request.js +22 -0
  34. package/dist/gate/egress-guard.js +185 -0
  35. package/dist/gate/host-match.js +48 -0
  36. package/dist/gate/verdict-core.js +101 -0
  37. package/dist/gate/verdict-fetch.js +214 -0
  38. package/dist/launcher/agent-check.js +1096 -42
  39. package/dist/launcher/agent-hook-exec.js +8 -1
  40. package/dist/launcher/agent-hook-io.js +5 -3
  41. package/dist/launcher/classify.js +110 -26
  42. package/dist/launcher/env.js +84 -18
  43. package/dist/launcher/install-preflight.js +26 -3
  44. package/dist/launcher/live-install.js +4 -2
  45. package/dist/launcher/manifest-screen.js +88 -7
  46. package/dist/launcher/output-redaction.js +153 -15
  47. package/dist/launcher/preflight-prompt.js +1 -1
  48. package/dist/launcher/resolve-real-binary.js +8 -3
  49. package/dist/launcher/run.js +237 -40
  50. package/dist/origin/artifact-cache.js +55 -0
  51. package/dist/origin/cargo-config.js +12 -0
  52. package/dist/origin/gate-mode.js +7 -0
  53. package/dist/origin/platform-paths.js +14 -0
  54. package/dist/origin/precedence.js +345 -0
  55. package/dist/origin/server.js +611 -0
  56. package/dist/origin/spike-worker.js +35 -0
  57. package/dist/origin/worker.js +116 -0
  58. package/dist/presentation/package-page.js +21 -1
  59. package/dist/presentation/safe-version.js +11 -0
  60. package/dist/presentation/sarif.js +109 -0
  61. package/dist/project/dgfile.js +54 -17
  62. package/dist/project/override-trust.js +0 -0
  63. package/dist/proxy/enforcement.js +27 -7
  64. package/dist/proxy/metadata-map.js +33 -7
  65. package/dist/proxy/preverified.js +0 -3
  66. package/dist/proxy/server.js +104 -446
  67. package/dist/proxy/upstream-proxy.js +23 -4
  68. package/dist/proxy/worker.js +3 -2
  69. package/dist/publish-set/collect.js +6 -0
  70. package/dist/runtime/fatal.js +2 -1
  71. package/dist/runtime/first-run.js +5 -1
  72. package/dist/runtime/node-version.js +51 -0
  73. package/dist/runtime/nudges.js +27 -1
  74. package/dist/sbom/cyclonedx.js +120 -33
  75. package/dist/sbom/graph.js +236 -0
  76. package/dist/sbom/spdx.js +53 -0
  77. package/dist/sbom-ui/SbomApp.js +5 -3
  78. package/dist/sbom-ui/inventory.js +8 -10
  79. package/dist/scan/analyze-worker.js +3 -1
  80. package/dist/scan/collect.js +38 -8
  81. package/dist/scan/command.js +77 -27
  82. package/dist/scan/discovery.js +18 -7
  83. package/dist/scan/installed.js +202 -0
  84. package/dist/scan/manifest-coverage.js +128 -0
  85. package/dist/scan/render.js +125 -40
  86. package/dist/scan/scanner-report.js +92 -22
  87. package/dist/scan/staged.js +68 -15
  88. package/dist/scan/types.js +7 -1
  89. package/dist/scan-ui/LegacyApp.js +3 -3
  90. package/dist/scan-ui/components/InteractiveResultsView.js +17 -14
  91. package/dist/scan-ui/hooks/useScan.js +37 -11
  92. package/dist/scan-ui/launch.js +2 -2
  93. package/dist/scan-ui/logo.js +6 -2
  94. package/dist/scan-ui/shims.js +6 -6
  95. package/dist/scripts/gate.js +53 -35
  96. package/dist/security/csv.js +9 -0
  97. package/dist/security/sanitize.js +11 -2
  98. package/dist/service/state.js +120 -37
  99. package/dist/service/trust-refresh.js +12 -1
  100. package/dist/service/trust-store.js +36 -5
  101. package/dist/service/worker.js +5 -7
  102. package/dist/setup/git-hook.js +2 -2
  103. package/dist/setup/install-method.js +36 -0
  104. package/dist/setup/plan.js +119 -57
  105. package/dist/setup/uninstall-standalone.js +7 -2
  106. package/dist/setup-ui/gate.js +1 -8
  107. package/dist/setup-ui/offer.js +14 -7
  108. package/dist/setup-ui/selector.js +1 -1
  109. package/dist/setup-ui/wizard.js +73 -78
  110. package/dist/standalone/uninstall.mjs +1114 -386
  111. package/dist/state/cleanup-registry.js +14 -3
  112. package/dist/state/cooldown-held.js +36 -13
  113. package/dist/state/locks.js +239 -34
  114. package/dist/state/sessions.js +2 -2
  115. package/dist/util/sh-escape.js +6 -0
  116. package/dist/util/tty-prompt.js +13 -4
  117. package/dist/verify/local.js +60 -10
  118. package/dist/verify/package-check.js +35 -49
  119. package/dist/verify/preflight.js +59 -33
  120. package/dist/verify/render.js +11 -12
  121. package/npm-shrinkwrap.json +714 -213
  122. package/package.json +3 -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
- return `${SITE}/${ecosystem}/${name}`;
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
+ }
@@ -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
- reason: decision.reason.slice(0, DECISION_REASON_MAX),
141
- acceptedAt: decision.acceptedAt ?? now.toISOString()
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
- return { ...file, decisions: [...file.decisions, ...added] };
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
- acceptedAt: a.acceptedAt ?? now.toISOString(),
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
- if (baseDecision.action !== "block") {
63
- return baseDecision;
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
- ...baseDecision,
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: baseDecision.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 baseDecision;
108
+ return decision;
108
109
  }
109
110
  return {
110
- ...baseDecision,
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
- const packageName = typeof parsed.name === "string" ? parsed.name : packageNameFromNpmMetadataPath(metadataUrl);
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(new URL(dist.tarball, metadataUrl)),
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
- const packageName = typeof info.name === "string" && info.name.length > 0
231
- ? info.name
232
- : packageNameFromPypiMetadataPath(metadataUrl);
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(new URL(rawFile.url, metadataUrl)),
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;