@westbayberry/dg 2.0.10 → 2.1.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/dist/api/analyze.js +5 -3
- package/dist/bin/dg.js +1 -1
- package/dist/commands/completion.js +2 -1
- package/dist/commands/config.js +11 -3
- package/dist/commands/decisions.js +155 -0
- package/dist/commands/explain.js +6 -2
- package/dist/commands/router.js +2 -0
- package/dist/commands/scan.js +2 -1
- package/dist/commands/status.js +5 -2
- package/dist/config/settings.js +144 -25
- package/dist/decisions/apply.js +128 -0
- package/dist/decisions/remember-prompt.js +97 -0
- package/dist/install-ui/block-render.js +21 -4
- package/dist/install-ui/prompt.js +14 -0
- package/dist/launcher/install-preflight.js +126 -13
- package/dist/launcher/preflight-prompt.js +29 -2
- package/dist/launcher/run.js +14 -3
- package/dist/policy/cooldown.js +104 -0
- package/dist/policy/evaluate.js +0 -15
- package/dist/presentation/provenance.js +23 -0
- package/dist/project/dgfile.js +307 -0
- package/dist/proxy/enforcement.js +2 -1
- package/dist/proxy/metadata-map.js +25 -1
- package/dist/proxy/server.js +31 -2
- package/dist/scan/collect.js +10 -4
- package/dist/scan/command.js +35 -8
- package/dist/scan/discovery.js +66 -4
- package/dist/scan/render.js +35 -4
- package/dist/scan/scanner-report.js +31 -4
- package/dist/scan/staged.js +69 -10
- package/dist/scan-ui/LegacyApp.js +4 -4
- package/dist/scan-ui/components/InteractiveResultsView.js +64 -7
- package/dist/scan-ui/hooks/useScan.js +31 -3
- package/dist/scan-ui/launch.js +4 -1
- package/dist/scan-ui/shims.js +3 -0
- package/dist/scripts/detect.js +153 -0
- package/dist/scripts/gate.js +170 -0
- package/dist/scripts/rebuild.js +28 -0
- package/dist/setup/plan.js +36 -1
- package/dist/util/json-file.js +24 -0
- package/dist/util/tty-prompt.js +13 -6
- package/dist/verify/package-check.js +12 -0
- package/package.json +9 -1
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { parseCooldownAge } from "../config/settings.js";
|
|
2
|
+
export function durationToDays(value) {
|
|
3
|
+
const trimmed = value.trim();
|
|
4
|
+
if (trimmed === "" || trimmed === "0" || trimmed === "off") {
|
|
5
|
+
return 0;
|
|
6
|
+
}
|
|
7
|
+
const match = /^([1-9]\d{0,3})(h|d)$/.exec(trimmed);
|
|
8
|
+
if (!match || !match[1] || !match[2]) {
|
|
9
|
+
return 0;
|
|
10
|
+
}
|
|
11
|
+
const amount = Number.parseInt(match[1], 10);
|
|
12
|
+
return match[2] === "h" ? amount / 24 : amount;
|
|
13
|
+
}
|
|
14
|
+
export function effectiveCooldownAge(config, env, ecosystem) {
|
|
15
|
+
const fromEnv = env.DG_COOLDOWN_AGE;
|
|
16
|
+
if (fromEnv !== undefined) {
|
|
17
|
+
try {
|
|
18
|
+
return parseCooldownAge(fromEnv, "DG_COOLDOWN_AGE", false);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
// fall through to config: a malformed env override must not change policy
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
const perEcosystem = ecosystem === "npm"
|
|
25
|
+
? config.cooldown.npmAge
|
|
26
|
+
: ecosystem === "pypi"
|
|
27
|
+
? config.cooldown.pypiAge
|
|
28
|
+
: config.cooldown.cargoAge;
|
|
29
|
+
return perEcosystem !== "" ? perEcosystem : config.cooldown.age;
|
|
30
|
+
}
|
|
31
|
+
function normalizePypiName(name) {
|
|
32
|
+
return name.toLowerCase().replace(/[-_.]+/g, "-");
|
|
33
|
+
}
|
|
34
|
+
export function isCooldownExempt(packageName, exempt, ecosystem) {
|
|
35
|
+
const name = ecosystem === "pypi" ? normalizePypiName(packageName) : packageName;
|
|
36
|
+
return exempt
|
|
37
|
+
.split(",")
|
|
38
|
+
.map((entry) => entry.trim())
|
|
39
|
+
.filter(Boolean)
|
|
40
|
+
.some((pattern) => {
|
|
41
|
+
const candidate = ecosystem === "pypi" ? normalizePypiName(pattern) : pattern;
|
|
42
|
+
if (candidate.endsWith("*")) {
|
|
43
|
+
return name.startsWith(candidate.slice(0, -1));
|
|
44
|
+
}
|
|
45
|
+
return name === candidate;
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
export function cooldownRequestParam(config, env, ecosystem, packageName) {
|
|
49
|
+
const minAgeDays = durationToDays(effectiveCooldownAge(config, env, ecosystem));
|
|
50
|
+
if (minAgeDays <= 0) {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
if (packageName && isCooldownExempt(packageName, config.cooldown.exempt, ecosystem)) {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
return { minAgeDays, onUnknown: config.cooldown.onUnknown };
|
|
57
|
+
}
|
|
58
|
+
export function formatCooldownDuration(days) {
|
|
59
|
+
const hours = days * 24;
|
|
60
|
+
if (hours <= 0) {
|
|
61
|
+
return "0h";
|
|
62
|
+
}
|
|
63
|
+
if (hours < 48) {
|
|
64
|
+
return `${Math.round(hours)}h`;
|
|
65
|
+
}
|
|
66
|
+
return `${Math.floor(days)}d`;
|
|
67
|
+
}
|
|
68
|
+
export function formatPackageAge(ageDays) {
|
|
69
|
+
if (ageDays < 0) {
|
|
70
|
+
return "in the future";
|
|
71
|
+
}
|
|
72
|
+
const hours = ageDays * 24;
|
|
73
|
+
if (hours < 1) {
|
|
74
|
+
return "<1h ago";
|
|
75
|
+
}
|
|
76
|
+
if (hours < 48) {
|
|
77
|
+
return `${Math.floor(hours)}h ago`;
|
|
78
|
+
}
|
|
79
|
+
return `${Math.floor(ageDays)}d ago`;
|
|
80
|
+
}
|
|
81
|
+
export function describeCooldownSettings(config, env) {
|
|
82
|
+
if (env.DG_COOLDOWN_AGE !== undefined) {
|
|
83
|
+
try {
|
|
84
|
+
const envDays = durationToDays(parseCooldownAge(env.DG_COOLDOWN_AGE, "DG_COOLDOWN_AGE", false));
|
|
85
|
+
return `${envDays > 0 ? formatCooldownDuration(envDays) : "off"} (DG_COOLDOWN_AGE)`;
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
// malformed env override is ignored everywhere; describe the config instead
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const baseDays = durationToDays(config.cooldown.age);
|
|
92
|
+
const base = baseDays > 0 ? formatCooldownDuration(baseDays) : "off";
|
|
93
|
+
const overrides = ["npm", "pypi", "cargo"]
|
|
94
|
+
.map((ecosystem) => {
|
|
95
|
+
const value = ecosystem === "npm" ? config.cooldown.npmAge : ecosystem === "pypi" ? config.cooldown.pypiAge : config.cooldown.cargoAge;
|
|
96
|
+
if (value === "") {
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
const days = durationToDays(value);
|
|
100
|
+
return `${ecosystem} ${days > 0 ? formatCooldownDuration(days) : "off"}`;
|
|
101
|
+
})
|
|
102
|
+
.filter((entry) => entry !== undefined);
|
|
103
|
+
return overrides.length > 0 ? `${base} (${overrides.join(", ")})` : base;
|
|
104
|
+
}
|
package/dist/policy/evaluate.js
CHANGED
|
@@ -100,18 +100,6 @@ export function applyForceOverride(options, env = process.env) {
|
|
|
100
100
|
webhookQueued: emitWebhookEvent(event, env)
|
|
101
101
|
};
|
|
102
102
|
}
|
|
103
|
-
export function scriptPolicyArgs(options) {
|
|
104
|
-
if (!options.policy.scriptHardening && options.policy.mode !== "strict") {
|
|
105
|
-
return options.existingArgs;
|
|
106
|
-
}
|
|
107
|
-
if (hasScriptDisableFlag(options.existingArgs)) {
|
|
108
|
-
return options.existingArgs;
|
|
109
|
-
}
|
|
110
|
-
if (options.manager === "yarn") {
|
|
111
|
-
return [...options.existingArgs, "--ignore-scripts"];
|
|
112
|
-
}
|
|
113
|
-
return [...options.existingArgs, "--ignore-scripts"];
|
|
114
|
-
}
|
|
115
103
|
function findTrustedAllowlist(packageName, policy, allowlists) {
|
|
116
104
|
return allowlists.find((entry) => {
|
|
117
105
|
if (entry.packageName !== packageName) {
|
|
@@ -123,6 +111,3 @@ function findTrustedAllowlist(packageName, policy, allowlists) {
|
|
|
123
111
|
return true;
|
|
124
112
|
});
|
|
125
113
|
}
|
|
126
|
-
function hasScriptDisableFlag(args) {
|
|
127
|
-
return args.some((arg) => arg === "--ignore-scripts" || arg === "--ignore-scripts=true");
|
|
128
|
-
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export function provenanceLabel(provenance) {
|
|
2
|
+
if (provenance.status === "attested") {
|
|
3
|
+
const tag = slsaTag(provenance.predicateType);
|
|
4
|
+
return tag ? `attested (${tag})` : "attested";
|
|
5
|
+
}
|
|
6
|
+
if (provenance.status === "none") {
|
|
7
|
+
return "none";
|
|
8
|
+
}
|
|
9
|
+
return "unknown (not yet checked)";
|
|
10
|
+
}
|
|
11
|
+
export function provenanceDowngradeLine(version, provenance) {
|
|
12
|
+
if (!provenance.downgrade) {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
return `provenance downgraded — ${provenance.downgrade.fromVersion} was attested, ${version} is not`;
|
|
16
|
+
}
|
|
17
|
+
function slsaTag(predicateType) {
|
|
18
|
+
if (!predicateType) {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
const match = /slsa\.dev\/provenance\/(v[0-9][0-9.]*)/.exec(predicateType);
|
|
22
|
+
return match ? `slsa ${match[1]}` : null;
|
|
23
|
+
}
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { userInfo } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { gitTrimmed } from "../util/git.js";
|
|
6
|
+
import { writeJsonAtomic } from "../util/json-file.js";
|
|
7
|
+
export const DG_FILE_NAME = "dg.json";
|
|
8
|
+
export const DECISION_ENTRY_CAP = 500;
|
|
9
|
+
export const DECISION_REASON_MAX = 500;
|
|
10
|
+
const KNOWN_TOP_LEVEL_KEYS = new Set(["version", "scriptApprovals", "decisions"]);
|
|
11
|
+
const KNOWN_SCRIPT_APPROVAL_KEYS = new Set(["npm", "observed"]);
|
|
12
|
+
const SCRIPT_HOOKS = ["preinstall", "install", "postinstall", "gyp"];
|
|
13
|
+
export function dgFilePath(root) {
|
|
14
|
+
return join(root, DG_FILE_NAME);
|
|
15
|
+
}
|
|
16
|
+
export function findProjectRoot(cwd, env = process.env) {
|
|
17
|
+
try {
|
|
18
|
+
return gitTrimmed(["rev-parse", "--show-toplevel"], { cwd, env });
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export function emptyDgFile(path) {
|
|
25
|
+
return { path, exists: false, readable: true, raw: {}, decisions: [], scriptApprovals: emptyScriptApprovals() };
|
|
26
|
+
}
|
|
27
|
+
export function loadDgFile(root) {
|
|
28
|
+
const path = dgFilePath(root);
|
|
29
|
+
if (!existsSync(path)) {
|
|
30
|
+
return emptyDgFile(path);
|
|
31
|
+
}
|
|
32
|
+
let raw;
|
|
33
|
+
try {
|
|
34
|
+
raw = JSON.parse(readFileSync(path, "utf8"));
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
return failOpen(path, `malformed JSON (${error instanceof Error ? error.message : "parse error"})`);
|
|
38
|
+
}
|
|
39
|
+
if (!isPlainObject(raw)) {
|
|
40
|
+
return failOpen(path, "top level must be a JSON object");
|
|
41
|
+
}
|
|
42
|
+
if (raw.version !== undefined && raw.version !== 1) {
|
|
43
|
+
return failOpen(path, `unsupported dg.json version ${String(raw.version)}`);
|
|
44
|
+
}
|
|
45
|
+
const listed = raw.decisions;
|
|
46
|
+
if (listed !== undefined && !Array.isArray(listed)) {
|
|
47
|
+
return failOpen(path, "decisions must be an array");
|
|
48
|
+
}
|
|
49
|
+
const entries = Array.isArray(listed) ? listed : [];
|
|
50
|
+
if (entries.length > DECISION_ENTRY_CAP) {
|
|
51
|
+
return failOpen(path, `more than ${DECISION_ENTRY_CAP} decisions`);
|
|
52
|
+
}
|
|
53
|
+
const approvalsRaw = raw.scriptApprovals;
|
|
54
|
+
if (approvalsRaw !== undefined && !isPlainObject(approvalsRaw)) {
|
|
55
|
+
return failOpen(path, "scriptApprovals must be an object");
|
|
56
|
+
}
|
|
57
|
+
const decisions = [];
|
|
58
|
+
for (const entry of entries) {
|
|
59
|
+
const parsed = parseDecisionEntry(entry);
|
|
60
|
+
if (parsed) {
|
|
61
|
+
decisions.push(parsed);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const approvals = approvalsRaw ?? {};
|
|
65
|
+
return {
|
|
66
|
+
path,
|
|
67
|
+
exists: true,
|
|
68
|
+
readable: true,
|
|
69
|
+
raw,
|
|
70
|
+
decisions,
|
|
71
|
+
scriptApprovals: {
|
|
72
|
+
npm: parseEntryMap(approvals.npm, parseApprovalEntry),
|
|
73
|
+
observed: parseEntryMap(approvals.observed, parseObservedEntry),
|
|
74
|
+
unknownKeys: unknownKeysOf(approvals, KNOWN_SCRIPT_APPROVAL_KEYS)
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
export function warnUnreadableDgFile(file, write = (line) => process.stderr.write(line)) {
|
|
79
|
+
if (!file.readable) {
|
|
80
|
+
write(`dg: ignoring decisions in ${file.path} — ${file.failure ?? "unreadable"} (no warnings suppressed)\n`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
export function appendDecisions(file, additions, now = new Date()) {
|
|
84
|
+
const added = additions.map((decision) => ({
|
|
85
|
+
...decision,
|
|
86
|
+
id: randomUUID(),
|
|
87
|
+
reason: decision.reason.slice(0, DECISION_REASON_MAX),
|
|
88
|
+
acceptedAt: decision.acceptedAt ?? now.toISOString()
|
|
89
|
+
}));
|
|
90
|
+
return { ...file, decisions: [...file.decisions, ...added] };
|
|
91
|
+
}
|
|
92
|
+
export function removeDecisions(file, ids) {
|
|
93
|
+
return { ...file, decisions: file.decisions.filter((entry) => !ids.has(entry.id)) };
|
|
94
|
+
}
|
|
95
|
+
export function saveDgFile(file) {
|
|
96
|
+
if (!file.readable) {
|
|
97
|
+
throw new Error(`refusing to rewrite ${file.path}: ${file.failure ?? "unreadable"}`);
|
|
98
|
+
}
|
|
99
|
+
const top = unknownKeysOf(file.raw, KNOWN_TOP_LEVEL_KEYS);
|
|
100
|
+
if (file.decisions.length > 0) {
|
|
101
|
+
top.decisions = file.decisions.map(serializeDecisionEntry);
|
|
102
|
+
}
|
|
103
|
+
const approvals = { ...file.scriptApprovals.unknownKeys };
|
|
104
|
+
if (Object.keys(file.scriptApprovals.npm).length > 0) {
|
|
105
|
+
approvals.npm = sortedRecord(file.scriptApprovals.npm);
|
|
106
|
+
}
|
|
107
|
+
if (Object.keys(file.scriptApprovals.observed).length > 0) {
|
|
108
|
+
approvals.observed = sortedRecord(file.scriptApprovals.observed);
|
|
109
|
+
}
|
|
110
|
+
if (Object.keys(approvals).length > 0) {
|
|
111
|
+
top.scriptApprovals = sortedRecord(approvals);
|
|
112
|
+
}
|
|
113
|
+
const ordered = { version: 1 };
|
|
114
|
+
for (const key of Object.keys(top).sort()) {
|
|
115
|
+
ordered[key] = top[key];
|
|
116
|
+
}
|
|
117
|
+
writeJsonAtomic(file.path, ordered, { fileMode: 0o644, dirMode: 0o755 });
|
|
118
|
+
}
|
|
119
|
+
export function resolveAcceptedBy(cwd, env = process.env) {
|
|
120
|
+
try {
|
|
121
|
+
const email = gitTrimmed(["config", "user.email"], { cwd, env });
|
|
122
|
+
if (email) {
|
|
123
|
+
return email;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
/* fall through */
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
return userInfo().username;
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
return "unknown";
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
function serializeDecisionEntry(entry) {
|
|
137
|
+
return {
|
|
138
|
+
...entry.extra,
|
|
139
|
+
id: entry.id,
|
|
140
|
+
ecosystem: entry.ecosystem,
|
|
141
|
+
name: entry.name,
|
|
142
|
+
scope: entry.scope.kind === "exact" ? { kind: "exact", version: entry.scope.version } : { kind: "any" },
|
|
143
|
+
findings: entry.findings,
|
|
144
|
+
reason: entry.reason,
|
|
145
|
+
acceptedBy: entry.acceptedBy,
|
|
146
|
+
acceptedAt: entry.acceptedAt,
|
|
147
|
+
...(entry.expiresAt ? { expiresAt: entry.expiresAt } : {})
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
const ENTRY_FIELDS = new Set(["id", "ecosystem", "name", "scope", "findings", "reason", "acceptedBy", "acceptedAt", "expiresAt"]);
|
|
151
|
+
function parseDecisionEntry(value) {
|
|
152
|
+
if (!isPlainObject(value)) {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
const ecosystem = value.ecosystem;
|
|
156
|
+
if (ecosystem !== "npm" && ecosystem !== "pypi") {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
const name = value.name;
|
|
160
|
+
if (typeof name !== "string" || name.length === 0) {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
const scope = parseScope(value.scope);
|
|
164
|
+
if (!scope) {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
const findings = parseFindings(value.findings);
|
|
168
|
+
if (!findings) {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
const expiresAt = value.expiresAt;
|
|
172
|
+
if (expiresAt !== undefined && typeof expiresAt !== "string") {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
const extra = {};
|
|
176
|
+
for (const [key, field] of Object.entries(value)) {
|
|
177
|
+
if (!ENTRY_FIELDS.has(key)) {
|
|
178
|
+
extra[key] = field;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return {
|
|
182
|
+
id: typeof value.id === "string" && value.id.length > 0 ? value.id : derivedEntryId(value),
|
|
183
|
+
ecosystem,
|
|
184
|
+
name,
|
|
185
|
+
scope,
|
|
186
|
+
findings,
|
|
187
|
+
reason: typeof value.reason === "string" ? value.reason.slice(0, DECISION_REASON_MAX) : "",
|
|
188
|
+
acceptedBy: typeof value.acceptedBy === "string" && value.acceptedBy.length > 0 ? value.acceptedBy : "unknown",
|
|
189
|
+
acceptedAt: typeof value.acceptedAt === "string" ? value.acceptedAt : "",
|
|
190
|
+
...(typeof expiresAt === "string" ? { expiresAt } : {}),
|
|
191
|
+
...(Object.keys(extra).length > 0 ? { extra } : {})
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
function parseScope(value) {
|
|
195
|
+
if (!isPlainObject(value)) {
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
if (value.kind === "any") {
|
|
199
|
+
return { kind: "any" };
|
|
200
|
+
}
|
|
201
|
+
if (value.kind === "exact" && typeof value.version === "string" && value.version.length > 0) {
|
|
202
|
+
return { kind: "exact", version: value.version };
|
|
203
|
+
}
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
function parseFindings(value) {
|
|
207
|
+
if (value === undefined) {
|
|
208
|
+
return {};
|
|
209
|
+
}
|
|
210
|
+
if (!isPlainObject(value)) {
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
const findings = {};
|
|
214
|
+
for (const [category, severity] of Object.entries(value)) {
|
|
215
|
+
if (typeof severity !== "number" || !Number.isInteger(severity) || severity < 1 || severity > 5) {
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
findings[category] = severity;
|
|
219
|
+
}
|
|
220
|
+
return findings;
|
|
221
|
+
}
|
|
222
|
+
function derivedEntryId(value) {
|
|
223
|
+
return createHash("sha256").update(JSON.stringify(value)).digest("hex").slice(0, 12);
|
|
224
|
+
}
|
|
225
|
+
function parseEntryMap(raw, parseEntry) {
|
|
226
|
+
if (!isPlainObject(raw)) {
|
|
227
|
+
return {};
|
|
228
|
+
}
|
|
229
|
+
const entries = {};
|
|
230
|
+
for (const [name, value] of Object.entries(raw)) {
|
|
231
|
+
const parsed = parseEntry(value);
|
|
232
|
+
if (parsed !== null) {
|
|
233
|
+
entries[name] = parsed;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return entries;
|
|
237
|
+
}
|
|
238
|
+
function parseApprovalEntry(value) {
|
|
239
|
+
if (!isPlainObject(value)) {
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
const decision = value.decision;
|
|
243
|
+
if (decision !== "allow" && decision !== "deny") {
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
if (typeof value.scriptsHash !== "string" || typeof value.approvedAt !== "string") {
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
const provenance = value.provenance;
|
|
250
|
+
if (provenance !== "prompt" && provenance !== "command" && provenance !== "imported-pnpm") {
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
return {
|
|
254
|
+
decision,
|
|
255
|
+
scriptsHash: value.scriptsHash,
|
|
256
|
+
hooks: parseHooks(value.hooks),
|
|
257
|
+
...(typeof value.approvedVersion === "string" ? { approvedVersion: value.approvedVersion } : {}),
|
|
258
|
+
...(typeof value.reason === "string" ? { reason: value.reason } : {}),
|
|
259
|
+
approvedAt: value.approvedAt,
|
|
260
|
+
provenance
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
function parseObservedEntry(value) {
|
|
264
|
+
if (!isPlainObject(value)) {
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
if (typeof value.version !== "string" || typeof value.scriptsHash !== "string" || typeof value.firstSeen !== "string") {
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
return {
|
|
271
|
+
version: value.version,
|
|
272
|
+
hooks: parseHooks(value.hooks),
|
|
273
|
+
scriptsHash: value.scriptsHash,
|
|
274
|
+
firstSeen: value.firstSeen
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
function parseHooks(raw) {
|
|
278
|
+
if (!Array.isArray(raw)) {
|
|
279
|
+
return [];
|
|
280
|
+
}
|
|
281
|
+
return SCRIPT_HOOKS.filter((hook) => raw.includes(hook));
|
|
282
|
+
}
|
|
283
|
+
function sortedRecord(record) {
|
|
284
|
+
const sorted = {};
|
|
285
|
+
for (const key of Object.keys(record).sort()) {
|
|
286
|
+
sorted[key] = record[key];
|
|
287
|
+
}
|
|
288
|
+
return sorted;
|
|
289
|
+
}
|
|
290
|
+
function unknownKeysOf(raw, known) {
|
|
291
|
+
const unknown = {};
|
|
292
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
293
|
+
if (!known.has(key)) {
|
|
294
|
+
unknown[key] = value;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return unknown;
|
|
298
|
+
}
|
|
299
|
+
function isPlainObject(value) {
|
|
300
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
301
|
+
}
|
|
302
|
+
function emptyScriptApprovals() {
|
|
303
|
+
return { npm: {}, observed: {}, unknownKeys: {} };
|
|
304
|
+
}
|
|
305
|
+
function failOpen(path, failure) {
|
|
306
|
+
return { path, exists: true, readable: false, failure, raw: {}, decisions: [], scriptApprovals: emptyScriptApprovals() };
|
|
307
|
+
}
|
|
@@ -140,6 +140,7 @@ function withOptionalDecisionFields(decision, verdict) {
|
|
|
140
140
|
...(verdict.dashboardUrl ? { dashboardUrl: verdict.dashboardUrl } : {}),
|
|
141
141
|
...(verdict.unauthenticated ? { unauthenticated: true } : {}),
|
|
142
142
|
...(verdict.resetsAt ? { resetsAt: verdict.resetsAt } : {}),
|
|
143
|
-
...(verdict.quotaBehavior ? { quotaBehavior: verdict.quotaBehavior } : {})
|
|
143
|
+
...(verdict.quotaBehavior ? { quotaBehavior: verdict.quotaBehavior } : {}),
|
|
144
|
+
...(verdict.cooldown ? { cooldown: verdict.cooldown } : {})
|
|
144
145
|
};
|
|
145
146
|
}
|
|
@@ -202,7 +202,9 @@ function fallbackIdentity(artifactUrl, classification) {
|
|
|
202
202
|
const ecosystem = ecosystemForManager(classification.manager);
|
|
203
203
|
const parsed = ecosystem === "pypi"
|
|
204
204
|
? parsePypiArtifactFilename(decodeURIComponent(artifactUrl.pathname.split("/").filter(Boolean).at(-1) ?? "")) ?? parsePackageVersionFromUrl(artifactUrl)
|
|
205
|
-
:
|
|
205
|
+
: ecosystem === "cargo"
|
|
206
|
+
? parseCargoArtifactUrl(artifactUrl) ?? parsePackageVersionFromUrl(artifactUrl)
|
|
207
|
+
: parsePackageVersionFromUrl(artifactUrl);
|
|
206
208
|
const requested = requestedIdentityFromArgs(classification, parsed.name);
|
|
207
209
|
return {
|
|
208
210
|
ecosystem,
|
|
@@ -213,6 +215,28 @@ function fallbackIdentity(artifactUrl, classification) {
|
|
|
213
215
|
sourceKind: "url-fallback"
|
|
214
216
|
};
|
|
215
217
|
}
|
|
218
|
+
// crates.io serves downloads at /api/v1/crates/{name}/{version}/download,
|
|
219
|
+
// static.crates.io at /crates/{name}/{version}/download and as
|
|
220
|
+
// {name}-{version}.crate files. Crate versions always start with a digit
|
|
221
|
+
// (semver), so the filename split is unambiguous even for names with dashes.
|
|
222
|
+
function parseCargoArtifactUrl(artifactUrl) {
|
|
223
|
+
const downloadMatch = /^\/(?:api\/v1\/)?crates\/([^/]+)\/([^/]+)\/download\/?$/.exec(artifactUrl.pathname);
|
|
224
|
+
if (downloadMatch && downloadMatch[1] && downloadMatch[2]) {
|
|
225
|
+
return {
|
|
226
|
+
name: decodeURIComponent(downloadMatch[1]),
|
|
227
|
+
version: decodeURIComponent(downloadMatch[2])
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
const file = decodeURIComponent(artifactUrl.pathname.split("/").filter(Boolean).at(-1) ?? "");
|
|
231
|
+
if (/\.crate$/i.test(file)) {
|
|
232
|
+
const stem = file.slice(0, -".crate".length);
|
|
233
|
+
const match = /^(.+)-(\d[0-9A-Za-z.+-]*)$/.exec(stem);
|
|
234
|
+
if (match && match[1] && match[2]) {
|
|
235
|
+
return { name: match[1], version: match[2] };
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
216
240
|
function parsePackageVersionFromUrl(artifactUrl) {
|
|
217
241
|
const parts = artifactUrl.pathname.split("/").filter(Boolean).map((part) => decodeURIComponent(part));
|
|
218
242
|
const file = parts.at(-1) ?? artifactUrl.hostname;
|
package/dist/proxy/server.js
CHANGED
|
@@ -11,6 +11,8 @@ import { createSecureContext, createServer as createTlsServer, connect as tlsCon
|
|
|
11
11
|
import { createEphemeralCertificateAuthority } from "./ca.js";
|
|
12
12
|
import { shouldMitmHost } from "./classify-host.js";
|
|
13
13
|
import { enforceProtectedInstall } from "./enforcement.js";
|
|
14
|
+
import { cooldownRequestParam } from "../policy/cooldown.js";
|
|
15
|
+
import { loadUserConfig } from "../config/settings.js";
|
|
14
16
|
import { artifactDisplayName, artifactUrlHash, extractRegistryMetadataIdentities, isRegistryIndexRequest, resolveArtifactIdentity } from "./metadata-map.js";
|
|
15
17
|
import { authorityFor, connectViaUpstreamProxy, selectUpstreamProxy } from "./upstream-proxy.js";
|
|
16
18
|
import { redactSecrets } from "../launcher/output-redaction.js";
|
|
@@ -625,6 +627,7 @@ async function lookupVerdict(options, target, sha256, upstream, identity) {
|
|
|
625
627
|
if (shouldUseScanTarball(options, target, identity)) {
|
|
626
628
|
return lookupScanTarballVerdict(options, target, sha256, upstream, identity);
|
|
627
629
|
}
|
|
630
|
+
const cooldown = resolveCooldownRequest(options.env, identity);
|
|
628
631
|
const controller = new AbortController();
|
|
629
632
|
const timeout = setTimeout(() => controller.abort(), options.verdictTimeoutMs ?? installVerdictTimeoutMs(options.env));
|
|
630
633
|
try {
|
|
@@ -647,7 +650,8 @@ async function lookupVerdict(options, target, sha256, upstream, identity) {
|
|
|
647
650
|
sourceKind: identity.sourceKind,
|
|
648
651
|
sha256,
|
|
649
652
|
statusCode: upstream.statusCode,
|
|
650
|
-
contentType: headerValue(upstream.headers["content-type"])
|
|
653
|
+
contentType: headerValue(upstream.headers["content-type"]),
|
|
654
|
+
...(cooldown ? { cooldown } : {})
|
|
651
655
|
}),
|
|
652
656
|
signal: controller.signal
|
|
653
657
|
});
|
|
@@ -762,13 +766,37 @@ function normalizeVerdict(value, target, identity, streamedSha256) {
|
|
|
762
766
|
};
|
|
763
767
|
}
|
|
764
768
|
const cause = typeof value.cause === "string" ? value.cause : undefined;
|
|
769
|
+
const cooldown = parseCooldownInfo(value.cooldown);
|
|
765
770
|
return {
|
|
766
771
|
verdict,
|
|
767
772
|
packageName: typeof value.packageName === "string" ? value.packageName : artifactDisplayName(identity) || packageNameFromUrl(target),
|
|
768
773
|
...(isProxyCause(cause) ? { cause } : {}),
|
|
769
774
|
reason: typeof value.reason === "string" ? value.reason : `API verdict ${verdict}`,
|
|
770
775
|
...(typeof value.dashboardUrl === "string" ? { dashboardUrl: value.dashboardUrl } : {}),
|
|
771
|
-
...(value.unauthenticated === true ? { unauthenticated: true } : {})
|
|
776
|
+
...(value.unauthenticated === true ? { unauthenticated: true } : {}),
|
|
777
|
+
...(cooldown ? { cooldown } : {})
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
function resolveCooldownRequest(env, identity) {
|
|
781
|
+
if (identity.ecosystem === "unknown" || identity.version === "unknown") {
|
|
782
|
+
return undefined;
|
|
783
|
+
}
|
|
784
|
+
try {
|
|
785
|
+
return cooldownRequestParam(loadUserConfig(env), env, identity.ecosystem, identity.name);
|
|
786
|
+
}
|
|
787
|
+
catch {
|
|
788
|
+
return undefined;
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
function parseCooldownInfo(value) {
|
|
792
|
+
if (!isRecord(value) || typeof value.requiredDays !== "number" || !Number.isFinite(value.requiredDays)) {
|
|
793
|
+
return undefined;
|
|
794
|
+
}
|
|
795
|
+
return {
|
|
796
|
+
requiredDays: value.requiredDays,
|
|
797
|
+
...(typeof value.ageDays === "number" && Number.isFinite(value.ageDays) ? { ageDays: value.ageDays } : {}),
|
|
798
|
+
...(typeof value.publishedAt === "string" ? { publishedAt: value.publishedAt } : {}),
|
|
799
|
+
...(typeof value.eligibleAt === "string" ? { eligibleAt: value.eligibleAt } : {})
|
|
772
800
|
};
|
|
773
801
|
}
|
|
774
802
|
function normalizeScanTarballVerdict(value, target, identity, streamedSha256) {
|
|
@@ -920,6 +948,7 @@ function isProxyCause(value) {
|
|
|
920
948
|
"api-timeout",
|
|
921
949
|
"registry-timeout",
|
|
922
950
|
"analysis-incomplete",
|
|
951
|
+
"cooldown",
|
|
923
952
|
"unsupported-manager",
|
|
924
953
|
"proxy-setup-failure"
|
|
925
954
|
].includes(value ?? "");
|
package/dist/scan/collect.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readdirSync } from "node:fs";
|
|
2
2
|
import { join, relative } from "node:path";
|
|
3
|
+
import { gitIgnoredDirectories } from "./discovery.js";
|
|
3
4
|
import { parseLockfilePackages } from "../verify/preflight.js";
|
|
4
5
|
export const LOCKFILE_ECOSYSTEMS = {
|
|
5
6
|
"package-lock.json": "npm",
|
|
@@ -49,11 +50,15 @@ function lockfilesPerEcosystem(entries) {
|
|
|
49
50
|
}
|
|
50
51
|
return matches;
|
|
51
52
|
}
|
|
52
|
-
function shouldDescend(entry) {
|
|
53
|
-
return entry.isDirectory() &&
|
|
53
|
+
function shouldDescend(entry, directory, gitIgnored) {
|
|
54
|
+
return (entry.isDirectory() &&
|
|
55
|
+
!IGNORED_DIRECTORIES.has(entry.name) &&
|
|
56
|
+
!entry.name.startsWith(".") &&
|
|
57
|
+
!gitIgnored.has(join(directory, entry.name)));
|
|
54
58
|
}
|
|
55
59
|
export function discoverScanProjects(root) {
|
|
56
60
|
const projects = [];
|
|
61
|
+
const gitIgnored = gitIgnoredDirectories(root);
|
|
57
62
|
walk(root, 0);
|
|
58
63
|
return projects;
|
|
59
64
|
function walk(directory, depth) {
|
|
@@ -71,7 +76,7 @@ export function discoverScanProjects(root) {
|
|
|
71
76
|
});
|
|
72
77
|
}
|
|
73
78
|
for (const entry of entries) {
|
|
74
|
-
if (shouldDescend(entry)) {
|
|
79
|
+
if (shouldDescend(entry, directory, gitIgnored)) {
|
|
75
80
|
walk(join(directory, entry.name), depth + 1);
|
|
76
81
|
}
|
|
77
82
|
}
|
|
@@ -79,6 +84,7 @@ export function discoverScanProjects(root) {
|
|
|
79
84
|
}
|
|
80
85
|
export async function discoverScanProjectsAsync(root, onProgress) {
|
|
81
86
|
const projects = [];
|
|
87
|
+
const gitIgnored = gitIgnoredDirectories(root);
|
|
82
88
|
let lastYield = Date.now();
|
|
83
89
|
await walk(root, 0);
|
|
84
90
|
return projects;
|
|
@@ -103,7 +109,7 @@ export async function discoverScanProjectsAsync(root, onProgress) {
|
|
|
103
109
|
onProgress?.({ path: relativePath === "." ? depFile : `${relativePath}/${depFile}`, found: projects.length });
|
|
104
110
|
}
|
|
105
111
|
for (const entry of entries) {
|
|
106
|
-
if (shouldDescend(entry)) {
|
|
112
|
+
if (shouldDescend(entry, directory, gitIgnored)) {
|
|
107
113
|
await walk(join(directory, entry.name), depth + 1);
|
|
108
114
|
}
|
|
109
115
|
}
|