@westbayberry/dg 2.3.3 → 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.
Files changed (122) hide show
  1. package/README.md +94 -217
  2. package/dist/agents/copilot-cli.js +7 -2
  3. package/dist/agents/persistence.js +83 -8
  4. package/dist/agents/registry.js +5 -2
  5. package/dist/agents/routing.js +31 -14
  6. package/dist/api/analyze.js +138 -41
  7. package/dist/audit/deep.js +2 -1
  8. package/dist/audit/detectors.js +23 -2
  9. package/dist/audit/rules.js +4 -1
  10. package/dist/audit-ui/export.js +5 -4
  11. package/dist/auth/device-login.js +33 -11
  12. package/dist/auth/login-app.js +17 -12
  13. package/dist/auth/store.js +65 -9
  14. package/dist/bin/dg.js +1 -1
  15. package/dist/commands/audit.js +12 -20
  16. package/dist/commands/{guard-commit.js → commit-guard.js} +12 -12
  17. package/dist/commands/cooldown.js +2 -1
  18. package/dist/commands/decisions.js +8 -7
  19. package/dist/commands/help.js +11 -3
  20. package/dist/commands/licenses.js +17 -8
  21. package/dist/commands/login.js +58 -21
  22. package/dist/commands/router.js +12 -2
  23. package/dist/commands/sbom.js +20 -5
  24. package/dist/commands/scan.js +5 -4
  25. package/dist/commands/service.js +1 -1
  26. package/dist/commands/setup.js +12 -12
  27. package/dist/commands/status.js +4 -4
  28. package/dist/commands/uninstall.js +1 -1
  29. package/dist/commands/update.js +20 -0
  30. package/dist/commands/verify.js +4 -4
  31. package/dist/config/settings.js +81 -12
  32. package/dist/export-ui/ExportDialog.js +1 -1
  33. package/dist/gate/cooldown-request.js +22 -0
  34. package/dist/gate/egress-guard.js +185 -0
  35. package/dist/gate/host-match.js +48 -0
  36. package/dist/gate/verdict-core.js +101 -0
  37. package/dist/gate/verdict-fetch.js +214 -0
  38. package/dist/launcher/agent-check.js +1096 -42
  39. package/dist/launcher/agent-hook-exec.js +8 -1
  40. package/dist/launcher/agent-hook-io.js +5 -3
  41. package/dist/launcher/classify.js +110 -26
  42. package/dist/launcher/env.js +84 -18
  43. package/dist/launcher/install-preflight.js +26 -3
  44. package/dist/launcher/live-install.js +4 -2
  45. package/dist/launcher/manifest-screen.js +88 -7
  46. package/dist/launcher/output-redaction.js +153 -15
  47. package/dist/launcher/preflight-prompt.js +1 -1
  48. package/dist/launcher/resolve-real-binary.js +8 -3
  49. package/dist/launcher/run.js +237 -40
  50. package/dist/origin/artifact-cache.js +55 -0
  51. package/dist/origin/cargo-config.js +12 -0
  52. package/dist/origin/gate-mode.js +7 -0
  53. package/dist/origin/platform-paths.js +14 -0
  54. package/dist/origin/precedence.js +345 -0
  55. package/dist/origin/server.js +611 -0
  56. package/dist/origin/spike-worker.js +35 -0
  57. package/dist/origin/worker.js +116 -0
  58. package/dist/presentation/package-page.js +21 -1
  59. package/dist/presentation/safe-version.js +11 -0
  60. package/dist/presentation/sarif.js +109 -0
  61. package/dist/project/dgfile.js +54 -17
  62. package/dist/project/override-trust.js +0 -0
  63. package/dist/proxy/enforcement.js +27 -7
  64. package/dist/proxy/metadata-map.js +33 -7
  65. package/dist/proxy/preverified.js +0 -3
  66. package/dist/proxy/server.js +104 -446
  67. package/dist/proxy/upstream-proxy.js +23 -4
  68. package/dist/proxy/worker.js +3 -2
  69. package/dist/publish-set/collect.js +6 -0
  70. package/dist/runtime/fatal.js +2 -1
  71. package/dist/runtime/first-run.js +5 -1
  72. package/dist/runtime/node-version.js +51 -0
  73. package/dist/runtime/nudges.js +27 -1
  74. package/dist/sbom/cyclonedx.js +120 -33
  75. package/dist/sbom/graph.js +236 -0
  76. package/dist/sbom/spdx.js +53 -0
  77. package/dist/sbom-ui/SbomApp.js +5 -3
  78. package/dist/sbom-ui/inventory.js +8 -10
  79. package/dist/scan/analyze-worker.js +3 -1
  80. package/dist/scan/collect.js +38 -8
  81. package/dist/scan/command.js +77 -27
  82. package/dist/scan/discovery.js +18 -7
  83. package/dist/scan/installed.js +202 -0
  84. package/dist/scan/manifest-coverage.js +128 -0
  85. package/dist/scan/render.js +125 -40
  86. package/dist/scan/scanner-report.js +92 -22
  87. package/dist/scan/staged.js +68 -15
  88. package/dist/scan/types.js +7 -1
  89. package/dist/scan-ui/LegacyApp.js +3 -3
  90. package/dist/scan-ui/components/InteractiveResultsView.js +17 -14
  91. package/dist/scan-ui/hooks/useScan.js +37 -11
  92. package/dist/scan-ui/launch.js +2 -2
  93. package/dist/scan-ui/logo.js +6 -2
  94. package/dist/scan-ui/shims.js +6 -6
  95. package/dist/scripts/gate.js +53 -35
  96. package/dist/security/csv.js +9 -0
  97. package/dist/security/sanitize.js +11 -2
  98. package/dist/service/state.js +120 -37
  99. package/dist/service/trust-refresh.js +12 -1
  100. package/dist/service/trust-store.js +36 -5
  101. package/dist/service/worker.js +5 -7
  102. package/dist/setup/git-hook.js +2 -2
  103. package/dist/setup/install-method.js +36 -0
  104. package/dist/setup/plan.js +119 -57
  105. package/dist/setup/uninstall-standalone.js +7 -2
  106. package/dist/setup-ui/gate.js +1 -8
  107. package/dist/setup-ui/offer.js +14 -7
  108. package/dist/setup-ui/selector.js +1 -1
  109. package/dist/setup-ui/wizard.js +73 -78
  110. package/dist/standalone/uninstall.mjs +1114 -386
  111. package/dist/state/cleanup-registry.js +14 -3
  112. package/dist/state/cooldown-held.js +36 -13
  113. package/dist/state/locks.js +239 -34
  114. package/dist/state/sessions.js +2 -2
  115. package/dist/util/sh-escape.js +6 -0
  116. package/dist/util/tty-prompt.js +13 -4
  117. package/dist/verify/local.js +60 -10
  118. package/dist/verify/package-check.js +35 -49
  119. package/dist/verify/preflight.js +59 -33
  120. package/dist/verify/render.js +11 -12
  121. package/npm-shrinkwrap.json +714 -213
  122. package/package.json +3 -2
@@ -1,8 +1,9 @@
1
- import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
1
+ import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
2
2
  import { randomUUID } from "node:crypto";
3
- import { dirname, join } from "node:path";
4
- import { loadUserConfig, parseUrl, saveUserConfig, withUserConfigLock } from "../config/settings.js";
3
+ import { basename, dirname, join } from "node:path";
4
+ import { assertVendorApiBaseUrl, loadUserConfig, parseUrl, saveUserConfig, withUserConfigLock } from "../config/settings.js";
5
5
  import { resolveDgPaths } from "../state/index.js";
6
+ import { sanitizeLine } from "../security/sanitize.js";
6
7
  import { envAuthToken } from "./env-token.js";
7
8
  export class AuthError extends Error {
8
9
  constructor(message) {
@@ -13,31 +14,62 @@ export class AuthError extends Error {
13
14
  export function authPath(paths) {
14
15
  return join(paths.configDir, "auth.json");
15
16
  }
17
+ // The bearer token is written 0600, so any group/other access bit means the file
18
+ // was loosened externally and the token must be treated as potentially exposed.
19
+ // Restore 0600 to close the leak; if it cannot be secured, refuse to trust it.
20
+ function enforceAuthFilePerms(path) {
21
+ let mode;
22
+ try {
23
+ mode = statSync(path).mode & 0o777;
24
+ }
25
+ catch {
26
+ return;
27
+ }
28
+ if ((mode & 0o077) === 0) {
29
+ return;
30
+ }
31
+ try {
32
+ chmodSync(path, 0o600);
33
+ }
34
+ catch {
35
+ throw new AuthError(`auth state at ${path} is group- or world-accessible and could not be secured; fix its permissions (chmod 600) or run 'dg login' again`);
36
+ }
37
+ }
16
38
  export function readAuthState(env = process.env) {
17
39
  const paths = resolveDgPaths(env);
18
40
  const path = authPath(paths);
19
41
  if (!existsSync(path)) {
20
42
  return undefined;
21
43
  }
44
+ enforceAuthFilePerms(path);
22
45
  try {
23
46
  const parsed = JSON.parse(readFileSync(path, "utf8"));
24
47
  if (parsed.version !== 1 || !parsed.token || !parsed.apiBaseUrl || parsed.loggedInAt === undefined) {
25
48
  throw new AuthError("unsupported auth state");
26
49
  }
50
+ let allowNonVendor = false;
51
+ try {
52
+ allowNonVendor = loadUserConfig(env).policy.allowNonVendorApiHost;
53
+ }
54
+ catch {
55
+ allowNonVendor = false;
56
+ }
27
57
  let apiBaseUrl;
28
58
  try {
29
- apiBaseUrl = parseUrl(parsed.apiBaseUrl);
59
+ apiBaseUrl = assertVendorApiBaseUrl(parseUrl(parsed.apiBaseUrl), allowNonVendor);
30
60
  }
31
61
  catch {
32
- throw new AuthError("auth state has an invalid api base URL; run 'dg login' again");
62
+ throw new AuthError("auth state has an invalid or non-vendor api base URL; run 'dg login' again");
33
63
  }
34
- const email = typeof parsed.email === "string" && parsed.email.length > 0 ? parsed.email : undefined;
35
- const tier = typeof parsed.tier === "string" && parsed.tier.length > 0 ? parsed.tier : undefined;
36
- const name = typeof parsed.name === "string" && parsed.name.length > 0 ? parsed.name : undefined;
64
+ // email / tier / name / tokenPreview are read from the auth file and rendered
65
+ // by `dg status`; a tampered file must not inject ANSI/control sequences.
66
+ const email = typeof parsed.email === "string" && parsed.email.length > 0 ? sanitizeLine(parsed.email) : undefined;
67
+ const tier = typeof parsed.tier === "string" && parsed.tier.length > 0 ? sanitizeLine(parsed.tier) : undefined;
68
+ const name = typeof parsed.name === "string" && parsed.name.length > 0 ? sanitizeLine(parsed.name) : undefined;
37
69
  return {
38
70
  version: 1,
39
71
  token: parsed.token,
40
- tokenPreview: parsed.tokenPreview ?? redactToken(parsed.token),
72
+ tokenPreview: sanitizeLine(parsed.tokenPreview ?? redactToken(parsed.token)),
41
73
  apiBaseUrl,
42
74
  orgId: parsed.orgId ?? "",
43
75
  loggedInAt: parsed.loggedInAt,
@@ -104,6 +136,7 @@ export function writeAuthState(options, env = process.env) {
104
136
  }
105
137
  export function clearAuthState(env = process.env) {
106
138
  const path = authPath(resolveDgPaths(env));
139
+ sweepOrphanAuthTemps(path);
107
140
  if (!existsSync(path)) {
108
141
  return false;
109
142
  }
@@ -153,6 +186,29 @@ export function redactToken(token) {
153
186
  }
154
187
  return `${trimmed.slice(0, 4)}...${trimmed.slice(-4)}`;
155
188
  }
189
+ // A SIGKILL between the temp write and the rename in writeJsonAtomic orphans a
190
+ // temp file that still holds the full token. Sweep them on logout (and before the
191
+ // canonical file is removed) so a credential is not left behind on disk.
192
+ function sweepOrphanAuthTemps(path) {
193
+ const dir = dirname(path);
194
+ const base = basename(path);
195
+ const pattern = new RegExp(`^${base.replace(/[.]/g, "\\.")}\\.\\d+\\.[0-9a-f-]{36}\\.tmp$`);
196
+ try {
197
+ for (const entry of readdirSync(dir)) {
198
+ if (pattern.test(entry)) {
199
+ try {
200
+ unlinkSync(join(dir, entry));
201
+ }
202
+ catch {
203
+ // a concurrent writer's temp, or already gone — skip
204
+ }
205
+ }
206
+ }
207
+ }
208
+ catch {
209
+ // configDir missing — nothing to sweep
210
+ }
211
+ }
156
212
  function writeJsonAtomic(path, value) {
157
213
  mkdirSync(dirname(path), {
158
214
  recursive: true,
package/dist/bin/dg.js CHANGED
@@ -8,7 +8,7 @@ process.on("uncaughtException", (error) => {
8
8
  }
9
9
  exitOnFatal(error);
10
10
  });
11
- process.on("unhandledRejection", exitOnFatal);
11
+ process.on("unhandledRejection", (reason) => exitOnFatal(reason));
12
12
  process.stdout.on("error", (error) => {
13
13
  if (error.code === "EPIPE") {
14
14
  process.exit(process.exitCode ?? 0);
@@ -4,8 +4,9 @@ import { writeReportAtomic } from "../util/report-writer.js";
4
4
  import { EXIT_TOOL_ERROR, EXIT_USAGE_VERDICT } from "./types.js";
5
5
  import { resolvePresentation } from "../presentation/mode.js";
6
6
  import { createTheme } from "../presentation/theme.js";
7
- import { actionForFindings, detectFindings, findingLocation } from "../audit/detectors.js";
7
+ import { actionForFindings, detectFindings, findingEvidenceSafe, findingLocation, findingTitleSafe } from "../audit/detectors.js";
8
8
  import { buildAuditFile } from "../publish-set/collect.js";
9
+ import { sanitizeLine } from "../security/sanitize.js";
9
10
  import { npmPublishSet } from "../publish-set/npm.js";
10
11
  import { pypiPublishSet } from "../publish-set/pypi.js";
11
12
  import { deepDecision, runDeepUpload, consentGiven, deepSummary, teamPolicyBlocksUpload } from "../audit/deep.js";
@@ -33,11 +34,11 @@ export const auditCommand = {
33
34
  "Audits exactly what you're about to publish — the resolved publish set of one package, never the whole repo.",
34
35
  "Basic checks run 100% locally and never upload anything. If you're on a paid plan (and your org allows it), it also runs a deep behavioral scan of your package on the scanner; raw bytes are never retained. Exit codes: 0 clean (warn counts as clean under the default --fail-on block), 1 warn with --fail-on warn, 2 block, 3 deep audit required but unavailable (--require-deep), 4 analysis incomplete, 64 usage error."
35
36
  ],
36
- handler: (context) => runAuditCommand(context.args)
37
+ handler: async (context) => (await maybeAudit(["audit", ...context.args])).result
37
38
  };
38
39
  function privatePackageResult(scope) {
39
40
  const theme = createTheme(resolvePresentation().color);
40
- const name = typeof scope.packageJson?.name === "string" ? scope.packageJson.name : basename(scope.root);
41
+ const name = sanitizeLine(typeof scope.packageJson?.name === "string" ? scope.packageJson.name : basename(scope.root));
41
42
  return {
42
43
  exitCode: 0,
43
44
  stdout: `${theme.paint("pass", "✓")} ${name} is private — npm refuses to publish it, so there is nothing to audit ${theme.paint("muted", "(remove \"private\": true to audit it for publish)")}\n`,
@@ -97,17 +98,6 @@ function finalize(gathered, deep) {
97
98
  function gatherError(gathered) {
98
99
  return gathered.usage ? usageError(gathered.error) : { exitCode: EXIT_USAGE_VERDICT, stdout: "", stderr: `dg audit: ${gathered.error}.\n` };
99
100
  }
100
- function runAuditCommand(args) {
101
- const gathered = gather(args);
102
- if ("error" in gathered) {
103
- return gatherError(gathered);
104
- }
105
- if ("privatePackage" in gathered) {
106
- return privatePackageResult(gathered.privatePackage);
107
- }
108
- const decision = deepDecision(gathered.scope, gathered.parsed.local);
109
- return finalize(gathered, { ran: false, reason: decision.reason });
110
- }
111
101
  export async function maybeAudit(args) {
112
102
  if (args[0] !== "audit") {
113
103
  return { handled: false, result: { exitCode: 0, stdout: "", stderr: "" } };
@@ -234,15 +224,17 @@ function resolveScope(target) {
234
224
  const packageJson = safeReadJson(resolve(root, "package.json"));
235
225
  const name = packageJson && typeof packageJson.name === "string" ? packageJson.name : basename(root);
236
226
  const version = packageJson && typeof packageJson.version === "string" ? packageJson.version : "(unknown)";
237
- return { root, ecosystem: "npm", packageJson, artifact: `${name}@${version}` };
227
+ // name/version are free-text from a (possibly hostile) package.json rendered
228
+ // into the header and report; strip control bytes so they cannot inject ANSI.
229
+ return { root, ecosystem: "npm", packageJson, artifact: sanitizeLine(`${name}@${version}`) };
238
230
  }
239
231
  if (existsSync(resolve(root, "pyproject.toml")) || existsSync(resolve(root, "setup.py")) || existsSync(resolve(root, "setup.cfg"))) {
240
- return { root, ecosystem: "pypi", packageJson: null, artifact: basename(root) };
232
+ return { root, ecosystem: "pypi", packageJson: null, artifact: sanitizeLine(basename(root)) };
241
233
  }
242
234
  if (existsSync(resolve(root, "Cargo.toml"))) {
243
- return { root, ecosystem: "cargo", packageJson: null, artifact: basename(root) };
235
+ return { root, ecosystem: "cargo", packageJson: null, artifact: sanitizeLine(basename(root)) };
244
236
  }
245
- return { root, ecosystem: "unknown", packageJson: null, artifact: basename(root) };
237
+ return { root, ecosystem: "unknown", packageJson: null, artifact: sanitizeLine(basename(root)) };
246
238
  }
247
239
  function findPackageRoot(start) {
248
240
  let current = start;
@@ -292,9 +284,9 @@ function renderReport(report, theme) {
292
284
  ? theme.paint("warn", WARN_GLYPH)
293
285
  : theme.paint("muted", "·");
294
286
  lines.push(` ${tag} ${accent(findingLocation(finding))}`);
295
- lines.push(` ${finding.title}`);
287
+ lines.push(` ${findingTitleSafe(finding)}`);
296
288
  if (finding.evidence && finding.evidence !== `path: ${finding.location}` && finding.evidence !== finding.location) {
297
- lines.push(` ${muted(finding.evidence)}`);
289
+ lines.push(` ${muted(findingEvidenceSafe(finding))}`);
298
290
  }
299
291
  lines.push(` ${accent("→")} ${finding.recommendation}`);
300
292
  lines.push("");
@@ -3,17 +3,17 @@ import { resolvePresentation } from "../presentation/mode.js";
3
3
  import { createTheme } from "../presentation/theme.js";
4
4
  import { promptYesNo } from "../util/tty-prompt.js";
5
5
  import { applyGitHook, gitHookState, planGitHook, removeGitHookForRepo, resolveGitRepo, verifyGitHook } from "../setup/git-hook.js";
6
- export const guardCommitCommand = {
7
- name: "guard-commit",
6
+ export const commitGuardCommand = {
7
+ name: "commit-guard",
8
8
  summary: "Scan staged dependencies before every commit in this repo.",
9
- usage: "dg guard-commit [off | --check | --print] [--yes]",
9
+ usage: "dg commit-guard [off | --check | --print] [--yes]",
10
10
  args: [{ name: "[off]", summary: "Remove the hook and restore any hook it chained (alias remove/uninstall)." }],
11
11
  flags: [
12
12
  { flag: "--check", summary: "Verify the hook is installed and will actually fire." },
13
13
  { flag: "--print", summary: "Preview what it will write, change nothing." },
14
14
  { flag: "--yes", summary: "Install without the confirmation prompt (alias -y)." }
15
15
  ],
16
- examples: ["dg guard-commit", "dg guard-commit --check", "dg guard-commit off"],
16
+ examples: ["dg commit-guard", "dg commit-guard --check", "dg commit-guard off"],
17
17
  details: [
18
18
  "Installs a git pre-commit hook that scans staged lockfile changes and blocks a commit that would add a malicious dependency. Override a block with 'git commit --no-verify'.",
19
19
  "It resolves the real hooks directory (honouring core.hooksPath/husky and worktrees) and chains any existing pre-commit hook. Tune behaviour with the gitHook.onWarn / gitHook.onIncomplete config keys."
@@ -53,13 +53,13 @@ function run(context) {
53
53
  return {
54
54
  exitCode: EXIT_USAGE,
55
55
  stdout: "",
56
- stderr: `dg guard-commit: ${parsed.error}. Usage: ${guardCommitCommand.usage}\n`
56
+ stderr: `dg commit-guard: ${parsed.error}. Usage: ${commitGuardCommand.usage}\n`
57
57
  };
58
58
  }
59
59
  const theme = createTheme(resolvePresentation().color);
60
60
  const repo = resolveGitRepo();
61
61
  if ("error" in repo) {
62
- return { exitCode: EXIT_USAGE, stdout: "", stderr: `dg guard-commit: ${repo.error}.\n` };
62
+ return { exitCode: EXIT_USAGE, stdout: "", stderr: `dg commit-guard: ${repo.error}.\n` };
63
63
  }
64
64
  if (parsed.off) {
65
65
  return remove(repo, theme);
@@ -82,7 +82,7 @@ function install(repo, theme, yes) {
82
82
  if (plan.willChain) {
83
83
  err.write(` ${muted("Your existing pre-commit hook will be kept and run after dg.")}\n`);
84
84
  }
85
- err.write(` ${muted("Reversible with")} ${accent("dg guard-commit off")}${muted(".")}\n\n`);
85
+ err.write(` ${muted("Reversible with")} ${accent("dg commit-guard off")}${muted(".")}\n\n`);
86
86
  const proceed = promptYesNo(` ${accent("Proceed?")}`, true);
87
87
  if (proceed === false) {
88
88
  return { exitCode: 0, stdout: "", stderr: ` ${muted("cancelled — nothing written")}\n` };
@@ -107,7 +107,7 @@ function check(repo, theme) {
107
107
  return {
108
108
  exitCode: 1,
109
109
  stdout: "",
110
- stderr: ` ${theme.paint("warn", "⚠")} not set up in this repo — run ${theme.paint("accent", "dg guard-commit")}\n`
110
+ stderr: ` ${theme.paint("warn", "⚠")} not set up in this repo — run ${theme.paint("accent", "dg commit-guard")}\n`
111
111
  };
112
112
  }
113
113
  const checks = verifyGitHook(repo);
@@ -120,9 +120,9 @@ function check(repo, theme) {
120
120
  function print(repo) {
121
121
  const plan = planGitHook(repo);
122
122
  const lines = [
123
- "dg guard-commit write plan",
123
+ "dg commit-guard write plan",
124
124
  "",
125
- "No files are changed until you run dg guard-commit.",
125
+ "No files are changed until you run dg commit-guard.",
126
126
  `- write pre-commit hook: ${plan.context.hookTarget}`
127
127
  ];
128
128
  if (plan.willChain) {
@@ -131,7 +131,7 @@ function print(repo) {
131
131
  else if (plan.state === "managed") {
132
132
  lines.push("- (a dg hook is already installed; it will be refreshed)");
133
133
  }
134
- lines.push("- record it for 'dg guard-commit off' / 'dg uninstall'");
134
+ lines.push("- record it for 'dg commit-guard off' / 'dg uninstall'");
135
135
  return { exitCode: 0, stdout: `${lines.join("\n")}\n`, stderr: "" };
136
136
  }
137
137
  function remove(repo, theme) {
@@ -140,7 +140,7 @@ function remove(repo, theme) {
140
140
  return {
141
141
  exitCode: 0,
142
142
  stdout: "",
143
- stderr: ` ${theme.paint("muted", "no dg guard-commit hook in this repo")}\n`
143
+ stderr: ` ${theme.paint("muted", "no dg commit-guard hook in this repo")}\n`
144
144
  };
145
145
  }
146
146
  const restored = result.removed.some((path) => path.includes(".dg-chained-"));
@@ -4,6 +4,7 @@ import { canonicalCooldownName } from "../policy/pypi-name.js";
4
4
  import { ConfigError, loadUserConfig, setConfigValue, updateUserConfig } from "../config/settings.js";
5
5
  import { appendCooldownExemptions, cooldownExemptionActive, CooldownExemptionCapError, findProjectRoot, loadDgFile, mutateDgFile, removeCooldownExemptions, resolveAcceptedBy } from "../project/dgfile.js";
6
6
  import { EXIT_USAGE } from "./types.js";
7
+ import { sanitizeLine } from "../security/sanitize.js";
7
8
  export const cooldownCommand = {
8
9
  name: "cooldown",
9
10
  summary: "Hold new releases for a window before they can install, with per-package exemptions.",
@@ -342,7 +343,7 @@ function listExemptions(cwd, env, json, now) {
342
343
  }
343
344
  const rows = file.cooldownExemptions.map((e) => [
344
345
  `${e.ecosystem}:${e.name}`,
345
- e.reason || "-",
346
+ sanitizeLine(e.reason) || "-",
346
347
  e.acceptedBy || "-",
347
348
  e.acceptedAt.slice(0, 10) || "-",
348
349
  e.expiresAt ? formatExpiryDisplay(e.expiresAt, e.acceptedAt) : "-",
@@ -1,5 +1,6 @@
1
1
  import { recordDecisionEvents } from "../decisions/remember-prompt.js";
2
2
  import { packageKey } from "../decisions/apply.js";
3
+ import { sanitizeLine } from "../security/sanitize.js";
3
4
  import { findProjectRoot, loadDgFile, mutateDgFile, removeDecisions } from "../project/dgfile.js";
4
5
  import { EXIT_USAGE } from "./types.js";
5
6
  export const decisionsCommand = {
@@ -63,11 +64,11 @@ function listDecisions(cwd, env, json) {
63
64
  }
64
65
  const rows = file.decisions.map((entry) => [
65
66
  entry.id.slice(0, 8),
66
- `${entry.ecosystem}:${entry.name}@${scopeLabel(entry)}`,
67
+ `${entry.ecosystem}:${sanitizeLine(entry.name)}@${scopeLabel(entry)}`,
67
68
  findingsLabel(entry),
68
- entry.acceptedBy,
69
- entry.acceptedAt.slice(0, 10) || "-",
70
- entry.expiresAt ? entry.expiresAt.slice(0, 10) : "-",
69
+ sanitizeLine(entry.acceptedBy),
70
+ sanitizeLine(entry.acceptedAt).slice(0, 10) || "-",
71
+ entry.expiresAt ? sanitizeLine(entry.expiresAt).slice(0, 10) : "-",
71
72
  isExpired(entry) ? "expired" : "active"
72
73
  ]);
73
74
  const header = ["ID", "PACKAGE", "ACCEPTED FINDINGS", "BY", "WHEN", "EXPIRES", "STATUS"];
@@ -103,7 +104,7 @@ function revokeDecisions(selector, cwd, env) {
103
104
  return { exitCode: 1, stdout: "", stderr: `dg decisions: nothing matches '${selector}' in ${located.file.path}.\n` };
104
105
  }
105
106
  recordDecisionEvents("decision.revoked", matched.map((entry) => `${entry.ecosystem}:${packageKey(entry.name, scopeLabel(entry))}`), `revoked via dg decisions (${selector})`, env);
106
- const lines = matched.map((entry) => `Revoked ${entry.ecosystem}:${entry.name}@${scopeLabel(entry)} (${entry.id.slice(0, 8)}) — the warn will surface again.`);
107
+ const lines = matched.map((entry) => `Revoked ${entry.ecosystem}:${sanitizeLine(entry.name)}@${scopeLabel(entry)} (${entry.id.slice(0, 8)}) — the warn will surface again.`);
107
108
  return { exitCode: 0, stdout: `${lines.join("\n")}\n`, stderr: "" };
108
109
  }
109
110
  function locateDgFile(cwd, env) {
@@ -133,10 +134,10 @@ function matchesSelector(entry, selector) {
133
134
  return entry.name === selector;
134
135
  }
135
136
  function scopeLabel(entry) {
136
- return entry.scope.kind === "exact" ? entry.scope.version : "*";
137
+ return entry.scope.kind === "exact" ? sanitizeLine(entry.scope.version) : "*";
137
138
  }
138
139
  function findingsLabel(entry) {
139
- const pairs = Object.entries(entry.findings).map(([category, severity]) => `${category}:${severity}`);
140
+ const pairs = Object.entries(entry.findings).map(([category, severity]) => `${sanitizeLine(category)}:${severity}`);
140
141
  return pairs.length > 0 ? pairs.sort().join(",") : "(action-only warn)";
141
142
  }
142
143
  function isExpired(entry) {
@@ -1,6 +1,7 @@
1
+ import { optionalPackageManagerNames } from "../setup/optional-support.js";
1
2
  const PRODUCT_DESCRIPTION = "Dependency Guardian";
2
- const COMMON_COMMANDS = ["scan", "verify", "audit", "licenses", "setup", "guard-commit", "doctor", "login"];
3
- const GLOBAL_FLAGS_FOOTER = "Global: --help/-h · --version/-v · --json (where supported) · --no-color / --force-color (also NO_COLOR, FORCE_COLOR).";
3
+ const COMMON_COMMANDS = ["scan", "verify", "audit", "licenses", "setup", "commit-guard", "doctor", "login"];
4
+ const GLOBAL_FLAGS_FOOTER = "Global: --help/-h · --version/-v";
4
5
  function isWrapper(command) {
5
6
  return command.summary.includes("prefix-mode routing");
6
7
  }
@@ -37,7 +38,14 @@ export function renderRootHelp(version, commands, options = {}) {
37
38
  for (const command of standalone) {
38
39
  lines.push(` ${command.name.padEnd(14)} ${command.summary}`);
39
40
  }
40
- lines.push("", "Package-manager prefix mode (run any through dg):", ` ${wrappers.map((command) => command.name).join(", ")}`);
41
+ const gated = new Set(optionalPackageManagerNames());
42
+ const wrapperNames = wrappers.map((command) => command.name);
43
+ const claimed = wrapperNames.filter((name) => !gated.has(name));
44
+ const gatedNames = wrapperNames.filter((name) => gated.has(name));
45
+ lines.push("", "Package-manager prefix mode (run any through dg):", ` ${claimed.join(", ")}`);
46
+ if (gatedNames.length > 0) {
47
+ lines.push("", "Gated (refuse with exit 69 until their support gate ships):", ` ${gatedNames.join(", ")}`);
48
+ }
41
49
  lines.push("", "Run 'dg <command> --help' for that command's flags and examples.", GLOBAL_FLAGS_FOOTER);
42
50
  return `${lines.join("\n")}\n`;
43
51
  }
@@ -4,7 +4,9 @@ import { writeReportAtomic } from "../util/report-writer.js";
4
4
  import { basename, dirname, relative, resolve, sep } from "node:path";
5
5
  import { scanProject } from "../scan/discovery.js";
6
6
  import { isSupportedLockfilePath, verifyLockfile } from "../verify/preflight.js";
7
- import { EXIT_ANALYSIS_INCOMPLETE, EXIT_USAGE_VERDICT } from "./types.js";
7
+ import { EXIT_ANALYSIS_INCOMPLETE, EXIT_TOOL_ERROR, EXIT_USAGE_VERDICT } from "./types.js";
8
+ import { sanitizeLine } from "../security/sanitize.js";
9
+ import { csvCell } from "../security/csv.js";
8
10
  const LICENSE_RISKS = [
9
11
  "network-copyleft",
10
12
  "no-license",
@@ -40,7 +42,7 @@ export const licensesCommand = {
40
42
  examples: ["dg licenses", "dg licenses --markdown -o licenses.md", "dg licenses --fail-on strong-copyleft,network-copyleft"],
41
43
  details: [
42
44
  "Reports project and lockfile license metadata without running package managers or package code.",
43
- "License exports support text, JSON, CSV, and Markdown. Exit codes: 0 pass, 2 policy block, 4 report error, 64 usage error."
45
+ "License exports support text, JSON, CSV, and Markdown. Exit codes: 0 pass, 2 policy block, 4 analysis incomplete, 64 usage error, 70 write error."
44
46
  ],
45
47
  handler: (context) => runLicensesCommand(context.args)
46
48
  };
@@ -92,7 +94,7 @@ async function runLicensesCommand(args) {
92
94
  }
93
95
  catch (error) {
94
96
  return {
95
- exitCode: EXIT_ANALYSIS_INCOMPLETE,
97
+ exitCode: EXIT_TOOL_ERROR,
96
98
  stdout: "",
97
99
  stderr: `dg licenses could not write ${parsed.outputPath}: ${error instanceof Error ? error.message : "unknown write error"}\n`
98
100
  };
@@ -406,10 +408,20 @@ function dedupeEntries(entries) {
406
408
  const seen = new Set();
407
409
  const deduped = [];
408
410
  for (const entry of entries) {
409
- const key = `${entry.ecosystem}|${entry.name}|${entry.version ?? ""}|${entry.license ?? ""}|${entry.source}|${entry.location}`;
411
+ // name / version / license / location come from untrusted lockfiles &
412
+ // manifests and are rendered to the terminal and md/csv exports — strip
413
+ // control bytes here, the single point every entry passes through.
414
+ const clean = {
415
+ ...entry,
416
+ name: sanitizeLine(entry.name),
417
+ ...(entry.version != null ? { version: sanitizeLine(entry.version) } : {}),
418
+ ...(entry.license != null ? { license: sanitizeLine(entry.license) } : {}),
419
+ location: sanitizeLine(entry.location)
420
+ };
421
+ const key = `${clean.ecosystem}|${clean.name}|${clean.version ?? ""}|${clean.license ?? ""}|${clean.source}|${clean.location}`;
410
422
  if (!seen.has(key)) {
411
423
  seen.add(key);
412
- deduped.push(entry);
424
+ deduped.push(clean);
413
425
  }
414
426
  }
415
427
  return deduped.sort((left, right) => `${left.ecosystem}:${left.name}`.localeCompare(`${right.ecosystem}:${right.name}`));
@@ -428,9 +440,6 @@ function displayPath(root, path) {
428
440
  const display = relativePath.length === 0 ? "." : relativePath;
429
441
  return display.split(sep).join("/");
430
442
  }
431
- function csvCell(value) {
432
- return /[",\n\r]/u.test(value) ? `"${value.replace(/"/gu, "\"\"")}"` : value;
433
- }
434
443
  function markdownCell(value) {
435
444
  return value.replace(/\|/gu, "\\|");
436
445
  }
@@ -1,22 +1,26 @@
1
+ import { readFileSync } from "node:fs";
1
2
  import { EXIT_TOOL_ERROR, EXIT_USAGE } from "./types.js";
2
3
  import { AuthError, writeAuthState } from "../auth/store.js";
3
4
  import { ConfigError, loadUserConfig } from "../config/settings.js";
5
+ import { envAuthToken } from "../auth/env-token.js";
4
6
  export const loginCommand = {
5
7
  name: "login",
6
8
  summary: "Authenticate this machine with Dependency Guardian.",
7
- usage: "dg login [--token <token>]",
9
+ usage: "dg login [--token <token> | --token-stdin]",
8
10
  flags: [
9
- { flag: "--token", value: "<token>", summary: "Authenticate with an API key instead of the browser (for CI/headless)." }
11
+ { flag: "--token", value: "<token>", summary: "Authenticate with an API key instead of the browser (for CI/headless)." },
12
+ { flag: "--token-stdin", summary: "Read the API key from stdin, keeping it out of the process list." }
10
13
  ],
11
- examples: ["dg login", "dg login --token dg_live_…", "DG_API_TOKEN=dg_live_… dg login"],
14
+ examples: ["dg login", "dg login --token-stdin", "DG_API_TOKEN=dg_live_… dg login"],
12
15
  details: [
13
16
  "In a terminal, 'dg login' opens your browser to sign in — no token to copy.",
14
- "For CI and headless shells, pass --token <key> or set the DG_API_TOKEN environment variable instead.",
17
+ "For CI and headless shells, set DG_API_TOKEN (or DG_API_KEY) before running 'dg login', pipe the key to --token-stdin, or pass --token <key>.",
18
+ "Prefer the environment variable or --token-stdin: a key passed as --token <key> is visible to other users in the process list.",
15
19
  "Stores dg-owned auth state under the user config directory; never executes project-local code or weakens install enforcement."
16
20
  ],
17
21
  handler: (context) => loginHandler(context.args)
18
22
  };
19
- function loginHandler(args) {
23
+ export function loginHandler(args, env = process.env, readStdin = readStdinSync) {
20
24
  const parsed = parseLoginArgs(args);
21
25
  if ("error" in parsed) {
22
26
  return {
@@ -25,17 +29,25 @@ function loginHandler(args) {
25
29
  stderr: `dg login: ${parsed.error}. Run 'dg login --help'.\n`
26
30
  };
27
31
  }
32
+ const resolved = resolveToken(parsed, env, readStdin);
33
+ if ("error" in resolved) {
34
+ return {
35
+ exitCode: EXIT_USAGE,
36
+ stdout: "",
37
+ stderr: `dg login: ${resolved.error}. Run 'dg login --help'.\n`
38
+ };
39
+ }
28
40
  try {
29
- const config = loadUserConfig();
41
+ const config = loadUserConfig(env);
30
42
  const state = writeAuthState({
31
- token: parsed.token,
43
+ token: resolved.token,
32
44
  apiBaseUrl: config.api.baseUrl,
33
45
  orgId: config.org.id
34
- });
46
+ }, env);
35
47
  return {
36
48
  exitCode: 0,
37
49
  stdout: `Logged in to ${state.apiBaseUrl}${state.orgId ? ` for org ${state.orgId}` : ""} with token ${state.tokenPreview}\n`,
38
- stderr: ""
50
+ stderr: resolved.source === "env" ? "Using the API key from DG_API_TOKEN/DG_API_KEY in the environment.\n" : ""
39
51
  };
40
52
  }
41
53
  catch (error) {
@@ -53,16 +65,34 @@ function loginHandler(args) {
53
65
  };
54
66
  }
55
67
  }
68
+ function resolveToken(parsed, env, readStdin) {
69
+ if (parsed.fromStdin) {
70
+ const token = readStdin().trim();
71
+ if (!token) {
72
+ return { error: "no API key was provided on stdin" };
73
+ }
74
+ return { token, source: "stdin" };
75
+ }
76
+ if (parsed.token !== undefined) {
77
+ return { token: parsed.token, source: "flag" };
78
+ }
79
+ const fromEnv = envAuthToken(env);
80
+ if (fromEnv) {
81
+ return { token: fromEnv, source: "env" };
82
+ }
83
+ return {
84
+ error: "run 'dg login' in a terminal to sign in via your browser, or set DG_API_TOKEN / pipe the key to 'dg login --token-stdin' / pass 'dg login --token <key>' for CI"
85
+ };
86
+ }
56
87
  function parseLoginArgs(args) {
57
- let token = "";
88
+ let token;
89
+ let fromStdin = false;
58
90
  for (let index = 0; index < args.length; index += 1) {
59
91
  const arg = args[index];
60
92
  if (arg === "--token") {
61
93
  const value = args[index + 1];
62
94
  if (!value) {
63
- return {
64
- error: "--token requires a value"
65
- };
95
+ return { error: "--token requires a value" };
66
96
  }
67
97
  token = value;
68
98
  index += 1;
@@ -70,10 +100,11 @@ function parseLoginArgs(args) {
70
100
  else if (arg?.startsWith("--token=")) {
71
101
  token = arg.slice("--token=".length);
72
102
  }
103
+ else if (arg === "--token-stdin") {
104
+ fromStdin = true;
105
+ }
73
106
  else if (arg?.startsWith("-")) {
74
- return {
75
- error: `unknown option '${arg}'`
76
- };
107
+ return { error: `unknown option '${arg}'` };
77
108
  }
78
109
  else {
79
110
  return {
@@ -81,10 +112,16 @@ function parseLoginArgs(args) {
81
112
  };
82
113
  }
83
114
  }
84
- if (!token) {
85
- return {
86
- error: "run 'dg login' in a terminal to sign in via your browser, or pass --token <key> (or set DG_API_TOKEN) for CI"
87
- };
115
+ if (token !== undefined && fromStdin) {
116
+ return { error: "--token and --token-stdin cannot be combined" };
117
+ }
118
+ return fromStdin ? { fromStdin: true } : token !== undefined ? { token } : {};
119
+ }
120
+ function readStdinSync() {
121
+ try {
122
+ return readFileSync(0, "utf8");
123
+ }
124
+ catch {
125
+ return "";
88
126
  }
89
- return { token };
90
127
  }
@@ -3,7 +3,7 @@ import { auditCommand } from "./audit.js";
3
3
  import { cooldownCommand } from "./cooldown.js";
4
4
  import { decisionsCommand } from "./decisions.js";
5
5
  import { doctorCommand } from "./doctor.js";
6
- import { guardCommitCommand } from "./guard-commit.js";
6
+ import { commitGuardCommand } from "./commit-guard.js";
7
7
  import { agentsCommand } from "./agents.js";
8
8
  import { renderCommandHelp, renderRootHelp } from "./help.js";
9
9
  import { licensesCommand } from "./licenses.js";
@@ -11,6 +11,7 @@ import { sbomCommand } from "./sbom.js";
11
11
  import { loginCommand } from "./login.js";
12
12
  import { logoutCommand } from "./logout.js";
13
13
  import { packageManagerCommands } from "./wrap.js";
14
+ import { isSupportedPackageManager, normalizeManagerName } from "../launcher/classify.js";
14
15
  import { scanCommand } from "./scan.js";
15
16
  import { serviceCommand } from "./service.js";
16
17
  import { setupCommand } from "./setup.js";
@@ -26,7 +27,7 @@ export const commandCatalog = [
26
27
  sbomCommand,
27
28
  verifyCommand,
28
29
  setupCommand,
29
- guardCommitCommand,
30
+ commitGuardCommand,
30
31
  agentsCommand,
31
32
  decisionsCommand,
32
33
  cooldownCommand,
@@ -79,6 +80,15 @@ export async function routeCommand(args) {
79
80
  }
80
81
  const command = commandCatalog.find((candidate) => candidate.name === commandName || candidate.aliases?.includes(commandName));
81
82
  if (!command) {
83
+ // The pip3 shim execs `dg pip3`, but the wrap catalog holds only canonical
84
+ // names; fold as the classifier does so `dg pip3 …` reaches the pip manager.
85
+ const normalizedManager = normalizeManagerName(commandName);
86
+ if (normalizedManager !== commandName && isSupportedPackageManager(normalizedManager)) {
87
+ const managerCommand = commandCatalog.find((candidate) => candidate.name === normalizedManager);
88
+ if (managerCommand) {
89
+ return managerCommand.handler({ commandPath: [normalizedManager], args: rest });
90
+ }
91
+ }
82
92
  const suggestion = closestCommand(commandName, commandNames());
83
93
  const hint = suggestion ? ` Did you mean '${suggestion}'?` : "";
84
94
  return {