@westbayberry/dg 2.0.11 → 2.1.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/dist/api/analyze.js +5 -3
- package/dist/bin/dg.js +1 -1
- package/dist/commands/completion.js +2 -1
- package/dist/commands/config.js +11 -3
- package/dist/commands/decisions.js +155 -0
- package/dist/commands/explain.js +6 -2
- package/dist/commands/router.js +2 -0
- package/dist/commands/scan.js +2 -1
- package/dist/commands/status.js +5 -2
- package/dist/config/settings.js +144 -25
- package/dist/decisions/apply.js +128 -0
- package/dist/decisions/remember-prompt.js +97 -0
- package/dist/install-ui/block-render.js +21 -4
- package/dist/install-ui/prompt.js +14 -0
- package/dist/launcher/install-preflight.js +126 -13
- package/dist/launcher/preflight-prompt.js +29 -2
- package/dist/launcher/run.js +14 -3
- package/dist/policy/cooldown.js +104 -0
- package/dist/policy/evaluate.js +0 -15
- package/dist/presentation/provenance.js +23 -0
- package/dist/project/dgfile.js +307 -0
- package/dist/proxy/enforcement.js +2 -1
- package/dist/proxy/metadata-map.js +25 -1
- package/dist/proxy/server.js +31 -2
- package/dist/scan/command.js +35 -8
- package/dist/scan/render.js +35 -4
- package/dist/scan/scanner-report.js +31 -4
- package/dist/scan/staged.js +69 -10
- package/dist/scan-ui/LegacyApp.js +4 -4
- package/dist/scan-ui/components/InteractiveResultsView.js +64 -7
- package/dist/scan-ui/hooks/useScan.js +31 -3
- package/dist/scan-ui/shims.js +3 -0
- package/dist/scripts/detect.js +153 -0
- package/dist/scripts/gate.js +170 -0
- package/dist/scripts/rebuild.js +28 -0
- package/dist/setup/plan.js +36 -1
- package/dist/util/json-file.js +24 -0
- package/dist/util/tty-prompt.js +13 -6
- package/dist/verify/package-check.js +12 -0
- package/package.json +9 -1
package/dist/api/analyze.js
CHANGED
|
@@ -103,7 +103,8 @@ export async function analyzePackages(packages, options) {
|
|
|
103
103
|
scanId: options.scanId ?? randomUUID(),
|
|
104
104
|
fetchImpl: options.fetchImpl ?? fetch,
|
|
105
105
|
timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
106
|
-
...(options.signal ? { signal: options.signal } : {})
|
|
106
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
107
|
+
...(options.cooldown ? { cooldown: options.cooldown } : {})
|
|
107
108
|
};
|
|
108
109
|
const total = packages.length;
|
|
109
110
|
const batches = [];
|
|
@@ -178,7 +179,7 @@ function delay(ms) {
|
|
|
178
179
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
179
180
|
}
|
|
180
181
|
async function analyzeBatch(context, batch, onBatchProgress) {
|
|
181
|
-
const { url, token, deviceId, scanId, fetchImpl, timeoutMs, signal } = context;
|
|
182
|
+
const { url, token, deviceId, scanId, fetchImpl, timeoutMs, signal, cooldown } = context;
|
|
182
183
|
const controller = new AbortController();
|
|
183
184
|
let timedOut = false;
|
|
184
185
|
let silenceTimer;
|
|
@@ -207,7 +208,8 @@ async function analyzeBatch(context, batch, onBatchProgress) {
|
|
|
207
208
|
...(token ? { Authorization: `Bearer ${token}` } : {})
|
|
208
209
|
},
|
|
209
210
|
body: JSON.stringify({
|
|
210
|
-
packages: batch.map((entry) => ({ name: entry.name, version: entry.version }))
|
|
211
|
+
packages: batch.map((entry) => ({ name: entry.name, version: entry.version })),
|
|
212
|
+
...(cooldown ? { cooldown } : {})
|
|
211
213
|
}),
|
|
212
214
|
signal: controller.signal
|
|
213
215
|
});
|
package/dist/bin/dg.js
CHANGED
|
@@ -53,7 +53,7 @@ else if (audit.handled) {
|
|
|
53
53
|
writeCliResult(audit.result);
|
|
54
54
|
}
|
|
55
55
|
else {
|
|
56
|
-
const preflight = await maybePreflightInstallPrompt(args);
|
|
56
|
+
const preflight = await maybePreflightInstallPrompt(args, { decisionsCwd: process.cwd() });
|
|
57
57
|
if (preflight.handled) {
|
|
58
58
|
writeCliResult(preflight.result);
|
|
59
59
|
}
|
|
@@ -2,7 +2,7 @@ import { commandCatalog } from "./router.js";
|
|
|
2
2
|
import { packageManagerCommandNames } from "./wrap.js";
|
|
3
3
|
import { EXIT_USAGE } from "./types.js";
|
|
4
4
|
const SHELLS = ["bash", "zsh", "fish"];
|
|
5
|
-
const COMMON_FLAGS = ["--help", "--version", "--json", "--sarif", "--output", "--yes", "--check", "--staged", "--dg-force-install"];
|
|
5
|
+
const COMMON_FLAGS = ["--help", "--version", "--json", "--sarif", "--output", "--yes", "--check", "--staged", "--no-decisions", "--dg-force-install"];
|
|
6
6
|
const FISH_FLAG_DESCRIPTIONS = {
|
|
7
7
|
"--help": "Show help",
|
|
8
8
|
"--version": "Show version",
|
|
@@ -12,6 +12,7 @@ const FISH_FLAG_DESCRIPTIONS = {
|
|
|
12
12
|
"--yes": "Apply a confirmed mutation",
|
|
13
13
|
"--check": "Report without mutating",
|
|
14
14
|
"--staged": "Limit to staged files",
|
|
15
|
+
"--no-decisions": "Ignore dg.json decision memory",
|
|
15
16
|
"--dg-force-install": "Install despite a block, where policy allows"
|
|
16
17
|
};
|
|
17
18
|
function completionCommands() {
|
package/dist/commands/config.js
CHANGED
|
@@ -6,13 +6,21 @@ export const configCommand = {
|
|
|
6
6
|
usage: "dg config <get|set|unset|list> [key] [value] [--json]",
|
|
7
7
|
args: [
|
|
8
8
|
{ name: "<action>", summary: "list | get <key> | set <key> <value> | unset <key>." },
|
|
9
|
-
{ name: "[key]", summary: "e.g. policy.mode, gitHook.onWarn,
|
|
9
|
+
{ name: "[key]", summary: "e.g. policy.mode, gitHook.onWarn, cooldown.age, cooldown.exempt, api.baseUrl." }
|
|
10
10
|
],
|
|
11
11
|
flags: [{ flag: "--json", summary: "Machine-readable output for list/get." }],
|
|
12
|
-
examples: [
|
|
12
|
+
examples: [
|
|
13
|
+
"dg config list",
|
|
14
|
+
"dg config get policy.mode",
|
|
15
|
+
"dg config set gitHook.onWarn allow",
|
|
16
|
+
"dg config set cooldown.age 7d",
|
|
17
|
+
"dg config set cooldown.exempt '@myorg/*,typescript'",
|
|
18
|
+
"dg config unset api.baseUrl"
|
|
19
|
+
],
|
|
13
20
|
details: [
|
|
14
21
|
"Reads and writes user-global dg configuration only.",
|
|
15
|
-
"Project-local config and allowlists remain untrusted for install-time firewall enforcement by default."
|
|
22
|
+
"Project-local config and allowlists remain untrusted for install-time firewall enforcement by default.",
|
|
23
|
+
"cooldown.age quarantines registry releases younger than the window on new installs (default 24h; 0 disables; per-ecosystem overrides via cooldown.npm.age / cooldown.pypi.age / cooldown.cargo.age; DG_COOLDOWN_AGE overrides for CI)."
|
|
16
24
|
],
|
|
17
25
|
handler: (context) => configHandler(context.args)
|
|
18
26
|
};
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { recordDecisionEvents } from "../decisions/remember-prompt.js";
|
|
2
|
+
import { packageKey } from "../decisions/apply.js";
|
|
3
|
+
import { findProjectRoot, loadDgFile, removeDecisions, saveDgFile } from "../project/dgfile.js";
|
|
4
|
+
import { EXIT_USAGE } from "./types.js";
|
|
5
|
+
export const decisionsCommand = {
|
|
6
|
+
name: "decisions",
|
|
7
|
+
summary: "List or revoke remembered warn acceptances stored in dg.json.",
|
|
8
|
+
usage: "dg decisions [list] [--json] | dg decisions revoke <id|name[@version]>",
|
|
9
|
+
subcommands: [
|
|
10
|
+
{ name: "list", summary: "Show every remembered acceptance (default).", usage: "dg decisions list [--json]", details: [], handler: () => unreachable() },
|
|
11
|
+
{ name: "revoke", summary: "Remove an acceptance by id prefix, name, or name@version.", usage: "dg decisions revoke <id|name[@version]>", details: [], handler: () => unreachable() }
|
|
12
|
+
],
|
|
13
|
+
flags: [{ flag: "--json", summary: "Machine-readable listing." }],
|
|
14
|
+
examples: ["dg decisions", "dg decisions list --json", "dg decisions revoke left-pad@1.3.0"],
|
|
15
|
+
details: [
|
|
16
|
+
"Acceptances live in dg.json at the git root and only ever soften how an acknowledged warn is presented — a block verdict is never suppressible. Revoking makes the warn surface again on the next scan."
|
|
17
|
+
],
|
|
18
|
+
handler: (context) => runDecisionsCommand(context)
|
|
19
|
+
};
|
|
20
|
+
function unreachable() {
|
|
21
|
+
throw new Error("subcommand handled by the decisions router");
|
|
22
|
+
}
|
|
23
|
+
export function runDecisionsCommand(context, cwd = process.cwd(), env = process.env) {
|
|
24
|
+
const [first, ...rest] = context.args;
|
|
25
|
+
if (first === undefined || first === "list" || first === "--json") {
|
|
26
|
+
const json = first === "--json" || rest.includes("--json");
|
|
27
|
+
const extras = (first === undefined || first === "--json" ? [] : rest).filter((arg) => arg !== "--json");
|
|
28
|
+
if (extras.length > 0) {
|
|
29
|
+
return usage(`unexpected argument '${extras[0]}'`);
|
|
30
|
+
}
|
|
31
|
+
return listDecisions(cwd, env, json);
|
|
32
|
+
}
|
|
33
|
+
if (first === "revoke") {
|
|
34
|
+
const [selector, ...extra] = rest;
|
|
35
|
+
if (!selector || extra.length > 0) {
|
|
36
|
+
return usage("revoke takes exactly one <id|name[@version]>");
|
|
37
|
+
}
|
|
38
|
+
return revokeDecisions(selector, cwd, env);
|
|
39
|
+
}
|
|
40
|
+
return usage(`unknown subcommand '${first}'`);
|
|
41
|
+
}
|
|
42
|
+
function listDecisions(cwd, env, json) {
|
|
43
|
+
const located = locateDgFile(cwd, env);
|
|
44
|
+
if ("error" in located) {
|
|
45
|
+
return located.error;
|
|
46
|
+
}
|
|
47
|
+
const file = located.file;
|
|
48
|
+
if (json) {
|
|
49
|
+
return {
|
|
50
|
+
exitCode: 0,
|
|
51
|
+
stdout: `${JSON.stringify({ path: file.path, decisions: file.decisions.map((entry) => entryJson(entry)) }, null, 2)}\n`,
|
|
52
|
+
stderr: ""
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
if (file.decisions.length === 0) {
|
|
56
|
+
return { exitCode: 0, stdout: `No remembered acceptances in ${file.path}.\n`, stderr: "" };
|
|
57
|
+
}
|
|
58
|
+
const rows = file.decisions.map((entry) => [
|
|
59
|
+
entry.id.slice(0, 8),
|
|
60
|
+
`${entry.ecosystem}:${entry.name}@${scopeLabel(entry)}`,
|
|
61
|
+
findingsLabel(entry),
|
|
62
|
+
entry.acceptedBy,
|
|
63
|
+
entry.acceptedAt.slice(0, 10) || "-",
|
|
64
|
+
entry.expiresAt ? entry.expiresAt.slice(0, 10) : "-",
|
|
65
|
+
isExpired(entry) ? "expired" : "active"
|
|
66
|
+
]);
|
|
67
|
+
const header = ["ID", "PACKAGE", "ACCEPTED FINDINGS", "BY", "WHEN", "EXPIRES", "STATUS"];
|
|
68
|
+
const widths = header.map((label, column) => Math.max(label.length, ...rows.map((row) => row[column]?.length ?? 0)));
|
|
69
|
+
const renderRow = (row) => row.map((cell, column) => cell.padEnd(widths[column] ?? 0)).join(" ").trimEnd();
|
|
70
|
+
const lines = [
|
|
71
|
+
`Remembered acceptances in ${file.path} (warns only — blocks are never suppressible):`,
|
|
72
|
+
"",
|
|
73
|
+
renderRow(header),
|
|
74
|
+
...rows.map(renderRow),
|
|
75
|
+
"",
|
|
76
|
+
`Revoke with: dg decisions revoke <id|name[@version]>`
|
|
77
|
+
];
|
|
78
|
+
return { exitCode: 0, stdout: `${lines.join("\n")}\n`, stderr: "" };
|
|
79
|
+
}
|
|
80
|
+
function revokeDecisions(selector, cwd, env) {
|
|
81
|
+
const located = locateDgFile(cwd, env);
|
|
82
|
+
if ("error" in located) {
|
|
83
|
+
return located.error;
|
|
84
|
+
}
|
|
85
|
+
const file = located.file;
|
|
86
|
+
const matched = file.decisions.filter((entry) => matchesSelector(entry, selector));
|
|
87
|
+
if (matched.length === 0) {
|
|
88
|
+
return { exitCode: 1, stdout: "", stderr: `dg decisions: nothing matches '${selector}' in ${file.path}.\n` };
|
|
89
|
+
}
|
|
90
|
+
saveDgFile(removeDecisions(file, new Set(matched.map((entry) => entry.id))));
|
|
91
|
+
recordDecisionEvents("decision.revoked", matched.map((entry) => `${entry.ecosystem}:${packageKey(entry.name, scopeLabel(entry))}`), `revoked via dg decisions (${selector})`, env);
|
|
92
|
+
const lines = matched.map((entry) => `Revoked ${entry.ecosystem}:${entry.name}@${scopeLabel(entry)} (${entry.id.slice(0, 8)}) — the warn will surface again.`);
|
|
93
|
+
return { exitCode: 0, stdout: `${lines.join("\n")}\n`, stderr: "" };
|
|
94
|
+
}
|
|
95
|
+
function locateDgFile(cwd, env) {
|
|
96
|
+
const root = findProjectRoot(cwd, env);
|
|
97
|
+
if (!root) {
|
|
98
|
+
return { error: { exitCode: EXIT_USAGE, stdout: "", stderr: "dg decisions: not inside a git repository.\n" } };
|
|
99
|
+
}
|
|
100
|
+
const file = loadDgFile(root);
|
|
101
|
+
if (!file.exists) {
|
|
102
|
+
return { error: { exitCode: 0, stdout: `No dg.json at ${root} — nothing remembered yet.\n`, stderr: "" } };
|
|
103
|
+
}
|
|
104
|
+
if (!file.readable) {
|
|
105
|
+
return { error: { exitCode: 1, stdout: "", stderr: `dg decisions: cannot use ${file.path} — ${file.failure ?? "unreadable"}.\n` } };
|
|
106
|
+
}
|
|
107
|
+
return { file };
|
|
108
|
+
}
|
|
109
|
+
function matchesSelector(entry, selector) {
|
|
110
|
+
if (selector.length >= 4 && entry.id.startsWith(selector)) {
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
const at = selector.lastIndexOf("@");
|
|
114
|
+
if (at > 0) {
|
|
115
|
+
const name = selector.slice(0, at);
|
|
116
|
+
const version = selector.slice(at + 1);
|
|
117
|
+
return entry.name === name && entry.scope.kind === "exact" && entry.scope.version === version;
|
|
118
|
+
}
|
|
119
|
+
return entry.name === selector;
|
|
120
|
+
}
|
|
121
|
+
function scopeLabel(entry) {
|
|
122
|
+
return entry.scope.kind === "exact" ? entry.scope.version : "*";
|
|
123
|
+
}
|
|
124
|
+
function findingsLabel(entry) {
|
|
125
|
+
const pairs = Object.entries(entry.findings).map(([category, severity]) => `${category}:${severity}`);
|
|
126
|
+
return pairs.length > 0 ? pairs.sort().join(",") : "(action-only warn)";
|
|
127
|
+
}
|
|
128
|
+
function isExpired(entry) {
|
|
129
|
+
if (!entry.expiresAt) {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
const expiry = Date.parse(entry.expiresAt);
|
|
133
|
+
return !Number.isFinite(expiry) || expiry <= Date.now();
|
|
134
|
+
}
|
|
135
|
+
function entryJson(entry) {
|
|
136
|
+
return {
|
|
137
|
+
id: entry.id,
|
|
138
|
+
ecosystem: entry.ecosystem,
|
|
139
|
+
name: entry.name,
|
|
140
|
+
scope: entry.scope,
|
|
141
|
+
findings: entry.findings,
|
|
142
|
+
reason: entry.reason,
|
|
143
|
+
acceptedBy: entry.acceptedBy,
|
|
144
|
+
acceptedAt: entry.acceptedAt,
|
|
145
|
+
...(entry.expiresAt ? { expiresAt: entry.expiresAt } : {}),
|
|
146
|
+
status: isExpired(entry) ? "expired" : "active"
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
function usage(message) {
|
|
150
|
+
return {
|
|
151
|
+
exitCode: EXIT_USAGE,
|
|
152
|
+
stdout: "",
|
|
153
|
+
stderr: `dg decisions: ${message}. Usage: ${decisionsCommand.usage}\n`
|
|
154
|
+
};
|
|
155
|
+
}
|
package/dist/commands/explain.js
CHANGED
|
@@ -3,7 +3,11 @@ import { closestCommand } from "./suggest.js";
|
|
|
3
3
|
export const EXPLANATIONS = {
|
|
4
4
|
"npm-lifecycle-script": {
|
|
5
5
|
what: "The package declares an install lifecycle script (preinstall/install/postinstall/prepare) that runs arbitrary code on your machine during 'npm install'.",
|
|
6
|
-
next: "Review the script in the package source
|
|
6
|
+
next: "Review the script in the package source and prefer a version without install scripts. Raw --ignore-scripts also skips your own project's lifecycle and leaves native modules unbuilt (recover with 'npm rebuild <pkg>'); see 'dg explain script-gate' for how dg tracks this layer."
|
|
7
|
+
},
|
|
8
|
+
"script-gate": {
|
|
9
|
+
what: "dg watches protected npm and yarn-classic installs and reports which installed packages ran install lifecycle scripts (preinstall/install/postinstall or an implicit node-gyp build) — the layer most npm supply-chain attacks execute through. Observe mode reports without blocking; when a dg.json exists at the project root the observations are recorded under scriptApprovals.observed. pnpm already blocks dependency build scripts natively ('pnpm approve-builds'); pip has no install-script analog, so there is nothing to gate there.",
|
|
10
|
+
next: "Review the reported packages. Per-package script approvals with dg.json persistence and 'npm rebuild' recovery ship in an upcoming release; 'dg config set scriptGate.mode off' silences the report."
|
|
7
11
|
},
|
|
8
12
|
"unverified-network-dependency": {
|
|
9
13
|
what: "A dependency is fetched from a raw URL (http/git) instead of the registry, so it has no registry identity or integrity to verify.",
|
|
@@ -67,7 +71,7 @@ export const EXPLANATIONS = {
|
|
|
67
71
|
},
|
|
68
72
|
lifecycle: {
|
|
69
73
|
what: "The scanner flagged an install lifecycle script (preinstall/install/postinstall) that runs arbitrary code during install.",
|
|
70
|
-
next: "Review the script in the package source
|
|
74
|
+
next: "Review the script in the package source before installing; see 'dg explain script-gate' for how dg tracks install scripts and why raw --ignore-scripts has no recovery story."
|
|
71
75
|
},
|
|
72
76
|
"install-script": {
|
|
73
77
|
what: "The scanner found an install-time script in this package that executes code on your machine during install.",
|
package/dist/commands/router.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { configCommand } from "./config.js";
|
|
2
2
|
import { completionCommand } from "./completion.js";
|
|
3
3
|
import { auditCommand } from "./audit.js";
|
|
4
|
+
import { decisionsCommand } from "./decisions.js";
|
|
4
5
|
import { doctorCommand } from "./doctor.js";
|
|
5
6
|
import { explainCommand } from "./explain.js";
|
|
6
7
|
import { guardCommitCommand } from "./guard-commit.js";
|
|
@@ -24,6 +25,7 @@ export const commandCatalog = [
|
|
|
24
25
|
verifyCommand,
|
|
25
26
|
setupCommand,
|
|
26
27
|
guardCommitCommand,
|
|
28
|
+
decisionsCommand,
|
|
27
29
|
uninstallCommand,
|
|
28
30
|
doctorCommand,
|
|
29
31
|
statusCommand,
|
package/dist/commands/scan.js
CHANGED
|
@@ -8,7 +8,8 @@ export const scanCommand = {
|
|
|
8
8
|
{ flag: "--json", summary: "Machine-readable JSON report." },
|
|
9
9
|
{ flag: "--sarif", summary: "SARIF report for code-scanning tools." },
|
|
10
10
|
{ flag: "--output", value: "<path>", summary: "Write the report to a file instead of stdout (alias -o)." },
|
|
11
|
-
{ flag: "--staged", summary: "Scan only the git-staged lockfile changes (what dg guard-commit runs)." }
|
|
11
|
+
{ flag: "--staged", summary: "Scan only the git-staged lockfile changes (what dg guard-commit runs)." },
|
|
12
|
+
{ flag: "--no-decisions", summary: "Ignore acceptances remembered in dg.json (see dg decisions)." }
|
|
12
13
|
],
|
|
13
14
|
examples: ["dg scan", "dg scan ./packages/api", "dg scan --json -o scan.json", "dg scan --staged"],
|
|
14
15
|
details: [
|
package/dist/commands/status.js
CHANGED
|
@@ -3,6 +3,7 @@ import { envAuthToken } from "../auth/env-token.js";
|
|
|
3
3
|
import { fetchAccountStatus } from "../auth/device-login.js";
|
|
4
4
|
import { formatUsage } from "../scan-ui/format-helpers.js";
|
|
5
5
|
import { loadUserConfig } from "../config/settings.js";
|
|
6
|
+
import { describeCooldownSettings } from "../policy/cooldown.js";
|
|
6
7
|
import { resolvePresentation } from "../presentation/mode.js";
|
|
7
8
|
import { createTheme } from "../presentation/theme.js";
|
|
8
9
|
import { currentShellActivation, doctorReport } from "../setup/plan.js";
|
|
@@ -49,14 +50,15 @@ async function buildStatusReport(io) {
|
|
|
49
50
|
const checks = doctorReport().checks;
|
|
50
51
|
const checkPassed = (name) => checks.find((check) => check.name === name)?.status === "pass";
|
|
51
52
|
const auth = safeAuthStatus();
|
|
52
|
-
const
|
|
53
|
+
const config = loadUserConfig();
|
|
53
54
|
return {
|
|
54
55
|
account: auth,
|
|
55
56
|
usage: await fetchStatusUsage(auth.connected, io),
|
|
56
57
|
protection: { shims: checkPassed("shims"), path: checkPassed("path"), configured: checkPassed("shell-rc") },
|
|
57
58
|
reloadHint: currentShellActivation(),
|
|
58
59
|
commitGuard: safeCommitGuard(),
|
|
59
|
-
policy: { mode: policy.mode, trustProjectAllowlists: policy.trustProjectAllowlists }
|
|
60
|
+
policy: { mode: config.policy.mode, trustProjectAllowlists: config.policy.trustProjectAllowlists },
|
|
61
|
+
cooldown: describeCooldownSettings(config, process.env)
|
|
60
62
|
};
|
|
61
63
|
}
|
|
62
64
|
async function fetchStatusUsage(connected, io) {
|
|
@@ -123,6 +125,7 @@ function renderStatus(report, theme) {
|
|
|
123
125
|
lines.push(` Commit guard ${guard}`);
|
|
124
126
|
}
|
|
125
127
|
lines.push(` Policy ${report.policy.mode} mode; project allowlists ${report.policy.trustProjectAllowlists ? "trusted" : "untrusted"}`);
|
|
128
|
+
lines.push(` Cooldown ${report.cooldown === "off" ? "off — new releases install immediately" : `${report.cooldown} release-age gate on new installs`}`);
|
|
126
129
|
lines.push("");
|
|
127
130
|
lines.push(`Full diagnostics: ${theme.paint("accent", "dg doctor")}`);
|
|
128
131
|
return `${lines.join("\n")}\n`;
|
package/dist/config/settings.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { existsSync,
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { writeJsonAtomic } from "../util/json-file.js";
|
|
4
4
|
import { acquireLockSyncWithRetry, resolveDgPaths } from "../state/index.js";
|
|
5
5
|
export const CONFIG_KEYS = Object.freeze([
|
|
6
6
|
"api.baseUrl",
|
|
@@ -9,8 +9,16 @@ export const CONFIG_KEYS = Object.freeze([
|
|
|
9
9
|
"policy.trustProjectAllowlists",
|
|
10
10
|
"policy.allowForceOverride",
|
|
11
11
|
"policy.scriptHardening",
|
|
12
|
+
"scriptGate.mode",
|
|
13
|
+
"scriptGate.observe",
|
|
12
14
|
"gitHook.onWarn",
|
|
13
15
|
"gitHook.onIncomplete",
|
|
16
|
+
"cooldown.age",
|
|
17
|
+
"cooldown.npm.age",
|
|
18
|
+
"cooldown.pypi.age",
|
|
19
|
+
"cooldown.cargo.age",
|
|
20
|
+
"cooldown.onUnknown",
|
|
21
|
+
"cooldown.exempt",
|
|
14
22
|
"audit.upload",
|
|
15
23
|
"telemetry.enabled",
|
|
16
24
|
"webhooks.enabled"
|
|
@@ -29,10 +37,22 @@ export const DEFAULT_CONFIG = Object.freeze({
|
|
|
29
37
|
allowForceOverride: true,
|
|
30
38
|
scriptHardening: false
|
|
31
39
|
},
|
|
40
|
+
scriptGate: {
|
|
41
|
+
mode: "observe",
|
|
42
|
+
observe: false
|
|
43
|
+
},
|
|
32
44
|
gitHook: {
|
|
33
45
|
onWarn: "prompt",
|
|
34
46
|
onIncomplete: "allow"
|
|
35
47
|
},
|
|
48
|
+
cooldown: {
|
|
49
|
+
age: "24h",
|
|
50
|
+
npmAge: "",
|
|
51
|
+
pypiAge: "",
|
|
52
|
+
cargoAge: "",
|
|
53
|
+
onUnknown: "allow",
|
|
54
|
+
exempt: ""
|
|
55
|
+
},
|
|
36
56
|
audit: {
|
|
37
57
|
upload: false
|
|
38
58
|
},
|
|
@@ -113,12 +133,36 @@ export function getConfigValue(config, key) {
|
|
|
113
133
|
if (key === "policy.scriptHardening") {
|
|
114
134
|
return String(config.policy.scriptHardening);
|
|
115
135
|
}
|
|
136
|
+
if (key === "scriptGate.mode") {
|
|
137
|
+
return config.scriptGate.mode;
|
|
138
|
+
}
|
|
139
|
+
if (key === "scriptGate.observe") {
|
|
140
|
+
return String(config.scriptGate.observe);
|
|
141
|
+
}
|
|
116
142
|
if (key === "gitHook.onWarn") {
|
|
117
143
|
return config.gitHook.onWarn;
|
|
118
144
|
}
|
|
119
145
|
if (key === "gitHook.onIncomplete") {
|
|
120
146
|
return config.gitHook.onIncomplete;
|
|
121
147
|
}
|
|
148
|
+
if (key === "cooldown.age") {
|
|
149
|
+
return config.cooldown.age;
|
|
150
|
+
}
|
|
151
|
+
if (key === "cooldown.npm.age") {
|
|
152
|
+
return config.cooldown.npmAge;
|
|
153
|
+
}
|
|
154
|
+
if (key === "cooldown.pypi.age") {
|
|
155
|
+
return config.cooldown.pypiAge;
|
|
156
|
+
}
|
|
157
|
+
if (key === "cooldown.cargo.age") {
|
|
158
|
+
return config.cooldown.cargoAge;
|
|
159
|
+
}
|
|
160
|
+
if (key === "cooldown.onUnknown") {
|
|
161
|
+
return config.cooldown.onUnknown;
|
|
162
|
+
}
|
|
163
|
+
if (key === "cooldown.exempt") {
|
|
164
|
+
return config.cooldown.exempt;
|
|
165
|
+
}
|
|
122
166
|
if (key === "audit.upload") {
|
|
123
167
|
return String(config.audit.upload);
|
|
124
168
|
}
|
|
@@ -168,6 +212,24 @@ export function setConfigValue(config, key, rawValue) {
|
|
|
168
212
|
if (key === "policy.scriptHardening") {
|
|
169
213
|
return withPolicyBoolean(config, "scriptHardening", rawValue);
|
|
170
214
|
}
|
|
215
|
+
if (key === "scriptGate.mode") {
|
|
216
|
+
return {
|
|
217
|
+
...config,
|
|
218
|
+
scriptGate: {
|
|
219
|
+
...config.scriptGate,
|
|
220
|
+
mode: parseScriptGateMode(rawValue)
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
if (key === "scriptGate.observe") {
|
|
225
|
+
return {
|
|
226
|
+
...config,
|
|
227
|
+
scriptGate: {
|
|
228
|
+
...config.scriptGate,
|
|
229
|
+
observe: parseBoolean(rawValue, key)
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
}
|
|
171
233
|
if (key === "gitHook.onWarn") {
|
|
172
234
|
return {
|
|
173
235
|
...config,
|
|
@@ -186,6 +248,24 @@ export function setConfigValue(config, key, rawValue) {
|
|
|
186
248
|
}
|
|
187
249
|
};
|
|
188
250
|
}
|
|
251
|
+
if (key === "cooldown.age") {
|
|
252
|
+
return withCooldown(config, { age: parseCooldownAge(rawValue, key, false) });
|
|
253
|
+
}
|
|
254
|
+
if (key === "cooldown.npm.age") {
|
|
255
|
+
return withCooldown(config, { npmAge: parseCooldownAge(rawValue, key, true) });
|
|
256
|
+
}
|
|
257
|
+
if (key === "cooldown.pypi.age") {
|
|
258
|
+
return withCooldown(config, { pypiAge: parseCooldownAge(rawValue, key, true) });
|
|
259
|
+
}
|
|
260
|
+
if (key === "cooldown.cargo.age") {
|
|
261
|
+
return withCooldown(config, { cargoAge: parseCooldownAge(rawValue, key, true) });
|
|
262
|
+
}
|
|
263
|
+
if (key === "cooldown.onUnknown") {
|
|
264
|
+
return withCooldown(config, { onUnknown: parseCooldownOnUnknown(rawValue) });
|
|
265
|
+
}
|
|
266
|
+
if (key === "cooldown.exempt") {
|
|
267
|
+
return withCooldown(config, { exempt: parseCooldownExempt(rawValue) });
|
|
268
|
+
}
|
|
189
269
|
if (key === "audit.upload") {
|
|
190
270
|
return {
|
|
191
271
|
...config,
|
|
@@ -225,10 +305,13 @@ function normalizeConfig(raw) {
|
|
|
225
305
|
const api = fieldObject(raw, "api");
|
|
226
306
|
const org = fieldObject(raw, "org");
|
|
227
307
|
const policy = fieldObject(raw, "policy");
|
|
308
|
+
const scriptGate = fieldObject(raw, "scriptGate");
|
|
228
309
|
const gitHook = fieldObject(raw, "gitHook");
|
|
310
|
+
const cooldown = fieldObject(raw, "cooldown");
|
|
229
311
|
const audit = fieldObject(raw, "audit");
|
|
230
312
|
const telemetry = fieldObject(raw, "telemetry");
|
|
231
313
|
const webhooks = fieldObject(raw, "webhooks");
|
|
314
|
+
const scriptHardening = fieldBoolean(policy, "policy.scriptHardening", "scriptHardening") ?? DEFAULT_CONFIG.policy.scriptHardening;
|
|
232
315
|
return {
|
|
233
316
|
version: 1,
|
|
234
317
|
api: {
|
|
@@ -241,12 +324,24 @@ function normalizeConfig(raw) {
|
|
|
241
324
|
mode: parsePolicyMode(fieldString(policy, "policy.mode", "mode") ?? DEFAULT_CONFIG.policy.mode),
|
|
242
325
|
trustProjectAllowlists: fieldBoolean(policy, "policy.trustProjectAllowlists", "trustProjectAllowlists") ?? DEFAULT_CONFIG.policy.trustProjectAllowlists,
|
|
243
326
|
allowForceOverride: fieldBoolean(policy, "policy.allowForceOverride", "allowForceOverride") ?? DEFAULT_CONFIG.policy.allowForceOverride,
|
|
244
|
-
scriptHardening
|
|
327
|
+
scriptHardening
|
|
328
|
+
},
|
|
329
|
+
scriptGate: {
|
|
330
|
+
mode: parseScriptGateMode(fieldString(scriptGate, "scriptGate.mode", "mode") ?? (scriptHardening ? "enforce" : DEFAULT_CONFIG.scriptGate.mode)),
|
|
331
|
+
observe: fieldBoolean(scriptGate, "scriptGate.observe", "observe") ?? DEFAULT_CONFIG.scriptGate.observe
|
|
245
332
|
},
|
|
246
333
|
gitHook: {
|
|
247
334
|
onWarn: parseOnWarn(fieldString(gitHook, "gitHook.onWarn", "onWarn") ?? DEFAULT_CONFIG.gitHook.onWarn),
|
|
248
335
|
onIncomplete: parseOnIncomplete(fieldString(gitHook, "gitHook.onIncomplete", "onIncomplete") ?? DEFAULT_CONFIG.gitHook.onIncomplete)
|
|
249
336
|
},
|
|
337
|
+
cooldown: {
|
|
338
|
+
age: parseCooldownAge(fieldString(cooldown, "cooldown.age", "age") ?? DEFAULT_CONFIG.cooldown.age, "cooldown.age", false),
|
|
339
|
+
npmAge: parseCooldownAge(fieldString(cooldown, "cooldown.npm.age", "npmAge") ?? DEFAULT_CONFIG.cooldown.npmAge, "cooldown.npm.age", true),
|
|
340
|
+
pypiAge: parseCooldownAge(fieldString(cooldown, "cooldown.pypi.age", "pypiAge") ?? DEFAULT_CONFIG.cooldown.pypiAge, "cooldown.pypi.age", true),
|
|
341
|
+
cargoAge: parseCooldownAge(fieldString(cooldown, "cooldown.cargo.age", "cargoAge") ?? DEFAULT_CONFIG.cooldown.cargoAge, "cooldown.cargo.age", true),
|
|
342
|
+
onUnknown: parseCooldownOnUnknown(fieldString(cooldown, "cooldown.onUnknown", "onUnknown") ?? DEFAULT_CONFIG.cooldown.onUnknown),
|
|
343
|
+
exempt: parseCooldownExempt(fieldString(cooldown, "cooldown.exempt", "exempt") ?? DEFAULT_CONFIG.cooldown.exempt)
|
|
344
|
+
},
|
|
250
345
|
audit: {
|
|
251
346
|
upload: fieldBoolean(audit, "audit.upload", "upload") ?? DEFAULT_CONFIG.audit.upload
|
|
252
347
|
},
|
|
@@ -321,6 +416,12 @@ function parsePolicyMode(value) {
|
|
|
321
416
|
}
|
|
322
417
|
throw new ConfigError("policy.mode must be one of: off, warn, block, strict");
|
|
323
418
|
}
|
|
419
|
+
function parseScriptGateMode(value) {
|
|
420
|
+
if (value === "observe" || value === "enforce" || value === "off") {
|
|
421
|
+
return value;
|
|
422
|
+
}
|
|
423
|
+
throw new ConfigError("scriptGate.mode must be one of: observe, enforce, off");
|
|
424
|
+
}
|
|
324
425
|
function parseOnWarn(value) {
|
|
325
426
|
if (value === "prompt" || value === "allow" || value === "block") {
|
|
326
427
|
return value;
|
|
@@ -333,6 +434,45 @@ function parseOnIncomplete(value) {
|
|
|
333
434
|
}
|
|
334
435
|
throw new ConfigError("gitHook.onIncomplete must be one of: allow, block");
|
|
335
436
|
}
|
|
437
|
+
const COOLDOWN_AGE_RE = /^([1-9]\d{0,3})(h|d)$/;
|
|
438
|
+
const COOLDOWN_EXEMPT_ENTRY_RE = /^[@A-Za-z0-9][@A-Za-z0-9._/-]*\*?$/;
|
|
439
|
+
export function parseCooldownAge(value, field, allowEmpty) {
|
|
440
|
+
const trimmed = value.trim();
|
|
441
|
+
if (trimmed === "" && allowEmpty) {
|
|
442
|
+
return "";
|
|
443
|
+
}
|
|
444
|
+
if (trimmed === "0" || trimmed === "off") {
|
|
445
|
+
return "0";
|
|
446
|
+
}
|
|
447
|
+
if (COOLDOWN_AGE_RE.test(trimmed)) {
|
|
448
|
+
return trimmed;
|
|
449
|
+
}
|
|
450
|
+
throw new ConfigError(`${field} must be a duration like 24h or 7d, or 0 to disable${allowEmpty ? ", or empty to inherit cooldown.age" : ""}`);
|
|
451
|
+
}
|
|
452
|
+
function parseCooldownOnUnknown(value) {
|
|
453
|
+
if (value === "allow" || value === "block") {
|
|
454
|
+
return value;
|
|
455
|
+
}
|
|
456
|
+
throw new ConfigError("cooldown.onUnknown must be one of: allow, block");
|
|
457
|
+
}
|
|
458
|
+
function parseCooldownExempt(value) {
|
|
459
|
+
const entries = value.split(",").map((entry) => entry.trim()).filter(Boolean);
|
|
460
|
+
for (const entry of entries) {
|
|
461
|
+
if (!COOLDOWN_EXEMPT_ENTRY_RE.test(entry)) {
|
|
462
|
+
throw new ConfigError(`cooldown.exempt entry '${entry}' is not a valid package name or pattern (use names or globs like @org/*)`);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
return entries.join(",");
|
|
466
|
+
}
|
|
467
|
+
function withCooldown(config, patch) {
|
|
468
|
+
return {
|
|
469
|
+
...config,
|
|
470
|
+
cooldown: {
|
|
471
|
+
...config.cooldown,
|
|
472
|
+
...patch
|
|
473
|
+
}
|
|
474
|
+
};
|
|
475
|
+
}
|
|
336
476
|
function parseBoolean(value, field) {
|
|
337
477
|
if (value === "true") {
|
|
338
478
|
return true;
|
|
@@ -359,24 +499,3 @@ function parseUrl(value) {
|
|
|
359
499
|
throw new ConfigError("api.baseUrl must be an absolute URL");
|
|
360
500
|
}
|
|
361
501
|
}
|
|
362
|
-
function writeJsonAtomic(path, value) {
|
|
363
|
-
mkdirSync(dirname(path), {
|
|
364
|
-
recursive: true,
|
|
365
|
-
mode: 0o700
|
|
366
|
-
});
|
|
367
|
-
const tempPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
368
|
-
try {
|
|
369
|
-
writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, {
|
|
370
|
-
encoding: "utf8",
|
|
371
|
-
flag: "wx",
|
|
372
|
-
mode: 0o600
|
|
373
|
-
});
|
|
374
|
-
renameSync(tempPath, path);
|
|
375
|
-
}
|
|
376
|
-
catch (error) {
|
|
377
|
-
rmSync(tempPath, {
|
|
378
|
-
force: true
|
|
379
|
-
});
|
|
380
|
-
throw error;
|
|
381
|
-
}
|
|
382
|
-
}
|