@westbayberry/dg 2.0.7 → 2.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +17 -12
  2. package/dist/api/analyze.js +134 -34
  3. package/dist/audit-ui/export.js +3 -4
  4. package/dist/auth/device-login.js +13 -9
  5. package/dist/auth/store.js +43 -26
  6. package/dist/bin/dg.js +5 -0
  7. package/dist/commands/audit.js +14 -4
  8. package/dist/commands/config.js +3 -5
  9. package/dist/commands/doctor.js +3 -3
  10. package/dist/commands/explain.js +138 -6
  11. package/dist/commands/licenses.js +37 -24
  12. package/dist/commands/login.js +12 -3
  13. package/dist/commands/logout.js +15 -4
  14. package/dist/commands/scan.js +1 -1
  15. package/dist/commands/service.js +76 -24
  16. package/dist/commands/status.js +38 -4
  17. package/dist/commands/types.js +1 -0
  18. package/dist/config/settings.js +102 -22
  19. package/dist/install-ui/prompt.js +5 -2
  20. package/dist/launcher/install-preflight.js +158 -0
  21. package/dist/launcher/live-install.js +11 -2
  22. package/dist/launcher/output-redaction.js +5 -3
  23. package/dist/launcher/pip-report.js +18 -2
  24. package/dist/launcher/preflight-prompt.js +31 -12
  25. package/dist/launcher/run.js +87 -8
  26. package/dist/proxy/ca.js +69 -29
  27. package/dist/proxy/enforcement.js +41 -3
  28. package/dist/proxy/worker.js +21 -9
  29. package/dist/runtime/first-run.js +33 -2
  30. package/dist/runtime/nudges.js +9 -2
  31. package/dist/scan/analyze-worker.js +18 -8
  32. package/dist/scan/collect.js +35 -28
  33. package/dist/scan/command.js +80 -40
  34. package/dist/scan/discovery.js +9 -3
  35. package/dist/scan/render.js +22 -6
  36. package/dist/scan/scanner-report.js +89 -12
  37. package/dist/scan/staged.js +69 -7
  38. package/dist/scan-ui/LegacyApp.js +10 -48
  39. package/dist/scan-ui/components/InteractiveResultsView.js +171 -111
  40. package/dist/scan-ui/components/ProjectSelector.js +3 -3
  41. package/dist/scan-ui/components/ScoreHeader.js +8 -4
  42. package/dist/scan-ui/hooks/useScan.js +74 -27
  43. package/dist/scan-ui/launch.js +18 -4
  44. package/dist/service/state.js +15 -4
  45. package/dist/service/trust-store.js +23 -2
  46. package/dist/setup/git-hook.js +28 -17
  47. package/dist/setup/plan.js +302 -18
  48. package/dist/state/cleanup-registry.js +65 -8
  49. package/dist/state/locks.js +95 -9
  50. package/dist/state/sessions.js +66 -2
  51. package/dist/verify/package-check.js +22 -3
  52. package/dist/verify/preflight.js +328 -170
  53. package/package.json +1 -1
@@ -1,7 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { dirname, join } from "node:path";
4
- import { resolveDgPaths } from "../state/index.js";
4
+ import { acquireLockSyncWithRetry, resolveDgPaths } from "../state/index.js";
5
5
  export const CONFIG_KEYS = Object.freeze([
6
6
  "api.baseUrl",
7
7
  "org.id",
@@ -59,10 +59,12 @@ export function loadUserConfig(env = process.env) {
59
59
  return DEFAULT_CONFIG;
60
60
  }
61
61
  try {
62
- const parsed = JSON.parse(readFileSync(path, "utf8"));
63
- return normalizeConfig(parsed);
62
+ return normalizeConfig(JSON.parse(readFileSync(path, "utf8")));
64
63
  }
65
64
  catch (error) {
65
+ if (error instanceof ConfigError) {
66
+ throw new ConfigError(`Invalid dg config at ${path}: ${error.message}`);
67
+ }
66
68
  throw new ConfigError(`Malformed dg config at ${path}: ${error instanceof Error ? error.message : "unknown error"}`);
67
69
  }
68
70
  }
@@ -70,6 +72,28 @@ export function saveUserConfig(config, env = process.env) {
70
72
  const paths = resolveDgPaths(env);
71
73
  writeJsonAtomic(userConfigPath(paths), config);
72
74
  }
75
+ export const USER_CONFIG_LOCK = "user-config";
76
+ const USER_CONFIG_LOCK_TIMEOUT_MS = 5_000;
77
+ const USER_CONFIG_LOCK_STALE_MS = 60_000;
78
+ export function withUserConfigLock(env, action) {
79
+ const lock = acquireLockSyncWithRetry(resolveDgPaths(env), USER_CONFIG_LOCK, {
80
+ staleMs: USER_CONFIG_LOCK_STALE_MS,
81
+ timeoutMs: USER_CONFIG_LOCK_TIMEOUT_MS
82
+ });
83
+ try {
84
+ return action();
85
+ }
86
+ finally {
87
+ lock.release();
88
+ }
89
+ }
90
+ export function updateUserConfig(apply, env = process.env) {
91
+ return withUserConfigLock(env, () => {
92
+ const next = apply(loadUserConfig(env));
93
+ saveUserConfig(next, env);
94
+ return next;
95
+ });
96
+ }
73
97
  export function getConfigValue(config, key) {
74
98
  if (key === "api.baseUrl") {
75
99
  return config.api.baseUrl;
@@ -166,7 +190,7 @@ export function setConfigValue(config, key, rawValue) {
166
190
  return {
167
191
  ...config,
168
192
  audit: {
169
- upload: parseBoolean(rawValue)
193
+ upload: parseBoolean(rawValue, key)
170
194
  }
171
195
  };
172
196
  }
@@ -174,14 +198,14 @@ export function setConfigValue(config, key, rawValue) {
174
198
  return {
175
199
  ...config,
176
200
  telemetry: {
177
- enabled: parseBoolean(rawValue)
201
+ enabled: parseBoolean(rawValue, key)
178
202
  }
179
203
  };
180
204
  }
181
205
  return {
182
206
  ...config,
183
207
  webhooks: {
184
- enabled: parseBoolean(rawValue)
208
+ enabled: parseBoolean(rawValue, key)
185
209
  }
186
210
  };
187
211
  }
@@ -192,38 +216,93 @@ export function isConfigKey(value) {
192
216
  return CONFIG_KEYS.includes(value);
193
217
  }
194
218
  function normalizeConfig(raw) {
219
+ if (!isPlainObject(raw)) {
220
+ throw new ConfigError(`config must be a JSON object, got ${describeJsonType(raw)}`);
221
+ }
195
222
  if (raw.version !== undefined && raw.version !== 1) {
196
223
  throw new ConfigError("unsupported config version");
197
224
  }
225
+ const api = fieldObject(raw, "api");
226
+ const org = fieldObject(raw, "org");
227
+ const policy = fieldObject(raw, "policy");
228
+ const gitHook = fieldObject(raw, "gitHook");
229
+ const audit = fieldObject(raw, "audit");
230
+ const telemetry = fieldObject(raw, "telemetry");
231
+ const webhooks = fieldObject(raw, "webhooks");
198
232
  return {
199
233
  version: 1,
200
234
  api: {
201
- baseUrl: parseUrl(raw.api?.baseUrl ?? DEFAULT_CONFIG.api.baseUrl)
235
+ baseUrl: parseUrl(fieldString(api, "api.baseUrl", "baseUrl") ?? DEFAULT_CONFIG.api.baseUrl)
202
236
  },
203
237
  org: {
204
- id: raw.org?.id ?? DEFAULT_CONFIG.org.id
238
+ id: fieldString(org, "org.id", "id") ?? DEFAULT_CONFIG.org.id
205
239
  },
206
240
  policy: {
207
- mode: parsePolicyMode(raw.policy?.mode ?? DEFAULT_CONFIG.policy.mode),
208
- trustProjectAllowlists: raw.policy?.trustProjectAllowlists ?? DEFAULT_CONFIG.policy.trustProjectAllowlists,
209
- allowForceOverride: raw.policy?.allowForceOverride ?? DEFAULT_CONFIG.policy.allowForceOverride,
210
- scriptHardening: raw.policy?.scriptHardening ?? DEFAULT_CONFIG.policy.scriptHardening
241
+ mode: parsePolicyMode(fieldString(policy, "policy.mode", "mode") ?? DEFAULT_CONFIG.policy.mode),
242
+ trustProjectAllowlists: fieldBoolean(policy, "policy.trustProjectAllowlists", "trustProjectAllowlists") ?? DEFAULT_CONFIG.policy.trustProjectAllowlists,
243
+ allowForceOverride: fieldBoolean(policy, "policy.allowForceOverride", "allowForceOverride") ?? DEFAULT_CONFIG.policy.allowForceOverride,
244
+ scriptHardening: fieldBoolean(policy, "policy.scriptHardening", "scriptHardening") ?? DEFAULT_CONFIG.policy.scriptHardening
211
245
  },
212
246
  gitHook: {
213
- onWarn: parseOnWarn(raw.gitHook?.onWarn ?? DEFAULT_CONFIG.gitHook.onWarn),
214
- onIncomplete: parseOnIncomplete(raw.gitHook?.onIncomplete ?? DEFAULT_CONFIG.gitHook.onIncomplete)
247
+ onWarn: parseOnWarn(fieldString(gitHook, "gitHook.onWarn", "onWarn") ?? DEFAULT_CONFIG.gitHook.onWarn),
248
+ onIncomplete: parseOnIncomplete(fieldString(gitHook, "gitHook.onIncomplete", "onIncomplete") ?? DEFAULT_CONFIG.gitHook.onIncomplete)
215
249
  },
216
250
  audit: {
217
- upload: raw.audit?.upload ?? DEFAULT_CONFIG.audit.upload
251
+ upload: fieldBoolean(audit, "audit.upload", "upload") ?? DEFAULT_CONFIG.audit.upload
218
252
  },
219
253
  telemetry: {
220
- enabled: raw.telemetry?.enabled ?? DEFAULT_CONFIG.telemetry.enabled
254
+ enabled: fieldBoolean(telemetry, "telemetry.enabled", "enabled") ?? DEFAULT_CONFIG.telemetry.enabled
221
255
  },
222
256
  webhooks: {
223
- enabled: raw.webhooks?.enabled ?? DEFAULT_CONFIG.webhooks.enabled
257
+ enabled: fieldBoolean(webhooks, "webhooks.enabled", "enabled") ?? DEFAULT_CONFIG.webhooks.enabled
224
258
  }
225
259
  };
226
260
  }
261
+ function fieldObject(root, field) {
262
+ const value = root[field];
263
+ if (value === undefined) {
264
+ return {};
265
+ }
266
+ if (!isPlainObject(value)) {
267
+ throw new ConfigError(`${field} must be a JSON object, got ${describeJsonType(value)}`);
268
+ }
269
+ return value;
270
+ }
271
+ function fieldBoolean(section, field, key) {
272
+ const value = section[key];
273
+ if (value === undefined) {
274
+ return undefined;
275
+ }
276
+ if (typeof value === "boolean") {
277
+ return value;
278
+ }
279
+ throw new ConfigError(`${field} must be a JSON boolean (true or false), got ${describeJsonType(value)}`);
280
+ }
281
+ function fieldString(section, field, key) {
282
+ const value = section[key];
283
+ if (value === undefined) {
284
+ return undefined;
285
+ }
286
+ if (typeof value === "string") {
287
+ return value;
288
+ }
289
+ throw new ConfigError(`${field} must be a string, got ${describeJsonType(value)}`);
290
+ }
291
+ function isPlainObject(value) {
292
+ return typeof value === "object" && value !== null && !Array.isArray(value);
293
+ }
294
+ function describeJsonType(value) {
295
+ if (value === null) {
296
+ return "null";
297
+ }
298
+ if (Array.isArray(value)) {
299
+ return "an array";
300
+ }
301
+ if (typeof value === "string") {
302
+ return `the string "${value.length > 32 ? `${value.slice(0, 32)}…` : value}"`;
303
+ }
304
+ return `a ${typeof value}`;
305
+ }
227
306
  function withPolicyBoolean(config, key, rawValue) {
228
307
  if (key === "mode") {
229
308
  return config;
@@ -232,7 +311,7 @@ function withPolicyBoolean(config, key, rawValue) {
232
311
  ...config,
233
312
  policy: {
234
313
  ...config.policy,
235
- [key]: parseBoolean(rawValue)
314
+ [key]: parseBoolean(rawValue, `policy.${key}`)
236
315
  }
237
316
  };
238
317
  }
@@ -254,21 +333,22 @@ function parseOnIncomplete(value) {
254
333
  }
255
334
  throw new ConfigError("gitHook.onIncomplete must be one of: allow, block");
256
335
  }
257
- function parseBoolean(value) {
336
+ function parseBoolean(value, field) {
258
337
  if (value === "true") {
259
338
  return true;
260
339
  }
261
340
  if (value === "false") {
262
341
  return false;
263
342
  }
264
- throw new ConfigError("boolean config values must be true or false");
343
+ throw new ConfigError(`${field} must be true or false`);
265
344
  }
266
345
  function parseUrl(value) {
267
346
  const trimmed = value.trim();
268
347
  try {
269
348
  const url = new URL(trimmed);
270
- if (url.protocol !== "https:" && url.hostname !== "localhost" && url.hostname !== "127.0.0.1") {
271
- throw new ConfigError("api.baseUrl must use https unless it targets localhost");
349
+ const localhost = url.hostname === "localhost" || url.hostname === "127.0.0.1";
350
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && localhost)) {
351
+ throw new ConfigError("api.baseUrl must use https, or http for localhost");
272
352
  }
273
353
  return url.toString().replace(/\/$/, "");
274
354
  }
@@ -6,16 +6,19 @@ export function defaultPromptIo() {
6
6
  isTTY: Boolean(process.stdin.isTTY && process.stderr.isTTY)
7
7
  };
8
8
  }
9
- export async function promptYesNo(question, io) {
9
+ export async function promptYesNo(question, io, defaultYes = false) {
10
10
  if (!io.isTTY) {
11
11
  return false;
12
12
  }
13
13
  const rl = createInterface({ input: io.input, output: io.output });
14
14
  try {
15
15
  const answer = await new Promise((resolve) => {
16
- rl.question(`${question} [y/N] `, resolve);
16
+ rl.question(`${question} ${defaultYes ? "[Y/n]" : "[y/N]"} `, resolve);
17
17
  });
18
18
  const normalized = answer.trim().toLowerCase();
19
+ if (normalized === "") {
20
+ return defaultYes;
21
+ }
19
22
  return normalized === "y" || normalized === "yes";
20
23
  }
21
24
  finally {
@@ -0,0 +1,158 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createInterface } from "node:readline";
3
+ import { analyzePackages } from "../api/analyze.js";
4
+ import { defaultPromptIo } from "../install-ui/prompt.js";
5
+ import { parsePipReportInstallCount, parsePipReportInstallSet } from "./pip-report.js";
6
+ const PROCEED = { proceed: true };
7
+ const UNRESOLVED = { set: undefined, count: undefined };
8
+ const approvedFlagRanks = new Map();
9
+ const pipResolutions = new Map();
10
+ export function resetInstallPreflightSession() {
11
+ approvedFlagRanks.clear();
12
+ pipResolutions.clear();
13
+ }
14
+ export function actionRank(action) {
15
+ return { pass: 0, analysis_incomplete: 1, warn: 2, block: 3 }[action];
16
+ }
17
+ export function recordPreflightApprovals(packages) {
18
+ for (const pkg of packages) {
19
+ const key = `${pkg.name}@${pkg.version}`;
20
+ const rank = actionRank(pkg.action);
21
+ if ((approvedFlagRanks.get(key) ?? -1) < rank) {
22
+ approvedFlagRanks.set(key, rank);
23
+ }
24
+ }
25
+ }
26
+ function isPreflightApproved(pkg) {
27
+ return (approvedFlagRanks.get(`${pkg.name}@${pkg.version}`) ?? -1) >= actionRank(pkg.action);
28
+ }
29
+ export function cachedPipResolution(binary, args) {
30
+ return pipResolutions.get(pipResolutionKey(binary, args));
31
+ }
32
+ function pipResolutionKey(binary, args) {
33
+ return JSON.stringify([binary, ...args]);
34
+ }
35
+ function resolvePipInstallSet(binary, args, env) {
36
+ if (!binary)
37
+ return Promise.resolve(UNRESOLVED);
38
+ return new Promise((resolve) => {
39
+ let stdout = "";
40
+ let settled = false;
41
+ let timer;
42
+ const finish = (value) => {
43
+ if (settled)
44
+ return;
45
+ settled = true;
46
+ if (timer)
47
+ clearTimeout(timer);
48
+ resolve(value);
49
+ };
50
+ let child;
51
+ try {
52
+ child = spawn(binary, [...args, "--dry-run", "--report", "-", "--quiet"], {
53
+ env,
54
+ stdio: ["ignore", "pipe", "ignore"]
55
+ });
56
+ }
57
+ catch {
58
+ finish(UNRESOLVED);
59
+ return;
60
+ }
61
+ timer = setTimeout(() => {
62
+ try {
63
+ child.kill();
64
+ }
65
+ catch { /* already exited */ }
66
+ finish(UNRESOLVED);
67
+ }, 30_000);
68
+ child.stdout?.on("data", (chunk) => { stdout += chunk.toString("utf8"); });
69
+ child.on("error", () => finish(UNRESOLVED));
70
+ child.on("close", (code) => finish(code === 0
71
+ ? { set: parsePipReportInstallSet(stdout), count: parsePipReportInstallCount(stdout) }
72
+ : UNRESOLVED));
73
+ });
74
+ }
75
+ function findingSummary(pkg) {
76
+ return pkg.reasons[0] ?? pkg.findings[0]?.title ?? pkg.findings[0]?.id ?? "flagged";
77
+ }
78
+ function renderPreflight(flagged, out) {
79
+ const blocks = flagged.filter((pkg) => pkg.action === "block").length;
80
+ const noun = flagged.length === 1 ? "package" : "packages";
81
+ const tail = blocks > 0 ? ` (${blocks} blocked)` : "";
82
+ out.write(`\n DG flagged ${flagged.length} ${noun} before install${tail}:\n`);
83
+ for (const pkg of flagged) {
84
+ const tag = pkg.action === "block" ? "block" : "warn";
85
+ out.write(` ${pkg.name}@${pkg.version} ${tag} ${findingSummary(pkg)}\n`);
86
+ }
87
+ out.write("\n");
88
+ }
89
+ export async function runInstallPreflight(manager, binary, childArgs, env) {
90
+ if (manager !== "pip" || !binary) {
91
+ return PROCEED;
92
+ }
93
+ const key = pipResolutionKey(binary, childArgs);
94
+ let resolution = pipResolutions.get(key);
95
+ if (!resolution) {
96
+ resolution = await resolvePipInstallSet(binary, childArgs, env);
97
+ pipResolutions.set(key, resolution);
98
+ }
99
+ const set = resolution.set;
100
+ if (!set || set.length === 0) {
101
+ return PROCEED;
102
+ }
103
+ let verdicts;
104
+ try {
105
+ verdicts = await analyzePackages(set.map((pkg) => ({ name: pkg.name, version: pkg.version })), { ecosystem: "pypi", env });
106
+ }
107
+ catch {
108
+ return PROCEED;
109
+ }
110
+ return decideFromVerdicts(verdicts.packages, defaultPromptIo());
111
+ }
112
+ export async function decideFromVerdicts(packages, io) {
113
+ const flagged = packages.filter((pkg) => (pkg.action === "warn" || pkg.action === "block") && !isPreflightApproved({ name: pkg.name, version: pkg.version, action: pkg.action }));
114
+ if (flagged.length === 0 || !io.isTTY) {
115
+ return PROCEED;
116
+ }
117
+ renderPreflight(flagged, io.output);
118
+ const hasBlock = flagged.some((pkg) => pkg.action === "block");
119
+ const accepted = await promptPreflightYesNo(hasBlock ? " Override and install anyway?" : " Proceed?", io, false);
120
+ if (!accepted) {
121
+ return { proceed: false };
122
+ }
123
+ recordPreflightApprovals(flagged.map((pkg) => ({ name: pkg.name, version: pkg.version, action: pkg.action ?? "warn" })));
124
+ return hasBlock ? { proceed: true, forceOverride: { force: true } } : PROCEED;
125
+ }
126
+ export async function promptPreflightYesNo(question, io, defaultYes) {
127
+ if (!io.isTTY) {
128
+ return false;
129
+ }
130
+ const rl = createInterface({ input: io.input, output: io.output });
131
+ try {
132
+ const answer = await new Promise((resolve) => {
133
+ rl.once("SIGINT", () => resolve(undefined));
134
+ rl.question(`${question} ${defaultYes ? "[Y/n]" : "[y/N]"} `, resolve);
135
+ });
136
+ if (answer === undefined) {
137
+ rl.close();
138
+ restoreRawInput(io.input);
139
+ io.output.write("\n");
140
+ process.exit(130);
141
+ }
142
+ const normalized = answer.trim().toLowerCase();
143
+ if (normalized === "") {
144
+ return defaultYes;
145
+ }
146
+ return normalized === "y" || normalized === "yes";
147
+ }
148
+ finally {
149
+ rl.close();
150
+ restoreRawInput(io.input);
151
+ }
152
+ }
153
+ function restoreRawInput(input) {
154
+ const stream = input;
155
+ if (stream.isTTY && typeof stream.setRawMode === "function") {
156
+ stream.setRawMode(false);
157
+ }
158
+ }
@@ -1,4 +1,5 @@
1
- import { createLaunchPlan, runWithProductionProxyLive } from "./run.js";
1
+ import { createLaunchPlan, runWithProductionProxyLive, EXIT_INSTALL_BLOCKED } from "./run.js";
2
+ import { runInstallPreflight } from "./install-preflight.js";
2
3
  import { isSupportedPackageManager } from "./classify.js";
3
4
  import { isCiEnv, resolvePresentation } from "../presentation/mode.js";
4
5
  const FALL_THROUGH = { handled: false };
@@ -16,9 +17,17 @@ export async function maybeRunLiveInstall(args, options = {}) {
16
17
  if (plan.classification.kind !== "protected" || !plan.realBinary.path) {
17
18
  return FALL_THROUGH;
18
19
  }
20
+ let effectiveOverride = forceOverride;
21
+ if (!forceOverride) {
22
+ const preflight = await runInstallPreflight(manager, plan.realBinary.path, childArgs, env);
23
+ if (!preflight.proceed) {
24
+ return { handled: true, result: { exitCode: EXIT_INSTALL_BLOCKED, stdout: "", stderr: " Install cancelled.\n" } };
25
+ }
26
+ effectiveOverride = preflight.forceOverride;
27
+ }
19
28
  const runOptions = {
20
29
  env,
21
- ...(forceOverride ? { forceOverride } : {})
30
+ ...(effectiveOverride ? { forceOverride: effectiveOverride } : {})
22
31
  };
23
32
  const { renderLiveInstall } = await import("../install-ui/live-install-app.js");
24
33
  try {
@@ -1,21 +1,23 @@
1
1
  const credentialUrlPattern = /\b([a-z][a-z0-9+.-]{0,31}:\/\/)([^/\s:@]+):([^/\s@]+)@/gi;
2
2
  const authHeaderPattern = /\b(proxy-authorization|authorization):\s*[^\r\n]+/gi;
3
- const tokenAssignmentPattern = /\b((?:npm_|pypi_|dg_)?(?:token|authToken|password|secret))=([^\s]+)/gi;
3
+ const tokenAssignmentPattern = /(?<![A-Za-z0-9])([A-Za-z0-9_-]*(?:secret[_-]key|access[_-]key|api[_-]key|token|password|secret))=([^\s]+)/gi;
4
4
  const npmrcAuthPattern = /(_authToken|_auth|_password)\s*=\s*("[^"]*"|[^\s;,]+)/gi;
5
+ const jsonSecretPattern = /("[A-Za-z0-9_.$-]*(?:secret[_-]key|access[_-]key|api[_-]key|token|password|secret)"\s*:\s*)"(?:[^"\\]|\\.)*"/gi;
5
6
  const bearerPattern = /\bBearer\s+[A-Za-z0-9._~+/=-]{8,}/g;
6
- const knownTokenShapePattern = /\b(npm_[A-Za-z0-9]{36}|gh[pousr]_[A-Za-z0-9]{36,255}|pypi-[A-Za-z0-9_-]{20,})\b/g;
7
+ const knownTokenShapePattern = /\b(npm_[A-Za-z0-9]{36}|gh[pousr]_[A-Za-z0-9]{36,255}|pypi-[A-Za-z0-9_-]{20,}|glpat-[A-Za-z0-9_-]{20,}|xox[bp]-[A-Za-z0-9-]{10,})\b/g;
7
8
  export function redactSecrets(text) {
8
9
  return text
9
10
  .replace(credentialUrlPattern, "$1<redacted>@")
10
11
  .replace(authHeaderPattern, (_match, header) => `${header}: <redacted>`)
11
12
  .replace(npmrcAuthPattern, "$1=<redacted>")
13
+ .replace(jsonSecretPattern, '$1"<redacted>"')
12
14
  .replace(tokenAssignmentPattern, "$1=<redacted>")
13
15
  .replace(bearerPattern, "Bearer <redacted>")
14
16
  .replace(knownTokenShapePattern, "<redacted>");
15
17
  }
16
18
  const STREAM_FLUSH_QUIET_MS = 80;
17
19
  const SECRET_TAIL_SCAN_CHARS = 80;
18
- const secretTailPattern = /(Bearer\s+[\w.~+/=-]*|npm_[A-Za-z0-9]*|gh[pousr]_[A-Za-z0-9]*|pypi-[\w-]*|(?:_authToken|_auth|_password|token|authToken|password|secret)=\S*)$/i;
20
+ const secretTailPattern = /(Bearer\s+[\w.~+/=-]*|npm_[A-Za-z0-9]*|gh[pousr]_[A-Za-z0-9]*|pypi-[\w-]*|glpat-[\w-]*|xox[bp]-[\w-]*|[\w-]*(?:_authToken|_auth|_password|secret[_-]key|access[_-]key|api[_-]key|token|password|secret)=\S*|"[\w.$-]*(?:secret[_-]key|access[_-]key|api[_-]key|token|password|secret)"\s*:\s*"?[^"\n]*)$/i;
19
21
  export function createStreamRedactor(emit) {
20
22
  let pending = "";
21
23
  let timer;
@@ -1,4 +1,4 @@
1
- export function parsePipReportInstallCount(stdout) {
1
+ function parseInstallArray(stdout) {
2
2
  const trimmed = stdout.trim();
3
3
  if (!trimmed)
4
4
  return undefined;
@@ -10,7 +10,7 @@ export function parsePipReportInstallCount(stdout) {
10
10
  try {
11
11
  const parsed = JSON.parse(candidate);
12
12
  if (Array.isArray(parsed.install)) {
13
- return parsed.install.length;
13
+ return parsed.install;
14
14
  }
15
15
  }
16
16
  catch {
@@ -19,3 +19,19 @@ export function parsePipReportInstallCount(stdout) {
19
19
  }
20
20
  return undefined;
21
21
  }
22
+ export function parsePipReportInstallCount(stdout) {
23
+ return parseInstallArray(stdout)?.length;
24
+ }
25
+ export function parsePipReportInstallSet(stdout) {
26
+ const install = parseInstallArray(stdout);
27
+ if (!install)
28
+ return undefined;
29
+ const set = [];
30
+ for (const entry of install) {
31
+ const metadata = entry.metadata;
32
+ if (metadata && typeof metadata.name === "string" && typeof metadata.version === "string") {
33
+ set.push({ name: metadata.name, version: metadata.version });
34
+ }
35
+ }
36
+ return set;
37
+ }
@@ -1,9 +1,10 @@
1
1
  import { analyzePackages } from "../api/analyze.js";
2
2
  import { isCiEnv } from "../presentation/mode.js";
3
3
  import { renderInstallDecision } from "../install-ui/block-render.js";
4
- import { promptYesNo, defaultPromptIo } from "../install-ui/prompt.js";
4
+ import { defaultPromptIo } from "../install-ui/prompt.js";
5
5
  import { enforceProtectedInstall } from "../proxy/enforcement.js";
6
6
  import { classifyPackageManagerInvocation, isSupportedPackageManager } from "./classify.js";
7
+ import { actionRank, promptPreflightYesNo, recordPreflightApprovals } from "./install-preflight.js";
7
8
  import { redactSecrets } from "./output-redaction.js";
8
9
  const ECOSYSTEM_BY_MANAGER = {
9
10
  npm: "npm",
@@ -37,25 +38,27 @@ export async function maybePreflightInstallPrompt(args, options = {}) {
37
38
  if (specs.length === 0) {
38
39
  return FALL_THROUGH;
39
40
  }
40
- let worst;
41
+ const flagged = [];
41
42
  try {
42
43
  const response = await (options.analyze ?? analyzePackages)(specs.map((spec) => ({ name: spec.name, version: spec.version })), { ecosystem, env });
43
44
  for (const spec of specs) {
44
45
  const pkg = response.packages.find((entry) => entry.name === spec.name && entry.version === spec.version);
45
46
  const action = pkg?.action ?? "pass";
46
- if (!worst || rank(action) > rank(worst.action)) {
47
- worst = {
48
- action,
49
- spec,
50
- reason: pkg?.reasons[0] ?? pkg?.findings[0]?.title ?? "flagged by the scanner"
51
- };
47
+ if (action === "pass") {
48
+ continue;
52
49
  }
50
+ flagged.push({
51
+ action,
52
+ spec,
53
+ reason: pkg?.reasons[0] ?? pkg?.findings[0]?.title ?? "flagged by the scanner"
54
+ });
53
55
  }
54
56
  }
55
57
  catch {
56
58
  return FALL_THROUGH;
57
59
  }
58
- if (!worst || worst.action === "pass") {
60
+ const worst = flagged.reduce((top, entry) => (!top || actionRank(entry.action) > actionRank(top.action) ? entry : top), undefined);
61
+ if (!worst) {
59
62
  return FALL_THROUGH;
60
63
  }
61
64
  if (worst.action === "block") {
@@ -80,8 +83,20 @@ export async function maybePreflightInstallPrompt(args, options = {}) {
80
83
  return FALL_THROUGH;
81
84
  }
82
85
  const label = `${worst.spec.name}@${worst.spec.version}`;
83
- const proceed = await promptYesNo(`⚠ DG flagged ${label} (warn) — ${worst.reason}. Proceed?`, io);
86
+ if (worst.action === "analysis_incomplete") {
87
+ const proceed = await promptPreflightYesNo(`? DG could not fully analyze ${label} (analysis incomplete) — ${worst.reason}. Proceed?`, io, true);
88
+ if (proceed) {
89
+ recordPreflightApprovals(flagged.map(asFlaggedPackage));
90
+ return FALL_THROUGH;
91
+ }
92
+ return {
93
+ handled: true,
94
+ result: { exitCode: 4, stdout: "", stderr: "Declined. Nothing was installed.\n" }
95
+ };
96
+ }
97
+ const proceed = await promptPreflightYesNo(`⚠ DG flagged ${label} (warn) — ${worst.reason}. Proceed?`, io, false);
84
98
  if (proceed) {
99
+ recordPreflightApprovals(flagged.map(asFlaggedPackage));
85
100
  return FALL_THROUGH;
86
101
  }
87
102
  return {
@@ -89,8 +104,12 @@ export async function maybePreflightInstallPrompt(args, options = {}) {
89
104
  result: { exitCode: 1, stdout: "", stderr: "Declined. Nothing was installed.\n" }
90
105
  };
91
106
  }
92
- function rank(action) {
93
- return { pass: 0, analysis_incomplete: 1, warn: 2, block: 3 }[action];
107
+ function asFlaggedPackage(entry) {
108
+ return {
109
+ name: entry.spec.name,
110
+ version: entry.spec.version,
111
+ action: entry.action
112
+ };
94
113
  }
95
114
  function stripControlArgs(args) {
96
115
  const childArgs = [];