@westbayberry/dg 2.1.0 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +216 -226
- package/dist/agents/claude-code.js +113 -0
- package/dist/agents/codex.js +65 -0
- package/dist/agents/copilot-cli.js +115 -0
- package/dist/agents/cursor.js +113 -0
- package/dist/agents/gemini.js +107 -0
- package/dist/agents/persistence.js +285 -0
- package/dist/agents/registry.js +127 -0
- package/dist/agents/types.js +1 -0
- package/dist/agents/windsurf.js +93 -0
- package/dist/api/analyze.js +1 -1
- package/dist/audit/detectors.js +0 -11
- package/dist/audit/events.js +5 -21
- package/dist/audit-ui/AuditApp.js +2 -0
- package/dist/audit-ui/components/AuditResultsView.js +55 -92
- package/dist/audit-ui/export.js +0 -4
- package/dist/audit-ui/format.js +0 -3
- package/dist/audit-ui/launch.js +10 -1
- package/dist/auth/device-login.js +4 -5
- package/dist/auth/login-app.js +7 -7
- package/dist/auth/store.js +8 -3
- package/dist/bin/dg.js +59 -52
- package/dist/commands/agents.js +231 -0
- package/dist/commands/audit.js +22 -3
- package/dist/commands/config.js +15 -7
- package/dist/commands/cooldown.js +389 -0
- package/dist/commands/decisions.js +23 -9
- package/dist/commands/doctor.js +1 -1
- package/dist/commands/help.js +1 -1
- package/dist/commands/licenses.js +10 -22
- package/dist/commands/logout.js +4 -11
- package/dist/commands/router.js +6 -4
- package/dist/commands/sbom.js +206 -0
- package/dist/commands/scan.js +1 -1
- package/dist/commands/service.js +32 -13
- package/dist/commands/setup.js +197 -27
- package/dist/commands/types.js +1 -0
- package/dist/commands/update.js +17 -8
- package/dist/commands/verify.js +8 -5
- package/dist/config/settings.js +14 -44
- package/dist/decisions/remember-prompt.js +5 -10
- package/dist/export-ui/ExportDialog.js +198 -0
- package/dist/install-ui/LiveInstall.js +2 -2
- package/dist/install-ui/block-render.js +1 -1
- package/dist/install-ui/prep-spinner.js +32 -0
- package/dist/launcher/agent-check.js +466 -0
- package/dist/launcher/agent-hook-exec.js +70 -0
- package/dist/launcher/agent-hook-io.js +31 -0
- package/dist/launcher/cargo-cache.js +40 -0
- package/dist/launcher/classify.js +17 -6
- package/dist/launcher/env.js +71 -24
- package/dist/launcher/install-preflight.js +51 -14
- package/dist/launcher/live-install.js +25 -5
- package/dist/launcher/output-redaction.js +7 -4
- package/dist/launcher/preflight-prompt.js +14 -1
- package/dist/launcher/run.js +89 -86
- package/dist/launcher/spawn-invocation.js +21 -0
- package/dist/policy/cooldown.js +18 -5
- package/dist/policy/evaluate.js +5 -6
- package/dist/policy/pypi-name.js +17 -0
- package/dist/presentation/mode.js +3 -2
- package/dist/presentation/package-page.js +9 -0
- package/dist/presentation/theme.js +7 -7
- package/dist/project/dgfile.js +145 -6
- package/dist/proxy/auth.js +42 -0
- package/dist/proxy/ca.js +29 -9
- package/dist/proxy/cooldown-exemptions-file.js +33 -0
- package/dist/proxy/enforcement.js +55 -16
- package/dist/proxy/metadata-map.js +41 -3
- package/dist/proxy/preverified.js +55 -0
- package/dist/proxy/server.js +448 -49
- package/dist/proxy/worker.js +16 -1
- package/dist/publish-set/collect.js +1 -4
- package/dist/publish-set/npm.js +8 -5
- package/dist/publish-set/pack.js +9 -3
- package/dist/runtime/cli.js +0 -4
- package/dist/runtime/fatal.js +31 -0
- package/dist/runtime/first-run.js +12 -11
- package/dist/runtime/node-version.js +43 -6
- package/dist/runtime/nudges.js +35 -2
- package/dist/sbom/cyclonedx.js +211 -0
- package/dist/sbom-ui/SbomApp.js +158 -0
- package/dist/sbom-ui/components/SbomHeader.js +32 -0
- package/dist/sbom-ui/components/SbomList.js +52 -0
- package/dist/sbom-ui/inventory.js +128 -0
- package/dist/sbom-ui/launch.js +51 -0
- package/dist/sbom-ui/run.js +55 -0
- package/dist/sbom-ui/store.js +26 -0
- package/dist/scan/collect.js +10 -6
- package/dist/scan/command.js +17 -10
- package/dist/scan/discovery.js +11 -2
- package/dist/scan/render.js +28 -4
- package/dist/scan/scanner-report.js +15 -9
- package/dist/scan/staged.js +2 -1
- package/dist/scan-ui/LegacyApp.js +9 -13
- package/dist/scan-ui/alt-screen.js +5 -8
- package/dist/scan-ui/components/InteractiveResultsView.js +133 -120
- package/dist/scan-ui/components/ProgressBar.js +3 -14
- package/dist/scan-ui/components/ProjectSelector.js +1 -1
- package/dist/scan-ui/components/ScoreHeader.js +2 -3
- package/dist/scan-ui/components/SetupBanner.js +0 -6
- package/dist/scan-ui/format-helpers.js +61 -5
- package/dist/scan-ui/hooks/useResizeRepaint.js +25 -0
- package/dist/scan-ui/hooks/useScan.js +16 -2
- package/dist/scan-ui/launch.js +7 -4
- package/dist/scan-ui/shims.js +11 -4
- package/dist/scripts/detect.js +7 -2
- package/dist/scripts/gate.js +1 -1
- package/dist/service/state.js +27 -8
- package/dist/service/trust-refresh.js +92 -0
- package/dist/service/worker.js +23 -1
- package/dist/setup/activate-shell.js +28 -0
- package/dist/setup/git-hook.js +49 -4
- package/dist/setup/plan.js +73 -39
- package/dist/setup-ui/gate.js +39 -0
- package/dist/setup-ui/offer.js +42 -0
- package/dist/setup-ui/selector.js +27 -0
- package/dist/setup-ui/tasks.js +56 -0
- package/dist/setup-ui/wizard.js +225 -0
- package/dist/state/cooldown-held.js +66 -0
- package/dist/state/index.js +1 -0
- package/dist/state/locks.js +4 -2
- package/dist/state/store.js +2 -1
- package/dist/util/external-tool.js +25 -0
- package/dist/util/git.js +10 -3
- package/dist/util/report-writer.js +57 -0
- package/dist/verify/local.js +240 -42
- package/dist/verify/package-check.js +84 -28
- package/dist/verify/preflight.js +242 -49
- package/dist/verify/render.js +15 -1
- package/npm-shrinkwrap.json +2383 -0
- package/package.json +6 -8
- package/NOTICE +0 -5
- package/dist/commands/completion.js +0 -117
- package/dist/commands/explain.js +0 -236
- package/dist/commands/unavailable.js +0 -11
- package/dist/scripts/rebuild.js +0 -28
- package/dist/telemetry/events.js +0 -40
package/dist/project/dgfile.js
CHANGED
|
@@ -4,12 +4,49 @@ import { userInfo } from "node:os";
|
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { gitTrimmed } from "../util/git.js";
|
|
6
6
|
import { writeJsonAtomic } from "../util/json-file.js";
|
|
7
|
+
import { canonicalCooldownName } from "../policy/pypi-name.js";
|
|
8
|
+
import { acquireLockSyncWithRetry, resolveDgPaths } from "../state/index.js";
|
|
9
|
+
const DG_FILE_LOCK_STALE_MS = 10_000;
|
|
10
|
+
const DG_FILE_LOCK_TIMEOUT_MS = 5_000;
|
|
11
|
+
export function withDgFileLock(root, env, action) {
|
|
12
|
+
const name = `dgfile-${createHash("sha1").update(root).digest("hex").slice(0, 16)}`;
|
|
13
|
+
const lock = acquireLockSyncWithRetry(resolveDgPaths(env), name, {
|
|
14
|
+
staleMs: DG_FILE_LOCK_STALE_MS,
|
|
15
|
+
timeoutMs: DG_FILE_LOCK_TIMEOUT_MS
|
|
16
|
+
});
|
|
17
|
+
try {
|
|
18
|
+
return action();
|
|
19
|
+
}
|
|
20
|
+
finally {
|
|
21
|
+
lock.release();
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
// Serialized read-modify-write: re-reads dg.json under the lock so a concurrent
|
|
25
|
+
// cooldown/decisions writer can never clobber the other's section.
|
|
26
|
+
export function mutateDgFile(root, env, mutate) {
|
|
27
|
+
return withDgFileLock(root, env, () => {
|
|
28
|
+
const file = loadDgFile(root);
|
|
29
|
+
if (!file.readable) {
|
|
30
|
+
throw new Error(`refusing to rewrite ${file.path}: ${file.failure ?? "unreadable"}`);
|
|
31
|
+
}
|
|
32
|
+
const next = mutate(file);
|
|
33
|
+
saveDgFile(next);
|
|
34
|
+
return next;
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
function canonicalExemptionName(ecosystem, name) {
|
|
38
|
+
return canonicalCooldownName(ecosystem, name);
|
|
39
|
+
}
|
|
40
|
+
export class CooldownExemptionCapError extends Error {
|
|
41
|
+
}
|
|
7
42
|
export const DG_FILE_NAME = "dg.json";
|
|
8
43
|
export const DECISION_ENTRY_CAP = 500;
|
|
44
|
+
export const COOLDOWN_EXEMPTION_CAP = 500;
|
|
9
45
|
export const DECISION_REASON_MAX = 500;
|
|
10
|
-
const KNOWN_TOP_LEVEL_KEYS = new Set(["version", "scriptApprovals", "decisions"]);
|
|
46
|
+
const KNOWN_TOP_LEVEL_KEYS = new Set(["version", "scriptApprovals", "decisions", "cooldownExemptions"]);
|
|
11
47
|
const KNOWN_SCRIPT_APPROVAL_KEYS = new Set(["npm", "observed"]);
|
|
12
48
|
const SCRIPT_HOOKS = ["preinstall", "install", "postinstall", "gyp"];
|
|
49
|
+
const UNSAFE_OBJECT_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
13
50
|
export function dgFilePath(root) {
|
|
14
51
|
return join(root, DG_FILE_NAME);
|
|
15
52
|
}
|
|
@@ -22,7 +59,7 @@ export function findProjectRoot(cwd, env = process.env) {
|
|
|
22
59
|
}
|
|
23
60
|
}
|
|
24
61
|
export function emptyDgFile(path) {
|
|
25
|
-
return { path, exists: false, readable: true, raw: {}, decisions: [], scriptApprovals: emptyScriptApprovals() };
|
|
62
|
+
return { path, exists: false, readable: true, raw: {}, decisions: [], cooldownExemptions: [], scriptApprovals: emptyScriptApprovals() };
|
|
26
63
|
}
|
|
27
64
|
export function loadDgFile(root) {
|
|
28
65
|
const path = dgFilePath(root);
|
|
@@ -61,6 +98,20 @@ export function loadDgFile(root) {
|
|
|
61
98
|
decisions.push(parsed);
|
|
62
99
|
}
|
|
63
100
|
}
|
|
101
|
+
const exemptionsRaw = raw.cooldownExemptions;
|
|
102
|
+
if (exemptionsRaw !== undefined && !Array.isArray(exemptionsRaw)) {
|
|
103
|
+
return failOpen(path, "cooldownExemptions must be an array");
|
|
104
|
+
}
|
|
105
|
+
if (Array.isArray(exemptionsRaw) && exemptionsRaw.length > COOLDOWN_EXEMPTION_CAP) {
|
|
106
|
+
return failOpen(path, `more than ${COOLDOWN_EXEMPTION_CAP} cooldownExemptions`);
|
|
107
|
+
}
|
|
108
|
+
const cooldownExemptions = [];
|
|
109
|
+
for (const entry of Array.isArray(exemptionsRaw) ? exemptionsRaw : []) {
|
|
110
|
+
const parsed = parseCooldownExemption(entry);
|
|
111
|
+
if (parsed) {
|
|
112
|
+
cooldownExemptions.push(parsed);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
64
115
|
const approvals = approvalsRaw ?? {};
|
|
65
116
|
return {
|
|
66
117
|
path,
|
|
@@ -68,6 +119,7 @@ export function loadDgFile(root) {
|
|
|
68
119
|
readable: true,
|
|
69
120
|
raw,
|
|
70
121
|
decisions,
|
|
122
|
+
cooldownExemptions,
|
|
71
123
|
scriptApprovals: {
|
|
72
124
|
npm: parseEntryMap(approvals.npm, parseApprovalEntry),
|
|
73
125
|
observed: parseEntryMap(approvals.observed, parseObservedEntry),
|
|
@@ -92,6 +144,85 @@ export function appendDecisions(file, additions, now = new Date()) {
|
|
|
92
144
|
export function removeDecisions(file, ids) {
|
|
93
145
|
return { ...file, decisions: file.decisions.filter((entry) => !ids.has(entry.id)) };
|
|
94
146
|
}
|
|
147
|
+
export function appendCooldownExemptions(file, additions, now = new Date()) {
|
|
148
|
+
const canonicalAll = additions.map((a) => ({ ...a, name: canonicalExemptionName(a.ecosystem, a.name) }));
|
|
149
|
+
const canonical = [...new Map(canonicalAll.map((a) => [`${a.ecosystem}:${a.name}`, a])).values()];
|
|
150
|
+
const priorExtra = new Map(file.cooldownExemptions.map((e) => [`${e.ecosystem}:${e.name}`, e.extra]));
|
|
151
|
+
const keep = file.cooldownExemptions.filter((e) => !canonical.some((a) => a.ecosystem === e.ecosystem && a.name === e.name));
|
|
152
|
+
const added = canonical.map((a) => {
|
|
153
|
+
const carried = priorExtra.get(`${a.ecosystem}:${a.name}`);
|
|
154
|
+
return {
|
|
155
|
+
...a,
|
|
156
|
+
reason: a.reason.slice(0, DECISION_REASON_MAX),
|
|
157
|
+
acceptedAt: a.acceptedAt ?? now.toISOString(),
|
|
158
|
+
...(carried !== undefined ? { extra: carried } : {})
|
|
159
|
+
};
|
|
160
|
+
});
|
|
161
|
+
const next = [...keep, ...added];
|
|
162
|
+
if (next.length > COOLDOWN_EXEMPTION_CAP) {
|
|
163
|
+
const live = next.filter((e) => cooldownExemptionActive(e, now));
|
|
164
|
+
if (live.length > COOLDOWN_EXEMPTION_CAP) {
|
|
165
|
+
throw new CooldownExemptionCapError(`cooldown exemptions would exceed the ${COOLDOWN_EXEMPTION_CAP} cap; run 'dg cooldown prune' or remove some with 'dg cooldown rm'`);
|
|
166
|
+
}
|
|
167
|
+
return { ...file, cooldownExemptions: live };
|
|
168
|
+
}
|
|
169
|
+
return { ...file, cooldownExemptions: next };
|
|
170
|
+
}
|
|
171
|
+
export function removeCooldownExemptions(file, predicate) {
|
|
172
|
+
return { ...file, cooldownExemptions: file.cooldownExemptions.filter((e) => !predicate(e)) };
|
|
173
|
+
}
|
|
174
|
+
// Active when there is no expiry, or the expiry parses to a future instant. A
|
|
175
|
+
// present-but-unparseable expiry fails CLOSED (treated as expired) so a typo'd
|
|
176
|
+
// date can never become a permanent cooldown bypass.
|
|
177
|
+
export function cooldownExemptionActive(e, now = new Date()) {
|
|
178
|
+
if (!e.expiresAt) {
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
181
|
+
const expiry = Date.parse(e.expiresAt);
|
|
182
|
+
return Number.isFinite(expiry) && expiry > now.getTime();
|
|
183
|
+
}
|
|
184
|
+
function serializeCooldownExemption(e) {
|
|
185
|
+
return {
|
|
186
|
+
...e.extra,
|
|
187
|
+
ecosystem: e.ecosystem,
|
|
188
|
+
name: e.name,
|
|
189
|
+
reason: e.reason,
|
|
190
|
+
acceptedBy: e.acceptedBy,
|
|
191
|
+
acceptedAt: e.acceptedAt,
|
|
192
|
+
...(e.expiresAt ? { expiresAt: e.expiresAt } : {})
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
const EXEMPTION_FIELDS = new Set(["ecosystem", "name", "reason", "acceptedBy", "acceptedAt", "expiresAt"]);
|
|
196
|
+
export function parseCooldownExemption(value) {
|
|
197
|
+
if (!isPlainObject(value)) {
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
const ecosystem = value.ecosystem;
|
|
201
|
+
if (ecosystem !== "npm" && ecosystem !== "pypi" && ecosystem !== "cargo") {
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
const rawName = value.name;
|
|
205
|
+
if (typeof rawName !== "string" || rawName.length === 0 || /[\u0000-\u001f\u007f\s*]/u.test(rawName)) {
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
const name = canonicalExemptionName(ecosystem, rawName);
|
|
209
|
+
const extra = {};
|
|
210
|
+
for (const [key, field] of Object.entries(value)) {
|
|
211
|
+
if (!EXEMPTION_FIELDS.has(key) && !UNSAFE_OBJECT_KEYS.has(key)) {
|
|
212
|
+
extra[key] = field;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
const expiresAt = value.expiresAt;
|
|
216
|
+
return {
|
|
217
|
+
ecosystem,
|
|
218
|
+
name,
|
|
219
|
+
reason: typeof value.reason === "string" ? value.reason.slice(0, DECISION_REASON_MAX) : "",
|
|
220
|
+
acceptedBy: typeof value.acceptedBy === "string" && value.acceptedBy.length > 0 ? value.acceptedBy : "unknown",
|
|
221
|
+
acceptedAt: typeof value.acceptedAt === "string" ? value.acceptedAt : "",
|
|
222
|
+
...(typeof expiresAt === "string" ? { expiresAt } : {}),
|
|
223
|
+
...(Object.keys(extra).length > 0 ? { extra } : {})
|
|
224
|
+
};
|
|
225
|
+
}
|
|
95
226
|
export function saveDgFile(file) {
|
|
96
227
|
if (!file.readable) {
|
|
97
228
|
throw new Error(`refusing to rewrite ${file.path}: ${file.failure ?? "unreadable"}`);
|
|
@@ -100,6 +231,9 @@ export function saveDgFile(file) {
|
|
|
100
231
|
if (file.decisions.length > 0) {
|
|
101
232
|
top.decisions = file.decisions.map(serializeDecisionEntry);
|
|
102
233
|
}
|
|
234
|
+
if (file.cooldownExemptions.length > 0) {
|
|
235
|
+
top.cooldownExemptions = file.cooldownExemptions.map(serializeCooldownExemption);
|
|
236
|
+
}
|
|
103
237
|
const approvals = { ...file.scriptApprovals.unknownKeys };
|
|
104
238
|
if (Object.keys(file.scriptApprovals.npm).length > 0) {
|
|
105
239
|
approvals.npm = sortedRecord(file.scriptApprovals.npm);
|
|
@@ -174,7 +308,7 @@ function parseDecisionEntry(value) {
|
|
|
174
308
|
}
|
|
175
309
|
const extra = {};
|
|
176
310
|
for (const [key, field] of Object.entries(value)) {
|
|
177
|
-
if (!ENTRY_FIELDS.has(key)) {
|
|
311
|
+
if (!ENTRY_FIELDS.has(key) && !UNSAFE_OBJECT_KEYS.has(key)) {
|
|
178
312
|
extra[key] = field;
|
|
179
313
|
}
|
|
180
314
|
}
|
|
@@ -215,7 +349,9 @@ function parseFindings(value) {
|
|
|
215
349
|
if (typeof severity !== "number" || !Number.isInteger(severity) || severity < 1 || severity > 5) {
|
|
216
350
|
return null;
|
|
217
351
|
}
|
|
218
|
-
|
|
352
|
+
if (!UNSAFE_OBJECT_KEYS.has(category)) {
|
|
353
|
+
findings[category] = severity;
|
|
354
|
+
}
|
|
219
355
|
}
|
|
220
356
|
return findings;
|
|
221
357
|
}
|
|
@@ -228,6 +364,9 @@ function parseEntryMap(raw, parseEntry) {
|
|
|
228
364
|
}
|
|
229
365
|
const entries = {};
|
|
230
366
|
for (const [name, value] of Object.entries(raw)) {
|
|
367
|
+
if (UNSAFE_OBJECT_KEYS.has(name)) {
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
231
370
|
const parsed = parseEntry(value);
|
|
232
371
|
if (parsed !== null) {
|
|
233
372
|
entries[name] = parsed;
|
|
@@ -290,7 +429,7 @@ function sortedRecord(record) {
|
|
|
290
429
|
function unknownKeysOf(raw, known) {
|
|
291
430
|
const unknown = {};
|
|
292
431
|
for (const [key, value] of Object.entries(raw)) {
|
|
293
|
-
if (!known.has(key)) {
|
|
432
|
+
if (!known.has(key) && !UNSAFE_OBJECT_KEYS.has(key)) {
|
|
294
433
|
unknown[key] = value;
|
|
295
434
|
}
|
|
296
435
|
}
|
|
@@ -303,5 +442,5 @@ function emptyScriptApprovals() {
|
|
|
303
442
|
return { npm: {}, observed: {}, unknownKeys: {} };
|
|
304
443
|
}
|
|
305
444
|
function failOpen(path, failure) {
|
|
306
|
-
return { path, exists: true, readable: false, failure, raw: {}, decisions: [], scriptApprovals: emptyScriptApprovals() };
|
|
445
|
+
return { path, exists: true, readable: false, failure, raw: {}, decisions: [], cooldownExemptions: [], scriptApprovals: emptyScriptApprovals() };
|
|
307
446
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
export const PROXY_AUTH_TOKEN_FILENAME = "proxy-auth-token";
|
|
5
|
+
export const PROXY_AUTH_USER = "dg";
|
|
6
|
+
export function generateProxyAuthToken() {
|
|
7
|
+
return randomBytes(32).toString("hex");
|
|
8
|
+
}
|
|
9
|
+
export function proxyAuthTokenPath(sessionDir) {
|
|
10
|
+
return join(sessionDir, PROXY_AUTH_TOKEN_FILENAME);
|
|
11
|
+
}
|
|
12
|
+
export function writeProxyAuthToken(sessionDir, token) {
|
|
13
|
+
writeFileSync(proxyAuthTokenPath(sessionDir), `${token}\n`, {
|
|
14
|
+
encoding: "utf8",
|
|
15
|
+
mode: 0o600
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
export function readProxyAuthToken(sessionDir) {
|
|
19
|
+
try {
|
|
20
|
+
const token = readFileSync(proxyAuthTokenPath(sessionDir), "utf8").trim();
|
|
21
|
+
return token.length > 0 ? token : undefined;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export function proxyUrlWithAuth(proxyUrl, token) {
|
|
28
|
+
const url = new URL(proxyUrl);
|
|
29
|
+
return `${url.protocol}//${PROXY_AUTH_USER}:${token}@${url.host}`;
|
|
30
|
+
}
|
|
31
|
+
export function proxyAuthorizationValue(token) {
|
|
32
|
+
return `Basic ${Buffer.from(`${PROXY_AUTH_USER}:${token}`, "utf8").toString("base64")}`;
|
|
33
|
+
}
|
|
34
|
+
export function verifyProxyAuthorization(header, token) {
|
|
35
|
+
if (typeof header !== "string" || header.length === 0) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
return timingSafeEqual(digest(header), digest(proxyAuthorizationValue(token)));
|
|
39
|
+
}
|
|
40
|
+
function digest(value) {
|
|
41
|
+
return createHash("sha256").update(value, "utf8").digest();
|
|
42
|
+
}
|
package/dist/proxy/ca.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { randomBytes } from "node:crypto";
|
|
1
|
+
import { generateKeyPairSync, randomBytes } from "node:crypto";
|
|
2
2
|
import { mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { dirname } from "node:path";
|
|
4
4
|
import { isIP } from "node:net";
|
|
@@ -17,6 +17,12 @@ export function createEphemeralCertificateAuthority(caPath, options = {}) {
|
|
|
17
17
|
}
|
|
18
18
|
active = issueAuthority(caPath, lifetimeMs, now(), active.certPem);
|
|
19
19
|
leafs.clear();
|
|
20
|
+
try {
|
|
21
|
+
options.onRotate?.(active.certPem);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
20
26
|
};
|
|
21
27
|
setInterval(rotateIfDue, ROTATION_CHECK_INTERVAL_MS).unref();
|
|
22
28
|
return {
|
|
@@ -37,11 +43,24 @@ export function createEphemeralCertificateAuthority(caPath, options = {}) {
|
|
|
37
43
|
}
|
|
38
44
|
};
|
|
39
45
|
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
46
|
+
// node:crypto keygen piped into forge via PEM so keygen never falls back to forge's pure-JS path.
|
|
47
|
+
function generateRsaKeyPair() {
|
|
48
|
+
const { privateKey, publicKey } = generateKeyPairSync("rsa", {
|
|
49
|
+
modulusLength: 2048
|
|
44
50
|
});
|
|
51
|
+
return {
|
|
52
|
+
privateKey: forge.pki.privateKeyFromPem(privateKey.export({
|
|
53
|
+
type: "pkcs8",
|
|
54
|
+
format: "pem"
|
|
55
|
+
}).toString()),
|
|
56
|
+
publicKey: forge.pki.publicKeyFromPem(publicKey.export({
|
|
57
|
+
type: "spki",
|
|
58
|
+
format: "pem"
|
|
59
|
+
}).toString())
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
function issueAuthority(caPath, lifetimeMs, nowMs, previousCertPem) {
|
|
63
|
+
const keys = generateRsaKeyPair();
|
|
45
64
|
const cert = forge.pki.createCertificate();
|
|
46
65
|
cert.publicKey = keys.publicKey;
|
|
47
66
|
cert.serialNumber = serialNumber();
|
|
@@ -65,6 +84,10 @@ function issueAuthority(caPath, lifetimeMs, nowMs, previousCertPem) {
|
|
|
65
84
|
cRLSign: true,
|
|
66
85
|
critical: true
|
|
67
86
|
},
|
|
87
|
+
{
|
|
88
|
+
name: "extKeyUsage",
|
|
89
|
+
serverAuth: true
|
|
90
|
+
},
|
|
68
91
|
{
|
|
69
92
|
name: "subjectKeyIdentifier"
|
|
70
93
|
}
|
|
@@ -102,10 +125,7 @@ function writeCaBundleAtomic(caPath, bundle) {
|
|
|
102
125
|
}
|
|
103
126
|
}
|
|
104
127
|
function createLeafCertificate(host, issuer, lifetimeMs, nowMs) {
|
|
105
|
-
const keys =
|
|
106
|
-
bits: 2048,
|
|
107
|
-
workers: -1
|
|
108
|
-
});
|
|
128
|
+
const keys = generateRsaKeyPair();
|
|
109
129
|
const cert = forge.pki.createCertificate();
|
|
110
130
|
cert.publicKey = keys.publicKey;
|
|
111
131
|
cert.serialNumber = serialNumber();
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { parseCooldownExemption } from "../project/dgfile.js";
|
|
4
|
+
export const COOLDOWN_EXEMPTIONS_FILE = "cooldown-exemptions.json";
|
|
5
|
+
export const COOLDOWN_EXEMPTIONS_ENV = "DG_PROXY_COOLDOWN_EXEMPTIONS_FILE";
|
|
6
|
+
export function writeCooldownExemptionsFile(sessionDir, exemptions) {
|
|
7
|
+
if (exemptions.length === 0) {
|
|
8
|
+
return {};
|
|
9
|
+
}
|
|
10
|
+
try {
|
|
11
|
+
const path = join(sessionDir, COOLDOWN_EXEMPTIONS_FILE);
|
|
12
|
+
writeFileSync(path, JSON.stringify(exemptions), { encoding: "utf8", mode: 0o600 });
|
|
13
|
+
return { [COOLDOWN_EXEMPTIONS_ENV]: path };
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return {};
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export function loadCooldownExemptionsFile(path) {
|
|
20
|
+
if (!path) {
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
try {
|
|
24
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
25
|
+
if (!Array.isArray(parsed)) {
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
return parsed.map((entry) => parseCooldownExemption(entry)).filter((entry) => entry !== null);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return [];
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { recordAuditEvent } from "../audit/events.js";
|
|
2
|
-
import { loadUserConfig } from "../config/settings.js";
|
|
2
|
+
import { DEFAULT_CONFIG, loadUserConfig } from "../config/settings.js";
|
|
3
3
|
import { applyForceOverride, evaluatePackagePolicy, resolveEffectivePolicy } from "../policy/evaluate.js";
|
|
4
4
|
export function parseForceOverrideRequest(raw) {
|
|
5
5
|
if (!raw) {
|
|
@@ -16,8 +16,31 @@ export function parseForceOverrideRequest(raw) {
|
|
|
16
16
|
return undefined;
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
|
+
let sideEffectFailureNoticed = false;
|
|
20
|
+
export function noteEnforcementSideEffectFailure(error) {
|
|
21
|
+
if (sideEffectFailureNoticed) {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
sideEffectFailureNoticed = true;
|
|
25
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
26
|
+
try {
|
|
27
|
+
process.stderr.write(`dg: local state read/write failed (${message}); the install decision is still enforced.\n`);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function loadUserConfigOrDefault(env) {
|
|
34
|
+
try {
|
|
35
|
+
return loadUserConfig(env);
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
noteEnforcementSideEffectFailure(error);
|
|
39
|
+
return DEFAULT_CONFIG;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
19
42
|
export function enforceProtectedInstall(request) {
|
|
20
|
-
const userConfig = request.userConfig ??
|
|
43
|
+
const userConfig = request.userConfig ?? loadUserConfigOrDefault(request.env);
|
|
21
44
|
const policy = resolveEffectivePolicy({ userConfig });
|
|
22
45
|
const proxyVerdict = request.proxyVerdict ?? failClosedVerdict(request.classification);
|
|
23
46
|
const packageName = proxyVerdict.packageName ?? derivePackageName(request.classification);
|
|
@@ -39,13 +62,24 @@ export function enforceProtectedInstall(request) {
|
|
|
39
62
|
if (baseDecision.action !== "block") {
|
|
40
63
|
return baseDecision;
|
|
41
64
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
65
|
+
let force;
|
|
66
|
+
try {
|
|
67
|
+
force = applyForceOverride({
|
|
68
|
+
packageName,
|
|
69
|
+
currentAction: "block",
|
|
70
|
+
force: request.forceOverride?.force ?? false,
|
|
71
|
+
policy,
|
|
72
|
+
...(request.now ? { now: request.now } : {})
|
|
73
|
+
}, request.env);
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
noteEnforcementSideEffectFailure(error);
|
|
77
|
+
force = {
|
|
78
|
+
allowed: false,
|
|
79
|
+
reason: "force override is unavailable because dg local state could not be read or written",
|
|
80
|
+
auditRecorded: false
|
|
81
|
+
};
|
|
82
|
+
}
|
|
49
83
|
if (force.allowed) {
|
|
50
84
|
return {
|
|
51
85
|
...baseDecision,
|
|
@@ -57,13 +91,18 @@ export function enforceProtectedInstall(request) {
|
|
|
57
91
|
}
|
|
58
92
|
};
|
|
59
93
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
94
|
+
try {
|
|
95
|
+
recordAuditEvent({
|
|
96
|
+
type: "install.blocked",
|
|
97
|
+
packageName,
|
|
98
|
+
reason: baseDecision.reason,
|
|
99
|
+
policyMode: policy.mode,
|
|
100
|
+
createdAt: (request.now ?? new Date()).toISOString()
|
|
101
|
+
}, request.env);
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
noteEnforcementSideEffectFailure(error);
|
|
105
|
+
}
|
|
67
106
|
if (!request.forceOverride?.force) {
|
|
68
107
|
return baseDecision;
|
|
69
108
|
}
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
+
import { brotliDecompressSync, gunzipSync, inflateRawSync, inflateSync } from "node:zlib";
|
|
2
3
|
export function extractRegistryMetadataIdentities(metadataUrl, response) {
|
|
4
|
+
const decoded = { headers: response.headers, body: decodeContentEncoding(response.headers, response.body) };
|
|
3
5
|
// PyPI Simple index (PEP 503 HTML or PEP 691 JSON) — the pip/uv/pipx flow.
|
|
4
6
|
if (isPypiSimpleIndexUrl(metadataUrl)) {
|
|
5
|
-
return extractPypiSimpleIdentities(metadataUrl,
|
|
7
|
+
return extractPypiSimpleIdentities(metadataUrl, decoded);
|
|
6
8
|
}
|
|
7
|
-
if (!looksLikeJson(
|
|
9
|
+
if (!looksLikeJson(decoded.headers["content-type"])) {
|
|
8
10
|
return [];
|
|
9
11
|
}
|
|
10
|
-
const parsed = parseJson(
|
|
12
|
+
const parsed = parseJson(decoded.body);
|
|
11
13
|
if (!isRecord(parsed)) {
|
|
12
14
|
return [];
|
|
13
15
|
}
|
|
@@ -16,6 +18,42 @@ export function extractRegistryMetadataIdentities(metadataUrl, response) {
|
|
|
16
18
|
...extractPypiIdentities(metadataUrl, parsed)
|
|
17
19
|
];
|
|
18
20
|
}
|
|
21
|
+
// Content-Encoding lists encodings in the order they were applied; decode in
|
|
22
|
+
// reverse. Any unknown token or decode failure returns the body untouched so
|
|
23
|
+
// the response falls back to artifact handling instead of crashing the proxy.
|
|
24
|
+
function decodeContentEncoding(headers, body) {
|
|
25
|
+
const raw = headers["content-encoding"];
|
|
26
|
+
const encodings = (Array.isArray(raw) ? raw.join(",") : String(raw ?? ""))
|
|
27
|
+
.split(",")
|
|
28
|
+
.map((token) => token.trim().toLowerCase())
|
|
29
|
+
.filter((token) => token.length > 0 && token !== "identity");
|
|
30
|
+
let decoded = body;
|
|
31
|
+
for (const encoding of encodings.reverse()) {
|
|
32
|
+
try {
|
|
33
|
+
if (encoding === "gzip" || encoding === "x-gzip") {
|
|
34
|
+
decoded = gunzipSync(decoded);
|
|
35
|
+
}
|
|
36
|
+
else if (encoding === "br") {
|
|
37
|
+
decoded = brotliDecompressSync(decoded);
|
|
38
|
+
}
|
|
39
|
+
else if (encoding === "deflate") {
|
|
40
|
+
try {
|
|
41
|
+
decoded = inflateSync(decoded);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
decoded = inflateRawSync(decoded);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
return body;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return body;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return decoded;
|
|
56
|
+
}
|
|
19
57
|
// A registry INDEX/metadata request (not an artifact download). These must be
|
|
20
58
|
// passed through untouched — never verified as a package — so e.g. pip's
|
|
21
59
|
// `pypi.org/simple/<pkg>/` index is not mistaken for a package named "simple".
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
const PREVERIFIED_FILE = "preverified.json";
|
|
4
|
+
export function preverifiedKey(ecosystem, name, version) {
|
|
5
|
+
const canonicalName = ecosystem === "pypi" ? name.toLowerCase().replace(/[-_.]+/g, "-") : name;
|
|
6
|
+
return `${ecosystem}:${canonicalName}@${version}`;
|
|
7
|
+
}
|
|
8
|
+
export function writePreverifiedFile(sessionDir, entries) {
|
|
9
|
+
writeFileSync(join(sessionDir, PREVERIFIED_FILE), `${JSON.stringify(entries)}\n`, {
|
|
10
|
+
encoding: "utf8",
|
|
11
|
+
mode: 0o600
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
let loaded = null;
|
|
15
|
+
export function loadPreverifiedMap(sessionDir) {
|
|
16
|
+
if (loaded?.dir === sessionDir) {
|
|
17
|
+
return loaded.map;
|
|
18
|
+
}
|
|
19
|
+
const path = join(sessionDir, PREVERIFIED_FILE);
|
|
20
|
+
if (!existsSync(path)) {
|
|
21
|
+
return new Map();
|
|
22
|
+
}
|
|
23
|
+
const map = new Map();
|
|
24
|
+
try {
|
|
25
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
26
|
+
if (Array.isArray(parsed)) {
|
|
27
|
+
for (const entry of parsed) {
|
|
28
|
+
if (isPreverifiedEntry(entry)) {
|
|
29
|
+
map.set(preverifiedKey(entry.ecosystem, entry.name, entry.version), entry);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return new Map();
|
|
36
|
+
}
|
|
37
|
+
loaded = { dir: sessionDir, map };
|
|
38
|
+
return map;
|
|
39
|
+
}
|
|
40
|
+
export function resetPreverifiedCache() {
|
|
41
|
+
loaded = null;
|
|
42
|
+
}
|
|
43
|
+
function isPreverifiedEntry(value) {
|
|
44
|
+
if (typeof value !== "object" || value === null) {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
const entry = value;
|
|
48
|
+
return ((entry.ecosystem === "npm" || entry.ecosystem === "pypi") &&
|
|
49
|
+
typeof entry.name === "string" && entry.name.length > 0 &&
|
|
50
|
+
typeof entry.version === "string" && entry.version.length > 0 &&
|
|
51
|
+
(entry.action === "pass" || entry.action === "warn") &&
|
|
52
|
+
(entry.reason === undefined || typeof entry.reason === "string") &&
|
|
53
|
+
(entry.scannedSha256 === undefined || typeof entry.scannedSha256 === "string") &&
|
|
54
|
+
typeof entry.cooldownEvaluated === "boolean");
|
|
55
|
+
}
|