@westbayberry/dg 2.0.11 → 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 +6 -4
- 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 +26 -10
- package/dist/commands/cooldown.js +389 -0
- package/dist/commands/decisions.js +169 -0
- 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 +8 -4
- package/dist/commands/sbom.js +206 -0
- package/dist/commands/scan.js +3 -2
- package/dist/commands/service.js +32 -13
- package/dist/commands/setup.js +197 -27
- package/dist/commands/status.js +5 -2
- 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 +154 -65
- package/dist/decisions/apply.js +128 -0
- package/dist/decisions/remember-prompt.js +92 -0
- package/dist/export-ui/ExportDialog.js +198 -0
- package/dist/install-ui/LiveInstall.js +2 -2
- package/dist/install-ui/block-render.js +21 -4
- package/dist/install-ui/prep-spinner.js +32 -0
- package/dist/install-ui/prompt.js +14 -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 +167 -17
- package/dist/launcher/live-install.js +25 -5
- package/dist/launcher/output-redaction.js +7 -4
- package/dist/launcher/preflight-prompt.js +43 -3
- package/dist/launcher/run.js +100 -86
- package/dist/launcher/spawn-invocation.js +21 -0
- package/dist/policy/cooldown.js +117 -0
- package/dist/policy/evaluate.js +5 -21
- 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/provenance.js +23 -0
- package/dist/presentation/theme.js +7 -7
- package/dist/project/dgfile.js +446 -0
- package/dist/proxy/auth.js +42 -0
- package/dist/proxy/ca.js +29 -9
- package/dist/proxy/cooldown-exemptions-file.js +33 -0
- package/dist/proxy/enforcement.js +57 -17
- package/dist/proxy/metadata-map.js +66 -4
- package/dist/proxy/preverified.js +55 -0
- package/dist/proxy/server.js +473 -45
- 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 +51 -17
- package/dist/scan/discovery.js +11 -2
- package/dist/scan/render.js +63 -8
- package/dist/scan/scanner-report.js +42 -9
- package/dist/scan/staged.js +71 -11
- package/dist/scan-ui/LegacyApp.js +12 -16
- package/dist/scan-ui/alt-screen.js +5 -8
- package/dist/scan-ui/components/InteractiveResultsView.js +193 -123
- 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 +46 -4
- package/dist/scan-ui/launch.js +7 -4
- package/dist/scan-ui/shims.js +14 -4
- package/dist/scripts/detect.js +158 -0
- package/dist/scripts/gate.js +170 -0
- 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 +98 -29
- 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/json-file.js +24 -0
- package/dist/util/report-writer.js +57 -0
- package/dist/util/tty-prompt.js +13 -6
- package/dist/verify/local.js +240 -42
- package/dist/verify/package-check.js +86 -18
- package/dist/verify/preflight.js +242 -49
- package/dist/verify/render.js +15 -1
- package/npm-shrinkwrap.json +2383 -0
- package/package.json +14 -8
- package/NOTICE +0 -5
- package/dist/commands/completion.js +0 -116
- package/dist/commands/explain.js +0 -232
- package/dist/commands/unavailable.js +0 -11
- package/dist/telemetry/events.js +0 -40
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { userInfo } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { gitTrimmed } from "../util/git.js";
|
|
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
|
+
}
|
|
42
|
+
export const DG_FILE_NAME = "dg.json";
|
|
43
|
+
export const DECISION_ENTRY_CAP = 500;
|
|
44
|
+
export const COOLDOWN_EXEMPTION_CAP = 500;
|
|
45
|
+
export const DECISION_REASON_MAX = 500;
|
|
46
|
+
const KNOWN_TOP_LEVEL_KEYS = new Set(["version", "scriptApprovals", "decisions", "cooldownExemptions"]);
|
|
47
|
+
const KNOWN_SCRIPT_APPROVAL_KEYS = new Set(["npm", "observed"]);
|
|
48
|
+
const SCRIPT_HOOKS = ["preinstall", "install", "postinstall", "gyp"];
|
|
49
|
+
const UNSAFE_OBJECT_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
50
|
+
export function dgFilePath(root) {
|
|
51
|
+
return join(root, DG_FILE_NAME);
|
|
52
|
+
}
|
|
53
|
+
export function findProjectRoot(cwd, env = process.env) {
|
|
54
|
+
try {
|
|
55
|
+
return gitTrimmed(["rev-parse", "--show-toplevel"], { cwd, env });
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
export function emptyDgFile(path) {
|
|
62
|
+
return { path, exists: false, readable: true, raw: {}, decisions: [], cooldownExemptions: [], scriptApprovals: emptyScriptApprovals() };
|
|
63
|
+
}
|
|
64
|
+
export function loadDgFile(root) {
|
|
65
|
+
const path = dgFilePath(root);
|
|
66
|
+
if (!existsSync(path)) {
|
|
67
|
+
return emptyDgFile(path);
|
|
68
|
+
}
|
|
69
|
+
let raw;
|
|
70
|
+
try {
|
|
71
|
+
raw = JSON.parse(readFileSync(path, "utf8"));
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
return failOpen(path, `malformed JSON (${error instanceof Error ? error.message : "parse error"})`);
|
|
75
|
+
}
|
|
76
|
+
if (!isPlainObject(raw)) {
|
|
77
|
+
return failOpen(path, "top level must be a JSON object");
|
|
78
|
+
}
|
|
79
|
+
if (raw.version !== undefined && raw.version !== 1) {
|
|
80
|
+
return failOpen(path, `unsupported dg.json version ${String(raw.version)}`);
|
|
81
|
+
}
|
|
82
|
+
const listed = raw.decisions;
|
|
83
|
+
if (listed !== undefined && !Array.isArray(listed)) {
|
|
84
|
+
return failOpen(path, "decisions must be an array");
|
|
85
|
+
}
|
|
86
|
+
const entries = Array.isArray(listed) ? listed : [];
|
|
87
|
+
if (entries.length > DECISION_ENTRY_CAP) {
|
|
88
|
+
return failOpen(path, `more than ${DECISION_ENTRY_CAP} decisions`);
|
|
89
|
+
}
|
|
90
|
+
const approvalsRaw = raw.scriptApprovals;
|
|
91
|
+
if (approvalsRaw !== undefined && !isPlainObject(approvalsRaw)) {
|
|
92
|
+
return failOpen(path, "scriptApprovals must be an object");
|
|
93
|
+
}
|
|
94
|
+
const decisions = [];
|
|
95
|
+
for (const entry of entries) {
|
|
96
|
+
const parsed = parseDecisionEntry(entry);
|
|
97
|
+
if (parsed) {
|
|
98
|
+
decisions.push(parsed);
|
|
99
|
+
}
|
|
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
|
+
}
|
|
115
|
+
const approvals = approvalsRaw ?? {};
|
|
116
|
+
return {
|
|
117
|
+
path,
|
|
118
|
+
exists: true,
|
|
119
|
+
readable: true,
|
|
120
|
+
raw,
|
|
121
|
+
decisions,
|
|
122
|
+
cooldownExemptions,
|
|
123
|
+
scriptApprovals: {
|
|
124
|
+
npm: parseEntryMap(approvals.npm, parseApprovalEntry),
|
|
125
|
+
observed: parseEntryMap(approvals.observed, parseObservedEntry),
|
|
126
|
+
unknownKeys: unknownKeysOf(approvals, KNOWN_SCRIPT_APPROVAL_KEYS)
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
export function warnUnreadableDgFile(file, write = (line) => process.stderr.write(line)) {
|
|
131
|
+
if (!file.readable) {
|
|
132
|
+
write(`dg: ignoring decisions in ${file.path} — ${file.failure ?? "unreadable"} (no warnings suppressed)\n`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
export function appendDecisions(file, additions, now = new Date()) {
|
|
136
|
+
const added = additions.map((decision) => ({
|
|
137
|
+
...decision,
|
|
138
|
+
id: randomUUID(),
|
|
139
|
+
reason: decision.reason.slice(0, DECISION_REASON_MAX),
|
|
140
|
+
acceptedAt: decision.acceptedAt ?? now.toISOString()
|
|
141
|
+
}));
|
|
142
|
+
return { ...file, decisions: [...file.decisions, ...added] };
|
|
143
|
+
}
|
|
144
|
+
export function removeDecisions(file, ids) {
|
|
145
|
+
return { ...file, decisions: file.decisions.filter((entry) => !ids.has(entry.id)) };
|
|
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
|
+
}
|
|
226
|
+
export function saveDgFile(file) {
|
|
227
|
+
if (!file.readable) {
|
|
228
|
+
throw new Error(`refusing to rewrite ${file.path}: ${file.failure ?? "unreadable"}`);
|
|
229
|
+
}
|
|
230
|
+
const top = unknownKeysOf(file.raw, KNOWN_TOP_LEVEL_KEYS);
|
|
231
|
+
if (file.decisions.length > 0) {
|
|
232
|
+
top.decisions = file.decisions.map(serializeDecisionEntry);
|
|
233
|
+
}
|
|
234
|
+
if (file.cooldownExemptions.length > 0) {
|
|
235
|
+
top.cooldownExemptions = file.cooldownExemptions.map(serializeCooldownExemption);
|
|
236
|
+
}
|
|
237
|
+
const approvals = { ...file.scriptApprovals.unknownKeys };
|
|
238
|
+
if (Object.keys(file.scriptApprovals.npm).length > 0) {
|
|
239
|
+
approvals.npm = sortedRecord(file.scriptApprovals.npm);
|
|
240
|
+
}
|
|
241
|
+
if (Object.keys(file.scriptApprovals.observed).length > 0) {
|
|
242
|
+
approvals.observed = sortedRecord(file.scriptApprovals.observed);
|
|
243
|
+
}
|
|
244
|
+
if (Object.keys(approvals).length > 0) {
|
|
245
|
+
top.scriptApprovals = sortedRecord(approvals);
|
|
246
|
+
}
|
|
247
|
+
const ordered = { version: 1 };
|
|
248
|
+
for (const key of Object.keys(top).sort()) {
|
|
249
|
+
ordered[key] = top[key];
|
|
250
|
+
}
|
|
251
|
+
writeJsonAtomic(file.path, ordered, { fileMode: 0o644, dirMode: 0o755 });
|
|
252
|
+
}
|
|
253
|
+
export function resolveAcceptedBy(cwd, env = process.env) {
|
|
254
|
+
try {
|
|
255
|
+
const email = gitTrimmed(["config", "user.email"], { cwd, env });
|
|
256
|
+
if (email) {
|
|
257
|
+
return email;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
/* fall through */
|
|
262
|
+
}
|
|
263
|
+
try {
|
|
264
|
+
return userInfo().username;
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
return "unknown";
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
function serializeDecisionEntry(entry) {
|
|
271
|
+
return {
|
|
272
|
+
...entry.extra,
|
|
273
|
+
id: entry.id,
|
|
274
|
+
ecosystem: entry.ecosystem,
|
|
275
|
+
name: entry.name,
|
|
276
|
+
scope: entry.scope.kind === "exact" ? { kind: "exact", version: entry.scope.version } : { kind: "any" },
|
|
277
|
+
findings: entry.findings,
|
|
278
|
+
reason: entry.reason,
|
|
279
|
+
acceptedBy: entry.acceptedBy,
|
|
280
|
+
acceptedAt: entry.acceptedAt,
|
|
281
|
+
...(entry.expiresAt ? { expiresAt: entry.expiresAt } : {})
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
const ENTRY_FIELDS = new Set(["id", "ecosystem", "name", "scope", "findings", "reason", "acceptedBy", "acceptedAt", "expiresAt"]);
|
|
285
|
+
function parseDecisionEntry(value) {
|
|
286
|
+
if (!isPlainObject(value)) {
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
const ecosystem = value.ecosystem;
|
|
290
|
+
if (ecosystem !== "npm" && ecosystem !== "pypi") {
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
const name = value.name;
|
|
294
|
+
if (typeof name !== "string" || name.length === 0) {
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
297
|
+
const scope = parseScope(value.scope);
|
|
298
|
+
if (!scope) {
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
301
|
+
const findings = parseFindings(value.findings);
|
|
302
|
+
if (!findings) {
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
const expiresAt = value.expiresAt;
|
|
306
|
+
if (expiresAt !== undefined && typeof expiresAt !== "string") {
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
const extra = {};
|
|
310
|
+
for (const [key, field] of Object.entries(value)) {
|
|
311
|
+
if (!ENTRY_FIELDS.has(key) && !UNSAFE_OBJECT_KEYS.has(key)) {
|
|
312
|
+
extra[key] = field;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return {
|
|
316
|
+
id: typeof value.id === "string" && value.id.length > 0 ? value.id : derivedEntryId(value),
|
|
317
|
+
ecosystem,
|
|
318
|
+
name,
|
|
319
|
+
scope,
|
|
320
|
+
findings,
|
|
321
|
+
reason: typeof value.reason === "string" ? value.reason.slice(0, DECISION_REASON_MAX) : "",
|
|
322
|
+
acceptedBy: typeof value.acceptedBy === "string" && value.acceptedBy.length > 0 ? value.acceptedBy : "unknown",
|
|
323
|
+
acceptedAt: typeof value.acceptedAt === "string" ? value.acceptedAt : "",
|
|
324
|
+
...(typeof expiresAt === "string" ? { expiresAt } : {}),
|
|
325
|
+
...(Object.keys(extra).length > 0 ? { extra } : {})
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
function parseScope(value) {
|
|
329
|
+
if (!isPlainObject(value)) {
|
|
330
|
+
return null;
|
|
331
|
+
}
|
|
332
|
+
if (value.kind === "any") {
|
|
333
|
+
return { kind: "any" };
|
|
334
|
+
}
|
|
335
|
+
if (value.kind === "exact" && typeof value.version === "string" && value.version.length > 0) {
|
|
336
|
+
return { kind: "exact", version: value.version };
|
|
337
|
+
}
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
function parseFindings(value) {
|
|
341
|
+
if (value === undefined) {
|
|
342
|
+
return {};
|
|
343
|
+
}
|
|
344
|
+
if (!isPlainObject(value)) {
|
|
345
|
+
return null;
|
|
346
|
+
}
|
|
347
|
+
const findings = {};
|
|
348
|
+
for (const [category, severity] of Object.entries(value)) {
|
|
349
|
+
if (typeof severity !== "number" || !Number.isInteger(severity) || severity < 1 || severity > 5) {
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
352
|
+
if (!UNSAFE_OBJECT_KEYS.has(category)) {
|
|
353
|
+
findings[category] = severity;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return findings;
|
|
357
|
+
}
|
|
358
|
+
function derivedEntryId(value) {
|
|
359
|
+
return createHash("sha256").update(JSON.stringify(value)).digest("hex").slice(0, 12);
|
|
360
|
+
}
|
|
361
|
+
function parseEntryMap(raw, parseEntry) {
|
|
362
|
+
if (!isPlainObject(raw)) {
|
|
363
|
+
return {};
|
|
364
|
+
}
|
|
365
|
+
const entries = {};
|
|
366
|
+
for (const [name, value] of Object.entries(raw)) {
|
|
367
|
+
if (UNSAFE_OBJECT_KEYS.has(name)) {
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
const parsed = parseEntry(value);
|
|
371
|
+
if (parsed !== null) {
|
|
372
|
+
entries[name] = parsed;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return entries;
|
|
376
|
+
}
|
|
377
|
+
function parseApprovalEntry(value) {
|
|
378
|
+
if (!isPlainObject(value)) {
|
|
379
|
+
return null;
|
|
380
|
+
}
|
|
381
|
+
const decision = value.decision;
|
|
382
|
+
if (decision !== "allow" && decision !== "deny") {
|
|
383
|
+
return null;
|
|
384
|
+
}
|
|
385
|
+
if (typeof value.scriptsHash !== "string" || typeof value.approvedAt !== "string") {
|
|
386
|
+
return null;
|
|
387
|
+
}
|
|
388
|
+
const provenance = value.provenance;
|
|
389
|
+
if (provenance !== "prompt" && provenance !== "command" && provenance !== "imported-pnpm") {
|
|
390
|
+
return null;
|
|
391
|
+
}
|
|
392
|
+
return {
|
|
393
|
+
decision,
|
|
394
|
+
scriptsHash: value.scriptsHash,
|
|
395
|
+
hooks: parseHooks(value.hooks),
|
|
396
|
+
...(typeof value.approvedVersion === "string" ? { approvedVersion: value.approvedVersion } : {}),
|
|
397
|
+
...(typeof value.reason === "string" ? { reason: value.reason } : {}),
|
|
398
|
+
approvedAt: value.approvedAt,
|
|
399
|
+
provenance
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
function parseObservedEntry(value) {
|
|
403
|
+
if (!isPlainObject(value)) {
|
|
404
|
+
return null;
|
|
405
|
+
}
|
|
406
|
+
if (typeof value.version !== "string" || typeof value.scriptsHash !== "string" || typeof value.firstSeen !== "string") {
|
|
407
|
+
return null;
|
|
408
|
+
}
|
|
409
|
+
return {
|
|
410
|
+
version: value.version,
|
|
411
|
+
hooks: parseHooks(value.hooks),
|
|
412
|
+
scriptsHash: value.scriptsHash,
|
|
413
|
+
firstSeen: value.firstSeen
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
function parseHooks(raw) {
|
|
417
|
+
if (!Array.isArray(raw)) {
|
|
418
|
+
return [];
|
|
419
|
+
}
|
|
420
|
+
return SCRIPT_HOOKS.filter((hook) => raw.includes(hook));
|
|
421
|
+
}
|
|
422
|
+
function sortedRecord(record) {
|
|
423
|
+
const sorted = {};
|
|
424
|
+
for (const key of Object.keys(record).sort()) {
|
|
425
|
+
sorted[key] = record[key];
|
|
426
|
+
}
|
|
427
|
+
return sorted;
|
|
428
|
+
}
|
|
429
|
+
function unknownKeysOf(raw, known) {
|
|
430
|
+
const unknown = {};
|
|
431
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
432
|
+
if (!known.has(key) && !UNSAFE_OBJECT_KEYS.has(key)) {
|
|
433
|
+
unknown[key] = value;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
return unknown;
|
|
437
|
+
}
|
|
438
|
+
function isPlainObject(value) {
|
|
439
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
440
|
+
}
|
|
441
|
+
function emptyScriptApprovals() {
|
|
442
|
+
return { npm: {}, observed: {}, unknownKeys: {} };
|
|
443
|
+
}
|
|
444
|
+
function failOpen(path, failure) {
|
|
445
|
+
return { path, exists: true, readable: false, failure, raw: {}, decisions: [], cooldownExemptions: [], scriptApprovals: emptyScriptApprovals() };
|
|
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
|
}
|
|
@@ -140,6 +179,7 @@ function withOptionalDecisionFields(decision, verdict) {
|
|
|
140
179
|
...(verdict.dashboardUrl ? { dashboardUrl: verdict.dashboardUrl } : {}),
|
|
141
180
|
...(verdict.unauthenticated ? { unauthenticated: true } : {}),
|
|
142
181
|
...(verdict.resetsAt ? { resetsAt: verdict.resetsAt } : {}),
|
|
143
|
-
...(verdict.quotaBehavior ? { quotaBehavior: verdict.quotaBehavior } : {})
|
|
182
|
+
...(verdict.quotaBehavior ? { quotaBehavior: verdict.quotaBehavior } : {}),
|
|
183
|
+
...(verdict.cooldown ? { cooldown: verdict.cooldown } : {})
|
|
144
184
|
};
|
|
145
185
|
}
|