@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
@@ -49,6 +49,7 @@ export function scanProject(options = {}) {
49
49
  });
50
50
  return {
51
51
  target: displayPath(cwd, absoluteTarget),
52
+ targetDir: displayPath(cwd, root),
52
53
  status,
53
54
  projects,
54
55
  findings,
@@ -257,17 +258,27 @@ function dependencyFindings(manifest, manifestPath, project) {
257
258
  }
258
259
  function dependencySpecifierSeverity(specifier) {
259
260
  const normalized = specifier.trim().toLowerCase();
260
- if (normalized.startsWith("http://")
261
- || normalized.startsWith("https://")
262
- || normalized.startsWith("git+")
263
- || normalized.startsWith("git://")
264
- || normalized.startsWith("ssh://")
265
- || normalized.startsWith("github:")) {
261
+ // git-based and ssh specifiers (github:, git+https, git+ssh, ssh://) are a
262
+ // common, legitimate way to pin private forks. They are unscanned, so surface
263
+ // them as a warn rather than failing CI — a repo full of internal git deps
264
+ // would otherwise hit a block on every run. (git+ over plaintext http is a
265
+ // genuine MITM risk and is still blocked below.)
266
+ if (normalized.startsWith("git+http://") || normalized.startsWith("git+git://")) {
266
267
  return "block";
267
268
  }
268
- if (normalized.startsWith("file:")) {
269
+ if (normalized.startsWith("git+")
270
+ || normalized.startsWith("ssh://")
271
+ || normalized.startsWith("github:")
272
+ || normalized.startsWith("file:")) {
269
273
  return "warn";
270
274
  }
275
+ // Plaintext transport, or a raw tarball pinned to an arbitrary host: unscanned
276
+ // and registry-bypassing, so block.
277
+ if (normalized.startsWith("http://")
278
+ || normalized.startsWith("https://")
279
+ || normalized.startsWith("git://")) {
280
+ return "block";
281
+ }
271
282
  return null;
272
283
  }
273
284
  function dependencyCount(manifest) {
@@ -0,0 +1,202 @@
1
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
2
+ import { basename, join } from "node:path";
3
+ function add(acc, ecosystem, name, version, origin) {
4
+ if (!name || !version)
5
+ return;
6
+ const key = `${ecosystem}:${name}@${version}`;
7
+ if (acc.seen.has(key))
8
+ return;
9
+ acc.seen.add(key);
10
+ const list = acc.byEcosystem.get(ecosystem) ?? [];
11
+ list.push({ name, version });
12
+ acc.byEcosystem.set(ecosystem, list);
13
+ acc.origins.set(key, origin);
14
+ }
15
+ function readDirentsSafe(dir) {
16
+ try {
17
+ return readdirSync(dir, { withFileTypes: true });
18
+ }
19
+ catch {
20
+ return [];
21
+ }
22
+ }
23
+ // PEP 503 normalization so a venv's `Foo_Bar` matches the registry's `foo-bar`.
24
+ function normalizePypiName(name) {
25
+ return name.toLowerCase().replace(/[-_.]+/g, "-");
26
+ }
27
+ function parseMetadataHeaders(text) {
28
+ let name = null;
29
+ let version = null;
30
+ for (const raw of text.split(/\r?\n/u)) {
31
+ if (raw.trim() === "")
32
+ break; // RFC822 headers end at the first blank line
33
+ if (name === null && raw.startsWith("Name:"))
34
+ name = raw.slice(5).trim();
35
+ else if (version === null && raw.startsWith("Version:"))
36
+ version = raw.slice(8).trim();
37
+ if (name !== null && version !== null)
38
+ break;
39
+ }
40
+ return name && version ? { name: normalizePypiName(name), version } : null;
41
+ }
42
+ const SUBTREE_MAX_DEPTH = 8;
43
+ const SUBTREE_PRUNED_DIRS = new Set([
44
+ "node_modules",
45
+ ".git",
46
+ "dist",
47
+ "build",
48
+ "vendor",
49
+ "__pycache__",
50
+ "target",
51
+ "coverage",
52
+ ]);
53
+ function discoverEnvironmentDirs(root) {
54
+ const venvs = [];
55
+ const nodeModules = [];
56
+ const visit = (dir, depth) => {
57
+ if (depth > SUBTREE_MAX_DEPTH)
58
+ return;
59
+ for (const entry of readDirentsSafe(dir)) {
60
+ if (!entry.isDirectory())
61
+ continue;
62
+ const child = join(dir, entry.name);
63
+ if (entry.name === "node_modules") {
64
+ nodeModules.push(child);
65
+ continue;
66
+ }
67
+ if (SUBTREE_PRUNED_DIRS.has(entry.name))
68
+ continue;
69
+ if (existsSync(join(child, "pyvenv.cfg"))) {
70
+ venvs.push(child);
71
+ continue;
72
+ }
73
+ visit(child, depth + 1);
74
+ }
75
+ };
76
+ visit(root, 0);
77
+ return { venvs, nodeModules };
78
+ }
79
+ function sitePackagesDirs(prefix) {
80
+ const out = [];
81
+ for (const entry of readDirentsSafe(join(prefix, "lib"))) {
82
+ if (entry.isDirectory() && entry.name.startsWith("python")) {
83
+ out.push(join(prefix, "lib", entry.name, "site-packages"));
84
+ }
85
+ }
86
+ out.push(join(prefix, "Lib", "site-packages")); // Windows layout
87
+ return out.filter(existsSync);
88
+ }
89
+ function pythonEnvironmentRoots(env, subtreeVenvs) {
90
+ const roots = new Set(subtreeVenvs);
91
+ if (env.VIRTUAL_ENV && existsSync(join(env.VIRTUAL_ENV, "pyvenv.cfg")))
92
+ roots.add(env.VIRTUAL_ENV);
93
+ if (env.CONDA_PREFIX && sitePackagesDirs(env.CONDA_PREFIX).length > 0)
94
+ roots.add(env.CONDA_PREFIX);
95
+ return [...roots];
96
+ }
97
+ function collectPython(env, subtreeVenvs, acc) {
98
+ for (const prefix of pythonEnvironmentRoots(env, subtreeVenvs)) {
99
+ for (const siteDir of sitePackagesDirs(prefix)) {
100
+ let found = false;
101
+ for (const entry of readDirentsSafe(siteDir)) {
102
+ if (!entry.isDirectory())
103
+ continue;
104
+ const metaPath = entry.name.endsWith(".dist-info")
105
+ ? join(siteDir, entry.name, "METADATA")
106
+ : entry.name.endsWith(".egg-info")
107
+ ? join(siteDir, entry.name, "PKG-INFO")
108
+ : null;
109
+ if (!metaPath || !existsSync(metaPath))
110
+ continue;
111
+ let parsed = null;
112
+ try {
113
+ parsed = parseMetadataHeaders(readFileSync(metaPath, "utf8"));
114
+ }
115
+ catch {
116
+ parsed = null;
117
+ }
118
+ if (parsed) {
119
+ add(acc, "pypi", parsed.name, parsed.version, `${basename(prefix)} (installed)`);
120
+ found = true;
121
+ }
122
+ }
123
+ if (found)
124
+ acc.sources.add(`${basename(prefix)}/${basename(siteDir)} (pypi)`);
125
+ }
126
+ }
127
+ }
128
+ const NODE_MODULES_MAX_DEPTH = 8;
129
+ const NODE_MODULES_ORIGIN = "node_modules (installed)";
130
+ const NODE_MODULES_SOURCE = "node_modules (npm)";
131
+ const PNPM_ORIGIN = "node_modules/.pnpm (installed)";
132
+ const PNPM_SOURCE = "node_modules/.pnpm (npm)";
133
+ function readPackageJson(dir) {
134
+ try {
135
+ const parsed = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
136
+ return parsed && typeof parsed === "object" ? parsed : null;
137
+ }
138
+ catch {
139
+ return null;
140
+ }
141
+ }
142
+ function collectNodeModules(nodeModulesDir, acc, depth) {
143
+ if (depth > NODE_MODULES_MAX_DEPTH)
144
+ return;
145
+ for (const entry of readDirentsSafe(nodeModulesDir)) {
146
+ if (!entry.isDirectory() || entry.name === ".bin" || entry.name === ".cache")
147
+ continue;
148
+ if (entry.name === ".pnpm") {
149
+ collectPnpmStore(join(nodeModulesDir, entry.name), acc);
150
+ continue;
151
+ }
152
+ if (entry.name.startsWith("@")) {
153
+ for (const scoped of readDirentsSafe(join(nodeModulesDir, entry.name))) {
154
+ if (scoped.isDirectory()) {
155
+ collectNodePackage(join(nodeModulesDir, entry.name, scoped.name), acc, depth, NODE_MODULES_ORIGIN, NODE_MODULES_SOURCE);
156
+ }
157
+ }
158
+ continue;
159
+ }
160
+ if (entry.name.startsWith("."))
161
+ continue;
162
+ collectNodePackage(join(nodeModulesDir, entry.name), acc, depth, NODE_MODULES_ORIGIN, NODE_MODULES_SOURCE);
163
+ }
164
+ }
165
+ function collectPnpmStore(storeDir, acc) {
166
+ for (const entry of readDirentsSafe(storeDir)) {
167
+ if (!entry.isDirectory())
168
+ continue;
169
+ const inner = join(storeDir, entry.name, "node_modules");
170
+ for (const pkgEntry of readDirentsSafe(inner)) {
171
+ if (!pkgEntry.isDirectory() || pkgEntry.name === ".bin")
172
+ continue;
173
+ if (pkgEntry.name.startsWith("@")) {
174
+ for (const scoped of readDirentsSafe(join(inner, pkgEntry.name))) {
175
+ if (scoped.isDirectory()) {
176
+ collectNodePackage(join(inner, pkgEntry.name, scoped.name), acc, 0, PNPM_ORIGIN, PNPM_SOURCE);
177
+ }
178
+ }
179
+ continue;
180
+ }
181
+ collectNodePackage(join(inner, pkgEntry.name), acc, 0, PNPM_ORIGIN, PNPM_SOURCE);
182
+ }
183
+ }
184
+ }
185
+ function collectNodePackage(packageDir, acc, depth, origin, source) {
186
+ const pkg = readPackageJson(packageDir);
187
+ if (pkg && typeof pkg.name === "string" && typeof pkg.version === "string") {
188
+ add(acc, "npm", pkg.name, pkg.version, origin);
189
+ acc.sources.add(source);
190
+ }
191
+ const nested = join(packageDir, "node_modules");
192
+ if (existsSync(nested))
193
+ collectNodeModules(nested, acc, depth + 1);
194
+ }
195
+ export function discoverInstalledPackages(root, env = process.env) {
196
+ const acc = { byEcosystem: new Map(), origins: new Map(), seen: new Set(), sources: new Set() };
197
+ const { venvs, nodeModules } = discoverEnvironmentDirs(root);
198
+ collectPython(env, venvs, acc);
199
+ for (const dir of nodeModules)
200
+ collectNodeModules(dir, acc, 0);
201
+ return { byEcosystem: acc.byEcosystem, origins: acc.origins, sources: [...acc.sources] };
202
+ }
@@ -0,0 +1,128 @@
1
+ import { readdirSync, readFileSync } from "node:fs";
2
+ import { join, relative } from "node:path";
3
+ import { gitIgnoredDirectories } from "./discovery.js";
4
+ const IGNORED_DIRECTORIES = new Set([
5
+ "node_modules",
6
+ ".git",
7
+ "dist",
8
+ "build",
9
+ "vendor",
10
+ "__pycache__",
11
+ ".venv",
12
+ "venv",
13
+ "target",
14
+ "coverage",
15
+ ".cache",
16
+ ".pytest_cache",
17
+ ".mypy_cache",
18
+ ".ruff_cache",
19
+ ".idea",
20
+ ".vscode"
21
+ ]);
22
+ const MAX_DISCOVERY_DEPTH = 12;
23
+ const NPM_LOCKFILES = ["package-lock.json", "npm-shrinkwrap.json", "yarn.lock", "pnpm-lock.yaml"];
24
+ const PYPI_LOCKFILES = ["requirements.txt", "Pipfile.lock", "poetry.lock", "uv.lock"];
25
+ const PYPI_MANIFEST_MARKERS = [
26
+ { file: "pyproject.toml", declares: pyprojectDeclaresDeps },
27
+ { file: "Pipfile", declares: (text) => /^\s*\[(?:dev-)?packages\]/mu.test(text) },
28
+ { file: "setup.cfg", declares: (text) => /install_requires\s*=/u.test(text) },
29
+ { file: "setup.py", declares: (text) => /install_requires\s*=/u.test(text) },
30
+ { file: "environment.yml", declares: (text) => /^\s*dependencies\s*:/mu.test(text) },
31
+ { file: "environment.yaml", declares: (text) => /^\s*dependencies\s*:/mu.test(text) }
32
+ ];
33
+ function pyprojectDeclaresDeps(text) {
34
+ return /^\s*dependencies\s*=/mu.test(text)
35
+ || /\[tool\.poetry\.dependencies\]/u.test(text)
36
+ || /\[build-system\]/u.test(text)
37
+ || /\[project\.optional-dependencies\]/u.test(text);
38
+ }
39
+ function readDirents(directory) {
40
+ try {
41
+ return readdirSync(directory, { withFileTypes: true });
42
+ }
43
+ catch {
44
+ return [];
45
+ }
46
+ }
47
+ function readTextSafe(path) {
48
+ try {
49
+ return readFileSync(path, "utf8");
50
+ }
51
+ catch {
52
+ return null;
53
+ }
54
+ }
55
+ function npmDeclaredCount(path) {
56
+ const text = readTextSafe(path);
57
+ if (text === null) {
58
+ return null;
59
+ }
60
+ try {
61
+ const parsed = JSON.parse(text);
62
+ let count = 0;
63
+ for (const section of ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"]) {
64
+ const deps = parsed[section];
65
+ if (deps && typeof deps === "object" && !Array.isArray(deps)) {
66
+ count += Object.keys(deps).length;
67
+ }
68
+ }
69
+ return count;
70
+ }
71
+ catch {
72
+ return null;
73
+ }
74
+ }
75
+ // A directory that declares dependencies in a manifest but ships no lockfile for
76
+ // that ecosystem cannot be version-resolved, so the scanner never sees those
77
+ // packages. Surfacing the manifest turns that silent "nothing to scan" into an
78
+ // actionable nudge (generate a lockfile) instead of a false all-clear.
79
+ export function detectUnlockedManifests(root) {
80
+ const found = [];
81
+ const gitIgnored = gitIgnoredDirectories(root);
82
+ walk(root, 0);
83
+ return found;
84
+ function walk(directory, depth) {
85
+ if (depth > MAX_DISCOVERY_DEPTH) {
86
+ return;
87
+ }
88
+ const entries = readDirents(directory);
89
+ const names = new Set(entries.filter((entry) => entry.isFile()).map((entry) => entry.name));
90
+ if (names.has("package.json") && !NPM_LOCKFILES.some((lock) => names.has(lock))) {
91
+ const declaredCount = npmDeclaredCount(join(directory, "package.json"));
92
+ if (declaredCount === null || declaredCount > 0) {
93
+ found.push({ file: "package.json", location: displayFile(root, directory, "package.json"), ecosystem: "npm", declaredCount });
94
+ }
95
+ }
96
+ if (!PYPI_LOCKFILES.some((lock) => names.has(lock))) {
97
+ for (const marker of PYPI_MANIFEST_MARKERS) {
98
+ if (!names.has(marker.file)) {
99
+ continue;
100
+ }
101
+ const text = readTextSafe(join(directory, marker.file));
102
+ if (text !== null && marker.declares(text)) {
103
+ found.push({ file: marker.file, location: displayFile(root, directory, marker.file), ecosystem: "pypi", declaredCount: null });
104
+ break;
105
+ }
106
+ }
107
+ }
108
+ for (const entry of entries) {
109
+ if (entry.isDirectory() &&
110
+ !IGNORED_DIRECTORIES.has(entry.name) &&
111
+ !gitIgnored.has(join(directory, entry.name))) {
112
+ walk(join(directory, entry.name), depth + 1);
113
+ }
114
+ }
115
+ }
116
+ }
117
+ function displayFile(root, directory, file) {
118
+ const rel = relative(root, directory);
119
+ return rel.length === 0 ? file : `${rel.split(/[\\/]/u).join("/")}/${file}`;
120
+ }
121
+ export function unlockedManifestNotice(manifests) {
122
+ if (manifests.length === 0) {
123
+ return null;
124
+ }
125
+ const sample = manifests.slice(0, 6).map((manifest) => manifest.location).join(", ");
126
+ const more = manifests.length > 6 ? `, plus ${manifests.length - 6} more` : "";
127
+ return `Declared dependencies without a lockfile — not verified: ${sample}${more}. Generate a lockfile (npm install, uv lock, poetry lock, or pip freeze > requirements.txt) so dg can resolve and scan them.`;
128
+ }
@@ -1,18 +1,82 @@
1
1
  import { createTheme } from "../presentation/theme.js";
2
2
  import { packagePageUrl } from "../presentation/package-page.js";
3
+ import { safeVersionLine } from "../presentation/safe-version.js";
4
+ import { automationCategory, findingFingerprint, sarifLocationEntry } from "../presentation/sarif.js";
3
5
  import { sanitizeDeep } from "../security/sanitize.js";
4
6
  const DETAIL_PROJECT_LIMIT = 8;
5
7
  const CLEAN_PROJECT_PREVIEW_LIMIT = 5;
6
8
  const FINDING_GROUP_PREVIEW_LIMIT = 3;
7
- const SCAN_STATUS_ROLE = {
9
+ const DISPLAY_STATUS_ROLE = {
8
10
  pass: "pass",
9
11
  warn: "warn",
10
12
  block: "block",
11
13
  unknown: "unknown",
14
+ analysis_incomplete: "unknown",
12
15
  error: "block"
13
16
  };
14
17
  export function displayScanStatus(report) {
15
- return report.scannerError && report.status === "unknown" ? "analysis_incomplete" : report.status;
18
+ if (report.scanner && report.decisions && !report.scannerError) {
19
+ return applyLocalFindingEscalation(report.decisions.effectiveAction, report.findings);
20
+ }
21
+ if (report.status === "unknown" && (report.scannerError || report.scanner?.action === "analysis_incomplete")) {
22
+ return "analysis_incomplete";
23
+ }
24
+ return report.status;
25
+ }
26
+ // The canonical scanner verdict (pass | warn | block | error), independent of any
27
+ // --fail-on policy gating applied to the exit code. Consumers like the GitHub
28
+ // Action read this from runs[0].properties.dgVerdict so they report the real
29
+ // verdict instead of inferring it from a policy-suppressed exit status.
30
+ export function sarifVerdict(report) {
31
+ if (report.scanner) {
32
+ const action = report.decisions?.effectiveAction ?? report.scanner.action;
33
+ if (action === "block" || action === "warn" || action === "pass") {
34
+ return action;
35
+ }
36
+ return "error";
37
+ }
38
+ if (report.status === "block" || report.status === "warn" || report.status === "pass") {
39
+ return report.status;
40
+ }
41
+ return "error";
42
+ }
43
+ // Root-manifest heuristics (http:/file: deps, install lifecycle scripts) are
44
+ // signals the server scan never receives, so they must be able to escalate the
45
+ // verdict on their own — a lockfile must never mask a local block.
46
+ export function applyLocalFindingEscalation(base, findings) {
47
+ const active = findings.filter((finding) => !finding.acknowledged);
48
+ if (active.some((finding) => finding.severity === "block")) {
49
+ return "block";
50
+ }
51
+ if (base !== "pass") {
52
+ return base;
53
+ }
54
+ return active.some((finding) => finding.severity === "warn") ? "warn" : "pass";
55
+ }
56
+ const UNSCANNED_REASON_LABELS = {
57
+ unpinned: "declared without a pinned version",
58
+ "off-registry": "resolved from an unexpected host",
59
+ git: "git or ssh source (not fetched from a registry)",
60
+ "direct-url": "direct URL artifact",
61
+ "unsupported-ecosystem": "ecosystem not yet analyzed by the scanner",
62
+ "index-override": "custom package index override"
63
+ };
64
+ function unscannedLines(unscanned, theme) {
65
+ const role = unscanned.escalating > 0 ? "unknown" : "warn";
66
+ const head = unscanned.escalating > 0
67
+ ? `${unscanned.total} package${unscanned.total === 1 ? "" : "s"} could not be verified (${unscanned.escalating} left the verdict incomplete)`
68
+ : `${unscanned.total} package${unscanned.total === 1 ? "" : "s"} not sent to the scanner`;
69
+ const lines = [theme.paint(role, `Not scanned: ${head}`)];
70
+ for (const [reason, count] of Object.entries(unscanned.byReason)) {
71
+ lines.push(` ${count} ${UNSCANNED_REASON_LABELS[reason] ?? reason}`);
72
+ }
73
+ for (const pkg of unscanned.sample.slice(0, 5)) {
74
+ lines.push(` ${pkg.name} (${pkg.location})`);
75
+ }
76
+ if (unscanned.byReason.unpinned) {
77
+ lines.push(" Pin versions (e.g. requests==2.31.0) or install first so dg can resolve and verify them.");
78
+ }
79
+ return lines;
16
80
  }
17
81
  const SCANNER_SKIP_MESSAGES = {
18
82
  no_lockfile: "no lockfile found — server verification skipped (local heuristics only)",
@@ -56,6 +120,10 @@ export function renderTextReport(rawReport, terminalWidth = terminalWidthFromEnv
56
120
  lines.push(SCANNER_SKIP_MESSAGES[scannerNotice]);
57
121
  lines.push("");
58
122
  }
123
+ if (report.unscanned) {
124
+ lines.push(...unscannedLines(report.unscanned, theme));
125
+ lines.push("");
126
+ }
59
127
  if (report.projects.length === 0 && report.errors.length === 0 && !report.scanner && !report.scannerError) {
60
128
  lines.push("No supported project manifests found.");
61
129
  }
@@ -98,12 +166,29 @@ export function renderTextReport(rawReport, terminalWidth = terminalWidthFromEnv
98
166
  lines.push("");
99
167
  }
100
168
  }
169
+ lines.push(...safeVersionLines(report, theme));
101
170
  lines.push(...packagePageLines(report, theme));
102
171
  for (const error of report.errors) {
103
172
  lines.push(...wrapLine(`ERROR ${error.location}: `, error.message, width));
104
173
  }
105
174
  return `${lines.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd()}\n`;
106
175
  }
176
+ function safeVersionLines(report, theme) {
177
+ const lines = [];
178
+ const seen = new Set();
179
+ for (const pkg of report.scanner?.packages ?? []) {
180
+ if (!pkg.safeVersion)
181
+ continue;
182
+ const key = `${pkg.name}@${pkg.version}`;
183
+ if (seen.has(key))
184
+ continue;
185
+ seen.add(key);
186
+ lines.push(...safeVersionLine(pkg, theme, pkg.ecosystem));
187
+ }
188
+ if (lines.length === 0)
189
+ return [];
190
+ return ["Nearest versions dg doesn't flag:", ...lines, ""];
191
+ }
107
192
  function packagePageLines(report, theme) {
108
193
  const urls = [];
109
194
  const seen = new Set();
@@ -129,14 +214,8 @@ function provenanceDowngradeLines(packages, theme) {
129
214
  .map((pkg) => theme.paint("warn", `Provenance downgrades: ${pkg.name}@${pkg.version} (was attested at ${pkg.provenance.downgrade.fromVersion})`));
130
215
  }
131
216
  function paintEffectiveStatus(report, theme) {
132
- if (report.scanner && report.decisions && !report.scannerError) {
133
- const action = report.decisions.effectiveAction;
134
- if (action === "analysis_incomplete") {
135
- return theme.paint("unknown", "analysis_incomplete");
136
- }
137
- return theme.paint(SCAN_STATUS_ROLE[action], action);
138
- }
139
- return theme.paint(SCAN_STATUS_ROLE[report.status], displayScanStatus(report));
217
+ const status = displayScanStatus(report);
218
+ return theme.paint(DISPLAY_STATUS_ROLE[status] ?? "unknown", status);
140
219
  }
141
220
  function formatProject(project, width) {
142
221
  const lines = [];
@@ -155,10 +234,14 @@ function formatProject(project, width) {
155
234
  }
156
235
  return lines;
157
236
  }
158
- export function renderJsonReport(report, scannerUnavailable = false, nothingToScan = false) {
237
+ export function renderJsonReport(rawReport, scannerUnavailable = false, nothingToScan = false) {
238
+ // Strip control bytes from attacker-influenced fields (names, findings) the
239
+ // same way the text renderer does, so JSON consumers / log viewers are safe.
240
+ const report = sanitizeDeep(rawReport);
159
241
  return `${JSON.stringify({ schemaVersion: 1, ...report, status: nothingToScan ? "nothing_to_scan" : displayScanStatus(report), scannerUnavailable }, null, 2)}\n`;
160
242
  }
161
- export function renderSarifReport(report) {
243
+ export function renderSarifReport(rawReport) {
244
+ const report = sanitizeDeep(rawReport);
162
245
  const rules = uniqueFindings(report.findings).map((finding) => ({
163
246
  id: finding.id,
164
247
  name: finding.title,
@@ -184,40 +267,42 @@ export function renderSarifReport(report) {
184
267
  rules
185
268
  }
186
269
  },
187
- results: report.findings.map((finding) => ({
188
- ruleId: finding.id,
189
- level: finding.severity === "block" ? "error" : "warning",
190
- message: {
191
- text: finding.message
192
- },
193
- locations: [
194
- {
195
- physicalLocation: {
196
- artifactLocation: {
197
- uri: artifactUri(finding.location)
198
- }
270
+ automationDetails: {
271
+ id: automationCategory(report.target)
272
+ },
273
+ properties: {
274
+ dgVerdict: sarifVerdict(report)
275
+ },
276
+ results: report.findings.map((finding) => {
277
+ return {
278
+ ruleId: finding.id,
279
+ level: finding.severity === "block" ? "error" : "warning",
280
+ message: {
281
+ text: finding.message
282
+ },
283
+ partialFingerprints: {
284
+ dgFindingHash: findingFingerprint(`${finding.id}${finding.project}${finding.location}`)
285
+ },
286
+ locations: [
287
+ sarifLocationEntry(finding.location, finding.manifestPath ?? report.projects[0]?.manifestPath, report.targetDir ?? report.target)
288
+ ],
289
+ ...(finding.acknowledged
290
+ ? {
291
+ suppressions: [
292
+ {
293
+ kind: "external",
294
+ justification: finding.acknowledged.reason || `accepted by ${finding.acknowledged.by}`
295
+ }
296
+ ]
199
297
  }
200
- }
201
- ],
202
- ...(finding.acknowledged
203
- ? {
204
- suppressions: [
205
- {
206
- kind: "external",
207
- justification: finding.acknowledged.reason || `accepted by ${finding.acknowledged.by}`
208
- }
209
- ]
210
- }
211
- : {})
212
- }))
298
+ : {})
299
+ };
300
+ })
213
301
  }
214
302
  ]
215
303
  };
216
304
  return `${JSON.stringify(sarif, null, 2)}\n`;
217
305
  }
218
- function artifactUri(location) {
219
- return location.replace(/:\d+$/u, "");
220
- }
221
306
  function uniqueFindings(findings) {
222
307
  const seen = new Set();
223
308
  const unique = [];