@westbayberry/dg 2.3.2 → 2.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +95 -208
- package/dist/agents/copilot-cli.js +7 -2
- package/dist/agents/persistence.js +83 -8
- package/dist/agents/registry.js +5 -2
- package/dist/agents/routing.js +31 -14
- package/dist/api/analyze.js +138 -41
- package/dist/audit/deep.js +2 -1
- package/dist/audit/detectors.js +23 -2
- package/dist/audit/rules.js +4 -1
- package/dist/audit-ui/export.js +5 -4
- package/dist/auth/device-login.js +33 -11
- package/dist/auth/login-app.js +17 -12
- package/dist/auth/store.js +65 -9
- package/dist/bin/dg.js +1 -1
- package/dist/commands/audit.js +12 -20
- package/dist/commands/{guard-commit.js → commit-guard.js} +12 -12
- package/dist/commands/cooldown.js +2 -1
- package/dist/commands/decisions.js +8 -7
- package/dist/commands/help.js +11 -3
- package/dist/commands/licenses.js +17 -8
- package/dist/commands/login.js +58 -21
- package/dist/commands/router.js +12 -2
- package/dist/commands/sbom.js +20 -5
- package/dist/commands/scan.js +5 -4
- package/dist/commands/service.js +1 -1
- package/dist/commands/setup.js +12 -12
- package/dist/commands/status.js +4 -4
- package/dist/commands/uninstall.js +1 -1
- package/dist/commands/update.js +20 -0
- package/dist/commands/verify.js +4 -4
- package/dist/config/settings.js +81 -12
- package/dist/export-ui/ExportDialog.js +1 -1
- package/dist/gate/cooldown-request.js +22 -0
- package/dist/gate/egress-guard.js +185 -0
- package/dist/gate/host-match.js +48 -0
- package/dist/gate/verdict-core.js +101 -0
- package/dist/gate/verdict-fetch.js +214 -0
- package/dist/launcher/agent-check.js +1096 -42
- package/dist/launcher/agent-hook-exec.js +8 -1
- package/dist/launcher/agent-hook-io.js +5 -3
- package/dist/launcher/classify.js +110 -26
- package/dist/launcher/env.js +84 -18
- package/dist/launcher/install-preflight.js +26 -3
- package/dist/launcher/live-install.js +4 -2
- package/dist/launcher/manifest-screen.js +88 -7
- package/dist/launcher/output-redaction.js +153 -15
- package/dist/launcher/preflight-prompt.js +1 -1
- package/dist/launcher/resolve-real-binary.js +8 -3
- package/dist/launcher/run.js +237 -40
- package/dist/origin/artifact-cache.js +55 -0
- package/dist/origin/cargo-config.js +12 -0
- package/dist/origin/gate-mode.js +7 -0
- package/dist/origin/platform-paths.js +14 -0
- package/dist/origin/precedence.js +345 -0
- package/dist/origin/server.js +611 -0
- package/dist/origin/spike-worker.js +35 -0
- package/dist/origin/worker.js +116 -0
- package/dist/presentation/package-page.js +21 -1
- package/dist/presentation/safe-version.js +11 -0
- package/dist/presentation/sarif.js +109 -0
- package/dist/project/dgfile.js +54 -17
- package/dist/project/override-trust.js +0 -0
- package/dist/proxy/enforcement.js +27 -7
- package/dist/proxy/metadata-map.js +33 -7
- package/dist/proxy/preverified.js +0 -3
- package/dist/proxy/server.js +104 -446
- package/dist/proxy/upstream-proxy.js +23 -4
- package/dist/proxy/worker.js +3 -2
- package/dist/publish-set/collect.js +6 -0
- package/dist/runtime/fatal.js +2 -1
- package/dist/runtime/first-run.js +5 -1
- package/dist/runtime/node-version.js +51 -0
- package/dist/runtime/nudges.js +27 -1
- package/dist/sbom/cyclonedx.js +120 -33
- package/dist/sbom/graph.js +236 -0
- package/dist/sbom/spdx.js +53 -0
- package/dist/sbom-ui/SbomApp.js +5 -3
- package/dist/sbom-ui/inventory.js +8 -10
- package/dist/scan/analyze-worker.js +3 -1
- package/dist/scan/collect.js +38 -8
- package/dist/scan/command.js +77 -27
- package/dist/scan/discovery.js +18 -7
- package/dist/scan/installed.js +202 -0
- package/dist/scan/manifest-coverage.js +128 -0
- package/dist/scan/render.js +125 -40
- package/dist/scan/scanner-report.js +92 -22
- package/dist/scan/staged.js +68 -15
- package/dist/scan/types.js +7 -1
- package/dist/scan-ui/LegacyApp.js +3 -3
- package/dist/scan-ui/components/InteractiveResultsView.js +17 -14
- package/dist/scan-ui/hooks/useScan.js +37 -11
- package/dist/scan-ui/launch.js +2 -2
- package/dist/scan-ui/logo.js +6 -2
- package/dist/scan-ui/shims.js +6 -6
- package/dist/scripts/gate.js +53 -35
- package/dist/security/csv.js +9 -0
- package/dist/security/sanitize.js +11 -2
- package/dist/service/state.js +120 -37
- package/dist/service/trust-refresh.js +12 -1
- package/dist/service/trust-store.js +36 -5
- package/dist/service/worker.js +5 -7
- package/dist/setup/git-hook.js +2 -2
- package/dist/setup/install-method.js +36 -0
- package/dist/setup/plan.js +119 -57
- package/dist/setup/uninstall-standalone.js +7 -2
- package/dist/setup-ui/gate.js +1 -8
- package/dist/setup-ui/offer.js +14 -7
- package/dist/setup-ui/selector.js +1 -1
- package/dist/setup-ui/wizard.js +73 -78
- package/dist/standalone/uninstall.mjs +1114 -386
- package/dist/state/cleanup-registry.js +14 -3
- package/dist/state/cooldown-held.js +36 -13
- package/dist/state/locks.js +239 -34
- package/dist/state/sessions.js +2 -2
- package/dist/util/sh-escape.js +6 -0
- package/dist/util/tty-prompt.js +13 -4
- package/dist/verify/local.js +60 -10
- package/dist/verify/package-check.js +35 -49
- package/dist/verify/preflight.js +59 -33
- package/dist/verify/render.js +11 -12
- package/npm-shrinkwrap.json +714 -213
- package/package.json +4 -2
package/dist/scan-ui/shims.js
CHANGED
|
@@ -26,15 +26,15 @@ export function accountHeaderLine(usageTier, env = process.env) {
|
|
|
26
26
|
export function effectiveScanAction(raw, effective, mode) {
|
|
27
27
|
return mode === "strict" || effective === undefined ? raw : effective;
|
|
28
28
|
}
|
|
29
|
-
export function
|
|
29
|
+
export function verdictExitCode(action, failOn) {
|
|
30
|
+
if (action === "analysis_incomplete") {
|
|
31
|
+
return 4;
|
|
32
|
+
}
|
|
30
33
|
if (action === "block") {
|
|
31
|
-
return 2;
|
|
34
|
+
return failOn === "never" ? 0 : 2;
|
|
32
35
|
}
|
|
33
36
|
if (action === "warn") {
|
|
34
|
-
return
|
|
35
|
-
}
|
|
36
|
-
if (action === "analysis_incomplete") {
|
|
37
|
-
return 4;
|
|
37
|
+
return failOn === "warn" ? 1 : 0;
|
|
38
38
|
}
|
|
39
39
|
return 0;
|
|
40
40
|
}
|
package/dist/scripts/gate.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { loadUserConfig } from "../config/settings.js";
|
|
2
2
|
import { resolvePresentation } from "../presentation/mode.js";
|
|
3
3
|
import { createTheme } from "../presentation/theme.js";
|
|
4
|
-
import { loadDgFile, saveDgFile } from "../project/dgfile.js";
|
|
4
|
+
import { dgFilePath, loadDgFile, saveDgFile, withDgFileLock } from "../project/dgfile.js";
|
|
5
5
|
import { detectPnpmIgnoredBuilds, detectScriptWanters } from "./detect.js";
|
|
6
6
|
export function evaluateScriptGate(wanters, approvals) {
|
|
7
7
|
const approved = [];
|
|
@@ -49,33 +49,38 @@ export function applyScriptDecisions(file, decisions, now) {
|
|
|
49
49
|
};
|
|
50
50
|
}
|
|
51
51
|
export function recordScriptObservations(options) {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
return { written: false, path: file.path };
|
|
52
|
+
if (options.wanters.length === 0) {
|
|
53
|
+
return { written: false, path: dgFilePath(options.projectDir) };
|
|
55
54
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
if (existing &&
|
|
61
|
-
existing.version === wanter.version &&
|
|
62
|
-
existing.scriptsHash === wanter.scriptsHash &&
|
|
63
|
-
sameHooks(existing.hooks, wanter.hooks)) {
|
|
64
|
-
continue;
|
|
55
|
+
return withDgFileLock(options.projectDir, options.env, () => {
|
|
56
|
+
const file = loadDgFile(options.projectDir);
|
|
57
|
+
if (!file.readable || (!file.exists && !options.createIfMissing)) {
|
|
58
|
+
return { written: false, path: file.path };
|
|
65
59
|
}
|
|
66
|
-
observed
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
60
|
+
const observed = { ...file.scriptApprovals.observed };
|
|
61
|
+
let changed = false;
|
|
62
|
+
for (const wanter of options.wanters) {
|
|
63
|
+
const existing = observed[wanter.name];
|
|
64
|
+
if (existing &&
|
|
65
|
+
existing.version === wanter.version &&
|
|
66
|
+
existing.scriptsHash === wanter.scriptsHash &&
|
|
67
|
+
sameHooks(existing.hooks, wanter.hooks)) {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
observed[wanter.name] = {
|
|
71
|
+
version: wanter.version,
|
|
72
|
+
hooks: wanter.hooks,
|
|
73
|
+
scriptsHash: wanter.scriptsHash,
|
|
74
|
+
firstSeen: existing ? existing.firstSeen : options.now.toISOString()
|
|
75
|
+
};
|
|
76
|
+
changed = true;
|
|
77
|
+
}
|
|
78
|
+
if (!changed) {
|
|
79
|
+
return { written: false, path: file.path };
|
|
80
|
+
}
|
|
81
|
+
saveDgFile({ ...file, scriptApprovals: { ...file.scriptApprovals, observed } });
|
|
82
|
+
return { written: true, path: file.path };
|
|
83
|
+
});
|
|
79
84
|
}
|
|
80
85
|
function sameHooks(a, b) {
|
|
81
86
|
return a.length === b.length && a.every((hook, index) => hook === b[index]);
|
|
@@ -86,11 +91,16 @@ export function hasExplicitScriptPreference(args, env) {
|
|
|
86
91
|
}
|
|
87
92
|
return env.npm_config_ignore_scripts !== undefined && env.npm_config_ignore_scripts !== "";
|
|
88
93
|
}
|
|
94
|
+
// Managers that honor `--ignore-scripts` (and the matching npm_config env key), so
|
|
95
|
+
// enforce mode can actually suppress lifecycle scripts. pip/uv build sdists via
|
|
96
|
+
// setup.py / PEP 517 backends and cargo runs build.rs with no equivalent flag —
|
|
97
|
+
// they cannot be enforced this way and are surfaced by runScriptGateAfterInstall.
|
|
98
|
+
const IGNORE_SCRIPTS_MANAGERS = new Set(["npm", "yarn", "pnpm"]);
|
|
89
99
|
export function scriptGateInstallArgs(options) {
|
|
90
100
|
if (options.mode !== "enforce") {
|
|
91
101
|
return options.args;
|
|
92
102
|
}
|
|
93
|
-
if (
|
|
103
|
+
if (!IGNORE_SCRIPTS_MANAGERS.has(options.manager)) {
|
|
94
104
|
return options.args;
|
|
95
105
|
}
|
|
96
106
|
if (hasExplicitScriptPreference(options.args, options.env)) {
|
|
@@ -99,7 +109,7 @@ export function scriptGateInstallArgs(options) {
|
|
|
99
109
|
return [...options.args, "--ignore-scripts"];
|
|
100
110
|
}
|
|
101
111
|
export function scriptGateChildEnv(options) {
|
|
102
|
-
if (options.mode !== "enforce" || (options.manager
|
|
112
|
+
if (options.mode !== "enforce" || !IGNORE_SCRIPTS_MANAGERS.has(options.manager)) {
|
|
103
113
|
return {};
|
|
104
114
|
}
|
|
105
115
|
if (hasExplicitScriptPreference(options.args, options.env)) {
|
|
@@ -125,6 +135,10 @@ export function scriptGateReportLine(options) {
|
|
|
125
135
|
const noun = wanters.length === 1 ? "package ran" : "packages ran";
|
|
126
136
|
return `\n ${theme.paint("muted", `dg scripts: ${wanters.length} ${noun} install scripts (${formatNames(names)}) — observed, not blocked · silence: dg config set scriptGate.mode off`)}\n`;
|
|
127
137
|
}
|
|
138
|
+
function unsuppressableScriptNotice(manager) {
|
|
139
|
+
const theme = createTheme(resolvePresentation().color);
|
|
140
|
+
return `\n ${theme.paint("muted", `dg scripts: enforce mode cannot suppress build/install scripts for ${manager} (no --ignore-scripts equivalent); they ran — review with dg scan`)}\n`;
|
|
141
|
+
}
|
|
128
142
|
function formatNames(names) {
|
|
129
143
|
if (names.length <= REPORTED_NAME_LIMIT) {
|
|
130
144
|
return names.join(", ");
|
|
@@ -139,17 +153,20 @@ const MUTATING_ACTIONS = {
|
|
|
139
153
|
export function runScriptGateAfterInstall(options) {
|
|
140
154
|
try {
|
|
141
155
|
const classification = options.classification;
|
|
142
|
-
if (classification.kind !== "protected"
|
|
143
|
-
return "";
|
|
144
|
-
}
|
|
145
|
-
const mutatingActions = MUTATING_ACTIONS[classification.manager];
|
|
146
|
-
if (!mutatingActions || !mutatingActions.has(classification.action)) {
|
|
156
|
+
if (classification.kind !== "protected") {
|
|
147
157
|
return "";
|
|
148
158
|
}
|
|
149
159
|
const config = loadUserConfig(options.env ?? process.env);
|
|
150
160
|
if (config.scriptGate.mode === "off") {
|
|
151
161
|
return "";
|
|
152
162
|
}
|
|
163
|
+
if (classification.ecosystem !== "javascript") {
|
|
164
|
+
return config.scriptGate.mode === "enforce" ? unsuppressableScriptNotice(classification.manager) : "";
|
|
165
|
+
}
|
|
166
|
+
const mutatingActions = MUTATING_ACTIONS[classification.manager];
|
|
167
|
+
if (!mutatingActions || !mutatingActions.has(classification.action)) {
|
|
168
|
+
return "";
|
|
169
|
+
}
|
|
153
170
|
const projectDir = options.projectDir ?? process.cwd();
|
|
154
171
|
if (classification.manager === "pnpm") {
|
|
155
172
|
return scriptGateReportLine({ manager: "pnpm", pnpmIgnoredBuilds: detectPnpmIgnoredBuilds(projectDir) });
|
|
@@ -158,8 +175,9 @@ export function runScriptGateAfterInstall(options) {
|
|
|
158
175
|
recordScriptObservations({
|
|
159
176
|
projectDir,
|
|
160
177
|
wanters,
|
|
161
|
-
createIfMissing: config.scriptGate.
|
|
162
|
-
now: options.now ?? new Date()
|
|
178
|
+
createIfMissing: config.scriptGate.persistObservations,
|
|
179
|
+
now: options.now ?? new Date(),
|
|
180
|
+
env: options.env ?? process.env
|
|
163
181
|
});
|
|
164
182
|
return scriptGateReportLine({ manager: classification.manager, wanters });
|
|
165
183
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Single CSV-cell escaper for every exporter (SBOM, licenses, scan TUI) so the
|
|
2
|
+
// formula-injection guard and the delimiter quoting can never drift apart again.
|
|
3
|
+
// A cell whose first character is = + - @ (or TAB/CR) is run as a formula by
|
|
4
|
+
// Excel / Google Sheets / LibreOffice on open, so prefix a sentinel apostrophe;
|
|
5
|
+
// then quote for the CSV delimiter / quote / newline.
|
|
6
|
+
export function csvCell(value) {
|
|
7
|
+
const guarded = /^[=+\-@\t\r]/u.test(value) ? `'${value}` : value;
|
|
8
|
+
return /[",\n\r]/u.test(guarded) ? `"${guarded.replace(/"/gu, "\"\"")}"` : guarded;
|
|
9
|
+
}
|
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
import { stripVTControlCharacters } from "node:util";
|
|
2
2
|
const CTRL_KEEP_NEWLINE = /[\x00-\x08\x0B-\x1F\x7F-\x9F]/g;
|
|
3
3
|
const CTRL_ALL = /[\x00-\x1F\x7F-\x9F]/g;
|
|
4
|
+
// Invisible / zero-width / bidi-format Unicode (Trojan-Source + display spoofing):
|
|
5
|
+
// these reorder or hide text on screen without changing its bytes, letting an
|
|
6
|
+
// attacker-named package, license, or finding spoof what the terminal/report shows.
|
|
7
|
+
// Covers zero-width (200B-200D), bidi marks (200E-200F), word-joiner + invisibles
|
|
8
|
+
// (2060-2064), bidi-format overrides (202A-202E, 2066-2069), the Arabic letter mark
|
|
9
|
+
// (061C), and the BOM (FEFF). Single source of truth — the audit detector re-exports
|
|
10
|
+
// this so the strip set and the detect set cannot drift.
|
|
11
|
+
export const INVISIBLE_UNICODE_RE = /[\u200B-\u200F\u2060-\u2064\u202A-\u202E\u2066-\u2069\u061C\uFEFF]/u;
|
|
12
|
+
const INVISIBLE_GLOBAL = new RegExp(INVISIBLE_UNICODE_RE.source, "gu");
|
|
4
13
|
export function sanitize(s) {
|
|
5
|
-
return stripVTControlCharacters(s).replace(CTRL_KEEP_NEWLINE, "");
|
|
14
|
+
return stripVTControlCharacters(s).replace(CTRL_KEEP_NEWLINE, "").replace(INVISIBLE_GLOBAL, "");
|
|
6
15
|
}
|
|
7
16
|
export function sanitizeLine(s) {
|
|
8
|
-
return stripVTControlCharacters(s).replace(/[\r\n]+/g, " ").replace(CTRL_ALL, "");
|
|
17
|
+
return stripVTControlCharacters(s).replace(/[\r\n]+/g, " ").replace(CTRL_ALL, "").replace(INVISIBLE_GLOBAL, "");
|
|
9
18
|
}
|
|
10
19
|
export function sanitizeDeep(value) {
|
|
11
20
|
if (typeof value === "string") {
|
package/dist/service/state.js
CHANGED
|
@@ -6,7 +6,7 @@ import { fileURLToPath } from "node:url";
|
|
|
6
6
|
import { loadUserConfig } from "../config/settings.js";
|
|
7
7
|
import { applyTrustInstall, applyTrustUninstall, readCertificateFingerprints, readServiceTrustRecord, renderTrustStorePlanLines, resolveTrustInstallPlan, TrustStoreError, TrustToolMissingError, writeServiceTrustRecord } from "./trust-store.js";
|
|
8
8
|
import { clearTrustRefreshError, readTrustRefreshError } from "./trust-refresh.js";
|
|
9
|
-
import { acquireLockSync, cleanupSessionSync, createSessionSync, preserveCorruptCleanupRegistrySync, resolveDgPaths, CLEANUP_REGISTRY_LOCK } from "../state/index.js";
|
|
9
|
+
import { acquireLockSync, acquireLockSyncWithRetry, cleanupSessionSync, createSessionSync, preserveCorruptCleanupRegistrySync, resolveDgPaths, CLEANUP_REGISTRY_LOCK } from "../state/index.js";
|
|
10
10
|
export const SERVICE_SENTINEL = "dg-service-mode-v1";
|
|
11
11
|
export const TRUST_SENTINEL = "dg-service-trust-v1";
|
|
12
12
|
export const SERVICE_LOCK = "service-control";
|
|
@@ -196,7 +196,7 @@ export function startService(env, now = new Date()) {
|
|
|
196
196
|
};
|
|
197
197
|
}
|
|
198
198
|
cleanupRuntime(paths, existingRuntime ?? current.proxy);
|
|
199
|
-
const runtime = startServiceProxyRuntime(paths, env ?? process.env
|
|
199
|
+
const runtime = startServiceProxyRuntime(paths, env ?? process.env);
|
|
200
200
|
const trustRemoval = removeTrustRecordIfDrifted(paths, current.trust, runtime, now);
|
|
201
201
|
const state = {
|
|
202
202
|
...current,
|
|
@@ -255,7 +255,7 @@ export function restartService(env, now = new Date()) {
|
|
|
255
255
|
throw new ServiceNotConfiguredError();
|
|
256
256
|
}
|
|
257
257
|
cleanupRuntime(paths, readRuntime(paths) ?? current.proxy);
|
|
258
|
-
const runtime = startServiceProxyRuntime(paths, env ?? process.env
|
|
258
|
+
const runtime = startServiceProxyRuntime(paths, env ?? process.env);
|
|
259
259
|
const trustRemoval = removeTrustRecordIfDrifted(paths, current.trust, runtime, now);
|
|
260
260
|
const state = {
|
|
261
261
|
...current,
|
|
@@ -313,6 +313,20 @@ export function installServiceTrust(env, now = new Date()) {
|
|
|
313
313
|
trustStoreOperation(() => applyTrustUninstall(existing));
|
|
314
314
|
}
|
|
315
315
|
const record = trustStoreOperation(() => applyTrustInstall(plan, now, TRUST_SENTINEL));
|
|
316
|
+
try {
|
|
317
|
+
writeServiceTrustRecord(paths.trustRecordPath, record);
|
|
318
|
+
}
|
|
319
|
+
catch (error) {
|
|
320
|
+
// The persisted record is what every uninstall path keys on to remove the CA;
|
|
321
|
+
// a failed write must not leave the OS trusting a CA dg can no longer find.
|
|
322
|
+
try {
|
|
323
|
+
applyTrustUninstall(record);
|
|
324
|
+
}
|
|
325
|
+
catch {
|
|
326
|
+
// rollback is best-effort; the original write failure is what propagates
|
|
327
|
+
}
|
|
328
|
+
throw error;
|
|
329
|
+
}
|
|
316
330
|
clearTrustRefreshError(paths.serviceDir);
|
|
317
331
|
const state = {
|
|
318
332
|
...current,
|
|
@@ -321,7 +335,6 @@ export function installServiceTrust(env, now = new Date()) {
|
|
|
321
335
|
trustDrift: undefined,
|
|
322
336
|
trustInstalledAt: current.trustInstalledAt ?? now.toISOString()
|
|
323
337
|
};
|
|
324
|
-
writeServiceTrustRecord(paths.trustRecordPath, record);
|
|
325
338
|
writeState(paths, state);
|
|
326
339
|
writeLog(paths, "service.trust.installed", now);
|
|
327
340
|
withRegistryLock(paths.paths, () => {
|
|
@@ -477,6 +490,21 @@ export function withServiceLock(paths, run) {
|
|
|
477
490
|
lock.release();
|
|
478
491
|
}
|
|
479
492
|
}
|
|
493
|
+
// Retry variant for callbacks that must NOT be dropped on transient contention —
|
|
494
|
+
// e.g. the CA-rotation trust refresh, which silently breaks TLS interception if
|
|
495
|
+
// it loses the lock and gives up.
|
|
496
|
+
export function withServiceLockRetry(paths, run) {
|
|
497
|
+
const lock = acquireLockSyncWithRetry(paths.paths, SERVICE_LOCK, {
|
|
498
|
+
staleMs: SERVICE_LOCK_STALE_MS,
|
|
499
|
+
timeoutMs: 5_000
|
|
500
|
+
});
|
|
501
|
+
try {
|
|
502
|
+
return run();
|
|
503
|
+
}
|
|
504
|
+
finally {
|
|
505
|
+
lock.release();
|
|
506
|
+
}
|
|
507
|
+
}
|
|
480
508
|
function withRegistryLock(paths, run) {
|
|
481
509
|
const lock = acquireLockSync(paths, CLEANUP_REGISTRY_LOCK, {
|
|
482
510
|
staleMs: SERVICE_LOCK_STALE_MS
|
|
@@ -554,10 +582,13 @@ function withTrustRefreshError(paths, state) {
|
|
|
554
582
|
if (!refreshError) {
|
|
555
583
|
return state;
|
|
556
584
|
}
|
|
585
|
+
// Surface the trust-refresh failure even when another lastError is already set
|
|
586
|
+
// (a `??` here would silently drop it — and a broken CA trust is exactly the
|
|
587
|
+
// failure the user must see to repair TLS interception).
|
|
588
|
+
const refreshMessage = `service CA trust refresh failed at ${refreshError.at}: ${refreshError.message}. Run 'dg service trust install --yes' to repair OS trust for the rotated CA.`;
|
|
557
589
|
return {
|
|
558
590
|
...state,
|
|
559
|
-
lastError: state.lastError
|
|
560
|
-
?? `service CA trust refresh failed at ${refreshError.at}: ${refreshError.message}. Run 'dg service trust install --yes' to repair OS trust for the rotated CA.`
|
|
591
|
+
lastError: state.lastError ? `${state.lastError}; ${refreshMessage}` : refreshMessage
|
|
561
592
|
};
|
|
562
593
|
}
|
|
563
594
|
function runtimeHealth(runtime) {
|
|
@@ -567,35 +598,72 @@ function runtimeHealth(runtime) {
|
|
|
567
598
|
reason: `recorded service worker pid ${runtime.pid} is not running`
|
|
568
599
|
};
|
|
569
600
|
}
|
|
570
|
-
|
|
571
|
-
return {
|
|
572
|
-
healthy: false,
|
|
573
|
-
reason: `health endpoint is unreachable at ${runtime.healthUrl}`
|
|
574
|
-
};
|
|
575
|
-
}
|
|
576
|
-
return {
|
|
577
|
-
healthy: true,
|
|
578
|
-
reason: undefined
|
|
579
|
-
};
|
|
601
|
+
return healthEndpointReachable(runtime);
|
|
580
602
|
}
|
|
581
|
-
function healthEndpointReachable(
|
|
603
|
+
function healthEndpointReachable(runtime) {
|
|
604
|
+
const expectedProxyPort = servicePortFromUrl(runtime.proxyUrl);
|
|
582
605
|
const script = `
|
|
583
606
|
const http = require("node:http");
|
|
584
607
|
const https = require("node:https");
|
|
585
608
|
const target = process.argv[1];
|
|
609
|
+
const expectedPid = Number(process.argv[2]);
|
|
610
|
+
const expectedProxyPort = Number(process.argv[3]);
|
|
586
611
|
const client = target.startsWith("https:") ? https : http;
|
|
587
612
|
const request = client.get(target, { timeout: 500 }, (response) => {
|
|
588
|
-
response.
|
|
589
|
-
|
|
613
|
+
if (response.statusCode !== 200) {
|
|
614
|
+
response.resume();
|
|
615
|
+
response.on("end", () => process.exit(1));
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
let body = "";
|
|
619
|
+
response.setEncoding("utf8");
|
|
620
|
+
response.on("data", (chunk) => {
|
|
621
|
+
if (body.length < 65536) {
|
|
622
|
+
body += chunk;
|
|
623
|
+
}
|
|
624
|
+
});
|
|
625
|
+
response.on("end", () => {
|
|
626
|
+
try {
|
|
627
|
+
const parsed = JSON.parse(body);
|
|
628
|
+
const identified = parsed && parsed.ok === true && parsed.pid === expectedPid && parsed.proxyPort === expectedProxyPort;
|
|
629
|
+
process.exit(identified ? 0 : 2);
|
|
630
|
+
} catch {
|
|
631
|
+
process.exit(2);
|
|
632
|
+
}
|
|
633
|
+
});
|
|
590
634
|
});
|
|
591
635
|
request.on("timeout", () => request.destroy(new Error("timeout")));
|
|
592
636
|
request.on("error", () => process.exit(1));
|
|
593
637
|
`;
|
|
594
|
-
const result = spawnSync(process.execPath, ["-e", script, healthUrl], {
|
|
638
|
+
const result = spawnSync(process.execPath, ["-e", script, runtime.healthUrl, String(runtime.pid), String(expectedProxyPort)], {
|
|
595
639
|
stdio: "ignore",
|
|
596
640
|
timeout: 1_000
|
|
597
641
|
});
|
|
598
|
-
|
|
642
|
+
if (result.status === 0) {
|
|
643
|
+
return {
|
|
644
|
+
healthy: true,
|
|
645
|
+
reason: undefined
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
if (result.status === 2) {
|
|
649
|
+
return {
|
|
650
|
+
healthy: false,
|
|
651
|
+
reason: `health endpoint at ${runtime.healthUrl} did not identify the dg service worker (expected pid ${runtime.pid})`
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
return {
|
|
655
|
+
healthy: false,
|
|
656
|
+
reason: `health endpoint is unreachable at ${runtime.healthUrl}`
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
function servicePortFromUrl(url) {
|
|
660
|
+
try {
|
|
661
|
+
const { port } = new URL(url);
|
|
662
|
+
return port ? Number(port) : Number.NaN;
|
|
663
|
+
}
|
|
664
|
+
catch {
|
|
665
|
+
return Number.NaN;
|
|
666
|
+
}
|
|
599
667
|
}
|
|
600
668
|
function detectTrustDrift(trust, runtime) {
|
|
601
669
|
if (!trust || !runtime?.caPath || !existsSync(runtime.caPath)) {
|
|
@@ -628,6 +696,15 @@ function removeTrustRecordIfDrifted(paths, trust, runtime, now) {
|
|
|
628
696
|
message: undefined
|
|
629
697
|
};
|
|
630
698
|
}
|
|
699
|
+
if (drift.activeFingerprintSha256 === undefined) {
|
|
700
|
+
// The active CA is merely unreadable (a transient IO / restart race), not a
|
|
701
|
+
// confirmed mismatch — surface the message but do NOT tear down the user's OS
|
|
702
|
+
// trust, which would force a manual reinstall over a recoverable condition.
|
|
703
|
+
return {
|
|
704
|
+
removed: false,
|
|
705
|
+
message: drift.message
|
|
706
|
+
};
|
|
707
|
+
}
|
|
631
708
|
trustStoreOperation(() => applyTrustUninstall(trust));
|
|
632
709
|
removeFile(paths.trustRecordPath);
|
|
633
710
|
withRegistryLock(paths.paths, () => {
|
|
@@ -639,8 +716,13 @@ function removeTrustRecordIfDrifted(paths, trust, runtime, now) {
|
|
|
639
716
|
message: `${drift.message} The stale dg-owned trust record was removed during service restart.`
|
|
640
717
|
};
|
|
641
718
|
}
|
|
642
|
-
function startServiceProxyRuntime(paths, env
|
|
643
|
-
|
|
719
|
+
function startServiceProxyRuntime(paths, env) {
|
|
720
|
+
// DG_SERVICE_WORKER_PATH is a test seam (point at a stub worker). Honouring an
|
|
721
|
+
// arbitrary path from the ambient environment in production is an arbitrary-code
|
|
722
|
+
// -execution vector, so the override is accepted only under NODE_ENV=test.
|
|
723
|
+
const workerPath = env.NODE_ENV === "test" && env.DG_SERVICE_WORKER_PATH
|
|
724
|
+
? env.DG_SERVICE_WORKER_PATH
|
|
725
|
+
: fileURLToPath(new URL("./worker.js", import.meta.url));
|
|
644
726
|
if (!existsSync(workerPath)) {
|
|
645
727
|
throw new ServiceProxyError("service proxy worker is unavailable until the CLI package is built");
|
|
646
728
|
}
|
|
@@ -651,18 +733,23 @@ function startServiceProxyRuntime(paths, env, now) {
|
|
|
651
733
|
mode: 0o600
|
|
652
734
|
});
|
|
653
735
|
const config = loadUserConfig(env);
|
|
736
|
+
const childEnv = {
|
|
737
|
+
...env,
|
|
738
|
+
DG_SERVICE_CLASSIFICATION: JSON.stringify({
|
|
739
|
+
kind: "protected",
|
|
740
|
+
manager: "npm",
|
|
741
|
+
realBinaryName: "npm",
|
|
742
|
+
action: "service-proxy",
|
|
743
|
+
args: []
|
|
744
|
+
})
|
|
745
|
+
};
|
|
746
|
+
// The service worker gates every install for the daemon's lifetime; it must
|
|
747
|
+
// never inherit a --dg-force-install request from whatever environment happened
|
|
748
|
+
// to start the service, which would silently downgrade every block.
|
|
749
|
+
delete childEnv.DG_FORCE_OVERRIDE_REQUEST;
|
|
654
750
|
const child = spawn(process.execPath, [workerPath, sessionBootstrapPath, config.api.baseUrl, paths.runtimePath], {
|
|
655
751
|
detached: true,
|
|
656
|
-
env:
|
|
657
|
-
...env,
|
|
658
|
-
DG_SERVICE_CLASSIFICATION: JSON.stringify({
|
|
659
|
-
kind: "protected",
|
|
660
|
-
manager: "npm",
|
|
661
|
-
realBinaryName: "npm",
|
|
662
|
-
action: "service-proxy",
|
|
663
|
-
args: []
|
|
664
|
-
})
|
|
665
|
-
},
|
|
752
|
+
env: childEnv,
|
|
666
753
|
stdio: "ignore"
|
|
667
754
|
});
|
|
668
755
|
child.unref();
|
|
@@ -674,7 +761,6 @@ function startServiceProxyRuntime(paths, env, now) {
|
|
|
674
761
|
cleanupSessionSync(session);
|
|
675
762
|
throw new ServiceProxyError("service proxy worker did not become healthy");
|
|
676
763
|
}
|
|
677
|
-
void now;
|
|
678
764
|
return runtime;
|
|
679
765
|
}
|
|
680
766
|
function waitForRuntime(paths, pid) {
|
|
@@ -823,9 +909,6 @@ function writeRegistry(paths, registry) {
|
|
|
823
909
|
}
|
|
824
910
|
function writeRegistryIfChanged(paths, registry, targets) {
|
|
825
911
|
const next = removeRegistryTargets(registry, targets);
|
|
826
|
-
if (!existsSync(paths.cleanupRegistryPath) && registry.entries.length === next.entries.length) {
|
|
827
|
-
return;
|
|
828
|
-
}
|
|
829
912
|
if (registry.entries.length === next.entries.length) {
|
|
830
913
|
return;
|
|
831
914
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, readdirSync, readFileSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
|
-
import { applyTrustInstall, applyTrustUninstall, readServiceTrustRecord, resolveTrustInstallPlan, writeServiceTrustRecord } from "./trust-store.js";
|
|
3
|
+
import { applyTrustInstall, applyTrustUninstall, reconcileLinuxTrustStore, readServiceTrustRecord, resolveTrustInstallPlan, writeServiceTrustRecord } from "./trust-store.js";
|
|
4
4
|
export const TRUST_REFRESH_ERROR_FILENAME = "trust-refresh-error.json";
|
|
5
5
|
export function trustRefreshErrorPath(serviceDir) {
|
|
6
6
|
return join(serviceDir, TRUST_REFRESH_ERROR_FILENAME);
|
|
@@ -58,6 +58,7 @@ function sweepStaleDgTrustFiles(provider, targetPath) {
|
|
|
58
58
|
catch {
|
|
59
59
|
return;
|
|
60
60
|
}
|
|
61
|
+
let removedAny = false;
|
|
61
62
|
for (const entry of entries) {
|
|
62
63
|
if (!/^dependency-guardian-[0-9a-f]{16}\.(pem|crt)$/.test(entry)) {
|
|
63
64
|
continue;
|
|
@@ -69,6 +70,16 @@ function sweepStaleDgTrustFiles(provider, targetPath) {
|
|
|
69
70
|
rmSync(path, {
|
|
70
71
|
force: true
|
|
71
72
|
});
|
|
73
|
+
removedAny = true;
|
|
74
|
+
}
|
|
75
|
+
if (removedAny && provider === "linux-system-ca") {
|
|
76
|
+
try {
|
|
77
|
+
reconcileLinuxTrustStore();
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
// best-effort: the new cert is already installed+trusted; a failed reconcile
|
|
81
|
+
// only delays pruning the swept-out roots until the next rotation.
|
|
82
|
+
}
|
|
72
83
|
}
|
|
73
84
|
}
|
|
74
85
|
function writeTrustRefreshError(serviceDir, error, now) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { X509Certificate } from "node:crypto";
|
|
2
|
-
import {
|
|
3
|
-
import { homedir } from "node:os";
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { homedir, tmpdir } from "node:os";
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
5
|
import { spawnSync } from "node:child_process";
|
|
6
6
|
import { writeJsonAtomic } from "../util/json-file.js";
|
|
@@ -92,6 +92,20 @@ export function renderTrustStorePlanLines(plan) {
|
|
|
92
92
|
];
|
|
93
93
|
return plan.reason ? [...lines, `support note: ${plan.reason}`] : lines;
|
|
94
94
|
}
|
|
95
|
+
// Install ONLY the active (first) cert from the on-disk rotation bundle (which
|
|
96
|
+
// holds new+previous certs). Copying the whole bundle would keep every rotated-out
|
|
97
|
+
// CA trusted, defeating the ephemeral-CA model. Remove any existing target first
|
|
98
|
+
// (rm does not follow a symlink) then create with O_EXCL so a pre-planted symlink
|
|
99
|
+
// can't redirect this (root-privileged) write to an arbitrary path.
|
|
100
|
+
function extractActiveCert(srcBundlePath) {
|
|
101
|
+
const bundle = readFileSync(srcBundlePath, "utf8");
|
|
102
|
+
const match = bundle.match(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----\s*/);
|
|
103
|
+
return match ? match[0] : bundle;
|
|
104
|
+
}
|
|
105
|
+
function installActiveCert(srcBundlePath, target) {
|
|
106
|
+
rmSync(target, { force: true });
|
|
107
|
+
writeFileSync(target, extractActiveCert(srcBundlePath), { mode: 0o644, flag: "wx" });
|
|
108
|
+
}
|
|
95
109
|
export function applyTrustInstall(plan, installedAt, sentinel) {
|
|
96
110
|
if (!plan.supported || plan.provider === "unsupported") {
|
|
97
111
|
throw new TrustStoreError(plan.reason ?? "Service trust-store installation is unsupported on this platform.");
|
|
@@ -101,17 +115,28 @@ export function applyTrustInstall(plan, installedAt, sentinel) {
|
|
|
101
115
|
recursive: true,
|
|
102
116
|
mode: 0o700
|
|
103
117
|
});
|
|
104
|
-
|
|
118
|
+
installActiveCert(plan.caPath, plan.target);
|
|
105
119
|
}
|
|
106
120
|
else if (plan.provider === "darwin-user-keychain") {
|
|
107
|
-
|
|
121
|
+
// `security add-trusted-cert` on the multi-cert rotation bundle would trust
|
|
122
|
+
// EVERY rotated-out CA too, accumulating perpetual trusted roots. Materialize
|
|
123
|
+
// and trust ONLY the active cert (the command's last arg is the bundle path).
|
|
124
|
+
const dir = mkdtempSync(join(tmpdir(), "dg-trust-"));
|
|
125
|
+
const activeCertPath = join(dir, "active.pem");
|
|
126
|
+
try {
|
|
127
|
+
writeFileSync(activeCertPath, extractActiveCert(plan.caPath), { mode: 0o600 });
|
|
128
|
+
runNativeCommand([...plan.installCommand.slice(0, -1), activeCertPath], "macOS user keychain trust installation failed");
|
|
129
|
+
}
|
|
130
|
+
finally {
|
|
131
|
+
rmSync(dir, { recursive: true, force: true });
|
|
132
|
+
}
|
|
108
133
|
}
|
|
109
134
|
else {
|
|
110
135
|
mkdirSync(dirname(plan.target), {
|
|
111
136
|
recursive: true,
|
|
112
137
|
mode: 0o755
|
|
113
138
|
});
|
|
114
|
-
|
|
139
|
+
installActiveCert(plan.caPath, plan.target);
|
|
115
140
|
try {
|
|
116
141
|
runNativeCommand(["update-ca-certificates"], "Linux trust-store refresh failed");
|
|
117
142
|
}
|
|
@@ -230,6 +255,12 @@ function normalizeFingerprint(value) {
|
|
|
230
255
|
function isTrustStoreProvider(value) {
|
|
231
256
|
return value === "darwin-user-keychain" || value === "linux-system-ca" || value === "file";
|
|
232
257
|
}
|
|
258
|
+
export function reconcileLinuxTrustStore() {
|
|
259
|
+
// Re-run update-ca-certificates so hashed symlinks / ca-certificates.crt entries
|
|
260
|
+
// for source files removed during a sweep are actually pruned (removing the .crt
|
|
261
|
+
// alone does not untrust the root until this runs).
|
|
262
|
+
runNativeCommand(["update-ca-certificates"], "Linux trust-store reconcile failed");
|
|
263
|
+
}
|
|
233
264
|
function runNativeCommand(command, failureMessage) {
|
|
234
265
|
const [program, ...args] = command;
|
|
235
266
|
if (!program) {
|
package/dist/service/worker.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { createServer } from "node:http";
|
|
2
2
|
import { readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { startProductionHttpProxy } from "../proxy/server.js";
|
|
4
|
-
import { resolveServicePaths, TRUST_SENTINEL,
|
|
4
|
+
import { resolveServicePaths, TRUST_SENTINEL, withServiceLockRetry } from "./state.js";
|
|
5
5
|
import { refreshServiceTrustAfterCaRotation } from "./trust-refresh.js";
|
|
6
|
+
import { redactSecrets } from "../launcher/output-redaction.js";
|
|
6
7
|
const sessionPath = process.argv[2];
|
|
7
8
|
const apiBaseUrl = process.argv[3];
|
|
8
9
|
const runtimePath = process.argv[4];
|
|
@@ -30,9 +31,6 @@ async function close() {
|
|
|
30
31
|
});
|
|
31
32
|
await Promise.all([proxy?.close(), closeHealthServer(healthServer)]);
|
|
32
33
|
}
|
|
33
|
-
process.stdin.on("end", () => {
|
|
34
|
-
close().finally(() => process.exit(0));
|
|
35
|
-
});
|
|
36
34
|
process.on("SIGTERM", () => {
|
|
37
35
|
close().finally(() => process.exit(0));
|
|
38
36
|
});
|
|
@@ -42,7 +40,7 @@ process.on("SIGINT", () => {
|
|
|
42
40
|
function exitOnFatal(error) {
|
|
43
41
|
const message = error instanceof Error ? error.message : String(error);
|
|
44
42
|
try {
|
|
45
|
-
process.stderr.write(`dg service worker: unexpected error — ${message}\n`);
|
|
43
|
+
process.stderr.write(`dg service worker: unexpected error — ${redactSecrets(message)}\n`);
|
|
46
44
|
}
|
|
47
45
|
catch {
|
|
48
46
|
// stderr is gone; nothing left to report to.
|
|
@@ -50,14 +48,14 @@ function exitOnFatal(error) {
|
|
|
50
48
|
close().finally(() => process.exit(1));
|
|
51
49
|
}
|
|
52
50
|
process.on("uncaughtException", exitOnFatal);
|
|
53
|
-
process.on("unhandledRejection", exitOnFatal);
|
|
51
|
+
process.on("unhandledRejection", (reason) => exitOnFatal(reason));
|
|
54
52
|
const servicePaths = resolveServicePaths(process.env);
|
|
55
53
|
proxy = await startProductionHttpProxy({
|
|
56
54
|
session,
|
|
57
55
|
apiBaseUrl: requiredApiBaseUrl,
|
|
58
56
|
classification,
|
|
59
57
|
env: process.env,
|
|
60
|
-
onCaRotate: () =>
|
|
58
|
+
onCaRotate: () => withServiceLockRetry(servicePaths, () => refreshServiceTrustAfterCaRotation({
|
|
61
59
|
serviceDir: servicePaths.serviceDir,
|
|
62
60
|
trustRecordPath: servicePaths.trustRecordPath,
|
|
63
61
|
sentinel: TRUST_SENTINEL,
|
package/dist/setup/git-hook.js
CHANGED
|
@@ -28,7 +28,7 @@ export function resolveGitRepo(options = {}) {
|
|
|
28
28
|
const cwd = options.cwd ?? process.cwd();
|
|
29
29
|
const inside = gitTrimmed(["rev-parse", "--is-inside-work-tree"], { cwd, env });
|
|
30
30
|
if (inside !== "true") {
|
|
31
|
-
return { error: "not a git repository — run dg guard
|
|
31
|
+
return { error: "not a git repository — run dg commit-guard inside a repo" };
|
|
32
32
|
}
|
|
33
33
|
const root = gitTrimmed(["rev-parse", "--show-toplevel"], { cwd, env });
|
|
34
34
|
if (!root) {
|
|
@@ -113,7 +113,7 @@ export function applyGitHook(context, now = new Date()) {
|
|
|
113
113
|
{
|
|
114
114
|
name: "hook-writable",
|
|
115
115
|
ok: false,
|
|
116
|
-
detail: `${context.hookTarget} is a symlink; dg refuses to replace it — remove the link or point it at a real file, then re-run dg guard
|
|
116
|
+
detail: `${context.hookTarget} is a symlink; dg refuses to replace it — remove the link or point it at a real file, then re-run dg commit-guard`
|
|
117
117
|
}
|
|
118
118
|
],
|
|
119
119
|
active: false
|