@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/pack.mjs
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Downloads, caches and validates the Footgun rule pack. Only the pack request leaves the machine.
|
|
2
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
3
|
+
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { ruleProblems } from "./rules.mjs";
|
|
7
|
+
|
|
8
|
+
export class NoRulesError extends Error {
|
|
9
|
+
constructor() { super("Could not download Footgun rules and no cached copy exists."); this.name = "NoRulesError"; }
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function defaultCacheDir() { return join(homedir(), ".footgun"); }
|
|
13
|
+
|
|
14
|
+
// The key itself never reaches disk: a keyed pack is cached under a short hash of it.
|
|
15
|
+
function cacheFile(cacheDir, key) {
|
|
16
|
+
return join(cacheDir, key ? `rules-${createHash("sha256").update(key).digest("hex").slice(0, 12)}.json` : "rules.json");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function isPack(p) {
|
|
20
|
+
return !!p && typeof p === "object" && typeof p.version === "string" && Array.isArray(p.rules);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function readCache(file) {
|
|
24
|
+
try {
|
|
25
|
+
const c = JSON.parse(readFileSync(file, "utf8"));
|
|
26
|
+
return c && typeof c.fetchedAt === "string" && isPack(c.pack) ? c : null;
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function writeCache(cacheDir, file, pack) {
|
|
33
|
+
try {
|
|
34
|
+
mkdirSync(cacheDir, { recursive: true, mode: 0o700 });
|
|
35
|
+
const tmp = `${file}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
36
|
+
try {
|
|
37
|
+
writeFileSync(tmp, JSON.stringify({ fetchedAt: new Date().toISOString(), pack }), { mode: 0o600 });
|
|
38
|
+
renameSync(tmp, file);
|
|
39
|
+
} catch (e) {
|
|
40
|
+
rmSync(tmp, { force: true });
|
|
41
|
+
throw e;
|
|
42
|
+
}
|
|
43
|
+
} catch {
|
|
44
|
+
// a cache we cannot write only costs the offline fallback next time
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Drops rules whose detect patterns are unsafe or malformed. scanProject checks the rest of the shape.
|
|
49
|
+
function validate(raw) {
|
|
50
|
+
const rules = [];
|
|
51
|
+
const skippedRules = [];
|
|
52
|
+
for (const rule of raw.rules) {
|
|
53
|
+
const id = rule && typeof rule.id === "string" && rule.id ? rule.id : "(unknown)";
|
|
54
|
+
let why = null;
|
|
55
|
+
try {
|
|
56
|
+
if (!rule || typeof rule !== "object" || Array.isArray(rule)) why = "rule must be an object";
|
|
57
|
+
else if (!Array.isArray(rule.detect)) why = "detect must be a list";
|
|
58
|
+
else {
|
|
59
|
+
const problems = [...new Set(rule.detect.flatMap((d) => ruleProblems(d)))];
|
|
60
|
+
if (problems.length) why = problems.join("; ");
|
|
61
|
+
}
|
|
62
|
+
} catch {
|
|
63
|
+
why = "rule could not be read";
|
|
64
|
+
}
|
|
65
|
+
if (why) skippedRules.push({ id, why });
|
|
66
|
+
else rules.push(rule);
|
|
67
|
+
}
|
|
68
|
+
return { version: raw.version, generatedAt: typeof raw.generatedAt === "string" ? raw.generatedAt : null, rules, skippedRules };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Returns { pack: { version, generatedAt, rules, skippedRules, cachedAt }, fromCache }.
|
|
73
|
+
* fromCache is true only when the download failed and the last cached pack was used.
|
|
74
|
+
*/
|
|
75
|
+
export async function loadPack({ url, key, cacheDir = defaultCacheDir(), timeoutMs = 5000 }) {
|
|
76
|
+
const file = cacheFile(cacheDir, key);
|
|
77
|
+
const cached = readCache(file);
|
|
78
|
+
const headers = { accept: "application/json" };
|
|
79
|
+
if (key) headers.authorization = `Bearer ${key}`;
|
|
80
|
+
if (cached) headers["if-none-match"] = `"${cached.pack.version}"`;
|
|
81
|
+
try {
|
|
82
|
+
const signal = AbortSignal.timeout(timeoutMs);
|
|
83
|
+
const r = await fetch(`${url.replace(/\/+$/, "")}/api/v1/rules`, { headers, signal });
|
|
84
|
+
if (r.status === 304 && cached) {
|
|
85
|
+
writeCache(cacheDir, file, cached.pack);
|
|
86
|
+
return { pack: { ...validate(cached.pack), cachedAt: null }, fromCache: false };
|
|
87
|
+
}
|
|
88
|
+
if (!r.ok) throw new Error(`rule pack request failed: ${r.status}`);
|
|
89
|
+
const body = await r.json();
|
|
90
|
+
if (!isPack(body)) throw new Error("rule pack has the wrong shape");
|
|
91
|
+
writeCache(cacheDir, file, body);
|
|
92
|
+
return { pack: { ...validate(body), cachedAt: null }, fromCache: false };
|
|
93
|
+
} catch {
|
|
94
|
+
if (!cached) throw new NoRulesError();
|
|
95
|
+
return { pack: { ...validate(cached.pack), cachedAt: cached.fetchedAt }, fromCache: true };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const SEVERITY_KEY = { breaks: "breaks", "will-break": "willBreak", silent: "silent", cost: "cost" };
|
|
100
|
+
|
|
101
|
+
/** Finding counts by severity for the usage ping. Manual "check yourself" findings count under their severity. */
|
|
102
|
+
export function pingCounts(findings) {
|
|
103
|
+
const counts = { breaks: 0, willBreak: 0, silent: 0, cost: 0 };
|
|
104
|
+
for (const f of findings) {
|
|
105
|
+
const k = SEVERITY_KEY[f?.severity];
|
|
106
|
+
if (k) counts[k] = Math.min(counts[k] + 1, 10000);
|
|
107
|
+
}
|
|
108
|
+
return counts;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Anonymous usage ping: the kind of run, whether Node was found and finding counts by severity. Nothing else.
|
|
113
|
+
* Skipped when FOOTGUN_SHARE is "false". Never throws, never prints, and gives up after timeoutMs.
|
|
114
|
+
*/
|
|
115
|
+
export async function sendPing({ url, kind, counts, nodeFound = true, timeoutMs = 2000 }) {
|
|
116
|
+
if (process.env.FOOTGUN_SHARE === "false") return;
|
|
117
|
+
try {
|
|
118
|
+
await fetch(`${url.replace(/\/+$/, "")}/api/v1/ping`, {
|
|
119
|
+
method: "POST",
|
|
120
|
+
headers: { "content-type": "application/json" },
|
|
121
|
+
body: JSON.stringify({ kind, nodeFound, counts }),
|
|
122
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
123
|
+
});
|
|
124
|
+
} catch {
|
|
125
|
+
// a ping that fails costs nothing
|
|
126
|
+
}
|
|
127
|
+
}
|
package/lib/report.mjs
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Text and JSON output for `footgun scan`. Every server-supplied string goes through clean().
|
|
2
|
+
import { clean } from "./clean.mjs";
|
|
3
|
+
|
|
4
|
+
const GROUPS = [
|
|
5
|
+
["breaks", "Breaks now"],
|
|
6
|
+
["will-break", "Will break"],
|
|
7
|
+
["silent", "Wrong results with no error"],
|
|
8
|
+
["cost", "Costs money"],
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
/** Severities that trip `--fail-on`. Check-yourself items never do. */
|
|
12
|
+
export const FAIL_ON = { breaks: ["breaks"], "will-break": ["breaks", "will-break"] };
|
|
13
|
+
|
|
14
|
+
export function failed(findings, failOn) {
|
|
15
|
+
if (!failOn) return false;
|
|
16
|
+
return findings.some((f) => !f.manual && FAIL_ON[failOn].includes(f.severity));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function details(f) {
|
|
20
|
+
const out = [` Fix: ${clean(f.fix)}`];
|
|
21
|
+
if (f.deadline) out.push(` Deadline: ${clean(f.deadline)}`);
|
|
22
|
+
if (f.url) out.push(` ${clean(f.url)}`);
|
|
23
|
+
return out;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function textReport(findings) {
|
|
27
|
+
if (!findings.length) return "No known traps found.";
|
|
28
|
+
const found = findings.filter((f) => !f.manual);
|
|
29
|
+
const manual = findings.filter((f) => f.manual);
|
|
30
|
+
const out = [];
|
|
31
|
+
for (const [sev, heading] of GROUPS) {
|
|
32
|
+
const group = found.filter((f) => f.severity === sev);
|
|
33
|
+
if (!group.length) continue;
|
|
34
|
+
out.push(`${heading}:`, "");
|
|
35
|
+
for (const f of group) out.push(`${clean(f.file)}:${f.line} ${clean(f.title)}`, ...details(f), "");
|
|
36
|
+
}
|
|
37
|
+
if (manual.length) {
|
|
38
|
+
out.push("Check these yourself:", "");
|
|
39
|
+
for (const f of manual) out.push(clean(f.title), ...details(f), "");
|
|
40
|
+
}
|
|
41
|
+
const now = found.filter((f) => f.severity === "breaks").length;
|
|
42
|
+
const dated = found.filter((f) => f.deadline).length;
|
|
43
|
+
out.push(`${found.length} ${found.length === 1 ? "problem" : "problems"} (${now} break now, ${dated} with a deadline)`);
|
|
44
|
+
return out.join("\n");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function jsonReport({ findings, skippedRules, fromCache, version }) {
|
|
48
|
+
return JSON.stringify({ version, fromCache, findings, skippedRules }, null, 2);
|
|
49
|
+
}
|
package/lib/rules.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export type PackageRef = {
|
|
2
|
+
ecosystem: "npm" | "pypi" | "github-action" | "service";
|
|
3
|
+
name: string;
|
|
4
|
+
range: string;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export type DetectRule = {
|
|
8
|
+
files: string[];
|
|
9
|
+
pattern: string;
|
|
10
|
+
unless?: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/** Empty array means the rule is safe to run. */
|
|
14
|
+
export function ruleProblems(rule: DetectRule): string[];
|
|
15
|
+
/** Paths are forward-slash and relative. Supports **, *, ? and {a,b}. */
|
|
16
|
+
export function globMatch(glob: string, relPath: string): boolean;
|
|
17
|
+
/** 1-based line numbers of matches; [] when not applicable. */
|
|
18
|
+
export function matchRule(rule: DetectRule, relPath: string, text: string): number[];
|
package/lib/rules.mjs
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// Shared trap-rule matcher. Dependency-free; used by the scanner and the rule gate.
|
|
2
|
+
// Rules must stay RE2-compatible: no backreferences, no lookarounds, no nested quantifiers.
|
|
3
|
+
|
|
4
|
+
const MAX_PATTERN = 300;
|
|
5
|
+
const MAX_LINE = 2000;
|
|
6
|
+
const MAX_GLOB = 200;
|
|
7
|
+
const MAX_GLOB_STARS = 6;
|
|
8
|
+
|
|
9
|
+
function scanPattern(src) {
|
|
10
|
+
const problems = [];
|
|
11
|
+
const groups = []; // each entry: { quant: has an unbounded/ranged quantifier inside, alt: has a top-level | }
|
|
12
|
+
const rangeAt = (i) => /^\{\d+,\d*\}/.exec(src.slice(i));
|
|
13
|
+
let inClass = false;
|
|
14
|
+
for (let i = 0; i < src.length; i++) {
|
|
15
|
+
const c = src[i];
|
|
16
|
+
if (c === "\\") {
|
|
17
|
+
const n = src[i + 1];
|
|
18
|
+
if (!inClass && n && ((n >= "1" && n <= "9") || (n === "k" && src[i + 2] === "<"))) {
|
|
19
|
+
problems.push("backreferences are not allowed");
|
|
20
|
+
}
|
|
21
|
+
i++;
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
if (inClass) {
|
|
25
|
+
if (c === "]") inClass = false;
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
if (c === "[") { inClass = true; continue; }
|
|
29
|
+
if (c === "(") {
|
|
30
|
+
if (src[i + 1] === "?" && (src[i + 2] === "=" || src[i + 2] === "!" ||
|
|
31
|
+
(src[i + 2] === "<" && (src[i + 3] === "=" || src[i + 3] === "!")))) {
|
|
32
|
+
problems.push("lookarounds are not allowed");
|
|
33
|
+
}
|
|
34
|
+
groups.push({ quant: false, alt: false });
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (c === "*" || c === "+") {
|
|
38
|
+
if (groups.length) groups[groups.length - 1].quant = true;
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (c === "{") {
|
|
42
|
+
const m = rangeAt(i);
|
|
43
|
+
if (m) {
|
|
44
|
+
if (groups.length) groups[groups.length - 1].quant = true;
|
|
45
|
+
i += m[0].length - 1;
|
|
46
|
+
}
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (c === "|") {
|
|
50
|
+
if (groups.length) groups[groups.length - 1].alt = true;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (c === ")") {
|
|
54
|
+
const g = groups.pop();
|
|
55
|
+
const next = src[i + 1];
|
|
56
|
+
const quantified = next === "*" || next === "+" || (next === "{" && rangeAt(i + 1));
|
|
57
|
+
if (g && quantified && g.quant) problems.push("nested quantifiers are not allowed");
|
|
58
|
+
if (g && quantified && g.alt) problems.push("quantified alternation is not allowed");
|
|
59
|
+
if (g && groups.length) {
|
|
60
|
+
if (g.quant) groups[groups.length - 1].quant = true;
|
|
61
|
+
if (g.alt) groups[groups.length - 1].alt = true;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return [...new Set(problems)];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function ruleProblems(rule) {
|
|
69
|
+
const problems = [];
|
|
70
|
+
if (!rule || typeof rule !== "object") return ["rule must be an object"];
|
|
71
|
+
if (!Array.isArray(rule.files) || rule.files.length === 0 || !rule.files.every((f) => typeof f === "string" && f)) {
|
|
72
|
+
problems.push("files must be a non-empty list of globs");
|
|
73
|
+
} else if (rule.files.some((g) => g.length > MAX_GLOB || g.split("*").length - 1 > MAX_GLOB_STARS)) {
|
|
74
|
+
// glob matching runs outside the matcher's time cap, so its size is capped here
|
|
75
|
+
problems.push(`files: a glob is longer than ${MAX_GLOB} characters or has more than ${MAX_GLOB_STARS} *`);
|
|
76
|
+
}
|
|
77
|
+
for (const key of ["pattern", "unless"]) {
|
|
78
|
+
const src = rule[key];
|
|
79
|
+
if (key === "unless" && src === undefined) continue;
|
|
80
|
+
if (typeof src !== "string" || src === "") {
|
|
81
|
+
problems.push(`${key} must be a non-empty string`);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (src.length > MAX_PATTERN) problems.push(`${key} is longer than ${MAX_PATTERN} characters`);
|
|
85
|
+
problems.push(...scanPattern(src).map((p) => `${key}: ${p}`));
|
|
86
|
+
try { new RegExp(src, "m"); } catch { problems.push(`${key} does not compile`); }
|
|
87
|
+
}
|
|
88
|
+
return problems;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const globCache = new Map();
|
|
92
|
+
|
|
93
|
+
function globToRegExp(glob) {
|
|
94
|
+
glob = glob.replace(/(?:\*\*\/)+/g, "**/"); // **/**/ matches what **/ matches
|
|
95
|
+
let out = "";
|
|
96
|
+
let braces = 0;
|
|
97
|
+
for (let i = 0; i < glob.length; i++) {
|
|
98
|
+
const c = glob[i];
|
|
99
|
+
if (c === "*") {
|
|
100
|
+
if (glob[i + 1] === "*") {
|
|
101
|
+
if (glob[i + 2] === "/") { out += "(?:.*/)?"; i += 2; } else { out += ".*"; i++; }
|
|
102
|
+
} else out += "[^/]*";
|
|
103
|
+
} else if (c === "?") out += "[^/]";
|
|
104
|
+
else if (c === "{") { out += "(?:"; braces++; }
|
|
105
|
+
else if (c === "}" && braces) { out += ")"; braces--; }
|
|
106
|
+
else if (c === "," && braces) out += "|";
|
|
107
|
+
else out += c.replace(/[.+^$()|[\]\\{}]/g, "\\$&");
|
|
108
|
+
}
|
|
109
|
+
while (braces--) out += ")";
|
|
110
|
+
return new RegExp(`^${out}$`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function globMatch(glob, relPath) {
|
|
114
|
+
let re = globCache.get(glob);
|
|
115
|
+
if (!re) { re = globToRegExp(glob); globCache.set(glob, re); }
|
|
116
|
+
return re.test(relPath);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const verdicts = new WeakMap();
|
|
120
|
+
|
|
121
|
+
function isSafe(rule) {
|
|
122
|
+
if (!rule || typeof rule !== "object") return false;
|
|
123
|
+
let v = verdicts.get(rule);
|
|
124
|
+
if (v === undefined) {
|
|
125
|
+
try { v = ruleProblems(rule).length === 0; } catch { v = false; }
|
|
126
|
+
verdicts.set(rule, v);
|
|
127
|
+
}
|
|
128
|
+
return v;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function matchRule(rule, relPath, text) {
|
|
132
|
+
if (!isSafe(rule)) return [];
|
|
133
|
+
if (!rule.files.some((g) => globMatch(g, relPath))) return [];
|
|
134
|
+
let re, unless;
|
|
135
|
+
try {
|
|
136
|
+
re = new RegExp(rule.pattern, "m");
|
|
137
|
+
unless = rule.unless ? new RegExp(rule.unless, "m") : null;
|
|
138
|
+
} catch {
|
|
139
|
+
return [];
|
|
140
|
+
}
|
|
141
|
+
if (unless && unless.test(text)) return [];
|
|
142
|
+
const hits = [];
|
|
143
|
+
const lines = text.split(/\r?\n/);
|
|
144
|
+
for (let i = 0; i < lines.length; i++) {
|
|
145
|
+
if (lines[i].length > MAX_LINE) continue;
|
|
146
|
+
if (re.test(lines[i])) hits.push(i + 1);
|
|
147
|
+
}
|
|
148
|
+
return hits;
|
|
149
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Runs detect patterns off the main thread so a runaway regex can be stopped.
|
|
2
|
+
// Before each (file, detect) pair it records the detect index and bumps a counter
|
|
3
|
+
// in shared memory; the main thread watches the counter and terminates on a stall.
|
|
4
|
+
|
|
5
|
+
import { parentPort, workerData } from "node:worker_threads";
|
|
6
|
+
import { matchRule } from "./rules.mjs";
|
|
7
|
+
|
|
8
|
+
const { detects } = workerData;
|
|
9
|
+
const progress = new Int32Array(workerData.progress);
|
|
10
|
+
|
|
11
|
+
parentPort.on("message", (batch) => {
|
|
12
|
+
batch.forEach((file, i) => {
|
|
13
|
+
const hits = [];
|
|
14
|
+
for (const d of file.ds) {
|
|
15
|
+
Atomics.store(progress, 1, d);
|
|
16
|
+
Atomics.add(progress, 0, 1);
|
|
17
|
+
const lines = matchRule(detects[d], file.rel, file.text);
|
|
18
|
+
if (lines.length) hits.push([d, lines]);
|
|
19
|
+
}
|
|
20
|
+
parentPort.postMessage({ i, hits });
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
parentPort.postMessage("ready");
|
package/lib/scan.d.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { PackageRef, DetectRule } from "./rules.mjs";
|
|
2
|
+
|
|
3
|
+
export type Severity = "breaks" | "will-break" | "silent" | "cost";
|
|
4
|
+
|
|
5
|
+
export type PackRule = {
|
|
6
|
+
id: string;
|
|
7
|
+
title: string;
|
|
8
|
+
provider: string;
|
|
9
|
+
severity: Severity;
|
|
10
|
+
deadline: string | null;
|
|
11
|
+
packages: PackageRef[];
|
|
12
|
+
detect: DetectRule[];
|
|
13
|
+
checkManually: boolean;
|
|
14
|
+
fix: string;
|
|
15
|
+
url: string | null;
|
|
16
|
+
private?: boolean;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** dir is relative to the scan root ("" for the root); values are resolved versions. */
|
|
20
|
+
export type Manifest = {
|
|
21
|
+
dir: string;
|
|
22
|
+
npm: Record<string, string>;
|
|
23
|
+
pypi: Record<string, string>;
|
|
24
|
+
actions: Record<string, string>;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type Finding = {
|
|
28
|
+
trapId: string;
|
|
29
|
+
title: string;
|
|
30
|
+
provider: string;
|
|
31
|
+
severity: Severity;
|
|
32
|
+
deadline: string | null;
|
|
33
|
+
file: string | null;
|
|
34
|
+
line: number | null;
|
|
35
|
+
fix: string;
|
|
36
|
+
url: string | null;
|
|
37
|
+
private: boolean;
|
|
38
|
+
manual: boolean;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export type SkippedRule = { id: string; why: string };
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A rule whose package ref the manifest at dir satisfies, hit or not. Rules with no npm, pypi or github-action refs,
|
|
45
|
+
* rules satisfied only through a broad range ("*", "", "x", "X", or any range that 0.0.0 and 999999.0.0 both satisfy) and rules skipped at run time never appear.
|
|
46
|
+
*/
|
|
47
|
+
export type Applicable = {
|
|
48
|
+
trapId: string;
|
|
49
|
+
package: { ecosystem: "npm" | "pypi" | "github-action"; name: string; version: string };
|
|
50
|
+
dir: string;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export function readManifests(root: string): Manifest[];
|
|
54
|
+
/** Never rejects for a bad rule: unsafe, malformed or runaway rules land in skippedRules. */
|
|
55
|
+
export function scanProject(root: string, rules: PackRule[]): Promise<{ findings: Finding[]; skippedRules: SkippedRule[]; applicable: Applicable[] }>;
|