@westbayberry/dg 2.3.2 → 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.
- package/README.md +95 -208
- package/dist/agents/copilot-cli.js +7 -2
- package/dist/agents/persistence.js +83 -8
- package/dist/agents/registry.js +5 -2
- package/dist/agents/routing.js +31 -14
- package/dist/api/analyze.js +138 -41
- package/dist/audit/deep.js +2 -1
- package/dist/audit/detectors.js +23 -2
- package/dist/audit/rules.js +4 -1
- package/dist/audit-ui/export.js +5 -4
- package/dist/auth/device-login.js +33 -11
- package/dist/auth/login-app.js +17 -12
- package/dist/auth/store.js +65 -9
- package/dist/bin/dg.js +1 -1
- package/dist/commands/audit.js +12 -20
- package/dist/commands/{guard-commit.js → commit-guard.js} +12 -12
- package/dist/commands/cooldown.js +2 -1
- package/dist/commands/decisions.js +8 -7
- package/dist/commands/help.js +11 -3
- package/dist/commands/licenses.js +17 -8
- package/dist/commands/login.js +58 -21
- package/dist/commands/router.js +12 -2
- package/dist/commands/sbom.js +20 -5
- package/dist/commands/scan.js +5 -4
- package/dist/commands/service.js +1 -1
- package/dist/commands/setup.js +12 -12
- package/dist/commands/status.js +4 -4
- package/dist/commands/uninstall.js +1 -1
- package/dist/commands/update.js +20 -0
- package/dist/commands/verify.js +4 -4
- package/dist/config/settings.js +81 -12
- package/dist/export-ui/ExportDialog.js +1 -1
- package/dist/gate/cooldown-request.js +22 -0
- package/dist/gate/egress-guard.js +185 -0
- package/dist/gate/host-match.js +48 -0
- package/dist/gate/verdict-core.js +101 -0
- package/dist/gate/verdict-fetch.js +214 -0
- package/dist/launcher/agent-check.js +1096 -42
- package/dist/launcher/agent-hook-exec.js +8 -1
- package/dist/launcher/agent-hook-io.js +5 -3
- package/dist/launcher/classify.js +110 -26
- package/dist/launcher/env.js +84 -18
- package/dist/launcher/install-preflight.js +26 -3
- package/dist/launcher/live-install.js +4 -2
- package/dist/launcher/manifest-screen.js +88 -7
- package/dist/launcher/output-redaction.js +153 -15
- package/dist/launcher/preflight-prompt.js +1 -1
- package/dist/launcher/resolve-real-binary.js +8 -3
- package/dist/launcher/run.js +237 -40
- package/dist/origin/artifact-cache.js +55 -0
- package/dist/origin/cargo-config.js +12 -0
- package/dist/origin/gate-mode.js +7 -0
- package/dist/origin/platform-paths.js +14 -0
- package/dist/origin/precedence.js +345 -0
- package/dist/origin/server.js +611 -0
- package/dist/origin/spike-worker.js +35 -0
- package/dist/origin/worker.js +116 -0
- package/dist/presentation/package-page.js +21 -1
- package/dist/presentation/safe-version.js +11 -0
- package/dist/presentation/sarif.js +109 -0
- package/dist/project/dgfile.js +54 -17
- package/dist/project/override-trust.js +0 -0
- package/dist/proxy/enforcement.js +27 -7
- package/dist/proxy/metadata-map.js +33 -7
- package/dist/proxy/preverified.js +0 -3
- package/dist/proxy/server.js +104 -446
- package/dist/proxy/upstream-proxy.js +23 -4
- package/dist/proxy/worker.js +3 -2
- package/dist/publish-set/collect.js +6 -0
- package/dist/runtime/fatal.js +2 -1
- package/dist/runtime/first-run.js +5 -1
- package/dist/runtime/node-version.js +51 -0
- package/dist/runtime/nudges.js +27 -1
- package/dist/sbom/cyclonedx.js +120 -33
- package/dist/sbom/graph.js +236 -0
- package/dist/sbom/spdx.js +53 -0
- package/dist/sbom-ui/SbomApp.js +5 -3
- package/dist/sbom-ui/inventory.js +8 -10
- package/dist/scan/analyze-worker.js +3 -1
- package/dist/scan/collect.js +38 -8
- package/dist/scan/command.js +77 -27
- package/dist/scan/discovery.js +18 -7
- package/dist/scan/installed.js +202 -0
- package/dist/scan/manifest-coverage.js +128 -0
- package/dist/scan/render.js +125 -40
- package/dist/scan/scanner-report.js +92 -22
- package/dist/scan/staged.js +68 -15
- package/dist/scan/types.js +7 -1
- package/dist/scan-ui/LegacyApp.js +3 -3
- package/dist/scan-ui/components/InteractiveResultsView.js +17 -14
- package/dist/scan-ui/hooks/useScan.js +37 -11
- package/dist/scan-ui/launch.js +2 -2
- package/dist/scan-ui/logo.js +6 -2
- package/dist/scan-ui/shims.js +6 -6
- package/dist/scripts/gate.js +53 -35
- package/dist/security/csv.js +9 -0
- package/dist/security/sanitize.js +11 -2
- package/dist/service/state.js +120 -37
- package/dist/service/trust-refresh.js +12 -1
- package/dist/service/trust-store.js +36 -5
- package/dist/service/worker.js +5 -7
- package/dist/setup/git-hook.js +2 -2
- package/dist/setup/install-method.js +36 -0
- package/dist/setup/plan.js +119 -57
- package/dist/setup/uninstall-standalone.js +7 -2
- package/dist/setup-ui/gate.js +1 -8
- package/dist/setup-ui/offer.js +14 -7
- package/dist/setup-ui/selector.js +1 -1
- package/dist/setup-ui/wizard.js +73 -78
- package/dist/standalone/uninstall.mjs +1114 -386
- package/dist/state/cleanup-registry.js +14 -3
- package/dist/state/cooldown-held.js +36 -13
- package/dist/state/locks.js +239 -34
- package/dist/state/sessions.js +2 -2
- package/dist/util/sh-escape.js +6 -0
- package/dist/util/tty-prompt.js +13 -4
- package/dist/verify/local.js +60 -10
- package/dist/verify/package-check.js +35 -49
- package/dist/verify/preflight.js +59 -33
- package/dist/verify/render.js +11 -12
- package/npm-shrinkwrap.json +714 -213
- package/package.json +4 -2
package/dist/scan/discovery.js
CHANGED
|
@@ -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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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("
|
|
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
|
+
}
|
package/dist/scan/render.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
133
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
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 = [];
|