@westbayberry/dg 1.3.2 → 2.0.0
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/LICENSE +1 -201
- package/NOTICE +1 -4
- package/README.md +293 -0
- package/dist/api/analyze.js +210 -0
- package/dist/audit/deep.js +180 -0
- package/dist/audit/detectors.js +247 -0
- package/dist/audit/events.js +41 -0
- package/dist/audit/rules.js +426 -0
- package/dist/audit-ui/AuditApp.js +39 -0
- package/dist/audit-ui/components/AuditHeader.js +24 -0
- package/dist/audit-ui/components/AuditResultsView.js +307 -0
- package/dist/audit-ui/components/DeepStatusRow.js +11 -0
- package/dist/audit-ui/export.js +85 -0
- package/dist/audit-ui/format.js +34 -0
- package/dist/audit-ui/launch.js +34 -0
- package/dist/auth/device-login.js +271 -0
- package/dist/auth/env-token.js +6 -0
- package/dist/auth/login-app.js +156 -0
- package/dist/auth/store.js +147 -0
- package/dist/bin/dg.js +71 -0
- package/dist/commands/audit.js +357 -0
- package/dist/commands/completion.js +116 -0
- package/dist/commands/config.js +99 -0
- package/dist/commands/doctor.js +39 -0
- package/dist/commands/explain.js +100 -0
- package/dist/commands/guard-commit.js +158 -0
- package/dist/commands/help.js +74 -0
- package/dist/commands/licenses.js +435 -0
- package/dist/commands/login.js +81 -0
- package/dist/commands/logout.js +37 -0
- package/dist/commands/router.js +98 -0
- package/dist/commands/scan.js +18 -0
- package/dist/commands/service.js +475 -0
- package/dist/commands/setup.js +302 -0
- package/dist/commands/status.js +115 -0
- package/dist/commands/suggest.js +35 -0
- package/dist/commands/types.js +4 -0
- package/dist/commands/unavailable.js +11 -0
- package/dist/commands/uninstall.js +111 -0
- package/dist/commands/update.js +210 -0
- package/dist/commands/verify.js +151 -0
- package/dist/commands/version.js +22 -0
- package/dist/commands/wrap.js +55 -0
- package/dist/config/settings.js +302 -0
- package/dist/install-ui/LiveInstall.js +24 -0
- package/dist/install-ui/block-render.js +83 -0
- package/dist/install-ui/live-install-app.js +48 -0
- package/dist/install-ui/prompt.js +24 -0
- package/dist/launcher/classify.js +116 -0
- package/dist/launcher/env.js +53 -0
- package/dist/launcher/live-install.js +50 -0
- package/dist/launcher/output-redaction.js +77 -0
- package/dist/launcher/preflight-prompt.js +139 -0
- package/dist/launcher/resolve-real-binary.js +73 -0
- package/dist/launcher/run.js +417 -0
- package/dist/policy/evaluate.js +128 -0
- package/dist/presentation/mode.js +52 -0
- package/dist/presentation/theme.js +29 -0
- package/dist/proxy/buffer-budget.js +64 -0
- package/dist/proxy/ca.js +126 -0
- package/dist/proxy/classify-host.js +26 -0
- package/dist/proxy/enforcement.js +102 -0
- package/dist/proxy/metadata-map.js +336 -0
- package/dist/proxy/server.js +909 -0
- package/dist/proxy/upstream-proxy.js +102 -0
- package/dist/proxy/worker.js +39 -0
- package/dist/publish-set/collect.js +51 -0
- package/dist/publish-set/no-exec-shell.js +19 -0
- package/dist/publish-set/npm.js +109 -0
- package/dist/publish-set/pack.js +36 -0
- package/dist/publish-set/pypi.js +59 -0
- package/dist/runtime/cli.js +17 -0
- package/dist/runtime/first-run.js +60 -0
- package/dist/runtime/node-version.js +58 -0
- package/dist/runtime/nudges.js +105 -0
- package/dist/scan/analyze-worker.js +21 -0
- package/dist/scan/collect.js +153 -0
- package/dist/scan/command.js +159 -0
- package/dist/scan/discovery.js +209 -0
- package/dist/scan/render.js +240 -0
- package/dist/scan/scanner-report.js +82 -0
- package/dist/scan/staged.js +173 -0
- package/dist/scan/types.js +1 -0
- package/dist/scan-ui/LegacyApp.js +156 -0
- package/dist/scan-ui/alt-screen.js +84 -0
- package/dist/scan-ui/api-aliases.js +1 -0
- package/dist/scan-ui/components/ErrorView.js +23 -0
- package/dist/scan-ui/components/InteractiveResultsView.js +1166 -0
- package/dist/scan-ui/components/ProgressBar.js +89 -0
- package/dist/scan-ui/components/ProjectSelector.js +62 -0
- package/dist/scan-ui/components/ScoreHeader.js +20 -0
- package/dist/scan-ui/components/SetupBanner.js +13 -0
- package/dist/scan-ui/components/Spinner.js +4 -0
- package/dist/scan-ui/format-helpers.js +40 -0
- package/dist/scan-ui/hooks/useExpandAnimation.js +40 -0
- package/dist/scan-ui/hooks/useScan.js +113 -0
- package/dist/scan-ui/hooks/useTerminalSize.js +24 -0
- package/dist/scan-ui/launch.js +27 -0
- package/dist/scan-ui/logo.js +91 -0
- package/dist/scan-ui/shims.js +30 -0
- package/dist/security/sanitize.js +28 -0
- package/dist/service/state.js +837 -0
- package/dist/service/trust-store.js +234 -0
- package/dist/service/worker.js +88 -0
- package/dist/setup/git-hook.js +244 -0
- package/dist/setup/optional-support.js +58 -0
- package/dist/setup/plan.js +899 -0
- package/dist/state/cleanup-registry.js +60 -0
- package/dist/state/index.js +5 -0
- package/dist/state/locks.js +161 -0
- package/dist/state/paths.js +24 -0
- package/dist/state/sessions.js +170 -0
- package/dist/state/store.js +50 -0
- package/dist/telemetry/events.js +40 -0
- package/dist/util/git.js +20 -0
- package/dist/util/tty-prompt.js +43 -0
- package/dist/verify/local.js +400 -0
- package/dist/verify/package-check.js +240 -0
- package/dist/verify/preflight.js +698 -0
- package/dist/verify/render.js +184 -0
- package/dist/verify/types.js +1 -0
- package/package.json +33 -50
- package/dist/index.mjs +0 -54141
- package/dist/postinstall.mjs +0 -731
- package/dist/python-hook/dg_pip_hook.pth +0 -1
- package/dist/python-hook/dg_pip_hook.py +0 -130
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { readAuthState } from "../auth/store.js";
|
|
5
|
+
import { envAuthToken } from "../auth/env-token.js";
|
|
6
|
+
import { loadUserConfig } from "../config/settings.js";
|
|
7
|
+
import { sanitize, sanitizeResponse } from "../security/sanitize.js";
|
|
8
|
+
import { resolveDgPaths } from "../state/index.js";
|
|
9
|
+
export class AnalyzeError extends Error {
|
|
10
|
+
statusCode;
|
|
11
|
+
body;
|
|
12
|
+
constructor(message, statusCode, body) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.statusCode = statusCode;
|
|
15
|
+
this.body = body;
|
|
16
|
+
this.name = "AnalyzeError";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
const ANALYZE_PATHS = {
|
|
20
|
+
npm: "/v1/analyze",
|
|
21
|
+
pypi: "/v1/pypi/analyze"
|
|
22
|
+
};
|
|
23
|
+
const BATCH_SIZE = 200;
|
|
24
|
+
const DEFAULT_TIMEOUT_MS = 180_000;
|
|
25
|
+
export async function analyzePackages(packages, options) {
|
|
26
|
+
const env = options.env ?? process.env;
|
|
27
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
28
|
+
const baseUrl = resolveApiBaseUrl(env);
|
|
29
|
+
const token = resolveToken(env);
|
|
30
|
+
const deviceId = getOrCreateDeviceId(env);
|
|
31
|
+
const url = `${baseUrl}${ANALYZE_PATHS[options.ecosystem]}`;
|
|
32
|
+
options.onProgress?.(0, packages.length, []);
|
|
33
|
+
const responses = [];
|
|
34
|
+
for (let index = 0; index < packages.length; index += BATCH_SIZE) {
|
|
35
|
+
const batch = packages.slice(index, index + BATCH_SIZE);
|
|
36
|
+
options.onProgress?.(index, packages.length, batch.map((entry) => entry.name));
|
|
37
|
+
responses.push(await analyzeBatchWithRetry(url, batch, token, deviceId, fetchImpl, options.timeoutMs ?? DEFAULT_TIMEOUT_MS));
|
|
38
|
+
options.onProgress?.(Math.min(index + batch.length, packages.length), packages.length, batch.map((entry) => entry.name));
|
|
39
|
+
}
|
|
40
|
+
return mergeAnalyzeResponses(responses);
|
|
41
|
+
}
|
|
42
|
+
const MAX_BATCH_ATTEMPTS = 3;
|
|
43
|
+
async function analyzeBatchWithRetry(url, batch, token, deviceId, fetchImpl, timeoutMs) {
|
|
44
|
+
let lastError;
|
|
45
|
+
for (let attempt = 1; attempt <= MAX_BATCH_ATTEMPTS; attempt += 1) {
|
|
46
|
+
try {
|
|
47
|
+
return await analyzeBatch(url, batch, token, deviceId, fetchImpl, timeoutMs);
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
lastError = error;
|
|
51
|
+
if (attempt === MAX_BATCH_ATTEMPTS || !isRetryableAnalyzeError(error)) {
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
await delay(300 * attempt);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (lastError instanceof AnalyzeError) {
|
|
58
|
+
throw lastError;
|
|
59
|
+
}
|
|
60
|
+
if (isNetworkFailure(lastError)) {
|
|
61
|
+
throw new AnalyzeError("could not reach the scanner — check your connection and try again", 0);
|
|
62
|
+
}
|
|
63
|
+
throw lastError;
|
|
64
|
+
}
|
|
65
|
+
function isNetworkFailure(error) {
|
|
66
|
+
if (error instanceof TypeError && error.message.toLowerCase().includes("fetch failed")) {
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
return error instanceof Error && error.name === "AbortError";
|
|
70
|
+
}
|
|
71
|
+
function isRetryableAnalyzeError(error) {
|
|
72
|
+
if (error instanceof AnalyzeError) {
|
|
73
|
+
return error.statusCode === 0 || error.statusCode >= 500;
|
|
74
|
+
}
|
|
75
|
+
return isNetworkFailure(error);
|
|
76
|
+
}
|
|
77
|
+
function delay(ms) {
|
|
78
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
79
|
+
}
|
|
80
|
+
async function analyzeBatch(url, batch, token, deviceId, fetchImpl, timeoutMs) {
|
|
81
|
+
const controller = new AbortController();
|
|
82
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
83
|
+
try {
|
|
84
|
+
const response = await fetchImpl(url, {
|
|
85
|
+
method: "POST",
|
|
86
|
+
headers: {
|
|
87
|
+
"Content-Type": "application/json",
|
|
88
|
+
"X-Device-Id": deviceId,
|
|
89
|
+
...(token ? { Authorization: `Bearer ${token}` } : {})
|
|
90
|
+
},
|
|
91
|
+
body: JSON.stringify({
|
|
92
|
+
packages: batch.map((entry) => ({ name: entry.name, version: entry.version }))
|
|
93
|
+
}),
|
|
94
|
+
signal: controller.signal
|
|
95
|
+
});
|
|
96
|
+
if (!response.ok) {
|
|
97
|
+
const body = await response.json().catch(() => undefined);
|
|
98
|
+
const serverMessage = body && typeof body === "object" && "error" in body && typeof body.error === "string"
|
|
99
|
+
? sanitize(body.error)
|
|
100
|
+
: `scanner returned ${response.status}`;
|
|
101
|
+
throw new AnalyzeError(serverMessage, response.status, body);
|
|
102
|
+
}
|
|
103
|
+
return normalizeAnalyzeResponse(await response.json());
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
107
|
+
throw new AnalyzeError(`scanner did not respond within ${timeoutMs}ms`, 0);
|
|
108
|
+
}
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
finally {
|
|
112
|
+
clearTimeout(timeout);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
export function normalizeAnalyzeResponse(raw) {
|
|
116
|
+
const candidate = raw;
|
|
117
|
+
if (!candidate || typeof candidate.score !== "number" || !Array.isArray(candidate.packages)) {
|
|
118
|
+
throw new AnalyzeError("invalid scanner response shape", 0);
|
|
119
|
+
}
|
|
120
|
+
return sanitizeResponse({
|
|
121
|
+
score: candidate.score,
|
|
122
|
+
action: normalizeAction(candidate.action),
|
|
123
|
+
packages: candidate.packages.map((entry) => ({
|
|
124
|
+
...entry,
|
|
125
|
+
action: entry.action === undefined ? undefined : normalizeAction(entry.action),
|
|
126
|
+
findings: Array.isArray(entry.findings) ? entry.findings : [],
|
|
127
|
+
reasons: Array.isArray(entry.reasons) ? entry.reasons : []
|
|
128
|
+
})),
|
|
129
|
+
safeVersions: candidate.safeVersions ?? {},
|
|
130
|
+
durationMs: typeof candidate.durationMs === "number" ? candidate.durationMs : 0,
|
|
131
|
+
...(candidate.freeScansRemaining !== undefined ? { freeScansRemaining: candidate.freeScansRemaining } : {}),
|
|
132
|
+
...(candidate.usage !== undefined ? { usage: candidate.usage } : {})
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
function normalizeAction(action) {
|
|
136
|
+
if (action === "block" || action === "warn" || action === "analysis_incomplete" || action === "pass") {
|
|
137
|
+
return action;
|
|
138
|
+
}
|
|
139
|
+
return "pass";
|
|
140
|
+
}
|
|
141
|
+
export function mergeAnalyzeResponses(responses) {
|
|
142
|
+
const first = responses[0];
|
|
143
|
+
if (!first) {
|
|
144
|
+
return {
|
|
145
|
+
score: 0,
|
|
146
|
+
action: "pass",
|
|
147
|
+
packages: [],
|
|
148
|
+
safeVersions: {},
|
|
149
|
+
durationMs: 0
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
if (responses.length === 1) {
|
|
153
|
+
return first;
|
|
154
|
+
}
|
|
155
|
+
const rank = {
|
|
156
|
+
pass: 0,
|
|
157
|
+
analysis_incomplete: 1,
|
|
158
|
+
warn: 2,
|
|
159
|
+
block: 3
|
|
160
|
+
};
|
|
161
|
+
return responses.reduce((merged, next) => ({
|
|
162
|
+
score: Math.max(merged.score, next.score),
|
|
163
|
+
action: rank[next.action] > rank[merged.action] ? next.action : merged.action,
|
|
164
|
+
packages: [...merged.packages, ...next.packages],
|
|
165
|
+
safeVersions: { ...merged.safeVersions, ...next.safeVersions },
|
|
166
|
+
durationMs: merged.durationMs + next.durationMs,
|
|
167
|
+
...(next.freeScansRemaining !== undefined
|
|
168
|
+
? { freeScansRemaining: next.freeScansRemaining }
|
|
169
|
+
: merged.freeScansRemaining !== undefined
|
|
170
|
+
? { freeScansRemaining: merged.freeScansRemaining }
|
|
171
|
+
: {}),
|
|
172
|
+
...(next.usage !== undefined ? { usage: next.usage } : merged.usage !== undefined ? { usage: merged.usage } : {})
|
|
173
|
+
}));
|
|
174
|
+
}
|
|
175
|
+
function resolveApiBaseUrl(env) {
|
|
176
|
+
const auth = readAuthStateSafe(env);
|
|
177
|
+
if (auth?.apiBaseUrl) {
|
|
178
|
+
return auth.apiBaseUrl;
|
|
179
|
+
}
|
|
180
|
+
return loadUserConfig(env).api.baseUrl;
|
|
181
|
+
}
|
|
182
|
+
function resolveToken(env) {
|
|
183
|
+
return envAuthToken(env) ?? readAuthStateSafe(env)?.token;
|
|
184
|
+
}
|
|
185
|
+
function readAuthStateSafe(env) {
|
|
186
|
+
try {
|
|
187
|
+
return readAuthState(env);
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
return undefined;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
function getOrCreateDeviceId(env) {
|
|
194
|
+
const path = join(resolveDgPaths(env).stateDir, "device-id");
|
|
195
|
+
try {
|
|
196
|
+
if (existsSync(path)) {
|
|
197
|
+
const existing = readFileSync(path, "utf8").trim();
|
|
198
|
+
if (existing) {
|
|
199
|
+
return existing;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
const id = randomUUID();
|
|
203
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
204
|
+
writeFileSync(path, `${id}\n`, { encoding: "utf8", mode: 0o600 });
|
|
205
|
+
return id;
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
return "anonymous";
|
|
209
|
+
}
|
|
210
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { authStatus, readAuthState } from "../auth/store.js";
|
|
2
|
+
import { envAuthToken } from "../auth/env-token.js";
|
|
3
|
+
import { loadUserConfig } from "../config/settings.js";
|
|
4
|
+
import { packNpmArtifact } from "../publish-set/pack.js";
|
|
5
|
+
export function deepDecision(scope, local, env = process.env) {
|
|
6
|
+
if (local) {
|
|
7
|
+
return { upload: false, reason: "local mode (--local)" };
|
|
8
|
+
}
|
|
9
|
+
if (scope.ecosystem !== "npm") {
|
|
10
|
+
return { upload: false, reason: `npm packages only (this is ${scope.ecosystem})` };
|
|
11
|
+
}
|
|
12
|
+
let authed = false;
|
|
13
|
+
try {
|
|
14
|
+
authed = authStatus(env).authenticated;
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
authed = false;
|
|
18
|
+
}
|
|
19
|
+
if (!authed) {
|
|
20
|
+
return { upload: false, reason: "not signed in — run dg login to enable" };
|
|
21
|
+
}
|
|
22
|
+
if (!consentGiven(env)) {
|
|
23
|
+
return { upload: false, reason: "upload not enabled — set DG_AUDIT_UPLOAD=1 or consent in a terminal" };
|
|
24
|
+
}
|
|
25
|
+
return { upload: true, reason: "" };
|
|
26
|
+
}
|
|
27
|
+
export function consentGiven(env = process.env) {
|
|
28
|
+
if (env.DG_AUDIT_UPLOAD === "1" || env.DG_AUDIT_UPLOAD === "true") {
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
return loadUserConfig(env).audit.upload === true;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export async function runDeepUpload(scope, packageJson, deps = {}) {
|
|
39
|
+
const env = deps.env ?? process.env;
|
|
40
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
41
|
+
const packed = packNpmArtifact(scope.root, env);
|
|
42
|
+
if ("error" in packed) {
|
|
43
|
+
return { ran: false, reason: `could not pack the package (${packed.error})` };
|
|
44
|
+
}
|
|
45
|
+
const token = resolveToken(env);
|
|
46
|
+
if (!token) {
|
|
47
|
+
return { ran: false, reason: "not signed in — run dg login to enable" };
|
|
48
|
+
}
|
|
49
|
+
const baseUrl = resolveBaseUrl(env);
|
|
50
|
+
const name = typeof packageJson?.name === "string" ? packageJson.name : scope.artifact.split("@")[0] ?? "unknown";
|
|
51
|
+
const version = typeof packageJson?.version === "string" ? packageJson.version : "0.0.0";
|
|
52
|
+
let response;
|
|
53
|
+
const controller = new AbortController();
|
|
54
|
+
const abortUpstream = () => controller.abort();
|
|
55
|
+
if (deps.signal?.aborted) {
|
|
56
|
+
controller.abort();
|
|
57
|
+
}
|
|
58
|
+
deps.signal?.addEventListener("abort", abortUpstream, { once: true });
|
|
59
|
+
try {
|
|
60
|
+
const timeout = setTimeout(() => controller.abort(), 600_000);
|
|
61
|
+
try {
|
|
62
|
+
response = await fetchImpl(`${baseUrl}/v1/scan-tarball`, {
|
|
63
|
+
method: "POST",
|
|
64
|
+
headers: {
|
|
65
|
+
Authorization: `Bearer ${token}`,
|
|
66
|
+
"Content-Type": "application/octet-stream",
|
|
67
|
+
"X-DG-Action": "audit",
|
|
68
|
+
"X-DG-Artifact-SHA256": packed.sha256,
|
|
69
|
+
"X-DG-Cache-Key": `sha256:${packed.sha256}`,
|
|
70
|
+
"X-DG-Ecosystem": "npm",
|
|
71
|
+
"X-DG-Manager": "dg-audit",
|
|
72
|
+
"X-DG-Package-Name": name,
|
|
73
|
+
"X-DG-Package-Version": version,
|
|
74
|
+
"X-DG-Privacy": "private-artifact",
|
|
75
|
+
"X-DG-Registry-Host": "registry.npmjs.org",
|
|
76
|
+
"X-DG-Source-Kind": "pre-publish"
|
|
77
|
+
},
|
|
78
|
+
body: packed.bytes,
|
|
79
|
+
signal: controller.signal
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
clearTimeout(timeout);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return { ran: false, reason: "offline — could not reach the scanner (basic audit still ran)" };
|
|
88
|
+
}
|
|
89
|
+
finally {
|
|
90
|
+
deps.signal?.removeEventListener("abort", abortUpstream);
|
|
91
|
+
}
|
|
92
|
+
if (response.status === 402 || response.status === 403) {
|
|
93
|
+
const body = await safeJson(response);
|
|
94
|
+
return { ran: false, reason: deniedReason(body?.code) };
|
|
95
|
+
}
|
|
96
|
+
if (!response.ok) {
|
|
97
|
+
return { ran: true, action: "analysis_incomplete", reason: `scanner error (HTTP ${response.status})` };
|
|
98
|
+
}
|
|
99
|
+
const body = await safeJson(response);
|
|
100
|
+
const verdict = body?.verdict;
|
|
101
|
+
if (verdict === "pass" || verdict === "warn" || verdict === "block") {
|
|
102
|
+
return { ran: true, action: verdict, reason: typeof body?.reason === "string" ? body.reason : `behavioral verdict: ${verdict}` };
|
|
103
|
+
}
|
|
104
|
+
return { ran: true, action: "analysis_incomplete", reason: "scanner returned an incomplete verdict" };
|
|
105
|
+
}
|
|
106
|
+
export function deepSummary(deep) {
|
|
107
|
+
if (!deep.ran) {
|
|
108
|
+
return deep.reason;
|
|
109
|
+
}
|
|
110
|
+
const redundant = !deep.reason || deep.reason.startsWith("behavioral verdict");
|
|
111
|
+
return redundant ? deep.action : `${deep.action} — ${deep.reason}`;
|
|
112
|
+
}
|
|
113
|
+
export async function teamPolicyBlocksUpload(env = process.env, fetchImpl = fetch) {
|
|
114
|
+
const token = resolveToken(env);
|
|
115
|
+
if (!token) {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
const baseUrl = resolveBaseUrl(env);
|
|
119
|
+
try {
|
|
120
|
+
const controller = new AbortController();
|
|
121
|
+
const timeout = setTimeout(() => controller.abort(), 3_000);
|
|
122
|
+
let response;
|
|
123
|
+
try {
|
|
124
|
+
response = await fetchImpl(`${baseUrl}/v1/cli/policy`, {
|
|
125
|
+
method: "GET",
|
|
126
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
127
|
+
signal: controller.signal
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
finally {
|
|
131
|
+
clearTimeout(timeout);
|
|
132
|
+
}
|
|
133
|
+
if (!response.ok) {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
const body = (await response.json());
|
|
137
|
+
return body.source === "org" && body.privateArtifactUpload === "disabled";
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
function resolveToken(env) {
|
|
144
|
+
const fromEnv = envAuthToken(env);
|
|
145
|
+
if (fromEnv) {
|
|
146
|
+
return fromEnv;
|
|
147
|
+
}
|
|
148
|
+
try {
|
|
149
|
+
const state = readAuthState(env);
|
|
150
|
+
return state && typeof state.token === "string" && state.token.length > 0 ? state.token : null;
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
function resolveBaseUrl(env) {
|
|
157
|
+
try {
|
|
158
|
+
return loadUserConfig(env).api.baseUrl.replace(/\/$/u, "");
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
return "https://api.westbayberry.com";
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
function deniedReason(code) {
|
|
165
|
+
if (code === "artifact-upload-disabled") {
|
|
166
|
+
return "your team disabled artifact uploads — a team admin can re-enable it in dashboard policy settings";
|
|
167
|
+
}
|
|
168
|
+
if (code === "org-policy-required") {
|
|
169
|
+
return "your team hasn't enabled artifact uploads — a team admin can enable it in dashboard policy settings";
|
|
170
|
+
}
|
|
171
|
+
return "deep behavioral scan requires a paid plan";
|
|
172
|
+
}
|
|
173
|
+
async function safeJson(response) {
|
|
174
|
+
try {
|
|
175
|
+
return (await response.json());
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { BIDI_OVERRIDE_RE, CONTENT_RULES, DANGEROUS_SCRIPT_RE, FILENAME_RULES, INVISIBLE_UNICODE_RE, RISKY_SCRIPT_NAMES } from "./rules.js";
|
|
2
|
+
export function findingLocation(finding) {
|
|
3
|
+
return finding.line ? `${finding.location}:${finding.line}` : finding.location;
|
|
4
|
+
}
|
|
5
|
+
function lineAt(body, index) {
|
|
6
|
+
let line = 1;
|
|
7
|
+
for (let cursor = 0; cursor < index && cursor < body.length; cursor += 1) {
|
|
8
|
+
if (body.charCodeAt(cursor) === 10) {
|
|
9
|
+
line += 1;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
return line;
|
|
13
|
+
}
|
|
14
|
+
const MAX_CONTENT_BYTES = 5 * 1024 * 1024;
|
|
15
|
+
export function detectFindings(files, context) {
|
|
16
|
+
const findings = [];
|
|
17
|
+
const seen = new Set();
|
|
18
|
+
const push = (finding) => {
|
|
19
|
+
const key = `${finding.id}|${finding.location}`;
|
|
20
|
+
if (seen.has(key)) {
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
seen.add(key);
|
|
24
|
+
findings.push(finding);
|
|
25
|
+
};
|
|
26
|
+
for (const file of files) {
|
|
27
|
+
structuralFileChecks(file, push);
|
|
28
|
+
filenameChecks(file, push);
|
|
29
|
+
contentChecks(file, push);
|
|
30
|
+
}
|
|
31
|
+
const manifest = files.find((file) => file.path === "package.json");
|
|
32
|
+
structuralProjectChecks(context, push);
|
|
33
|
+
lifecycleChecks(context, push, manifest ? readText(manifest) : null);
|
|
34
|
+
return findings.sort((left, right) => right.severity - left.severity || left.location.localeCompare(right.location));
|
|
35
|
+
}
|
|
36
|
+
function structuralFileChecks(file, push) {
|
|
37
|
+
if (file.symlinkEscapes) {
|
|
38
|
+
push({
|
|
39
|
+
id: "symlink-escape",
|
|
40
|
+
category: "structural",
|
|
41
|
+
severity: 5,
|
|
42
|
+
title: "Symlink points outside the package",
|
|
43
|
+
recommendation: "Remove the symlink — it can leak host files or escape extraction.",
|
|
44
|
+
location: file.path,
|
|
45
|
+
evidence: `symlink: ${file.path}`
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
if ((file.mode & 0o4000) !== 0 || (file.mode & 0o2000) !== 0) {
|
|
49
|
+
push({
|
|
50
|
+
id: "setuid-bit",
|
|
51
|
+
category: "structural",
|
|
52
|
+
severity: 5,
|
|
53
|
+
title: "File has a setuid/setgid bit",
|
|
54
|
+
recommendation: "Strip the setuid/setgid bit — it is a privilege-escalation vector.",
|
|
55
|
+
location: file.path,
|
|
56
|
+
evidence: `mode: ${file.mode.toString(8)}`
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
if (INVISIBLE_UNICODE_RE.test(file.path)) {
|
|
60
|
+
push({
|
|
61
|
+
id: "trojan-source-filename",
|
|
62
|
+
category: "structural",
|
|
63
|
+
severity: 4,
|
|
64
|
+
title: "Invisible/bidi unicode in a filename",
|
|
65
|
+
recommendation: "Rename the file — hidden unicode can disguise a malicious filename.",
|
|
66
|
+
location: file.path,
|
|
67
|
+
evidence: "invisible unicode in path"
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function filenameChecks(file, push) {
|
|
72
|
+
for (const rule of FILENAME_RULES) {
|
|
73
|
+
if (!rule.re.test(file.path)) {
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (rule.exempt && rule.exempt.test(file.path)) {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (rule.gateContent) {
|
|
80
|
+
const body = readText(file);
|
|
81
|
+
if (body === null || !rule.gateContent.test(body)) {
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
push({
|
|
86
|
+
id: rule.id,
|
|
87
|
+
category: rule.category,
|
|
88
|
+
severity: rule.severity,
|
|
89
|
+
title: rule.title,
|
|
90
|
+
recommendation: rule.recommendation,
|
|
91
|
+
location: file.path,
|
|
92
|
+
evidence: `path: ${file.path}`
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
function contentChecks(file, push) {
|
|
97
|
+
const body = readText(file);
|
|
98
|
+
if (body === null) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const bidiIndex = body.search(BIDI_OVERRIDE_RE);
|
|
102
|
+
if (bidiIndex !== -1) {
|
|
103
|
+
push({
|
|
104
|
+
id: "trojan-source-content",
|
|
105
|
+
category: "structural",
|
|
106
|
+
severity: 4,
|
|
107
|
+
title: "Bidirectional-override unicode in source",
|
|
108
|
+
recommendation: "Remove the bidi control characters — they hide code from human review.",
|
|
109
|
+
location: file.path,
|
|
110
|
+
evidence: "bidi override character in file",
|
|
111
|
+
line: lineAt(body, bidiIndex)
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
for (const rule of CONTENT_RULES) {
|
|
115
|
+
const match = body.match(rule.re);
|
|
116
|
+
if (!match) {
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (rule.allow && rule.allow.test(match[0])) {
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
push({
|
|
123
|
+
id: rule.id,
|
|
124
|
+
category: rule.category,
|
|
125
|
+
severity: rule.severity,
|
|
126
|
+
title: rule.title,
|
|
127
|
+
recommendation: rule.recommendation,
|
|
128
|
+
location: file.path,
|
|
129
|
+
evidence: redact(match[0]),
|
|
130
|
+
line: lineAt(body, body.indexOf(match[0]))
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
function structuralProjectChecks(context, push) {
|
|
135
|
+
if (context.ecosystem === "npm" && context.packageJson) {
|
|
136
|
+
if (context.packageJson.private === true) {
|
|
137
|
+
push({
|
|
138
|
+
id: "publishing-private",
|
|
139
|
+
category: "structural",
|
|
140
|
+
severity: 4,
|
|
141
|
+
title: "Package is marked private but is being audited for publish",
|
|
142
|
+
recommendation: "A private package being published is almost always a mistake — remove \"private\" or do not publish.",
|
|
143
|
+
location: "package.json",
|
|
144
|
+
evidence: "\"private\": true"
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
if (!context.hasFilesAllowlist) {
|
|
148
|
+
push({
|
|
149
|
+
id: "no-files-allowlist",
|
|
150
|
+
category: "structural",
|
|
151
|
+
severity: 3,
|
|
152
|
+
title: "No publish allowlist — the whole directory may ship",
|
|
153
|
+
recommendation: "Add a \"files\" array to package.json (or an .npmignore) so only intended files publish.",
|
|
154
|
+
location: "package.json",
|
|
155
|
+
evidence: "no \"files\" field and no .npmignore"
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (context.ecosystem === "pypi" && !context.hasFilesAllowlist) {
|
|
160
|
+
push({
|
|
161
|
+
id: "no-manifest-discipline",
|
|
162
|
+
category: "structural",
|
|
163
|
+
severity: 3,
|
|
164
|
+
title: "No MANIFEST.in / packaging include discipline",
|
|
165
|
+
recommendation: "Define an explicit include set (MANIFEST.in or pyproject include) so the sdist does not sweep the repo.",
|
|
166
|
+
location: ".",
|
|
167
|
+
evidence: "no MANIFEST.in / include config"
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
function lifecycleChecks(context, push, manifestText = null) {
|
|
172
|
+
const scripts = context.packageJson && isRecord(context.packageJson.scripts) ? context.packageJson.scripts : null;
|
|
173
|
+
if (!scripts) {
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
const scriptLine = (name) => {
|
|
177
|
+
if (!manifestText) {
|
|
178
|
+
return undefined;
|
|
179
|
+
}
|
|
180
|
+
const index = manifestText.indexOf(`"${name}"`);
|
|
181
|
+
return index === -1 ? undefined : lineAt(manifestText, index);
|
|
182
|
+
};
|
|
183
|
+
for (const [name, value] of Object.entries(scripts)) {
|
|
184
|
+
if (typeof value !== "string") {
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
const risky = RISKY_SCRIPT_NAMES.includes(name);
|
|
188
|
+
const line = scriptLine(name);
|
|
189
|
+
if (DANGEROUS_SCRIPT_RE.test(value)) {
|
|
190
|
+
push({
|
|
191
|
+
id: "lifecycle-dangerous",
|
|
192
|
+
category: "lifecycle-risk",
|
|
193
|
+
severity: risky ? 4 : 3,
|
|
194
|
+
title: `Dangerous shell pattern in the ${name} script`,
|
|
195
|
+
recommendation: `Review the ${name} script — it fetches-and-runs, evals, or pipes to a shell.`,
|
|
196
|
+
location: "package.json",
|
|
197
|
+
evidence: redact(`${name}: ${value}`, 90),
|
|
198
|
+
...(line ? { line } : {})
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
else if (risky) {
|
|
202
|
+
push({
|
|
203
|
+
id: "lifecycle-present",
|
|
204
|
+
category: "lifecycle-risk",
|
|
205
|
+
severity: 2,
|
|
206
|
+
title: `Install-time ${name} script present`,
|
|
207
|
+
recommendation: `The ${name} script runs on every consumer's install — confirm it is intentional and safe.`,
|
|
208
|
+
location: "package.json",
|
|
209
|
+
evidence: redact(`${name}: ${value}`, 90),
|
|
210
|
+
...(line ? { line } : {})
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
function readText(file) {
|
|
216
|
+
if (file.size === 0 || file.size > MAX_CONTENT_BYTES) {
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
const buffer = file.read();
|
|
220
|
+
if (buffer === null || buffer.length === 0) {
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
const sniffLength = Math.min(buffer.length, 8192);
|
|
224
|
+
for (let index = 0; index < sniffLength; index += 1) {
|
|
225
|
+
if (buffer[index] === 0) {
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return buffer.toString("utf8");
|
|
230
|
+
}
|
|
231
|
+
export function redact(value, max = 64) {
|
|
232
|
+
const collapsed = value.replace(/[\r\n]+/gu, " ").trim();
|
|
233
|
+
const masked = collapsed.replace(/[A-Za-z0-9_+/=-]{16,}/gu, (match) => `${match.slice(0, 4)}***`);
|
|
234
|
+
return masked.length > max ? `${masked.slice(0, max - 1)}…` : masked;
|
|
235
|
+
}
|
|
236
|
+
export function actionForFindings(findings) {
|
|
237
|
+
if (findings.some((finding) => finding.severity >= 4)) {
|
|
238
|
+
return "block";
|
|
239
|
+
}
|
|
240
|
+
if (findings.some((finding) => finding.severity >= 3)) {
|
|
241
|
+
return "warn";
|
|
242
|
+
}
|
|
243
|
+
return "pass";
|
|
244
|
+
}
|
|
245
|
+
function isRecord(value) {
|
|
246
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
247
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { appendFileSync, mkdirSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { authStatus } from "../auth/store.js";
|
|
4
|
+
import { loadUserConfig } from "../config/settings.js";
|
|
5
|
+
import { resolveDgPaths } from "../state/index.js";
|
|
6
|
+
export function auditLogPath(paths) {
|
|
7
|
+
return join(paths.stateDir, "audit.jsonl");
|
|
8
|
+
}
|
|
9
|
+
export function webhookOutboxPath(paths) {
|
|
10
|
+
return join(paths.stateDir, "webhooks.jsonl");
|
|
11
|
+
}
|
|
12
|
+
export function recordAuditEvent(event, env = process.env) {
|
|
13
|
+
const paths = resolveDgPaths(env);
|
|
14
|
+
appendJsonLine(auditLogPath(paths), event);
|
|
15
|
+
}
|
|
16
|
+
export function emitWebhookEvent(event, env = process.env) {
|
|
17
|
+
const config = loadUserConfig(env);
|
|
18
|
+
const status = authStatus(env);
|
|
19
|
+
if (!config.webhooks.enabled || !status.authenticated) {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
const paths = resolveDgPaths(env);
|
|
23
|
+
appendJsonLine(webhookOutboxPath(paths), {
|
|
24
|
+
...event,
|
|
25
|
+
auth: {
|
|
26
|
+
source: status.source,
|
|
27
|
+
token: status.tokenPreview
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
function appendJsonLine(path, value) {
|
|
33
|
+
mkdirSync(dirname(path), {
|
|
34
|
+
recursive: true,
|
|
35
|
+
mode: 0o700
|
|
36
|
+
});
|
|
37
|
+
appendFileSync(path, `${JSON.stringify(value)}\n`, {
|
|
38
|
+
encoding: "utf8",
|
|
39
|
+
mode: 0o600
|
|
40
|
+
});
|
|
41
|
+
}
|