@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
|
@@ -22,13 +22,32 @@ export function selectUpstreamProxy(target, env) {
|
|
|
22
22
|
redactedUrl: redactSecrets(url.toString())
|
|
23
23
|
};
|
|
24
24
|
}
|
|
25
|
+
// A stalled upstream proxy (TCP connect or CONNECT response never completing)
|
|
26
|
+
// would otherwise hang the whole install with no bound; cap the handshake.
|
|
27
|
+
const CONNECT_HANDSHAKE_TIMEOUT_MS = 30_000;
|
|
25
28
|
export function connectViaUpstreamProxy(target, upstream, extraHeaders = []) {
|
|
26
29
|
return new Promise((resolve, reject) => {
|
|
27
30
|
const socket = connect(Number(upstream.url.port || "80"), upstream.url.hostname);
|
|
28
31
|
const chunks = [];
|
|
32
|
+
let settled = false;
|
|
33
|
+
const timer = setTimeout(() => {
|
|
34
|
+
if (settled)
|
|
35
|
+
return;
|
|
36
|
+
settled = true;
|
|
37
|
+
socket.destroy();
|
|
38
|
+
reject(new Error("upstream proxy CONNECT handshake timed out"));
|
|
39
|
+
}, CONNECT_HANDSHAKE_TIMEOUT_MS);
|
|
40
|
+
timer.unref?.();
|
|
41
|
+
const settle = (action) => {
|
|
42
|
+
if (settled)
|
|
43
|
+
return;
|
|
44
|
+
settled = true;
|
|
45
|
+
clearTimeout(timer);
|
|
46
|
+
action();
|
|
47
|
+
};
|
|
29
48
|
const fail = (error) => {
|
|
30
49
|
socket.destroy();
|
|
31
|
-
reject(error);
|
|
50
|
+
settle(() => reject(error));
|
|
32
51
|
};
|
|
33
52
|
socket.once("error", fail);
|
|
34
53
|
socket.once("connect", () => {
|
|
@@ -50,7 +69,7 @@ export function connectViaUpstreamProxy(target, upstream, extraHeaders = []) {
|
|
|
50
69
|
if (buffered.length > 64 * 1024) {
|
|
51
70
|
socket.off("data", onData);
|
|
52
71
|
socket.destroy();
|
|
53
|
-
reject(new Error("upstream proxy CONNECT response exceeded the 64KiB header limit"));
|
|
72
|
+
settle(() => reject(new Error("upstream proxy CONNECT response exceeded the 64KiB header limit")));
|
|
54
73
|
return;
|
|
55
74
|
}
|
|
56
75
|
const headerEnd = buffered.indexOf("\r\n\r\n");
|
|
@@ -63,13 +82,13 @@ export function connectViaUpstreamProxy(target, upstream, extraHeaders = []) {
|
|
|
63
82
|
const statusLine = buffered.subarray(0, headerEnd).toString("latin1").split("\r\n")[0] ?? "";
|
|
64
83
|
if (!/^HTTP\/1\.[01] 2\d\d\b/.test(statusLine)) {
|
|
65
84
|
socket.destroy();
|
|
66
|
-
reject(new Error(`upstream proxy CONNECT failed: ${statusLine}`));
|
|
85
|
+
settle(() => reject(new Error(`upstream proxy CONNECT failed: ${statusLine}`)));
|
|
67
86
|
return;
|
|
68
87
|
}
|
|
69
88
|
if (head.length > 0) {
|
|
70
89
|
socket.unshift(head);
|
|
71
90
|
}
|
|
72
|
-
resolve(socket);
|
|
91
|
+
settle(() => resolve(socket));
|
|
73
92
|
});
|
|
74
93
|
});
|
|
75
94
|
}
|
package/dist/proxy/worker.js
CHANGED
|
@@ -3,6 +3,7 @@ import { startProductionHttpProxy } from "./server.js";
|
|
|
3
3
|
import { parseForceOverrideRequest } from "./enforcement.js";
|
|
4
4
|
import { cleanupSessionSync } from "../state/index.js";
|
|
5
5
|
import { COOLDOWN_EXEMPTIONS_ENV, loadCooldownExemptionsFile } from "./cooldown-exemptions-file.js";
|
|
6
|
+
import { redactSecrets } from "../launcher/output-redaction.js";
|
|
6
7
|
const PARENT_POLL_MS = 500;
|
|
7
8
|
const sessionPath = process.argv[2];
|
|
8
9
|
const apiBaseUrl = process.argv[3];
|
|
@@ -35,7 +36,7 @@ function shutdown() {
|
|
|
35
36
|
function exitOnFatal(error) {
|
|
36
37
|
const message = error instanceof Error ? error.message : String(error);
|
|
37
38
|
try {
|
|
38
|
-
process.stderr.write(`dg proxy worker: unexpected error — ${message}\n`);
|
|
39
|
+
process.stderr.write(`dg proxy worker: unexpected error — ${redactSecrets(message)}\n`);
|
|
39
40
|
}
|
|
40
41
|
catch {
|
|
41
42
|
// stderr is gone; nothing left to report to.
|
|
@@ -43,7 +44,7 @@ function exitOnFatal(error) {
|
|
|
43
44
|
close().finally(() => process.exit(1));
|
|
44
45
|
}
|
|
45
46
|
process.on("uncaughtException", exitOnFatal);
|
|
46
|
-
process.on("unhandledRejection", exitOnFatal);
|
|
47
|
+
process.on("unhandledRejection", (reason) => exitOnFatal(reason));
|
|
47
48
|
process.stdin.resume();
|
|
48
49
|
process.stdin.on("end", shutdown);
|
|
49
50
|
process.stdin.on("error", shutdown);
|
|
@@ -33,6 +33,12 @@ export function buildAuditFile(root, relPath) {
|
|
|
33
33
|
symlinkEscapes,
|
|
34
34
|
mode: stat.mode,
|
|
35
35
|
read: () => {
|
|
36
|
+
// A symlink whose target escapes the package root would, if read, leak an
|
|
37
|
+
// arbitrary host file (e.g. /etc/passwd) into the audit — and into any
|
|
38
|
+
// redacted excerpt shown. Flag it, never follow it.
|
|
39
|
+
if (isSymlink && symlinkEscapes) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
36
42
|
try {
|
|
37
43
|
return readFileSync(absolute);
|
|
38
44
|
}
|
package/dist/runtime/fatal.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { EXIT_TOOL_ERROR } from "../commands/types.js";
|
|
2
2
|
import { leaveTui, tuiIsActive } from "../scan-ui/alt-screen.js";
|
|
3
|
+
import { redactSecrets } from "../launcher/output-redaction.js";
|
|
3
4
|
const defaultIo = {
|
|
4
5
|
writeStderr: (text) => {
|
|
5
6
|
process.stderr.write(text);
|
|
@@ -22,7 +23,7 @@ export function exitOnFatal(error, io = defaultIo) {
|
|
|
22
23
|
// screen restore must never mask the fatal report
|
|
23
24
|
}
|
|
24
25
|
try {
|
|
25
|
-
io.writeStderr(`dg: unexpected error — ${message}\nRun 'dg doctor' to check your installation; remove ~/.dg/config.json if it is corrupted.\n`);
|
|
26
|
+
io.writeStderr(`dg: unexpected error — ${redactSecrets(message)}\nRun 'dg doctor' to check your installation; remove ~/.dg/config.json if it is corrupted.\n`);
|
|
26
27
|
}
|
|
27
28
|
catch {
|
|
28
29
|
// stderr is gone; nothing left to report to.
|
|
@@ -42,7 +42,11 @@ export function sweepLegacyHooksOnVersionChange(env = process.env, version = dgV
|
|
|
42
42
|
return false;
|
|
43
43
|
}
|
|
44
44
|
sweepLegacyPythonHooks(resolveDgPaths(env).homeDir, [], []);
|
|
45
|
-
refreshSetupOnUpgrade(env)
|
|
45
|
+
if (refreshSetupOnUpgrade(env) === "lock-busy") {
|
|
46
|
+
// Don't record this version as refreshed; retry on the next run once the
|
|
47
|
+
// setup lock is free, so a momentary contention can't skip the upgrade.
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
46
50
|
mkdirSync(dirname(marker), { recursive: true, mode: 0o700 });
|
|
47
51
|
writeFileSync(marker, `${version}\n`, { encoding: "utf8", mode: 0o600 });
|
|
48
52
|
return true;
|
|
@@ -71,11 +71,62 @@ export async function resolveShimFallback(env = process.env, argv = process.argv
|
|
|
71
71
|
return null;
|
|
72
72
|
}
|
|
73
73
|
}
|
|
74
|
+
// An agent invokes `dg hook-exec <agent>` directly (no shim), so the shim
|
|
75
|
+
// fallback never applies. Exiting 1 with empty stdout reads as ALLOW to the
|
|
76
|
+
// agents we integrate, which would let an install run unscanned on an old-Node
|
|
77
|
+
// shell. Emit this agent's deny shape instead, so the firewall fails CLOSED.
|
|
78
|
+
export async function agentHookNodeGuardDeny(version, argv = process.argv) {
|
|
79
|
+
if (argv[2] !== "hook-exec") {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
const agent = argv[3];
|
|
83
|
+
const blockedStderr = `dg hook: Node ${version} is below the required >=22.14.0; install blocked\n`;
|
|
84
|
+
try {
|
|
85
|
+
const [{ isAgentId, getAgent }, { ESCALATE }] = await Promise.all([
|
|
86
|
+
import("../agents/registry.js"),
|
|
87
|
+
import("../launcher/agent-check.js")
|
|
88
|
+
]);
|
|
89
|
+
if (agent && isAgentId(agent)) {
|
|
90
|
+
const emitted = getAgent(agent).emitDecision({
|
|
91
|
+
decision: "deny",
|
|
92
|
+
reason: `dg cannot run the install firewall on Node ${version} (requires >=22.14.0), so the install is blocked. Upgrade Node, then retry. ${ESCALATE}`
|
|
93
|
+
});
|
|
94
|
+
return {
|
|
95
|
+
stdout: emitted.stdout,
|
|
96
|
+
stderr: blockedStderr,
|
|
97
|
+
exitCode: emitted.exitCode || 2
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
stdout: "",
|
|
102
|
+
stderr: `dg hook-exec: Node ${version} is below >=22.14.0 and agent '${agent ?? ""}' is unknown; install blocked under the firewall — upgrade Node and dg\n`,
|
|
103
|
+
exitCode: 2
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
// The agent modules failed to load on this runtime. Still refuse: a non-zero
|
|
108
|
+
// exit with a block payload keeps the hook fail-closed rather than letting an
|
|
109
|
+
// unhandled rejection surface as a silent allow.
|
|
110
|
+
return {
|
|
111
|
+
stdout: JSON.stringify({ decision: "block", reason: `dg requires Node.js >=22.14.0; install blocked on Node ${version}` }),
|
|
112
|
+
stderr: blockedStderr,
|
|
113
|
+
exitCode: 2
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
}
|
|
74
117
|
export async function assertCurrentNode() {
|
|
75
118
|
const version = currentNodeVersion();
|
|
76
119
|
if (isSupportedNode(version)) {
|
|
77
120
|
return;
|
|
78
121
|
}
|
|
122
|
+
const hookDeny = await agentHookNodeGuardDeny(version);
|
|
123
|
+
if (hookDeny) {
|
|
124
|
+
if (hookDeny.stdout) {
|
|
125
|
+
process.stdout.write(hookDeny.stdout);
|
|
126
|
+
}
|
|
127
|
+
process.stderr.write(hookDeny.stderr);
|
|
128
|
+
process.exit(hookDeny.exitCode);
|
|
129
|
+
}
|
|
79
130
|
const fallback = await resolveShimFallback();
|
|
80
131
|
if (fallback) {
|
|
81
132
|
process.stderr.write(fallback.warning);
|
package/dist/runtime/nudges.js
CHANGED
|
@@ -7,6 +7,7 @@ import { isCiEnv } from "../presentation/mode.js";
|
|
|
7
7
|
import { createTheme } from "../presentation/theme.js";
|
|
8
8
|
import { resolveDgPaths } from "../state/index.js";
|
|
9
9
|
const UPDATE_THROTTLE_MS = 24 * 60 * 60 * 1000;
|
|
10
|
+
const UPDATE_FAILURE_BACKOFF_MS = 5 * 60 * 1000;
|
|
10
11
|
const LOGIN_THROTTLE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
11
12
|
const LATEST_LOOKUP_TIMEOUT_MS = 1500;
|
|
12
13
|
const SKIP_COMMANDS = new Set([
|
|
@@ -47,16 +48,21 @@ export function maybeShowNudges(args, options = {}) {
|
|
|
47
48
|
const theme = createTheme(true);
|
|
48
49
|
const lines = [];
|
|
49
50
|
let stateChanged = false;
|
|
50
|
-
if (due(state.updateCheckedAt, UPDATE_THROTTLE_MS, now)) {
|
|
51
|
+
if (due(state.updateCheckedAt, UPDATE_THROTTLE_MS, now) && due(state.updateCheckFailedAt, UPDATE_FAILURE_BACKOFF_MS, now)) {
|
|
51
52
|
const latest = readLatestVersion(LATEST_LOOKUP_TIMEOUT_MS);
|
|
52
53
|
if (latest) {
|
|
53
54
|
state.updateCheckedAt = now.toISOString();
|
|
55
|
+
delete state.updateCheckFailedAt;
|
|
54
56
|
state.updateLatest = latest;
|
|
55
57
|
stateChanged = true;
|
|
56
58
|
if (compareVersions(latest, dgVersion()) > 0) {
|
|
57
59
|
lines.push(`${theme.paint("warn", "⚠")} ${theme.paint("muted", `Update available: ${dgVersion()} → ${latest}. Run`)} ${theme.paint("accent", "dg update")}${theme.paint("muted", ".")}`);
|
|
58
60
|
}
|
|
59
61
|
}
|
|
62
|
+
else {
|
|
63
|
+
state.updateCheckFailedAt = now.toISOString();
|
|
64
|
+
stateChanged = true;
|
|
65
|
+
}
|
|
60
66
|
}
|
|
61
67
|
if (due(state.loginNudgedAt, LOGIN_THROTTLE_MS, now) && !isAuthenticated(env)) {
|
|
62
68
|
state.loginNudgedAt = now.toISOString();
|
|
@@ -108,6 +114,26 @@ export function pendingUpdate(env = process.env) {
|
|
|
108
114
|
}
|
|
109
115
|
return { current: dgVersion(), latest };
|
|
110
116
|
}
|
|
117
|
+
// Surface the cached pending update on a non-TTY path (agent hook, install
|
|
118
|
+
// wrapper) where maybeShowNudges' live lookup never runs. Reads the cache only —
|
|
119
|
+
// no network — and throttles to once per window so it doesn't print on every
|
|
120
|
+
// screened command. Returns "" when there's nothing to show.
|
|
121
|
+
export function maybeUpdateNudge(options = {}) {
|
|
122
|
+
const env = options.env ?? process.env;
|
|
123
|
+
const now = options.now ?? new Date();
|
|
124
|
+
const update = pendingUpdate(env);
|
|
125
|
+
if (!update) {
|
|
126
|
+
return "";
|
|
127
|
+
}
|
|
128
|
+
const statePath = nudgeStatePath(env);
|
|
129
|
+
const state = readState(statePath);
|
|
130
|
+
if (!due(state.updateNudgedAt, UPDATE_THROTTLE_MS, now)) {
|
|
131
|
+
return "";
|
|
132
|
+
}
|
|
133
|
+
state.updateNudgedAt = now.toISOString();
|
|
134
|
+
writeState(statePath, state);
|
|
135
|
+
return `\n dg ${update.current} → ${update.latest} available — run dg update\n`;
|
|
136
|
+
}
|
|
111
137
|
function isAuthenticated(env) {
|
|
112
138
|
try {
|
|
113
139
|
return authStatus(env).authenticated;
|
package/dist/sbom/cyclonedx.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
2
|
import { existsSync, readFileSync } from "node:fs";
|
|
3
3
|
import { parseLockfilePackages } from "../verify/preflight.js";
|
|
4
|
+
import { sanitizeLine } from "../security/sanitize.js";
|
|
4
5
|
import { normalizePypiName } from "../policy/cooldown.js";
|
|
6
|
+
import { canonicalSpdxId } from "./spdx.js";
|
|
5
7
|
export function collectSbomComponents(projects) {
|
|
6
8
|
const seen = new Set();
|
|
7
9
|
const droppedSeen = new Set();
|
|
@@ -11,7 +13,7 @@ export function collectSbomComponents(projects) {
|
|
|
11
13
|
const parsed = parseLockfilePackages(join(project.path, project.depFile));
|
|
12
14
|
for (const identity of parsed.packages) {
|
|
13
15
|
if (identity.ecosystem === "unknown" || !identity.version) {
|
|
14
|
-
const label = `${identity.ecosystem}:${identity.name}
|
|
16
|
+
const label = sanitizeLine(`${identity.ecosystem}:${identity.name}`);
|
|
15
17
|
if (identity.name.length > 0 && !droppedSeen.has(label)) {
|
|
16
18
|
droppedSeen.add(label);
|
|
17
19
|
dropped.push(label);
|
|
@@ -30,21 +32,76 @@ export function collectSbomComponents(projects) {
|
|
|
30
32
|
return { components, dropped };
|
|
31
33
|
}
|
|
32
34
|
export function readRootComponent(root) {
|
|
35
|
+
return npmRootComponent(root) ?? cargoRootComponent(root) ?? pyprojectRootComponent(root) ?? {};
|
|
36
|
+
}
|
|
37
|
+
// name/version reach the SBOM summary subject on the terminal; strip control bytes
|
|
38
|
+
// so a hostile repo manifest can't inject ANSI into the rendered subject line.
|
|
39
|
+
function npmRootComponent(root) {
|
|
33
40
|
const manifest = join(root, "package.json");
|
|
34
41
|
if (!existsSync(manifest)) {
|
|
35
|
-
return
|
|
42
|
+
return null;
|
|
36
43
|
}
|
|
37
44
|
try {
|
|
38
45
|
const parsed = JSON.parse(readFileSync(manifest, "utf8"));
|
|
39
46
|
if (typeof parsed.name !== "string" || parsed.name.length === 0) {
|
|
40
47
|
return {};
|
|
41
48
|
}
|
|
42
|
-
return {
|
|
49
|
+
return {
|
|
50
|
+
component: {
|
|
51
|
+
name: sanitizeLine(parsed.name),
|
|
52
|
+
ecosystem: "npm",
|
|
53
|
+
...(typeof parsed.version === "string" ? { version: sanitizeLine(parsed.version) } : {}),
|
|
54
|
+
...(typeof parsed.license === "string" && parsed.license.length > 0 ? { license: parsed.license } : {})
|
|
55
|
+
}
|
|
56
|
+
};
|
|
43
57
|
}
|
|
44
58
|
catch {
|
|
45
59
|
return { malformed: true };
|
|
46
60
|
}
|
|
47
61
|
}
|
|
62
|
+
function cargoRootComponent(root) {
|
|
63
|
+
return tomlManifestComponent(join(root, "Cargo.toml"), "package", "cargo");
|
|
64
|
+
}
|
|
65
|
+
function pyprojectRootComponent(root) {
|
|
66
|
+
const manifest = join(root, "pyproject.toml");
|
|
67
|
+
if (!existsSync(manifest)) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
return tomlManifestComponent(manifest, "project", "pypi") ?? tomlManifestComponent(manifest, "tool.poetry", "pypi");
|
|
71
|
+
}
|
|
72
|
+
function tomlManifestComponent(manifest, table, ecosystem) {
|
|
73
|
+
if (!existsSync(manifest)) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
let section;
|
|
77
|
+
try {
|
|
78
|
+
section = tomlTable(readFileSync(manifest, "utf8"), table);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
const name = section ? tomlField(section, "name") : null;
|
|
84
|
+
if (!section || !name) {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
const version = tomlField(section, "version");
|
|
88
|
+
const license = tomlField(section, "license");
|
|
89
|
+
return {
|
|
90
|
+
component: {
|
|
91
|
+
name: sanitizeLine(name),
|
|
92
|
+
ecosystem,
|
|
93
|
+
...(version ? { version: sanitizeLine(version) } : {}),
|
|
94
|
+
...(license ? { license } : {})
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
function tomlTable(text, table) {
|
|
99
|
+
const escaped = table.replace(/[.]/gu, "\\.");
|
|
100
|
+
return new RegExp(`^\\s*\\[${escaped}\\]([\\s\\S]*?)(?=^\\s*\\[|$)`, "mu").exec(text)?.[1] ?? null;
|
|
101
|
+
}
|
|
102
|
+
function tomlField(section, key) {
|
|
103
|
+
return new RegExp(`^\\s*${key}\\s*=\\s*"([^"]*)"`, "mu").exec(section)?.[1] ?? null;
|
|
104
|
+
}
|
|
48
105
|
export function buildCycloneDxSbom(components, opts) {
|
|
49
106
|
const byRef = new Map();
|
|
50
107
|
for (const component of components) {
|
|
@@ -54,26 +111,36 @@ export function buildCycloneDxSbom(components, opts) {
|
|
|
54
111
|
}
|
|
55
112
|
}
|
|
56
113
|
const mapped = [...byRef.values()].sort((a, b) => compareCodePoints(a["bom-ref"], b["bom-ref"]));
|
|
114
|
+
const dependencies = opts.dependencies ?? [];
|
|
57
115
|
return {
|
|
116
|
+
$schema: "http://cyclonedx.org/schema/bom-1.5.schema.json",
|
|
58
117
|
bomFormat: "CycloneDX",
|
|
59
118
|
specVersion: "1.5",
|
|
60
119
|
serialNumber: opts.serialNumber,
|
|
61
120
|
version: 1,
|
|
62
121
|
metadata: {
|
|
63
122
|
...(opts.timestamp ? { timestamp: opts.timestamp } : {}),
|
|
123
|
+
lifecycles: [{ phase: "build" }],
|
|
64
124
|
tools: [{ vendor: "WestBayBerry", name: "dg", version: opts.toolVersion }],
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
type: "application",
|
|
69
|
-
"bom-ref": "root",
|
|
70
|
-
name: opts.rootComponent.name,
|
|
71
|
-
...(opts.rootComponent.version ? { version: opts.rootComponent.version } : {})
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
: {})
|
|
125
|
+
authors: [{ name: "dg (WestBayBerry)" }],
|
|
126
|
+
supplier: { name: "WestBayBerry", url: ["https://westbayberry.com"] },
|
|
127
|
+
...(opts.rootComponent ? { component: rootComponentOf(opts.rootComponent) } : {})
|
|
75
128
|
},
|
|
76
|
-
components: mapped
|
|
129
|
+
components: mapped,
|
|
130
|
+
...(dependencies.length > 0 ? { dependencies } : {}),
|
|
131
|
+
...(opts.completeness ? { compositions: [{ aggregate: opts.completeness }] } : {})
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
function rootComponentOf(root) {
|
|
135
|
+
const purl = root.ecosystem && root.version ? purlForParts(root.ecosystem, root.name, root.version, null) : null;
|
|
136
|
+
const licenses = licensesFor(root.license);
|
|
137
|
+
return {
|
|
138
|
+
type: "application",
|
|
139
|
+
"bom-ref": "root",
|
|
140
|
+
name: root.name,
|
|
141
|
+
...(root.version ? { version: root.version } : {}),
|
|
142
|
+
...(purl ? { purl } : {}),
|
|
143
|
+
...(licenses ? { licenses } : {})
|
|
77
144
|
};
|
|
78
145
|
}
|
|
79
146
|
function compareCodePoints(a, b) {
|
|
@@ -95,31 +162,51 @@ function canonicalPurlName(ecosystem, name) {
|
|
|
95
162
|
}
|
|
96
163
|
return name;
|
|
97
164
|
}
|
|
98
|
-
// The declared license string is emitted verbatim as a CycloneDX `license.name`,
|
|
99
|
-
// which is always schema-valid for any free text. We deliberately do NOT classify
|
|
100
|
-
// it into the SPDX `expression` form: correctly distinguishing a valid SPDX
|
|
101
|
-
// expression from free text requires validating operands against the SPDX license
|
|
102
|
-
// registry, and any heuristic shortcut emits schema-invalid expressions for some
|
|
103
|
-
// inputs (unbalanced parens, operator-only strings, unregistered ids).
|
|
104
165
|
function toCycloneDxComponent(component) {
|
|
105
166
|
const purl = purlFor(component);
|
|
106
167
|
const hashes = hashesFor(component.integrity);
|
|
107
|
-
const
|
|
108
|
-
const
|
|
168
|
+
const licenses = licensesFor(component.license);
|
|
169
|
+
const externalReferences = distributionReference(component.resolvedUrl);
|
|
109
170
|
return {
|
|
110
171
|
type: "library",
|
|
111
172
|
"bom-ref": bomRefOf(component),
|
|
112
|
-
name: component.name,
|
|
113
|
-
version: component.version,
|
|
173
|
+
name: sanitizeLine(component.name),
|
|
174
|
+
version: sanitizeLine(component.version),
|
|
114
175
|
...(purl ? { purl } : {}),
|
|
115
176
|
...(licenses ? { licenses } : {}),
|
|
116
|
-
...(hashes.length > 0 ? { hashes } : {})
|
|
177
|
+
...(hashes.length > 0 ? { hashes } : {}),
|
|
178
|
+
...(externalReferences ? { externalReferences } : {})
|
|
117
179
|
};
|
|
118
180
|
}
|
|
181
|
+
// An exact-match SPDX id becomes the machine-readable license.id; everything else
|
|
182
|
+
// stays free-text license.name. A compound expression is never synthesized: only
|
|
183
|
+
// validating operands against the SPDX registry avoids emitting an invalid id, so
|
|
184
|
+
// the conservative id-or-name split keeps every license object schema-valid. name
|
|
185
|
+
// is lockfile-controlled so it is sanitized; a promoted id is from our allowlist.
|
|
186
|
+
function licensesFor(declared) {
|
|
187
|
+
const license = declared?.trim();
|
|
188
|
+
if (!license) {
|
|
189
|
+
return undefined;
|
|
190
|
+
}
|
|
191
|
+
const id = canonicalSpdxId(license);
|
|
192
|
+
return id ? [{ license: { id } }] : [{ license: { name: sanitizeLine(license) } }];
|
|
193
|
+
}
|
|
194
|
+
function distributionReference(resolvedUrl) {
|
|
195
|
+
if (!resolvedUrl || !/^https?:\/\//iu.test(resolvedUrl)) {
|
|
196
|
+
return undefined;
|
|
197
|
+
}
|
|
198
|
+
return [{ type: "distribution", url: sanitizeLine(resolvedUrl) }];
|
|
199
|
+
}
|
|
119
200
|
export function purlFor(component) {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
201
|
+
return purlForParts(component.ecosystem, component.name, component.version, component.resolvedUrl);
|
|
202
|
+
}
|
|
203
|
+
export function componentRef(parts) {
|
|
204
|
+
return purlForParts(parts.ecosystem, parts.name, parts.version, parts.resolvedUrl ?? null) ?? `${parts.ecosystem}:${parts.name}@${parts.version}`;
|
|
205
|
+
}
|
|
206
|
+
function purlForParts(ecosystem, name, rawVersion, resolvedUrl) {
|
|
207
|
+
const version = encodeURIComponent(rawVersion);
|
|
208
|
+
if (ecosystem === "npm") {
|
|
209
|
+
const lower = name.toLowerCase();
|
|
123
210
|
if (lower.startsWith("@") && lower.includes("/")) {
|
|
124
211
|
const slash = lower.indexOf("/");
|
|
125
212
|
const scope = lower.slice(1, slash);
|
|
@@ -128,12 +215,12 @@ export function purlFor(component) {
|
|
|
128
215
|
}
|
|
129
216
|
return `pkg:npm/${encodeURIComponent(lower)}@${version}`;
|
|
130
217
|
}
|
|
131
|
-
if (
|
|
132
|
-
return `pkg:pypi/${encodeURIComponent(normalizePypiName(
|
|
218
|
+
if (ecosystem === "pypi") {
|
|
219
|
+
return `pkg:pypi/${encodeURIComponent(normalizePypiName(name))}@${version}`;
|
|
133
220
|
}
|
|
134
|
-
if (
|
|
135
|
-
const base = `pkg:cargo/${encodeURIComponent(
|
|
136
|
-
const repository = cargoAlternateRegistry(
|
|
221
|
+
if (ecosystem === "cargo") {
|
|
222
|
+
const base = `pkg:cargo/${encodeURIComponent(name.toLowerCase())}@${version}`;
|
|
223
|
+
const repository = cargoAlternateRegistry(resolvedUrl);
|
|
137
224
|
return repository ? `${base}?repository_url=${encodeURIComponent(repository)}` : base;
|
|
138
225
|
}
|
|
139
226
|
return null;
|