@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
@@ -0,0 +1,236 @@
1
+ import { join } from "node:path";
2
+ import { readFileSync } from "node:fs";
3
+ import { componentRef } from "./cyclonedx.js";
4
+ export const SBOM_ROOT_REF = "root";
5
+ export function buildDependencyGraph(projects, componentRefs) {
6
+ const edges = new Map();
7
+ for (const project of projects) {
8
+ let text;
9
+ try {
10
+ text = readFileSync(join(project.path, project.depFile), "utf8");
11
+ }
12
+ catch {
13
+ continue;
14
+ }
15
+ if (project.depFile === "package-lock.json" || project.depFile === "npm-shrinkwrap.json") {
16
+ collectNpmEdges(text, componentRefs, edges);
17
+ }
18
+ else if (project.depFile === "Cargo.lock") {
19
+ collectCargoEdges(text, componentRefs, edges);
20
+ }
21
+ }
22
+ const dependencies = [];
23
+ for (const [ref, dependsOn] of edges) {
24
+ if (ref !== SBOM_ROOT_REF && !componentRefs.has(ref)) {
25
+ continue;
26
+ }
27
+ dependencies.push({ ref, dependsOn: [...dependsOn].sort(compareCodePoints) });
28
+ }
29
+ return dependencies.sort((a, b) => compareCodePoints(a.ref, b.ref));
30
+ }
31
+ function record(edges, from, to) {
32
+ if (from === to) {
33
+ return;
34
+ }
35
+ const set = edges.get(from) ?? new Set();
36
+ set.add(to);
37
+ edges.set(from, set);
38
+ }
39
+ function ensureNode(edges, ref) {
40
+ if (!edges.has(ref)) {
41
+ edges.set(ref, new Set());
42
+ }
43
+ }
44
+ const NPM_DEPENDENCY_SECTIONS = ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"];
45
+ function collectNpmEdges(text, componentRefs, edges) {
46
+ let parsed;
47
+ try {
48
+ parsed = JSON.parse(text);
49
+ }
50
+ catch {
51
+ return;
52
+ }
53
+ if (!isRecord(parsed) || !isRecord(parsed.packages)) {
54
+ return;
55
+ }
56
+ const packages = parsed.packages;
57
+ for (const [path, entry] of Object.entries(packages)) {
58
+ if (!isRecord(entry) || entry.link === true) {
59
+ continue;
60
+ }
61
+ if (path.length > 0 && !path.includes("node_modules")) {
62
+ continue;
63
+ }
64
+ const fromRef = path.length === 0 ? SBOM_ROOT_REF : npmEntryRef(path, entry, componentRefs);
65
+ if (fromRef === null) {
66
+ continue;
67
+ }
68
+ ensureNode(edges, fromRef);
69
+ for (const depName of npmDeclaredDeps(entry)) {
70
+ const targetPath = resolveNpmDep(packages, path, depName);
71
+ if (targetPath === null) {
72
+ continue;
73
+ }
74
+ const targetEntry = packages[targetPath];
75
+ if (!isRecord(targetEntry)) {
76
+ continue;
77
+ }
78
+ const targetRef = npmEntryRef(targetPath, targetEntry, componentRefs);
79
+ if (targetRef !== null) {
80
+ record(edges, fromRef, targetRef);
81
+ }
82
+ }
83
+ }
84
+ }
85
+ function npmDeclaredDeps(entry) {
86
+ const names = new Set();
87
+ for (const section of NPM_DEPENDENCY_SECTIONS) {
88
+ const deps = entry[section];
89
+ if (isRecord(deps)) {
90
+ for (const name of Object.keys(deps)) {
91
+ names.add(name);
92
+ }
93
+ }
94
+ }
95
+ return [...names];
96
+ }
97
+ function resolveNpmDep(packages, fromPath, depName) {
98
+ let scope = fromPath;
99
+ for (;;) {
100
+ const candidate = scope.length === 0 ? `node_modules/${depName}` : `${scope}/node_modules/${depName}`;
101
+ if (Object.prototype.hasOwnProperty.call(packages, candidate)) {
102
+ return candidate;
103
+ }
104
+ if (scope.length === 0) {
105
+ return null;
106
+ }
107
+ const cut = scope.lastIndexOf("/node_modules/");
108
+ scope = cut === -1 ? "" : scope.slice(0, cut);
109
+ }
110
+ }
111
+ function npmEntryRef(path, entry, componentRefs) {
112
+ const declaredName = typeof entry.name === "string" ? entry.name : nameFromNodeModulesPath(path);
113
+ const alias = npmAliasVersion(typeof entry.version === "string" ? entry.version : null);
114
+ const name = alias?.name ?? declaredName;
115
+ const version = alias?.version ?? (typeof entry.version === "string" ? entry.version : null);
116
+ if (!name || !version) {
117
+ return null;
118
+ }
119
+ const ref = componentRef({ ecosystem: "npm", name, version });
120
+ return componentRefs.has(ref) ? ref : null;
121
+ }
122
+ function nameFromNodeModulesPath(path) {
123
+ const marker = path.lastIndexOf("node_modules/");
124
+ if (marker === -1) {
125
+ return null;
126
+ }
127
+ const tail = path.slice(marker + "node_modules/".length);
128
+ return tail.length > 0 ? tail : null;
129
+ }
130
+ function npmAliasVersion(version) {
131
+ if (!version || !version.startsWith("npm:")) {
132
+ return null;
133
+ }
134
+ const spec = version.slice("npm:".length);
135
+ const at = spec.lastIndexOf("@");
136
+ if (at <= 0) {
137
+ return null;
138
+ }
139
+ return { name: spec.slice(0, at), version: spec.slice(at + 1) || null };
140
+ }
141
+ function collectCargoEdges(text, componentRefs, edges) {
142
+ const byName = new Map();
143
+ const packages = [];
144
+ const rootDeps = [];
145
+ for (const block of cargoPackageBlocks(text)) {
146
+ const name = tomlValue(block, "name");
147
+ const version = tomlValue(block, "version");
148
+ if (!name || !version) {
149
+ continue;
150
+ }
151
+ const source = tomlValue(block, "source");
152
+ const deps = tomlArray(block, "dependencies");
153
+ if (source === null) {
154
+ rootDeps.push(deps);
155
+ continue;
156
+ }
157
+ const ref = componentRef({ ecosystem: "cargo", name, version, resolvedUrl: source });
158
+ const list = byName.get(name) ?? [];
159
+ list.push({ version, ref });
160
+ byName.set(name, list);
161
+ packages.push({ ref, deps });
162
+ }
163
+ for (const pkg of packages) {
164
+ if (!componentRefs.has(pkg.ref)) {
165
+ continue;
166
+ }
167
+ ensureNode(edges, pkg.ref);
168
+ for (const dep of pkg.deps) {
169
+ const targetRef = resolveCargoDep(byName, dep);
170
+ if (targetRef && componentRefs.has(targetRef)) {
171
+ record(edges, pkg.ref, targetRef);
172
+ }
173
+ }
174
+ }
175
+ if (rootDeps.length === 1 && rootDeps[0]) {
176
+ ensureNode(edges, SBOM_ROOT_REF);
177
+ for (const dep of rootDeps[0]) {
178
+ const targetRef = resolveCargoDep(byName, dep);
179
+ if (targetRef && componentRefs.has(targetRef)) {
180
+ record(edges, SBOM_ROOT_REF, targetRef);
181
+ }
182
+ }
183
+ }
184
+ }
185
+ function resolveCargoDep(byName, dep) {
186
+ const parts = dep.trim().split(/\s+/u);
187
+ const name = parts[0];
188
+ if (!name) {
189
+ return null;
190
+ }
191
+ const candidates = byName.get(name);
192
+ if (!candidates || candidates.length === 0) {
193
+ return null;
194
+ }
195
+ const wantedVersion = parts[1];
196
+ if (wantedVersion) {
197
+ return candidates.find((candidate) => candidate.version === wantedVersion)?.ref ?? null;
198
+ }
199
+ return candidates.length === 1 && candidates[0] ? candidates[0].ref : null;
200
+ }
201
+ function cargoPackageBlocks(text) {
202
+ const blocks = [];
203
+ const segments = text.split(/^\s*\[\[package\]\]\s*$/mu);
204
+ for (let i = 1; i < segments.length; i += 1) {
205
+ const segment = segments[i];
206
+ if (segment === undefined) {
207
+ continue;
208
+ }
209
+ const end = segment.search(/^\s*\[/mu);
210
+ blocks.push(end === -1 ? segment : segment.slice(0, end));
211
+ }
212
+ return blocks;
213
+ }
214
+ function tomlValue(block, key) {
215
+ const match = new RegExp(`^\\s*${key}\\s*=\\s*"([^"]*)"`, "mu").exec(block);
216
+ return match?.[1] ?? null;
217
+ }
218
+ function tomlArray(block, key) {
219
+ const match = new RegExp(`^\\s*${key}\\s*=\\s*\\[([\\s\\S]*?)\\]`, "mu").exec(block);
220
+ if (!match || match[1] === undefined) {
221
+ return [];
222
+ }
223
+ const values = [];
224
+ for (const item of match[1].matchAll(/"([^"]*)"/gu)) {
225
+ if (item[1] !== undefined) {
226
+ values.push(item[1]);
227
+ }
228
+ }
229
+ return values;
230
+ }
231
+ function compareCodePoints(a, b) {
232
+ return a < b ? -1 : a > b ? 1 : 0;
233
+ }
234
+ function isRecord(value) {
235
+ return typeof value === "object" && value !== null && !Array.isArray(value);
236
+ }
@@ -0,0 +1,53 @@
1
+ const CANONICAL_SPDX_IDS = [
2
+ "0BSD",
3
+ "AGPL-3.0-only",
4
+ "AGPL-3.0-or-later",
5
+ "Apache-1.1",
6
+ "Apache-2.0",
7
+ "Artistic-2.0",
8
+ "Beerware",
9
+ "BlueOak-1.0.0",
10
+ "BSD-2-Clause",
11
+ "BSD-3-Clause",
12
+ "BSD-3-Clause-Clear",
13
+ "BSL-1.0",
14
+ "CC-BY-4.0",
15
+ "CC-BY-SA-4.0",
16
+ "CC0-1.0",
17
+ "CDDL-1.0",
18
+ "CDDL-1.1",
19
+ "EPL-1.0",
20
+ "EPL-2.0",
21
+ "EUPL-1.1",
22
+ "EUPL-1.2",
23
+ "GPL-2.0-only",
24
+ "GPL-2.0-or-later",
25
+ "GPL-3.0-only",
26
+ "GPL-3.0-or-later",
27
+ "ISC",
28
+ "LGPL-2.0-only",
29
+ "LGPL-2.0-or-later",
30
+ "LGPL-2.1-only",
31
+ "LGPL-2.1-or-later",
32
+ "LGPL-3.0-only",
33
+ "LGPL-3.0-or-later",
34
+ "MIT",
35
+ "MIT-0",
36
+ "MPL-1.1",
37
+ "MPL-2.0",
38
+ "MS-PL",
39
+ "MS-RL",
40
+ "NCSA",
41
+ "OFL-1.1",
42
+ "PostgreSQL",
43
+ "Python-2.0",
44
+ "Ruby",
45
+ "Unicode-DFS-2016",
46
+ "Unlicense",
47
+ "WTFPL",
48
+ "Zlib"
49
+ ];
50
+ const BY_LOWERCASE = new Map(CANONICAL_SPDX_IDS.map((id) => [id.toLowerCase(), id]));
51
+ export function canonicalSpdxId(declared) {
52
+ return BY_LOWERCASE.get(declared.trim().toLowerCase()) ?? null;
53
+ }
@@ -16,8 +16,9 @@ const HEADER_LINES = 9;
16
16
  const FOOTER_LINES = 2;
17
17
  const COUNTER_LINE = 1;
18
18
  const SAFETY_LINE = 1;
19
+ const RULE_LINES = 2;
19
20
  const FILTER_CYCLE = ["all", "risky", "unlicensed"];
20
- const FILTER_LABEL = { all: "all", risky: "risky", unlicensed: "unlicensed", unpinned: "unpinned" };
21
+ const FILTER_LABEL = { all: "all", risky: "risky", unlicensed: "unlicensed" };
21
22
  function ecosystemCounts(rows) {
22
23
  const counts = new Map();
23
24
  for (const row of rows) {
@@ -150,9 +151,10 @@ export const SbomApp = ({ store, document, cwd }) => {
150
151
  openExport();
151
152
  }
152
153
  });
153
- const listHeight = Math.max(3, termRows - HEADER_LINES - FOOTER_LINES - COUNTER_LINE - SAFETY_LINE);
154
+ const listHeight = Math.max(3, termRows - HEADER_LINES - FOOTER_LINES - COUNTER_LINE - SAFETY_LINE - RULE_LINES);
155
+ const rule = chalk.dim("─".repeat(Math.max(20, termCols - 4)));
154
156
  if (exportDialog) {
155
157
  return (_jsxs(Box, { flexDirection: "column", children: [_jsx(SbomHeader, { total: view.rows.length, ecosystems: ecosystemCounts(view.rows), phase: view.phase, tally: tally, scannable: view.scannable, scanProgress: view.scanProgress, scanError: view.scanError, usage: view.usage, subject: view.subject, cargoCount: cargoCount }), _jsx(ExportDialog, { options: exportDialog, theme: theme, cwd: cwd, onDone: handleExportDone })] }));
156
158
  }
157
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(SbomHeader, { total: view.rows.length, ecosystems: ecosystemCounts(view.rows), phase: view.phase, tally: tally, scannable: view.scannable, scanProgress: view.scanProgress, scanError: view.scanError, usage: view.usage, subject: view.subject, cargoCount: cargoCount }), _jsx(SbomList, { rows: visible, selected: clamped, height: listHeight, width: termCols, emptyMessage: emptyFilterMessage(filter, query, view.phase, tally, view.scanError) }), _jsxs(Box, { paddingLeft: 1, flexDirection: "column", children: [toast ? _jsx(Text, { children: toast }) : null, searching ? (_jsxs(Text, { children: [chalk.cyan("/"), query, chalk.dim(" esc to clear")] })) : query ? (_jsxs(Text, { children: [chalk.cyan("/"), query, chalk.dim(` ${visible.length} of ${view.rows.length} components esc clear q quit`)] })) : (_jsxs(Text, { children: [chalk.dim("↑↓ move / search f "), chalk.dim(`filter: ${FILTER_LABEL[filter]}`), chalk.dim(" "), chalk.bold("e"), chalk.dim(" export q quit")] }))] })] }));
159
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(SbomHeader, { total: view.rows.length, ecosystems: ecosystemCounts(view.rows), phase: view.phase, tally: tally, scannable: view.scannable, scanProgress: view.scanProgress, scanError: view.scanError, usage: view.usage, subject: view.subject, cargoCount: cargoCount }), _jsx(Text, { dimColor: true, children: rule }), _jsx(SbomList, { rows: visible, selected: clamped, height: listHeight, width: termCols, emptyMessage: emptyFilterMessage(filter, query, view.phase, tally, view.scanError) }), _jsx(Text, { dimColor: true, children: rule }), _jsxs(Box, { paddingLeft: 1, flexDirection: "column", children: [toast ? _jsx(Text, { children: toast }) : null, searching ? (_jsxs(Text, { children: [chalk.cyan("/"), query, chalk.dim(" esc to clear")] })) : query ? (_jsxs(Text, { children: [chalk.cyan("/"), query, chalk.dim(` ${visible.length} of ${view.rows.length} components esc clear q quit`)] })) : (_jsxs(Text, { children: [chalk.dim("↑↓ move / search f "), chalk.dim(`filter: ${FILTER_LABEL[filter]}`), chalk.dim(" "), chalk.bold("e"), chalk.dim(" export q quit")] }))] })] }));
158
160
  };
@@ -1,3 +1,5 @@
1
+ import { sanitizeLine } from "../security/sanitize.js";
2
+ import { csvCell } from "../security/csv.js";
1
3
  const ROW_ECOSYSTEMS = { npm: "npm", pypi: "pypi", cargo: "cargo" };
2
4
  export function rowKey(ecosystem, name, version) {
3
5
  return `${ecosystem}:${name.toLowerCase()}@${version}`;
@@ -6,12 +8,15 @@ export function buildSbomRows(components) {
6
8
  return components
7
9
  .map((component) => {
8
10
  const ecosystem = ROW_ECOSYSTEMS[component.ecosystem] ?? "other";
11
+ // Lockfile-declared name/version/license are attacker-controlled and reach
12
+ // the terminal (TUI rows, summary) + exports; strip control bytes here so
13
+ // every downstream renderer is safe without re-remembering to.
9
14
  return {
10
15
  key: rowKey(ecosystem, component.name, component.version),
11
- name: component.name,
12
- version: component.version,
16
+ name: sanitizeLine(component.name),
17
+ version: sanitizeLine(component.version),
13
18
  ecosystem,
14
- license: component.license ?? null,
19
+ license: component.license != null ? sanitizeLine(component.license) : null,
15
20
  hasHash: component.integrity != null && component.integrity.length > 0,
16
21
  scannable: ecosystem === "npm" || ecosystem === "pypi"
17
22
  };
@@ -94,13 +99,6 @@ export function emptyFilterMessage(filter, query, phase, tally, scanError) {
94
99
  }
95
100
  return "no components";
96
101
  }
97
- function csvCell(value) {
98
- // Neutralize spreadsheet formula injection: a cell starting with = + - @ (or
99
- // tab/CR) is interpreted as a formula by Excel/Sheets, letting an attacker-named
100
- // dependency run on open. Prefix a sentinel apostrophe so it is treated as text.
101
- const guarded = /^[=+\-@\t\r]/.test(value) ? `'${value}` : value;
102
- return /[",\n\r]/.test(guarded) ? `"${guarded.replace(/"/g, '""')}"` : guarded;
103
- }
104
102
  export function componentsCsv(rows) {
105
103
  const lines = ["name,version,ecosystem,license,verdict"];
106
104
  for (const row of rows) {
@@ -20,7 +20,9 @@ async function main() {
20
20
  }
21
21
  responses.push(await analyzePackages(group.packages, {
22
22
  ecosystem: group.ecosystem,
23
- ...(payload.scanId ? { scanId: payload.scanId } : {})
23
+ publicFallback: true,
24
+ ...(payload.scanId ? { scanId: payload.scanId } : {}),
25
+ ...(payload.installTreeHash ? { installTreeHash: payload.installTreeHash } : {})
24
26
  }));
25
27
  }
26
28
  process.stdout.write(JSON.stringify(mergeAnalyzeResponses(responses)));
@@ -29,9 +29,15 @@ const IGNORED_DIRECTORIES = new Set([
29
29
  ".venv",
30
30
  "venv",
31
31
  "target",
32
- "coverage"
32
+ "coverage",
33
+ ".cache",
34
+ ".pytest_cache",
35
+ ".mypy_cache",
36
+ ".ruff_cache",
37
+ ".idea",
38
+ ".vscode"
33
39
  ]);
34
- const MAX_DISCOVERY_DEPTH = 8;
40
+ const MAX_DISCOVERY_DEPTH = 12;
35
41
  function readDirents(directory) {
36
42
  try {
37
43
  return readdirSync(directory, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
@@ -40,16 +46,14 @@ function readDirents(directory) {
40
46
  return [];
41
47
  }
42
48
  }
49
+ // Every lockfile in a directory is scanned, not just the first per ecosystem:
50
+ // side-by-side lockfiles can resolve to different trees, and the collector dedups
51
+ // by name@version, so a package unique to a non-primary lockfile is not dropped.
43
52
  function lockfilesPerEcosystem(entries, ecosystems) {
44
53
  const matches = [];
45
- const claimed = new Set();
46
54
  for (const [lockfile, ecosystem] of Object.entries(ecosystems)) {
47
- if (claimed.has(ecosystem)) {
48
- continue;
49
- }
50
55
  if (entries.some((entry) => entry.name === lockfile && entry.isFile())) {
51
56
  matches.push([lockfile, ecosystem]);
52
- claimed.add(ecosystem);
53
57
  }
54
58
  }
55
59
  return matches;
@@ -127,8 +131,31 @@ function yieldToEventLoop() {
127
131
  function countLockfilePackages(lockfilePath) {
128
132
  return parseLockfilePackages(lockfilePath).packages.length;
129
133
  }
134
+ export function mergeInstalledPackages(collected, installed) {
135
+ const byEcosystem = new Map([...collected.byEcosystem].map(([eco, list]) => [eco, [...list]]));
136
+ const origins = new Map(collected.origins);
137
+ const seen = new Set();
138
+ for (const [eco, list] of byEcosystem) {
139
+ for (const pkg of list)
140
+ seen.add(`${eco}:${pkg.name}@${pkg.version}`);
141
+ }
142
+ for (const [eco, list] of installed.byEcosystem) {
143
+ for (const pkg of list) {
144
+ const key = `${eco}:${pkg.name}@${pkg.version}`;
145
+ if (seen.has(key))
146
+ continue;
147
+ seen.add(key);
148
+ const arr = byEcosystem.get(eco) ?? [];
149
+ arr.push(pkg);
150
+ byEcosystem.set(eco, arr);
151
+ origins.set(key, installed.origins.get(key) ?? "installed");
152
+ }
153
+ }
154
+ return { ...collected, byEcosystem, origins };
155
+ }
130
156
  export function collectScanPackages(projects) {
131
157
  const byEcosystem = new Map();
158
+ const origins = new Map();
132
159
  const seen = new Set();
133
160
  const skippedPackages = [];
134
161
  const parseErrors = [];
@@ -149,10 +176,12 @@ export function collectScanPackages(projects) {
149
176
  }
150
177
  for (const identity of parsed.packages) {
151
178
  if (identity.ecosystem !== "npm" && identity.ecosystem !== "pypi") {
179
+ skippedPackages.push({ name: identity.name, reason: "unsupported-ecosystem", location: `${depFilePath}: ${identity.requested}` });
152
180
  skipped += 1;
153
181
  continue;
154
182
  }
155
183
  if (!identity.version) {
184
+ skippedPackages.push({ name: identity.name, reason: "unpinned", location: `${depFilePath}: ${identity.requested}` });
156
185
  skipped += 1;
157
186
  continue;
158
187
  }
@@ -164,7 +193,8 @@ export function collectScanPackages(projects) {
164
193
  const list = byEcosystem.get(identity.ecosystem) ?? [];
165
194
  list.push({ name: identity.name, version: identity.version });
166
195
  byEcosystem.set(identity.ecosystem, list);
196
+ origins.set(`${identity.ecosystem}:${identity.name}@${identity.version}`, depFilePath);
167
197
  }
168
198
  }
169
- return { byEcosystem, skipped, skippedPackages, parseErrors };
199
+ return { byEcosystem, origins, skipped, skippedPackages, parseErrors };
170
200
  }
@@ -2,21 +2,39 @@ import { statSync } from "node:fs";
2
2
  import { dirname, resolve } from "node:path";
3
3
  import { writeReportAtomic } from "../util/report-writer.js";
4
4
  import { findProjectRoot, loadDgFile, warnUnreadableDgFile } from "../project/dgfile.js";
5
+ import { gateProjectDecisions } from "../project/override-trust.js";
5
6
  import { scanProject } from "./discovery.js";
6
- import { renderJsonReport, renderSarifReport, renderTextReport } from "./render.js";
7
+ import { applyLocalFindingEscalation, renderJsonReport, renderSarifReport, renderTextReport } from "./render.js";
7
8
  import { resolvePresentation } from "../presentation/mode.js";
8
9
  import { createTheme } from "../presentation/theme.js";
9
10
  import { launchScanTui, shouldLaunchScanTui } from "../scan-ui/launch.js";
10
11
  import { runScannerScan } from "./scanner-report.js";
12
+ import { detectUnlockedManifests, unlockedManifestNotice } from "./manifest-coverage.js";
11
13
  import { runStagedScan, stagedScanReport } from "./staged.js";
12
- import { scanExitCode } from "../scan-ui/shims.js";
13
- import { loadUserConfig } from "../config/settings.js";
14
- import { EXIT_ANALYSIS_INCOMPLETE, EXIT_NOTHING_TO_SCAN, EXIT_USAGE_VERDICT } from "../commands/types.js";
14
+ import { verdictExitCode } from "../scan-ui/shims.js";
15
+ import { authStatus } from "../auth/store.js";
16
+ import { EXIT_ANALYSIS_INCOMPLETE, EXIT_NOTHING_TO_SCAN, EXIT_TOOL_ERROR, EXIT_USAGE_VERDICT } from "../commands/types.js";
15
17
  export function runScanCommand(context) {
16
18
  const parsed = parseScanArgs(context.args);
17
19
  if ("error" in parsed) {
18
20
  return usageError(parsed.error);
19
21
  }
22
+ // The interactive project scan is the product and requires sign-in. The
23
+ // staged path (dg commit-guard) is automated protection like the install
24
+ // firewall, so it stays account-free and resolves verdicts through the public
25
+ // endpoint when signed out.
26
+ if (!parsed.staged && !authStatus(process.env).authenticated) {
27
+ // The install-origin metering scan runs detached after an install; with no
28
+ // signed-in key there is nothing to bill, so exit quietly instead of nagging.
29
+ if (parsed.installOrigin) {
30
+ return { exitCode: 0, stdout: "", stderr: "" };
31
+ }
32
+ return {
33
+ exitCode: EXIT_USAGE_VERDICT,
34
+ stdout: "",
35
+ stderr: "Sign in to run dg scan. Run `dg login`.\n"
36
+ };
37
+ }
20
38
  const stagedTarget = parsed.sawTarget ? parsed.targetPath : null;
21
39
  const machineOutput = parsed.format !== "text" || parsed.outputPath !== null;
22
40
  if (parsed.staged && !machineOutput) {
@@ -33,12 +51,13 @@ export function runScanCommand(context) {
33
51
  outcome = staged.outcome;
34
52
  }
35
53
  else {
36
- if (shouldLaunchScanTui({
37
- targetPath: parsed.targetPath,
38
- format: parsed.format,
39
- outputPath: parsed.outputPath ?? undefined
40
- })) {
41
- void launchScanTui().catch((error) => {
54
+ if (!parsed.installOrigin &&
55
+ shouldLaunchScanTui({
56
+ targetPath: parsed.targetPath,
57
+ format: parsed.format,
58
+ outputPath: parsed.outputPath ?? undefined
59
+ })) {
60
+ void launchScanTui("results", parsed.failOn).catch((error) => {
42
61
  process.stderr.write(`dg scan TUI failed: ${error instanceof Error ? error.message : "unknown error"}\n`);
43
62
  process.exitCode = 1;
44
63
  });
@@ -60,7 +79,7 @@ export function runScanCommand(context) {
60
79
  stderr: `dg scan failed: ${error instanceof Error ? error.message : "unknown scan error"}\n`
61
80
  };
62
81
  }
63
- outcome = runScannerScan(parsed.targetPath, report, process.env, parsed.noDecisions ? null : loadScanDecisions(parsed.targetPath));
82
+ outcome = runScannerScan(parsed.targetPath, report, process.env, parsed.noDecisions ? null : loadScanDecisions(parsed.targetPath), parsed.installOrigin);
64
83
  }
65
84
  if (outcome.kind === "report") {
66
85
  report = outcome.report;
@@ -75,15 +94,23 @@ export function runScanCommand(context) {
75
94
  !report.scannerError &&
76
95
  report.summary.projectCount === 0 &&
77
96
  report.summary.errorCount === 0;
78
- const exitCode = nothingToScan ? EXIT_NOTHING_TO_SCAN : exitCodeForReport(report);
97
+ const exitCode = nothingToScan ? EXIT_NOTHING_TO_SCAN : exitCodeForReport(report, parsed.failOn);
98
+ // With no server verdict, name any dependency manifest that lacks a lockfile so
99
+ // the run is not a silent "nothing to scan".
100
+ const manifestNotice = !report.scanner && !report.scannerError
101
+ ? unlockedManifestNotice(detectUnlockedManifests(resolve(parsed.targetPath)))
102
+ : null;
79
103
  const rendered = renderReport(report, parsed.format, scannerUnavailable, skipNotice, nothingToScan);
80
104
  if (parsed.outputPath) {
81
105
  try {
82
106
  writeReportAtomic(resolve(parsed.outputPath), rendered);
83
107
  }
84
108
  catch (error) {
109
+ // A write failure is a tool error, not an incomplete analysis — match dg
110
+ // sbom/audit (EXIT_TOOL_ERROR) so a consumer can tell "couldn't produce the
111
+ // report" apart from "couldn't reach the scanner".
85
112
  return {
86
- exitCode: EXIT_ANALYSIS_INCOMPLETE,
113
+ exitCode: EXIT_TOOL_ERROR,
87
114
  stdout: "",
88
115
  stderr: `dg scan could not write ${parsed.outputPath}: ${error instanceof Error ? error.message : "unknown write error"}\n`
89
116
  };
@@ -91,13 +118,15 @@ export function runScanCommand(context) {
91
118
  return {
92
119
  exitCode,
93
120
  stdout: `Wrote ${parsed.format} scan report to ${parsed.outputPath}\n`,
94
- stderr: ""
121
+ // Surface a degraded scan (e.g. a rejected key) even when writing to a file,
122
+ // so the failure isn't silent in --json/--sarif mode.
123
+ stderr: report.scannerError ? `dg scan: server scan failed: ${report.scannerError.message}\n` : ""
95
124
  };
96
125
  }
97
126
  return {
98
127
  exitCode,
99
128
  stdout: rendered,
100
- stderr: ""
129
+ stderr: manifestNotice ? `${manifestNotice}\n` : ""
101
130
  };
102
131
  }
103
132
  function parseScanArgs(args) {
@@ -108,6 +137,8 @@ function parseScanArgs(args) {
108
137
  let staged = false;
109
138
  let hook = false;
110
139
  let noDecisions = false;
140
+ let failOn = "block";
141
+ let installOrigin = false;
111
142
  for (let index = 0; index < args.length; index += 1) {
112
143
  const arg = args[index];
113
144
  if (!arg) {
@@ -117,6 +148,10 @@ function parseScanArgs(args) {
117
148
  staged = true;
118
149
  continue;
119
150
  }
151
+ if (arg === "--install-origin") {
152
+ installOrigin = true;
153
+ continue;
154
+ }
120
155
  if (arg === "--hook") {
121
156
  hook = true;
122
157
  continue;
@@ -148,6 +183,14 @@ function parseScanArgs(args) {
148
183
  index += 1;
149
184
  continue;
150
185
  }
186
+ if (arg === "--fail-on" || arg.startsWith("--fail-on=")) {
187
+ const value = arg === "--fail-on" ? args[(index += 1)] : arg.slice("--fail-on=".length);
188
+ if (value !== "block" && value !== "warn" && value !== "never") {
189
+ return { error: "--fail-on requires 'block', 'warn', or 'never'" };
190
+ }
191
+ failOn = value;
192
+ continue;
193
+ }
151
194
  if (arg.startsWith("-")) {
152
195
  return { error: `unknown option '${arg}'` };
153
196
  }
@@ -164,14 +207,16 @@ function parseScanArgs(args) {
164
207
  sawTarget,
165
208
  staged,
166
209
  hook,
167
- noDecisions
210
+ noDecisions,
211
+ failOn,
212
+ installOrigin
168
213
  };
169
214
  }
170
215
  function usageError(message) {
171
216
  return {
172
217
  exitCode: EXIT_USAGE_VERDICT,
173
218
  stdout: "",
174
- stderr: `dg scan: ${message}. Usage: dg scan [path] [--json|--sarif] [--output <path>] [--no-decisions]\n`
219
+ stderr: `dg scan: ${message}. Usage: dg scan [path] [--json|--sarif] [--output <path>] [--fail-on <block|warn|never>] [--no-decisions]\n`
175
220
  };
176
221
  }
177
222
  function loadScanDecisions(targetPath, env = process.env) {
@@ -190,7 +235,9 @@ function loadScanDecisions(targetPath, env = process.env) {
190
235
  }
191
236
  const file = loadDgFile(root);
192
237
  warnUnreadableDgFile(file);
193
- return file.readable ? file : null;
238
+ // Gate decisions/exemptions through the trust check so a cloned hostile repo's
239
+ // dg.json cannot suppress a warn it did not author on this machine.
240
+ return file.readable ? gateProjectDecisions(file, root, env) : null;
194
241
  }
195
242
  function degradeReport(report, error) {
196
243
  const status = report.status === "block" || report.status === "warn" ? report.status : "unknown";
@@ -214,20 +261,23 @@ function renderReport(report, format, scannerUnavailable, skipNotice, nothingToS
214
261
  }
215
262
  return renderTextReport(report, undefined, createTheme(resolvePresentation().color), skipNotice);
216
263
  }
217
- function exitCodeForReport(report) {
264
+ function exitCodeForReport(report, failOn) {
218
265
  if (report.scanner) {
219
- const mode = loadUserConfig().policy.mode;
220
- const action = mode === "strict" ? report.scanner.action : report.decisions?.effectiveAction ?? report.scanner.action;
221
- return scanExitCode(action, mode);
266
+ const action = report.decisions?.effectiveAction ?? report.scanner.action;
267
+ return verdictExitCode(applyLocalFindingEscalation(action, report.findings), failOn);
222
268
  }
223
- if (report.status === "block") {
224
- return 2;
269
+ if (report.status === "block" || report.status === "warn") {
270
+ return verdictExitCode(report.status, failOn);
225
271
  }
226
- if (report.status === "warn") {
227
- return 1;
272
+ // A 401 is a permanent configuration error (a rejected/expired key), not a
273
+ // transient outage — surface it as a usage error so CI fails closed rather than
274
+ // passing green the way analysis-incomplete (4) does under a fail-open policy. A
275
+ // 403 can be edge/proxy/WAF-injected and transient, so leave it to the 4 path.
276
+ if (report.scannerError?.kind === "auth" && report.scannerError.statusCode === 401) {
277
+ return EXIT_USAGE_VERDICT;
228
278
  }
229
279
  if (report.status === "error" || report.status === "unknown") {
230
280
  return 4;
231
281
  }
232
- return 0;
282
+ return verdictExitCode(report.status, failOn);
233
283
  }