@getfootgun/footgun 0.0.0-stage → 0.2.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/README.md +20 -2
- package/bin/footgun.mjs +107 -0
- package/lib/clean.mjs +3 -0
- package/lib/context.mjs +138 -0
- package/lib/manifests.mjs +295 -0
- package/lib/pack.mjs +127 -0
- package/lib/report.mjs +49 -0
- package/lib/rules.d.ts +18 -0
- package/lib/rules.mjs +149 -0
- package/lib/scan-worker.mjs +24 -0
- package/lib/scan.d.ts +55 -0
- package/lib/scan.mjs +333 -0
- package/lib/scrub.mjs +22 -0
- package/lib/versions.d.ts +7 -0
- package/lib/versions.mjs +265 -0
- package/package.json +1 -6
package/lib/scan.mjs
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
// Local scanner core: reads manifests, decides which trap rules apply to which
|
|
2
|
+
// subtree, and runs detect patterns over files. Nothing leaves the machine.
|
|
3
|
+
|
|
4
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { Worker } from "node:worker_threads";
|
|
7
|
+
import { ruleProblems, globMatch } from "./rules.mjs";
|
|
8
|
+
import { satisfiesNpm, satisfiesPep440 } from "./versions.mjs";
|
|
9
|
+
import { buildManifests, normalizePypi } from "./manifests.mjs";
|
|
10
|
+
|
|
11
|
+
const SKIP_DIRS = new Set(["node_modules", ".git", ".next", "dist", "build", "out", ".venv", "__pycache__"]);
|
|
12
|
+
const MAX_FILE = 1024 * 1024;
|
|
13
|
+
const BUDGET_MS = 200;
|
|
14
|
+
const BATCH_FILES = 64;
|
|
15
|
+
const BATCH_BYTES = 8 * 1024 * 1024;
|
|
16
|
+
const SEVERITIES = ["breaks", "will-break", "silent", "cost"];
|
|
17
|
+
const ECOSYSTEMS = ["npm", "pypi", "github-action", "service"];
|
|
18
|
+
|
|
19
|
+
// ---------- walking ----------
|
|
20
|
+
|
|
21
|
+
function loadGitignore(root) {
|
|
22
|
+
let text = "";
|
|
23
|
+
try { text = readFileSync(join(root, ".gitignore"), "utf8"); } catch { return []; }
|
|
24
|
+
const out = [];
|
|
25
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
26
|
+
let p = raw.trim();
|
|
27
|
+
if (!p || p.startsWith("#") || p.startsWith("!")) continue;
|
|
28
|
+
const dirOnly = p.endsWith("/");
|
|
29
|
+
if (dirOnly) p = p.replace(/\/+$/, "");
|
|
30
|
+
const anchored = p.includes("/");
|
|
31
|
+
p = p.replace(/^\//, "");
|
|
32
|
+
if (p) out.push({ p, dirOnly, anchored });
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function ignored(rules, rel, name, isDir) {
|
|
38
|
+
return rules.some((r) => (!r.dirOnly || isDir) && globMatch(r.p, r.anchored ? rel : name));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isSecretEnv(name) {
|
|
42
|
+
return (name === ".env" || name.startsWith(".env.")) && name !== ".env.example";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Test and fixture code is skipped by default; a detect whose globs name test code still runs there.
|
|
46
|
+
const TEST_DIRS = new Set(["test", "tests", "__tests__", "spec", "specs", "__mocks__", "__fixtures__", "fixtures", "e2e"]);
|
|
47
|
+
const TEST_FILE = /\.(test|spec|stories)\./;
|
|
48
|
+
|
|
49
|
+
function isTestPath(rel) {
|
|
50
|
+
const parts = rel.split("/");
|
|
51
|
+
return parts.slice(0, -1).some((p) => TEST_DIRS.has(p)) || TEST_FILE.test(parts[parts.length - 1]);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// a glob targets test code when one of its path segments is a test directory name or its file name is a test file
|
|
55
|
+
// pattern; "test" inside another word ("latest", "contest") does not count
|
|
56
|
+
const TEST_SEGMENT = /^(?:__tests__|tests?|specs?|e2e|playwright)$/;
|
|
57
|
+
const TEST_NAME = /[.{,](?:test|spec)[.},]/;
|
|
58
|
+
|
|
59
|
+
function targetsTests(d) {
|
|
60
|
+
return d.files.some((g) => {
|
|
61
|
+
const parts = g.split("/");
|
|
62
|
+
return parts.some((p) => TEST_SEGMENT.test(p)) || TEST_NAME.test(parts[parts.length - 1]);
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function walk(root) {
|
|
67
|
+
const rules = loadGitignore(root);
|
|
68
|
+
const out = [];
|
|
69
|
+
const visit = (dir) => {
|
|
70
|
+
let entries;
|
|
71
|
+
try { entries = readdirSync(join(root, dir), { withFileTypes: true }); } catch { return; }
|
|
72
|
+
entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
73
|
+
for (const e of entries) {
|
|
74
|
+
const rel = dir ? `${dir}/${e.name}` : e.name;
|
|
75
|
+
if (e.isDirectory()) {
|
|
76
|
+
if (!SKIP_DIRS.has(e.name) && !ignored(rules, rel, e.name, true)) visit(rel);
|
|
77
|
+
} else if (e.isFile() && !isSecretEnv(e.name) && !ignored(rules, rel, e.name, false)) {
|
|
78
|
+
out.push(rel);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
visit("");
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function readManifests(root) {
|
|
87
|
+
return buildManifests(root, walk(root));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function readText(abs) {
|
|
91
|
+
try {
|
|
92
|
+
if (statSync(abs).size > MAX_FILE) return null;
|
|
93
|
+
const buf = readFileSync(abs);
|
|
94
|
+
if (buf.subarray(0, 8192).includes(0)) return null;
|
|
95
|
+
return buf.toString("utf8");
|
|
96
|
+
} catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ---------- rules ----------
|
|
102
|
+
|
|
103
|
+
function shapeProblem(rule) {
|
|
104
|
+
if (!rule || typeof rule !== "object" || Array.isArray(rule)) return "rule must be an object";
|
|
105
|
+
if (typeof rule.id !== "string" || !rule.id) return "id must be a non-empty string";
|
|
106
|
+
for (const k of ["title", "provider", "fix"]) if (typeof rule[k] !== "string") return `${k} must be a string`;
|
|
107
|
+
if (!SEVERITIES.includes(rule.severity)) return "severity is not recognised";
|
|
108
|
+
for (const k of ["deadline", "url"]) {
|
|
109
|
+
if (rule[k] != null && typeof rule[k] !== "string") return `${k} must be a string or null`;
|
|
110
|
+
}
|
|
111
|
+
if (typeof rule.checkManually !== "boolean") return "checkManually must be a boolean";
|
|
112
|
+
if (!Array.isArray(rule.packages) || !rule.packages.every((p) =>
|
|
113
|
+
p && ECOSYSTEMS.includes(p.ecosystem) && typeof p.name === "string" && typeof p.range === "string")) {
|
|
114
|
+
return "packages must be a list of package refs";
|
|
115
|
+
}
|
|
116
|
+
if (!Array.isArray(rule.detect)) return "detect must be a list";
|
|
117
|
+
const problems = rule.detect.flatMap((d) => ruleProblems(d));
|
|
118
|
+
return problems.length ? [...new Set(problems)].join("; ") : null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const BROAD = new Set(["*", "", "x", "X"]);
|
|
122
|
+
|
|
123
|
+
// "any version": a literal wildcard, or a range that both the lowest and a very high version satisfy (">=0", "^0 || >=1")
|
|
124
|
+
function isBroad(range, check) {
|
|
125
|
+
return BROAD.has(range.trim()) || (check("0.0.0", range) === true && check("999999.0.0", range) === true);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* null when m satisfies none of the rule's npm, pypi or github-action refs (a rule with none of those,
|
|
130
|
+
* such as one with only service refs, never applies to a local scan); else the satisfied ref as
|
|
131
|
+
* { pkg: { ecosystem, name, version }, broad }, preferring one whose range is narrower than "any version".
|
|
132
|
+
*/
|
|
133
|
+
function applies(rule, m) {
|
|
134
|
+
const gating = rule.packages.filter((p) => p.ecosystem !== "service");
|
|
135
|
+
let found = null;
|
|
136
|
+
for (const p of gating) {
|
|
137
|
+
const table = p.ecosystem === "npm" ? m.npm : p.ecosystem === "pypi" ? m.pypi : m.actions;
|
|
138
|
+
const name = p.ecosystem === "pypi" ? normalizePypi(p.name) : p.name;
|
|
139
|
+
if (!Object.hasOwn(table, name)) continue;
|
|
140
|
+
const check = p.ecosystem === "pypi" ? satisfiesPep440 : satisfiesNpm;
|
|
141
|
+
if (check(table[name], p.range) !== true) continue;
|
|
142
|
+
const broad = isBroad(p.range, check);
|
|
143
|
+
if (!found || (found.broad && !broad)) found = { pkg: { ecosystem: p.ecosystem, name, version: table[name] }, broad };
|
|
144
|
+
if (!broad) break;
|
|
145
|
+
}
|
|
146
|
+
return found;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ---------- matching in a worker ----------
|
|
150
|
+
|
|
151
|
+
function matcherError(cause) {
|
|
152
|
+
const why = cause instanceof Error ? cause.message : String(cause);
|
|
153
|
+
return new Error(`Footgun scanner could not start its matcher: ${why}`, { cause });
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function startWorker(detects, progress) {
|
|
157
|
+
let worker;
|
|
158
|
+
try {
|
|
159
|
+
worker = new Worker(new URL("./scan-worker.mjs", import.meta.url), { workerData: { detects, progress } });
|
|
160
|
+
} catch (err) {
|
|
161
|
+
throw matcherError(err);
|
|
162
|
+
}
|
|
163
|
+
return new Promise((resolve, reject) => {
|
|
164
|
+
const fail = (err) => { worker.off("message", ready); reject(matcherError(err)); };
|
|
165
|
+
const exited = (code) => fail(`the matcher exited with code ${code}`);
|
|
166
|
+
const ready = () => { worker.off("error", fail); worker.off("exit", exited); resolve(worker); };
|
|
167
|
+
worker.once("message", ready);
|
|
168
|
+
worker.once("error", fail);
|
|
169
|
+
worker.once("exit", exited);
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Resolves with the hits, plus the detect index that stalled or crashed (if any) and the files not finished. */
|
|
174
|
+
function runBatch(worker, batch, progress) {
|
|
175
|
+
return new Promise((resolve) => {
|
|
176
|
+
const done = new Set();
|
|
177
|
+
const hits = [];
|
|
178
|
+
// The stall clock starts only once the worker reports its first (file, detect) pair.
|
|
179
|
+
Atomics.store(progress, 1, -1);
|
|
180
|
+
let seen = Atomics.load(progress, 0);
|
|
181
|
+
let seenAt = null;
|
|
182
|
+
const finish = (stuck, why) => {
|
|
183
|
+
clearInterval(timer);
|
|
184
|
+
worker.off("message", onMessage);
|
|
185
|
+
worker.off("error", onFail);
|
|
186
|
+
worker.off("exit", onFail);
|
|
187
|
+
resolve({ hits, stuck, why, rest: batch.filter((_, i) => !done.has(i)) });
|
|
188
|
+
};
|
|
189
|
+
const onMessage = ({ i, hits: found }) => {
|
|
190
|
+
done.add(i);
|
|
191
|
+
for (const [d, lines] of found) for (const line of lines) hits.push({ rel: batch[i].rel, d, line });
|
|
192
|
+
if (done.size === batch.length) finish();
|
|
193
|
+
};
|
|
194
|
+
const onFail = () => finish(Atomics.load(progress, 1), "rule failed");
|
|
195
|
+
const timer = setInterval(() => {
|
|
196
|
+
const now = Atomics.load(progress, 0);
|
|
197
|
+
if (now !== seen) { seen = now; seenAt = Date.now(); }
|
|
198
|
+
else if (seenAt !== null && Date.now() - seenAt > BUDGET_MS) finish(Atomics.load(progress, 1), "rule timed out");
|
|
199
|
+
}, 20);
|
|
200
|
+
worker.on("message", onMessage);
|
|
201
|
+
worker.on("error", onFail);
|
|
202
|
+
worker.on("exit", onFail);
|
|
203
|
+
worker.postMessage(batch.map(({ rel, text, ds }) => ({ rel, text, ds })));
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Runs jobs ({ rel, ds }) and returns hits ({ rel, d, line }). Calls onSkip(ruleIndex, why) for a rule that stalls. */
|
|
208
|
+
async function runDetects(root, jobs, detects, onSkip) {
|
|
209
|
+
const banned = new Set();
|
|
210
|
+
const hits = [];
|
|
211
|
+
const queue = jobs.slice();
|
|
212
|
+
const nextBatch = () => {
|
|
213
|
+
const batch = [];
|
|
214
|
+
let bytes = 0;
|
|
215
|
+
while (queue.length && batch.length < BATCH_FILES && bytes < BATCH_BYTES) {
|
|
216
|
+
const job = queue.shift();
|
|
217
|
+
const ds = job.ds.filter((d) => !banned.has(detects[d].ri));
|
|
218
|
+
if (!ds.length) continue;
|
|
219
|
+
const text = job.text ?? readText(join(root, job.rel));
|
|
220
|
+
if (text === null) continue;
|
|
221
|
+
batch.push({ rel: job.rel, text, ds });
|
|
222
|
+
bytes += text.length;
|
|
223
|
+
}
|
|
224
|
+
return batch;
|
|
225
|
+
};
|
|
226
|
+
const payload = detects.map(({ files, pattern, unless }) => ({ files, pattern, unless }));
|
|
227
|
+
let batch = nextBatch();
|
|
228
|
+
while (batch.length) {
|
|
229
|
+
const progress = new Int32Array(new SharedArrayBuffer(8));
|
|
230
|
+
const worker = await startWorker(payload, progress.buffer);
|
|
231
|
+
try {
|
|
232
|
+
while (batch.length) {
|
|
233
|
+
const res = await runBatch(worker, batch, progress);
|
|
234
|
+
hits.push(...res.hits);
|
|
235
|
+
if (res.stuck === -1) throw matcherError("the matcher stopped before checking any file");
|
|
236
|
+
if (res.stuck !== undefined) {
|
|
237
|
+
const ri = detects[res.stuck].ri;
|
|
238
|
+
banned.add(ri);
|
|
239
|
+
onSkip(ri, res.why);
|
|
240
|
+
queue.unshift(...res.rest);
|
|
241
|
+
batch = nextBatch();
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
244
|
+
batch = nextBatch();
|
|
245
|
+
}
|
|
246
|
+
} finally {
|
|
247
|
+
await worker.terminate();
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return hits.filter((h) => !banned.has(detects[h.d].ri));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ---------- scan ----------
|
|
254
|
+
|
|
255
|
+
function finding(rule, file, line, manual) {
|
|
256
|
+
return {
|
|
257
|
+
trapId: rule.id,
|
|
258
|
+
title: rule.title,
|
|
259
|
+
provider: rule.provider,
|
|
260
|
+
severity: rule.severity,
|
|
261
|
+
deadline: rule.deadline ?? null,
|
|
262
|
+
file,
|
|
263
|
+
line,
|
|
264
|
+
fix: rule.fix,
|
|
265
|
+
url: rule.url ?? null,
|
|
266
|
+
private: rule.private === true,
|
|
267
|
+
manual,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export async function scanProject(root, rules) {
|
|
272
|
+
const skippedRules = [];
|
|
273
|
+
const good = [];
|
|
274
|
+
for (const rule of Array.isArray(rules) ? rules : []) {
|
|
275
|
+
let why;
|
|
276
|
+
try { why = shapeProblem(rule); } catch { why = "rule could not be read"; }
|
|
277
|
+
if (why) skippedRules.push({ id: typeof rule?.id === "string" && rule.id ? rule.id : "(unknown)", why });
|
|
278
|
+
else good.push(rule);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const files = walk(root);
|
|
282
|
+
const manifests = buildManifests(root, files);
|
|
283
|
+
const matches = manifests.map((m) => good.map((r) => applies(r, m)));
|
|
284
|
+
const byDepth = manifests.map((m, i) => ({ dir: m.dir, i })).sort((a, b) => b.dir.length - a.dir.length);
|
|
285
|
+
const owner = (rel) => byDepth.find(({ dir }) => dir === "" || rel.startsWith(`${dir}/`)).i;
|
|
286
|
+
|
|
287
|
+
const detects = good.flatMap((r, ri) => r.detect.map((d) => ({ ri, ...d })));
|
|
288
|
+
const jobs = [];
|
|
289
|
+
for (const rel of files) {
|
|
290
|
+
const ok = matches[owner(rel)];
|
|
291
|
+
const ds = [];
|
|
292
|
+
const inTests = isTestPath(rel);
|
|
293
|
+
detects.forEach((d, k) => { if (ok[d.ri] && (!inTests || targetsTests(d)) && d.files.some((g) => globMatch(g, rel))) ds.push(k); });
|
|
294
|
+
if (ds.length) jobs.push({ rel, ds });
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const skipped = new Set();
|
|
298
|
+
const hits = jobs.length
|
|
299
|
+
? await runDetects(root, jobs, detects, (ri, why) => {
|
|
300
|
+
skipped.add(ri);
|
|
301
|
+
skippedRules.push({ id: good[ri].id, why });
|
|
302
|
+
})
|
|
303
|
+
: [];
|
|
304
|
+
|
|
305
|
+
const findings = [];
|
|
306
|
+
const seen = new Set();
|
|
307
|
+
const hitRules = new Set();
|
|
308
|
+
for (const { rel, d, line } of hits) {
|
|
309
|
+
const ri = detects[d].ri;
|
|
310
|
+
const key = `${ri}\0${rel}\0${line}`;
|
|
311
|
+
if (seen.has(key)) continue;
|
|
312
|
+
seen.add(key);
|
|
313
|
+
hitRules.add(ri);
|
|
314
|
+
findings.push(finding(good[ri], rel, line, false));
|
|
315
|
+
}
|
|
316
|
+
findings.sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : a.line - b.line));
|
|
317
|
+
// a manual finding and the applicable list both need a ref narrower than "any version": a broad ref
|
|
318
|
+
// only shows the package is present, which is enough to run detect rules but not to say the trap applies
|
|
319
|
+
const narrow = (ri) => matches.findIndex((row) => row[ri] && !row[ri].broad);
|
|
320
|
+
good.forEach((r, ri) => {
|
|
321
|
+
if (r.checkManually && !skipped.has(ri) && !hitRules.has(ri) && narrow(ri) !== -1) {
|
|
322
|
+
findings.push(finding(r, null, null, true));
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
// rules a manifest satisfies through a ref narrower than "any version", hit or not (the first such
|
|
326
|
+
// manifest); a rule the matcher skipped is left out, as it is from findings
|
|
327
|
+
const applicable = [];
|
|
328
|
+
good.forEach((r, ri) => {
|
|
329
|
+
const mi = narrow(ri);
|
|
330
|
+
if (mi !== -1 && !skipped.has(ri)) applicable.push({ trapId: r.id, package: matches[mi][ri].pkg, dir: manifests[mi].dir });
|
|
331
|
+
});
|
|
332
|
+
return { findings, skippedRules, applicable };
|
|
333
|
+
}
|
package/lib/scrub.mjs
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Copy of SECRET_PATTERNS in lib/sanitize.ts (this package cannot import from the app).
|
|
2
|
+
// tests/cli.test.ts fails if the two lists drift apart.
|
|
3
|
+
export const SECRET_PATTERNS = [
|
|
4
|
+
["anthropic key", /sk-ant-[a-z0-9-]{20,}/i],
|
|
5
|
+
["openai key", /(?<![A-Za-z0-9])sk-(?!ant-)(?:proj-|svcacct-|admin-)?[A-Za-z0-9_-]{32,}/],
|
|
6
|
+
["aws access key", /AKIA[0-9A-Z]{16}/],
|
|
7
|
+
["github token", /gh[pousr]_[A-Za-z0-9]{30,}/],
|
|
8
|
+
["github fine-grained token", /github_pat_[A-Za-z0-9_]{50,}/],
|
|
9
|
+
["slack token", /xox[baprs]-[A-Za-z0-9-]{10,}/],
|
|
10
|
+
["stripe key", /[sr]k_(live|test)_[A-Za-z0-9]{16,}/],
|
|
11
|
+
["google api key", /AIza[0-9A-Za-z_-]{35}/],
|
|
12
|
+
["private key block", /-----BEGIN [A-Z ]*PRIVATE KEY-----/],
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
// Replaces anything shaped like a known secret before the text leaves this machine.
|
|
16
|
+
export function scrub(text) {
|
|
17
|
+
let t = String(text ?? "");
|
|
18
|
+
for (const [name, re] of SECRET_PATTERNS) {
|
|
19
|
+
t = t.replace(new RegExp(re.source, re.flags.includes("g") ? re.flags : `${re.flags}g`), `<${name} redacted>`);
|
|
20
|
+
}
|
|
21
|
+
return t;
|
|
22
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { PackageRef } from "./rules.mjs";
|
|
2
|
+
|
|
3
|
+
/** true/false when decidable, null when the version or range is not understood. */
|
|
4
|
+
export function satisfiesNpm(version: string, range: string): boolean | null;
|
|
5
|
+
export function satisfiesPep440(version: string, spec: string): boolean | null;
|
|
6
|
+
/** True when satisfiesX can decide this range, so the rule gate may accept it. */
|
|
7
|
+
export function rangeSupported(ecosystem: PackageRef["ecosystem"], range: string): boolean;
|
package/lib/versions.mjs
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
// Dependency-free npm semver and PEP 440 range matching.
|
|
2
|
+
// Anything not understood yields null ("unknown, do not match").
|
|
3
|
+
|
|
4
|
+
const NUM = "0|[1-9]\\d*";
|
|
5
|
+
const PARTIAL = new RegExp(
|
|
6
|
+
`^[v=]*(${NUM}|[xX*])(?:\\.(${NUM}|[xX*])(?:\\.(${NUM}|[xX*])(?:-([0-9A-Za-z.-]+))?(?:\\+[0-9A-Za-z.-]+)?)?)?$`,
|
|
7
|
+
);
|
|
8
|
+
|
|
9
|
+
// ---------- npm ----------
|
|
10
|
+
|
|
11
|
+
function wild(s) {
|
|
12
|
+
return s === undefined || s === "x" || s === "X" || s === "*";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Full version -> [maj, min, pat, pre[]] or null. */
|
|
16
|
+
function parseNpmVersion(v) {
|
|
17
|
+
const m = PARTIAL.exec(String(v).trim());
|
|
18
|
+
if (!m || wild(m[1]) || wild(m[2]) || wild(m[3])) return null;
|
|
19
|
+
return [Number(m[1]), Number(m[2]), Number(m[3]), m[4] ? m[4].split(".") : []];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function cmpPre(a, b) {
|
|
23
|
+
if (!a.length && !b.length) return 0;
|
|
24
|
+
if (!a.length) return 1;
|
|
25
|
+
if (!b.length) return -1;
|
|
26
|
+
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
|
27
|
+
if (a[i] === undefined) return -1;
|
|
28
|
+
if (b[i] === undefined) return 1;
|
|
29
|
+
const an = /^\d+$/.test(a[i]);
|
|
30
|
+
const bn = /^\d+$/.test(b[i]);
|
|
31
|
+
if (an && bn) {
|
|
32
|
+
const d = Number(a[i]) - Number(b[i]);
|
|
33
|
+
if (d) return d < 0 ? -1 : 1;
|
|
34
|
+
} else if (an !== bn) return an ? -1 : 1;
|
|
35
|
+
else if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1;
|
|
36
|
+
}
|
|
37
|
+
return 0;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function cmpNpm(a, b) {
|
|
41
|
+
for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1;
|
|
42
|
+
return cmpPre(a[3], b[3]);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Partial -> { n: [maj, min, pat] numbers or null for wildcard, pre, count: specified parts } or null. */
|
|
46
|
+
function parsePartial(s) {
|
|
47
|
+
const m = PARTIAL.exec(s);
|
|
48
|
+
if (!m) return null;
|
|
49
|
+
const parts = [m[1], m[2], m[3]];
|
|
50
|
+
let count = 0;
|
|
51
|
+
while (count < 3 && !wild(parts[count])) count++;
|
|
52
|
+
for (let i = count; i < 3; i++) if (!wild(parts[i])) return null;
|
|
53
|
+
if (m[4] && count < 3) return null;
|
|
54
|
+
return { n: parts.map((p, i) => (i < count ? Number(p) : 0)), count, pre: m[4] ? m[4].split(".") : [] };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const lo = (p) => [p.n[0], p.n[1], p.n[2], p.pre];
|
|
58
|
+
const bump = (p, at) => {
|
|
59
|
+
const n = [p.n[0], p.n[1], p.n[2]];
|
|
60
|
+
n[at]++;
|
|
61
|
+
for (let i = at + 1; i < 3; i++) n[i] = 0;
|
|
62
|
+
return [...n, []];
|
|
63
|
+
};
|
|
64
|
+
const ge = (v) => ({ op: ">=", v });
|
|
65
|
+
const gt = (v) => ({ op: ">", v });
|
|
66
|
+
const lt = (v) => ({ op: "<", v });
|
|
67
|
+
const le = (v) => ({ op: "<=", v });
|
|
68
|
+
|
|
69
|
+
/** One token (operator + partial) -> comparator list, or null. */
|
|
70
|
+
function npmToken(tok) {
|
|
71
|
+
const m = /^(\^|~>?|>=|<=|>|<|=)?(.*)$/.exec(tok);
|
|
72
|
+
const op = m[1] === "~>" ? "~" : m[1] || "";
|
|
73
|
+
const p = parsePartial(m[2]);
|
|
74
|
+
if (!p) return null;
|
|
75
|
+
const { count } = p;
|
|
76
|
+
if (count === 0) return op === "" || op === "=" || op === ">=" || op === "<=" || op === "^" || op === "~" ? [] : null;
|
|
77
|
+
const at = Math.min(count, 3) - 1; // index of the last specified part
|
|
78
|
+
switch (op) {
|
|
79
|
+
case "":
|
|
80
|
+
case "=":
|
|
81
|
+
return count === 3 ? [ge(lo(p)), le(lo(p))] : [ge(lo(p)), lt(bump(p, at))];
|
|
82
|
+
case "^": {
|
|
83
|
+
// bump the left-most non-zero specified part
|
|
84
|
+
let i = 0;
|
|
85
|
+
while (i < at && p.n[i] === 0) i++;
|
|
86
|
+
return [ge(lo(p)), lt(bump(p, i))];
|
|
87
|
+
}
|
|
88
|
+
case "~":
|
|
89
|
+
return [ge(lo(p)), lt(bump(p, count === 1 ? 0 : 1))];
|
|
90
|
+
case ">=":
|
|
91
|
+
return [ge(lo(p))];
|
|
92
|
+
case ">":
|
|
93
|
+
return [count === 3 ? gt(lo(p)) : ge(bump(p, at))];
|
|
94
|
+
case "<":
|
|
95
|
+
return [lt(lo(p))];
|
|
96
|
+
case "<=":
|
|
97
|
+
return [count === 3 ? le(lo(p)) : lt(bump(p, at))];
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Comparator set (AND) from a space-separated string, or null. */
|
|
103
|
+
function npmSet(text) {
|
|
104
|
+
let s = text.trim();
|
|
105
|
+
if (s === "") return [];
|
|
106
|
+
const hy = /^(\S+)\s+-\s+(\S+)$/.exec(s);
|
|
107
|
+
if (hy) {
|
|
108
|
+
const a = parsePartial(hy[1].replace(/^v/, ""));
|
|
109
|
+
const b = parsePartial(hy[2].replace(/^v/, ""));
|
|
110
|
+
if (!a || !b) return null;
|
|
111
|
+
const out = [];
|
|
112
|
+
if (a.count) out.push(ge(lo(a)));
|
|
113
|
+
if (b.count === 3) out.push(le(lo(b)));
|
|
114
|
+
else if (b.count) out.push(lt(bump(b, b.count - 1)));
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
s = s.replace(/(\^|~>?|>=|<=|>|<|=)\s+/g, "$1");
|
|
118
|
+
const out = [];
|
|
119
|
+
for (const tok of s.split(/\s+/)) {
|
|
120
|
+
const c = npmToken(tok);
|
|
121
|
+
if (!c) return null;
|
|
122
|
+
out.push(...c);
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function parseNpmRange(range) {
|
|
128
|
+
if (typeof range !== "string") return null;
|
|
129
|
+
const sets = [];
|
|
130
|
+
for (const part of range.split("||")) {
|
|
131
|
+
const set = npmSet(part);
|
|
132
|
+
if (!set) return null;
|
|
133
|
+
sets.push(set);
|
|
134
|
+
}
|
|
135
|
+
return sets;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function holds(c, v) {
|
|
139
|
+
const d = cmpNpm(v, c.v);
|
|
140
|
+
return c.op === ">=" ? d >= 0 : c.op === ">" ? d > 0 : c.op === "<" ? d < 0 : d <= 0;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function satisfiesNpm(version, range) {
|
|
144
|
+
const v = parseNpmVersion(version);
|
|
145
|
+
const sets = parseNpmRange(range);
|
|
146
|
+
if (!v || !sets) return null;
|
|
147
|
+
return sets.some((set) => {
|
|
148
|
+
if (!set.every((c) => holds(c, v))) return false;
|
|
149
|
+
if (!v[3].length) return true;
|
|
150
|
+
// a prerelease only matches a set that names a prerelease of the same major.minor.patch
|
|
151
|
+
return set.some((c) => c.v[3].length && c.v[0] === v[0] && c.v[1] === v[1] && c.v[2] === v[2]);
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ---------- PEP 440 ----------
|
|
156
|
+
|
|
157
|
+
const PEP_VERSION =
|
|
158
|
+
/^v?(?:\d+!)?(\d+(?:\.\d+)*)(?:[-_.]?(a|alpha|b|beta|c|rc|pre|preview)[-_.]?(\d*))?(?:(?:-(\d+))|(?:[-_.]?(?:post|rev|r)[-_.]?(\d*)))?(?:[-_.]?dev[-_.]?(\d*))?(?:\+[a-z0-9]+(?:[-_.][a-z0-9]+)*)?$/i;
|
|
159
|
+
|
|
160
|
+
function parsePep(v) {
|
|
161
|
+
const m = PEP_VERSION.exec(String(v).trim());
|
|
162
|
+
if (!m) return null;
|
|
163
|
+
const tag = m[2] && m[2].toLowerCase();
|
|
164
|
+
const kind = !tag ? null : tag === "a" || tag === "alpha" ? 0 : tag === "b" || tag === "beta" ? 1 : 2;
|
|
165
|
+
const hasPost = m[4] !== undefined || /post|rev|r/i.test(m[0].slice(m[1].length)) && m[5] !== undefined;
|
|
166
|
+
return {
|
|
167
|
+
rel: m[1].split(".").map(Number),
|
|
168
|
+
pre: kind === null ? null : [kind, Number(m[3] || 0)],
|
|
169
|
+
post: m[4] !== undefined ? Number(m[4]) : m[5] !== undefined && hasPost ? Number(m[5] || 0) : null,
|
|
170
|
+
dev: m[6] !== undefined ? Number(m[6] || 0) : null,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function cmpRel(a, b) {
|
|
175
|
+
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
|
176
|
+
const x = a[i] ?? 0;
|
|
177
|
+
const y = b[i] ?? 0;
|
|
178
|
+
if (x !== y) return x < y ? -1 : 1;
|
|
179
|
+
}
|
|
180
|
+
return 0;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function cmpPep(a, b) {
|
|
184
|
+
const r = cmpRel(a.rel, b.rel);
|
|
185
|
+
if (r) return r;
|
|
186
|
+
const preKey = (v) => (v.pre ? v.pre : v.post === null && v.dev !== null ? [-1, 0] : [Infinity, 0]);
|
|
187
|
+
const pa = preKey(a);
|
|
188
|
+
const pb = preKey(b);
|
|
189
|
+
if (pa[0] !== pb[0]) return pa[0] < pb[0] ? -1 : 1;
|
|
190
|
+
if (pa[1] !== pb[1]) return pa[1] < pb[1] ? -1 : 1;
|
|
191
|
+
const sa = a.post === null ? -1 : a.post;
|
|
192
|
+
const sb = b.post === null ? -1 : b.post;
|
|
193
|
+
if (sa !== sb) return sa < sb ? -1 : 1;
|
|
194
|
+
const da = a.dev === null ? Infinity : a.dev;
|
|
195
|
+
const db = b.dev === null ? Infinity : b.dev;
|
|
196
|
+
return da === db ? 0 : da < db ? -1 : 1;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Clause -> predicate over a parsed version, or null. */
|
|
200
|
+
function pepClause(text) {
|
|
201
|
+
const m = /^(~=|==|!=|>=|<=|>|<)\s*(\S+)$/.exec(text.trim());
|
|
202
|
+
if (!m) return null;
|
|
203
|
+
const [, op, raw] = m;
|
|
204
|
+
const prefix = /^(.*)\.\*$/.exec(raw);
|
|
205
|
+
if (prefix) {
|
|
206
|
+
if (op !== "==" && op !== "!=") return null;
|
|
207
|
+
const p = parsePep(prefix[1]);
|
|
208
|
+
if (!p || p.pre || p.post !== null || p.dev !== null) return null;
|
|
209
|
+
const hit = (v) => p.rel.every((n, i) => (v.rel[i] ?? 0) === n);
|
|
210
|
+
return op === "==" ? hit : (v) => !hit(v);
|
|
211
|
+
}
|
|
212
|
+
const t = parsePep(raw);
|
|
213
|
+
if (!t) return null;
|
|
214
|
+
switch (op) {
|
|
215
|
+
case "==":
|
|
216
|
+
return (v) => cmpPep(v, t) === 0;
|
|
217
|
+
case "!=":
|
|
218
|
+
return (v) => cmpPep(v, t) !== 0;
|
|
219
|
+
case ">=":
|
|
220
|
+
return (v) => cmpPep(v, t) >= 0;
|
|
221
|
+
case "<=":
|
|
222
|
+
return (v) => cmpPep(v, t) <= 0;
|
|
223
|
+
case ">":
|
|
224
|
+
return (v) => cmpPep(v, t) > 0;
|
|
225
|
+
case "<": {
|
|
226
|
+
// <V never matches a prerelease of V itself
|
|
227
|
+
const plain = !t.pre && t.dev === null && t.post === null;
|
|
228
|
+
return (v) => cmpPep(v, t) < 0 && !(plain && (v.pre || v.dev !== null) && cmpRel(v.rel, t.rel) === 0);
|
|
229
|
+
}
|
|
230
|
+
case "~=": {
|
|
231
|
+
if (t.rel.length < 2) return null;
|
|
232
|
+
const head = t.rel.slice(0, -1);
|
|
233
|
+
return (v) => cmpPep(v, t) >= 0 && head.every((n, i) => (v.rel[i] ?? 0) === n);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function parsePepSpec(spec) {
|
|
240
|
+
if (typeof spec !== "string" || spec.trim() === "") return null;
|
|
241
|
+
const preds = [];
|
|
242
|
+
for (const part of spec.split(",")) {
|
|
243
|
+
const p = pepClause(part);
|
|
244
|
+
if (!p) return null;
|
|
245
|
+
preds.push(p);
|
|
246
|
+
}
|
|
247
|
+
return preds;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function satisfiesPep440(version, spec) {
|
|
251
|
+
const v = parsePep(version);
|
|
252
|
+
const preds = parsePepSpec(spec);
|
|
253
|
+
if (!v || !preds) return null;
|
|
254
|
+
return preds.every((p) => p(v));
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ---------- rule gate ----------
|
|
258
|
+
|
|
259
|
+
export function rangeSupported(ecosystem, range) {
|
|
260
|
+
const r = typeof range === "string" ? range.trim() : "";
|
|
261
|
+
if (ecosystem === "service") return r === "" || r === "*";
|
|
262
|
+
if (ecosystem === "pypi") return parsePepSpec(r) !== null;
|
|
263
|
+
if (ecosystem === "npm" || ecosystem === "github-action") return parseNpmRange(r) !== null;
|
|
264
|
+
return false;
|
|
265
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@getfootgun/footgun",
|
|
3
|
-
"version": "0.0.0-stage",
|
|
4
|
-
"stub": true,
|
|
5
|
-
"description": "Temporary package placeholder for staged publishing"
|
|
6
|
-
}
|
|
1
|
+
{ "name": "@getfootgun/footgun", "version": "0.2.0", "description": "Ask Footgun why a third-party API, platform, or AI tool just broke.", "bin": { "footgun": "bin/footgun.mjs" }, "scripts": { "prepublishOnly": "node ../scripts/check-publish.mjs" }, "type": "module", "license": "MIT", "files": ["bin", "lib", "README.md"], "engines": { "node": ">=20" } }
|