@westbayberry/dg 2.0.8 → 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 (50) 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/launcher/install-preflight.js +81 -12
  20. package/dist/launcher/output-redaction.js +5 -3
  21. package/dist/launcher/preflight-prompt.js +31 -12
  22. package/dist/launcher/run.js +87 -8
  23. package/dist/proxy/ca.js +69 -29
  24. package/dist/proxy/enforcement.js +41 -3
  25. package/dist/proxy/worker.js +21 -9
  26. package/dist/runtime/first-run.js +33 -2
  27. package/dist/runtime/nudges.js +9 -2
  28. package/dist/scan/analyze-worker.js +18 -8
  29. package/dist/scan/collect.js +35 -28
  30. package/dist/scan/command.js +80 -40
  31. package/dist/scan/discovery.js +9 -3
  32. package/dist/scan/render.js +22 -6
  33. package/dist/scan/scanner-report.js +89 -12
  34. package/dist/scan/staged.js +69 -7
  35. package/dist/scan-ui/LegacyApp.js +10 -48
  36. package/dist/scan-ui/components/InteractiveResultsView.js +171 -111
  37. package/dist/scan-ui/components/ProjectSelector.js +3 -3
  38. package/dist/scan-ui/components/ScoreHeader.js +8 -4
  39. package/dist/scan-ui/hooks/useScan.js +74 -27
  40. package/dist/scan-ui/launch.js +18 -4
  41. package/dist/service/state.js +15 -4
  42. package/dist/service/trust-store.js +23 -2
  43. package/dist/setup/git-hook.js +28 -17
  44. package/dist/setup/plan.js +302 -18
  45. package/dist/state/cleanup-registry.js +65 -8
  46. package/dist/state/locks.js +95 -9
  47. package/dist/state/sessions.js +66 -2
  48. package/dist/verify/package-check.js +22 -3
  49. package/dist/verify/preflight.js +328 -170
  50. package/package.json +1 -1
package/dist/proxy/ca.js CHANGED
@@ -1,9 +1,43 @@
1
1
  import { randomBytes } from "node:crypto";
2
- import { mkdirSync, writeFileSync } from "node:fs";
2
+ import { mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { dirname } from "node:path";
4
4
  import { isIP } from "node:net";
5
5
  import forge from "node-forge";
6
- export function createEphemeralCertificateAuthority(caPath) {
6
+ export const CA_LIFETIME_MS = 24 * 60 * 60 * 1_000;
7
+ export const CA_ROTATE_AFTER_FRACTION = 0.75;
8
+ const ROTATION_CHECK_INTERVAL_MS = 60_000;
9
+ export function createEphemeralCertificateAuthority(caPath, options = {}) {
10
+ const lifetimeMs = options.lifetimeMs ?? CA_LIFETIME_MS;
11
+ const now = options.now ?? Date.now;
12
+ let active = issueAuthority(caPath, lifetimeMs, now(), undefined);
13
+ const leafs = new Map();
14
+ const rotateIfDue = () => {
15
+ if (now() < active.rotateAtMs) {
16
+ return;
17
+ }
18
+ active = issueAuthority(caPath, lifetimeMs, now(), active.certPem);
19
+ leafs.clear();
20
+ };
21
+ setInterval(rotateIfDue, ROTATION_CHECK_INTERVAL_MS).unref();
22
+ return {
23
+ get caCertPem() {
24
+ return active.certPem;
25
+ },
26
+ caPath,
27
+ leafForHost(host) {
28
+ rotateIfDue();
29
+ const normalized = normalizeHost(host);
30
+ const existing = leafs.get(normalized);
31
+ if (existing) {
32
+ return existing;
33
+ }
34
+ const leaf = createLeafCertificate(normalized, active, lifetimeMs, now());
35
+ leafs.set(normalized, leaf);
36
+ return leaf;
37
+ }
38
+ };
39
+ }
40
+ function issueAuthority(caPath, lifetimeMs, nowMs, previousCertPem) {
7
41
  const keys = forge.pki.rsa.generateKeyPair({
8
42
  bits: 2048,
9
43
  workers: -1
@@ -11,8 +45,8 @@ export function createEphemeralCertificateAuthority(caPath) {
11
45
  const cert = forge.pki.createCertificate();
12
46
  cert.publicKey = keys.publicKey;
13
47
  cert.serialNumber = serialNumber();
14
- cert.validity.notBefore = new Date(Date.now() - 60_000);
15
- cert.validity.notAfter = new Date(Date.now() + 24 * 60 * 60 * 1_000);
48
+ cert.validity.notBefore = new Date(nowMs - 60_000);
49
+ cert.validity.notAfter = new Date(nowMs + lifetimeMs);
16
50
  const attrs = [{
17
51
  name: "commonName",
18
52
  value: "Dependency Guardian per-session proxy CA"
@@ -36,32 +70,38 @@ export function createEphemeralCertificateAuthority(caPath) {
36
70
  }
37
71
  ]);
38
72
  cert.sign(keys.privateKey, forge.md.sha256.create());
39
- const caCertPem = forge.pki.certificateToPem(cert);
73
+ const certPem = forge.pki.certificateToPem(cert);
74
+ writeCaBundleAtomic(caPath, previousCertPem ? `${certPem}${previousCertPem}` : certPem);
75
+ return {
76
+ cert,
77
+ privateKey: keys.privateKey,
78
+ certPem,
79
+ notAfterMs: nowMs + lifetimeMs,
80
+ rotateAtMs: nowMs + Math.floor(lifetimeMs * CA_ROTATE_AFTER_FRACTION)
81
+ };
82
+ }
83
+ function writeCaBundleAtomic(caPath, bundle) {
40
84
  mkdirSync(dirname(caPath), {
41
85
  recursive: true,
42
86
  mode: 0o700
43
87
  });
44
- writeFileSync(caPath, caCertPem, {
45
- encoding: "utf8",
46
- mode: 0o600
47
- });
48
- const leafs = new Map();
49
- return {
50
- caCertPem,
51
- caPath,
52
- leafForHost(host) {
53
- const normalized = normalizeHost(host);
54
- const existing = leafs.get(normalized);
55
- if (existing) {
56
- return existing;
57
- }
58
- const leaf = createLeafCertificate(normalized, cert, keys.privateKey);
59
- leafs.set(normalized, leaf);
60
- return leaf;
61
- }
62
- };
88
+ const tempPath = `${caPath}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
89
+ try {
90
+ writeFileSync(tempPath, bundle, {
91
+ encoding: "utf8",
92
+ flag: "wx",
93
+ mode: 0o600
94
+ });
95
+ renameSync(tempPath, caPath);
96
+ }
97
+ catch (error) {
98
+ rmSync(tempPath, {
99
+ force: true
100
+ });
101
+ throw error;
102
+ }
63
103
  }
64
- function createLeafCertificate(host, issuerCert, issuerKey) {
104
+ function createLeafCertificate(host, issuer, lifetimeMs, nowMs) {
65
105
  const keys = forge.pki.rsa.generateKeyPair({
66
106
  bits: 2048,
67
107
  workers: -1
@@ -69,13 +109,13 @@ function createLeafCertificate(host, issuerCert, issuerKey) {
69
109
  const cert = forge.pki.createCertificate();
70
110
  cert.publicKey = keys.publicKey;
71
111
  cert.serialNumber = serialNumber();
72
- cert.validity.notBefore = new Date(Date.now() - 60_000);
73
- cert.validity.notAfter = new Date(Date.now() + 24 * 60 * 60 * 1_000);
112
+ cert.validity.notBefore = new Date(nowMs - 60_000);
113
+ cert.validity.notAfter = new Date(Math.min(nowMs + lifetimeMs, issuer.notAfterMs));
74
114
  cert.setSubject([{
75
115
  name: "commonName",
76
116
  value: host
77
117
  }]);
78
- cert.setIssuer(issuerCert.subject.attributes);
118
+ cert.setIssuer(issuer.cert.subject.attributes);
79
119
  cert.setExtensions([
80
120
  {
81
121
  name: "basicConstraints",
@@ -97,7 +137,7 @@ function createLeafCertificate(host, issuerCert, issuerKey) {
97
137
  altNames: subjectAlternativeNames(host)
98
138
  }
99
139
  ]);
100
- cert.sign(issuerKey, forge.md.sha256.create());
140
+ cert.sign(issuer.privateKey, forge.md.sha256.create());
101
141
  return {
102
142
  certPem: forge.pki.certificateToPem(cert),
103
143
  keyPem: forge.pki.privateKeyToPem(keys.privateKey)
@@ -83,9 +83,47 @@ function failClosedVerdict(classification) {
83
83
  reason: "per-invocation proxy enforcement is not available, so protected installs fail closed"
84
84
  };
85
85
  }
86
- function derivePackageName(classification) {
87
- const spec = classification.args.find((arg) => !arg.startsWith("-") && arg !== classification.action);
88
- return spec ?? `${classification.manager}:${classification.action || "install"}`;
86
+ const VALUE_CONSUMING_FLAGS = {
87
+ javascript: new Set([
88
+ "--registry", "--userconfig", "--globalconfig", "--cache", "--prefix", "--loglevel",
89
+ "--tag", "--workspace", "-w", "--filter", "-C", "--dir", "--cwd", "--modules-folder",
90
+ "--otp", "--script-shell", "--network-concurrency", "--mutex"
91
+ ]),
92
+ python: new Set([
93
+ "--index-url", "-i", "--extra-index-url", "--find-links", "-f", "--trusted-host",
94
+ "--proxy", "--requirement", "-r", "--constraint", "-c", "--target", "-t", "--prefix",
95
+ "--root", "--src", "--platform", "--python", "--python-version", "--implementation",
96
+ "--abi", "--cache-dir", "--timeout", "--retries", "--cert", "--client-cert", "--log",
97
+ "--progress-bar", "--upgrade-strategy", "--no-binary", "--only-binary", "--report",
98
+ "--editable", "-e", "--index", "--default-index"
99
+ ]),
100
+ rust: new Set([
101
+ "--registry", "--index", "--git", "--branch", "--tag", "--rev", "--path", "--vers",
102
+ "--version", "--features", "-F", "--package", "-p", "--manifest-path", "--target-dir",
103
+ "--profile", "--jobs", "-j", "--target", "--config", "-Z"
104
+ ]),
105
+ gated: new Set()
106
+ };
107
+ export function derivePackageName(classification) {
108
+ const valueFlags = VALUE_CONSUMING_FLAGS[classification.ecosystem];
109
+ const args = classification.args;
110
+ for (let index = 0; index < args.length; index += 1) {
111
+ const arg = args[index];
112
+ if (!arg) {
113
+ continue;
114
+ }
115
+ if (arg.startsWith("-")) {
116
+ if (valueFlags.has(arg)) {
117
+ index += 1;
118
+ }
119
+ continue;
120
+ }
121
+ if (arg === classification.action) {
122
+ continue;
123
+ }
124
+ return arg;
125
+ }
126
+ return `${classification.manager}:${classification.action || "install"}`;
89
127
  }
90
128
  function causeFromVerdict(verdict, action) {
91
129
  if (verdict === "pass" && action === "pass") {
@@ -1,6 +1,8 @@
1
- import { readFileSync } from "node:fs";
1
+ import { readFileSync, writeFileSync } from "node:fs";
2
2
  import { startProductionHttpProxy } from "./server.js";
3
3
  import { parseForceOverrideRequest } from "./enforcement.js";
4
+ import { cleanupSessionSync } from "../state/index.js";
5
+ const PARENT_POLL_MS = 500;
4
6
  const sessionPath = process.argv[2];
5
7
  const apiBaseUrl = process.argv[3];
6
8
  const classificationJson = process.env.DG_PROXY_CLASSIFICATION;
@@ -11,6 +13,10 @@ if (!sessionPath || !apiBaseUrl || !classificationJson) {
11
13
  const session = JSON.parse(readFileSync(sessionPath, "utf8"));
12
14
  const classification = JSON.parse(classificationJson);
13
15
  const forceOverride = parseForceOverrideRequest(process.env.DG_FORCE_OVERRIDE_REQUEST);
16
+ writeFileSync(session.files.pid, `${process.pid}\n`, {
17
+ encoding: "utf8",
18
+ mode: 0o600
19
+ });
14
20
  let handle = null;
15
21
  let closed = false;
16
22
  async function close() {
@@ -19,16 +25,22 @@ async function close() {
19
25
  }
20
26
  closed = true;
21
27
  await handle?.close();
28
+ cleanupSessionSync(session);
22
29
  }
23
- process.stdin.on("end", () => {
24
- close().finally(() => process.exit(0));
25
- });
26
- process.on("SIGTERM", () => {
27
- close().finally(() => process.exit(0));
28
- });
29
- process.on("SIGINT", () => {
30
+ function shutdown() {
30
31
  close().finally(() => process.exit(0));
31
- });
32
+ }
33
+ process.stdin.resume();
34
+ process.stdin.on("end", shutdown);
35
+ process.stdin.on("error", shutdown);
36
+ process.on("SIGTERM", shutdown);
37
+ process.on("SIGINT", shutdown);
38
+ const parentWatch = setInterval(() => {
39
+ if (process.ppid === 1) {
40
+ shutdown();
41
+ }
42
+ }, PARENT_POLL_MS);
43
+ parentWatch.unref();
32
44
  handle = await startProductionHttpProxy({
33
45
  session,
34
46
  apiBaseUrl,
@@ -1,7 +1,9 @@
1
- import { existsSync, mkdirSync, writeFileSync } from "node:fs";
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
+ import { dgVersion } from "../commands/version.js";
3
4
  import { isCiEnv } from "../presentation/mode.js";
4
5
  import { createTheme } from "../presentation/theme.js";
6
+ import { sweepLegacyPythonHooks } from "../setup/plan.js";
5
7
  import { resolveDgPaths } from "../state/index.js";
6
8
  const SKIP_COMMANDS = new Set([
7
9
  "help",
@@ -18,17 +20,46 @@ const SKIP_COMMANDS = new Set([
18
20
  "upgrade",
19
21
  "uninstall"
20
22
  ]);
23
+ const MACHINE_OUTPUT_FLAGS = new Set([
24
+ "--json",
25
+ "--sarif",
26
+ "--csv",
27
+ "--markdown",
28
+ "--output",
29
+ "-o",
30
+ "--quiet"
31
+ ]);
21
32
  export function firstRunMarkerPath(env = process.env) {
22
33
  return join(resolveDgPaths(env).stateDir, "first-run-shown");
23
34
  }
35
+ export function lastRunVersionMarkerPath(env = process.env) {
36
+ return join(resolveDgPaths(env).stateDir, "last-run-version");
37
+ }
38
+ export function sweepLegacyHooksOnVersionChange(env = process.env, version = dgVersion()) {
39
+ try {
40
+ const marker = lastRunVersionMarkerPath(env);
41
+ const recorded = existsSync(marker) ? readFileSync(marker, "utf8").trim() : undefined;
42
+ if (recorded === version) {
43
+ return false;
44
+ }
45
+ sweepLegacyPythonHooks(resolveDgPaths(env).homeDir, [], []);
46
+ mkdirSync(dirname(marker), { recursive: true, mode: 0o700 });
47
+ writeFileSync(marker, `${version}\n`, { encoding: "utf8", mode: 0o600 });
48
+ return true;
49
+ }
50
+ catch {
51
+ return false;
52
+ }
53
+ }
24
54
  export function maybeShowFirstRun(args, options = {}) {
25
55
  const env = options.env ?? process.env;
26
56
  const stderr = options.stderr ?? process.stderr;
27
57
  const command = args[0] ?? "";
58
+ sweepLegacyHooksOnVersionChange(env);
28
59
  if (!stderr.isTTY || isCiEnv(env)) {
29
60
  return false;
30
61
  }
31
- if (SKIP_COMMANDS.has(command) || args.some((arg) => arg === "--json" || arg === "--sarif" || arg === "--quiet")) {
62
+ if (SKIP_COMMANDS.has(command) || args.some((arg) => MACHINE_OUTPUT_FLAGS.has(arg))) {
32
63
  return false;
33
64
  }
34
65
  const marker = firstRunMarkerPath(env);
@@ -46,11 +46,11 @@ export function maybeShowNudges(args, options = {}) {
46
46
  const lines = [];
47
47
  let stateChanged = false;
48
48
  if (due(state.updateCheckedAt, UPDATE_THROTTLE_MS, now)) {
49
- state.updateCheckedAt = now.toISOString();
50
- stateChanged = true;
51
49
  const latest = readLatestVersion(LATEST_LOOKUP_TIMEOUT_MS);
52
50
  if (latest) {
51
+ state.updateCheckedAt = now.toISOString();
53
52
  state.updateLatest = latest;
53
+ stateChanged = true;
54
54
  if (compareVersions(latest, dgVersion()) > 0) {
55
55
  lines.push(`${theme.paint("warn", "⚠")} ${theme.paint("muted", `Update available: ${dgVersion()} → ${latest}. Run`)} ${theme.paint("accent", "dg update")}${theme.paint("muted", ".")}`);
56
56
  }
@@ -68,6 +68,13 @@ export function maybeShowNudges(args, options = {}) {
68
68
  writeState(statePath, state);
69
69
  }
70
70
  }
71
+ export function pendingUpdate(env = process.env) {
72
+ const latest = readState(nudgeStatePath(env)).updateLatest;
73
+ if (!latest || compareVersions(latest, dgVersion()) <= 0) {
74
+ return null;
75
+ }
76
+ return { current: dgVersion(), latest };
77
+ }
71
78
  function isAuthenticated(env) {
72
79
  try {
73
80
  return authStatus(env).authenticated;
@@ -1,21 +1,31 @@
1
- import { analyzePackages, mergeAnalyzeResponses } from "../api/analyze.js";
1
+ import { analyzePackages, mergeAnalyzeResponses, scannerErrorFromUnknown } from "../api/analyze.js";
2
+ async function readStdin() {
3
+ process.stdin.setEncoding("utf8");
4
+ let raw = "";
5
+ for await (const chunk of process.stdin) {
6
+ raw += chunk;
7
+ }
8
+ return raw;
9
+ }
2
10
  async function main() {
3
- const raw = process.argv[2];
11
+ const raw = (await readStdin()).trim();
4
12
  if (!raw) {
5
- process.stderr.write("analyze-worker: missing input payload\n");
6
- process.exit(2);
13
+ throw new Error("analyze-worker: missing input payload");
7
14
  }
8
- const groups = JSON.parse(raw);
15
+ const payload = JSON.parse(raw);
9
16
  const responses = [];
10
- for (const group of groups) {
17
+ for (const group of payload.groups) {
11
18
  if (group.packages.length === 0) {
12
19
  continue;
13
20
  }
14
- responses.push(await analyzePackages(group.packages, { ecosystem: group.ecosystem }));
21
+ responses.push(await analyzePackages(group.packages, {
22
+ ecosystem: group.ecosystem,
23
+ ...(payload.scanId ? { scanId: payload.scanId } : {})
24
+ }));
15
25
  }
16
26
  process.stdout.write(JSON.stringify(mergeAnalyzeResponses(responses)));
17
27
  }
18
28
  main().catch((error) => {
19
- process.stderr.write(`analyze-worker: ${error instanceof Error ? error.message : "unknown error"}\n`);
29
+ process.stdout.write(JSON.stringify({ scannerError: scannerErrorFromUnknown(error) }));
20
30
  process.exit(1);
21
31
  });
@@ -1,6 +1,6 @@
1
1
  import { readdirSync } from "node:fs";
2
2
  import { join, relative } from "node:path";
3
- import { verifyLockfile } from "../verify/preflight.js";
3
+ import { parseLockfilePackages } from "../verify/preflight.js";
4
4
  export const LOCKFILE_ECOSYSTEMS = {
5
5
  "package-lock.json": "npm",
6
6
  "npm-shrinkwrap.json": "npm",
@@ -8,6 +8,7 @@ export const LOCKFILE_ECOSYSTEMS = {
8
8
  "pnpm-lock.yaml": "npm",
9
9
  "Pipfile.lock": "pypi",
10
10
  "poetry.lock": "pypi",
11
+ "uv.lock": "pypi",
11
12
  "requirements.txt": "pypi"
12
13
  };
13
14
  export function isLockfileName(name) {
@@ -34,13 +35,19 @@ function readDirents(directory) {
34
35
  return [];
35
36
  }
36
37
  }
37
- function firstLockfile(entries) {
38
+ function lockfilesPerEcosystem(entries) {
39
+ const matches = [];
40
+ const claimed = new Set();
38
41
  for (const [lockfile, ecosystem] of Object.entries(LOCKFILE_ECOSYSTEMS)) {
42
+ if (claimed.has(ecosystem)) {
43
+ continue;
44
+ }
39
45
  if (entries.some((entry) => entry.name === lockfile && entry.isFile())) {
40
- return [lockfile, ecosystem];
46
+ matches.push([lockfile, ecosystem]);
47
+ claimed.add(ecosystem);
41
48
  }
42
49
  }
43
- return null;
50
+ return matches;
44
51
  }
45
52
  function shouldDescend(entry) {
46
53
  return entry.isDirectory() && !IGNORED_DIRECTORIES.has(entry.name) && !entry.name.startsWith(".");
@@ -54,14 +61,13 @@ export function discoverScanProjects(root) {
54
61
  return;
55
62
  }
56
63
  const entries = readDirents(directory);
57
- const match = firstLockfile(entries);
58
- if (match) {
64
+ for (const [depFile, ecosystem] of lockfilesPerEcosystem(entries)) {
59
65
  projects.push({
60
66
  path: directory,
61
67
  relativePath: relative(root, directory) || ".",
62
- ecosystem: match[1],
63
- depFile: match[0],
64
- packageCount: countLockfilePackages(join(directory, match[0]))
68
+ ecosystem,
69
+ depFile,
70
+ packageCount: countLockfilePackages(join(directory, depFile))
65
71
  });
66
72
  }
67
73
  for (const entry of entries) {
@@ -85,17 +91,16 @@ export async function discoverScanProjectsAsync(root, onProgress) {
85
91
  lastYield = Date.now();
86
92
  }
87
93
  const entries = readDirents(directory);
88
- const match = firstLockfile(entries);
89
- if (match) {
94
+ for (const [depFile, ecosystem] of lockfilesPerEcosystem(entries)) {
90
95
  const relativePath = relative(root, directory) || ".";
91
96
  projects.push({
92
97
  path: directory,
93
98
  relativePath,
94
- ecosystem: match[1],
95
- depFile: match[0],
96
- packageCount: countLockfilePackages(join(directory, match[0]))
99
+ ecosystem,
100
+ depFile,
101
+ packageCount: countLockfilePackages(join(directory, depFile))
97
102
  });
98
- onProgress?.({ path: relativePath === "." ? match[0] : `${relativePath}/${match[0]}`, found: projects.length });
103
+ onProgress?.({ path: relativePath === "." ? depFile : `${relativePath}/${depFile}`, found: projects.length });
99
104
  }
100
105
  for (const entry of entries) {
101
106
  if (shouldDescend(entry)) {
@@ -110,27 +115,29 @@ function yieldToEventLoop() {
110
115
  });
111
116
  }
112
117
  function countLockfilePackages(lockfilePath) {
113
- try {
114
- return verifyLockfile(lockfilePath).packages.length;
115
- }
116
- catch {
117
- return 0;
118
- }
118
+ return parseLockfilePackages(lockfilePath).packages.length;
119
119
  }
120
120
  export function collectScanPackages(projects) {
121
121
  const byEcosystem = new Map();
122
122
  const seen = new Set();
123
+ const skippedPackages = [];
124
+ const parseErrors = [];
123
125
  let skipped = 0;
124
126
  for (const project of projects) {
125
- let identities;
126
- try {
127
- identities = verifyLockfile(join(project.path, project.depFile)).packages;
127
+ const depFilePath = project.relativePath === "." ? project.depFile : `${project.relativePath}/${project.depFile}`;
128
+ const parsed = parseLockfilePackages(join(project.path, project.depFile));
129
+ for (const skippedPackage of parsed.skipped) {
130
+ skippedPackages.push({ ...skippedPackage, location: `${depFilePath}: ${skippedPackage.location}` });
131
+ skipped += 1;
128
132
  }
129
- catch {
133
+ if (parsed.parseError) {
134
+ parseErrors.push({
135
+ file: parsed.parseError.file === project.depFile ? depFilePath : parsed.parseError.file,
136
+ reason: parsed.parseError.reason
137
+ });
130
138
  skipped += 1;
131
- continue;
132
139
  }
133
- for (const identity of identities) {
140
+ for (const identity of parsed.packages) {
134
141
  if (identity.ecosystem !== "npm" && identity.ecosystem !== "pypi") {
135
142
  skipped += 1;
136
143
  continue;
@@ -149,5 +156,5 @@ export function collectScanPackages(projects) {
149
156
  byEcosystem.set(identity.ecosystem, list);
150
157
  }
151
158
  }
152
- return { byEcosystem, skipped };
159
+ return { byEcosystem, skipped, skippedPackages, parseErrors };
153
160
  }
@@ -5,53 +5,70 @@ import { renderJsonReport, renderSarifReport, renderTextReport } from "./render.
5
5
  import { resolvePresentation } from "../presentation/mode.js";
6
6
  import { createTheme } from "../presentation/theme.js";
7
7
  import { launchScanTui, shouldLaunchScanTui } from "../scan-ui/launch.js";
8
- import { tryScannerScan } from "./scanner-report.js";
9
- import { runStagedScan } from "./staged.js";
8
+ import { runScannerScan } from "./scanner-report.js";
9
+ import { runStagedScan, stagedScanReport } from "./staged.js";
10
10
  import { scanExitCode } from "../scan-ui/shims.js";
11
11
  import { loadUserConfig } from "../config/settings.js";
12
- import { EXIT_USAGE } from "../commands/types.js";
12
+ import { EXIT_USAGE_VERDICT } from "../commands/types.js";
13
13
  export function runScanCommand(context) {
14
14
  const parsed = parseScanArgs(context.args);
15
15
  if ("error" in parsed) {
16
16
  return usageError(parsed.error);
17
17
  }
18
- if (parsed.staged) {
19
- return runStagedScan({ hook: parsed.hook });
20
- }
21
- if (shouldLaunchScanTui({
22
- targetPath: parsed.targetPath,
23
- format: parsed.format,
24
- outputPath: parsed.outputPath ?? undefined
25
- })) {
26
- void launchScanTui().catch((error) => {
27
- process.stderr.write(`dg scan TUI failed: ${error instanceof Error ? error.message : "unknown error"}\n`);
28
- process.exitCode = 1;
29
- });
30
- return {
31
- exitCode: 0,
32
- stdout: "",
33
- stderr: ""
34
- };
18
+ const stagedTarget = parsed.sawTarget ? parsed.targetPath : null;
19
+ const machineOutput = parsed.format !== "text" || parsed.outputPath !== null;
20
+ if (parsed.staged && !machineOutput) {
21
+ return runStagedScan({ hook: parsed.hook, targetPath: stagedTarget });
35
22
  }
36
23
  let report;
37
- try {
38
- report = scanProject({
39
- targetPath: parsed.targetPath
40
- });
24
+ let outcome;
25
+ if (parsed.staged) {
26
+ const staged = stagedScanReport({ targetPath: stagedTarget });
27
+ if ("result" in staged) {
28
+ return staged.result;
29
+ }
30
+ report = staged.report;
31
+ outcome = staged.outcome;
41
32
  }
42
- catch (error) {
43
- return {
44
- exitCode: 1,
45
- stdout: "",
46
- stderr: `dg scan failed: ${error instanceof Error ? error.message : "unknown scan error"}\n`
47
- };
33
+ else {
34
+ if (shouldLaunchScanTui({
35
+ targetPath: parsed.targetPath,
36
+ format: parsed.format,
37
+ outputPath: parsed.outputPath ?? undefined
38
+ })) {
39
+ void launchScanTui().catch((error) => {
40
+ process.stderr.write(`dg scan TUI failed: ${error instanceof Error ? error.message : "unknown error"}\n`);
41
+ process.exitCode = 1;
42
+ });
43
+ return {
44
+ exitCode: 0,
45
+ stdout: "",
46
+ stderr: ""
47
+ };
48
+ }
49
+ try {
50
+ report = scanProject({
51
+ targetPath: parsed.targetPath
52
+ });
53
+ }
54
+ catch (error) {
55
+ return {
56
+ exitCode: 1,
57
+ stdout: "",
58
+ stderr: `dg scan failed: ${error instanceof Error ? error.message : "unknown scan error"}\n`
59
+ };
60
+ }
61
+ outcome = runScannerScan(parsed.targetPath, report);
62
+ }
63
+ if (outcome.kind === "report") {
64
+ report = outcome.report;
48
65
  }
49
- const scannerReport = tryScannerScan(parsed.targetPath, report);
50
- if (scannerReport) {
51
- report = scannerReport;
66
+ else if (outcome.kind === "failed") {
67
+ report = degradeReport(report, outcome.error);
52
68
  }
53
- const scannerUnavailable = scannerReport === null && report.summary.projectCount > 0;
54
- const rendered = renderReport(report, parsed.format, scannerUnavailable);
69
+ const skipNotice = skipNoticeFor(outcome, report);
70
+ const scannerUnavailable = !report.scanner && report.summary.projectCount > 0;
71
+ const rendered = renderReport(report, parsed.format, scannerUnavailable, skipNotice);
55
72
  if (parsed.outputPath) {
56
73
  try {
57
74
  writeFileSync(resolve(parsed.outputPath), rendered, "utf8");
@@ -111,7 +128,7 @@ function parseScanArgs(args) {
111
128
  }
112
129
  if (arg === "--output" || arg === "-o") {
113
130
  const next = args[index + 1];
114
- if (!next) {
131
+ if (!next || next.startsWith("-")) {
115
132
  return { error: `${arg} requires a path` };
116
133
  }
117
134
  outputPath = next;
@@ -131,29 +148,52 @@ function parseScanArgs(args) {
131
148
  format,
132
149
  outputPath,
133
150
  targetPath,
151
+ sawTarget,
134
152
  staged,
135
153
  hook
136
154
  };
137
155
  }
138
156
  function usageError(message) {
139
157
  return {
140
- exitCode: EXIT_USAGE,
158
+ exitCode: EXIT_USAGE_VERDICT,
141
159
  stdout: "",
142
160
  stderr: `dg scan: ${message}. Usage: dg scan [path] [--json|--sarif] [--output <path>]\n`
143
161
  };
144
162
  }
145
- function renderReport(report, format, scannerUnavailable) {
163
+ function degradeReport(report, error) {
164
+ const status = report.status === "block" || report.status === "warn" ? report.status : "unknown";
165
+ return { ...report, status, scannerError: error };
166
+ }
167
+ function skipNoticeFor(outcome, report) {
168
+ if (outcome.kind !== "skipped") {
169
+ return undefined;
170
+ }
171
+ if (outcome.reason === "no_lockfiles") {
172
+ return report.summary.projectCount > 0 ? "no_lockfile" : undefined;
173
+ }
174
+ return "empty_lockfile";
175
+ }
176
+ function renderReport(report, format, scannerUnavailable, skipNotice) {
146
177
  if (format === "json") {
147
178
  return renderJsonReport(report, scannerUnavailable);
148
179
  }
149
180
  if (format === "sarif") {
150
181
  return renderSarifReport(report);
151
182
  }
152
- return renderTextReport(report, undefined, createTheme(resolvePresentation().color), scannerUnavailable);
183
+ return renderTextReport(report, undefined, createTheme(resolvePresentation().color), skipNotice);
153
184
  }
154
185
  function exitCodeForReport(report) {
155
186
  if (report.scanner) {
156
187
  return scanExitCode(report.scanner.action, loadUserConfig().policy.mode);
157
188
  }
158
- return report.status === "block" || report.status === "error" ? 1 : 0;
189
+ if (report.status === "block") {
190
+ return 2;
191
+ }
192
+ if (report.status === "warn") {
193
+ return 1;
194
+ }
195
+ if (report.status === "error" || report.status === "unknown") {
196
+ return 4;
197
+ }
198
+ return 0;
159
199
  }