@getfootgun/footgun 0.2.1 → 0.2.3
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 +2 -0
- package/bin/footgun.mjs +4 -1
- package/lib/badge.mjs +13 -0
- package/lib/report.mjs +2 -2
- package/lib/rules.mjs +9 -0
- package/lib/scan.mjs +5 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -14,6 +14,8 @@ npx @getfootgun/footgun scan [path] [--json] [--fail-on breaks|will-break] [--ke
|
|
|
14
14
|
- `--fail-on breaks` exits 1 when something breaks now. `--fail-on will-break` also exits 1 for anything with a known break date or version. Use either in CI.
|
|
15
15
|
- Exit code 2 means the rules could not be downloaded and there is no cached copy. Exit code 3 means the scan could not run.
|
|
16
16
|
|
|
17
|
+
After a scan, `scan` prints a Markdown badge for your README, dated the day it ran. It reads "no known API traps" only when the scan found nothing at all, and "scanned" otherwise. `--json` puts the same Markdown in a `badge` field.
|
|
18
|
+
|
|
17
19
|
Requires Node 20 or later.
|
|
18
20
|
|
|
19
21
|
`scan` sends one anonymous usage count (that it ran, and how many traps matched at each severity, never paths, package names or code). Set `FOOTGUN_SHARE=false` to turn it off.
|
package/bin/footgun.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import { loadPack, NoRulesError, pingCounts, sendPing } from "../lib/pack.mjs";
|
|
|
7
7
|
import { scanProject } from "../lib/scan.mjs";
|
|
8
8
|
import { runContext } from "../lib/context.mjs";
|
|
9
9
|
import { textReport, jsonReport, failed, FAIL_ON } from "../lib/report.mjs";
|
|
10
|
+
import { badgeMarkdown } from "../lib/badge.mjs";
|
|
10
11
|
const BASE = process.env.FOOTGUN_URL ?? "https://getfootgun.com";
|
|
11
12
|
const KEY = process.env.FOOTGUN_API_KEY;
|
|
12
13
|
const [a, ...rest] = process.argv.slice(2);
|
|
@@ -77,11 +78,13 @@ async function scan(args) {
|
|
|
77
78
|
// a matcher that cannot start rejects here and exits 3 in main, so no clean report is printed
|
|
78
79
|
const result = await scanProject(resolve(o.path), pack.rules);
|
|
79
80
|
const skippedRules = [...pack.skippedRules, ...result.skippedRules];
|
|
81
|
+
const badge = badgeMarkdown(result.findings, { base: BASE, date: new Date().toISOString().slice(0, 10) });
|
|
80
82
|
if (o.json) {
|
|
81
|
-
console.log(jsonReport({ findings: result.findings, skippedRules, fromCache, version: pack.version }));
|
|
83
|
+
console.log(jsonReport({ findings: result.findings, skippedRules, fromCache, version: pack.version, badge }));
|
|
82
84
|
} else {
|
|
83
85
|
for (const s of skippedRules) console.error(clean(`Skipped rule ${s.id}: ${s.why}`));
|
|
84
86
|
console.log(textReport(result.findings));
|
|
87
|
+
console.log(`\nREADME badge for this scan:\n${badge}`);
|
|
85
88
|
}
|
|
86
89
|
if (failed(result.findings, o.failOn)) process.exitCode = 1;
|
|
87
90
|
await sendPing({ url: BASE, kind: "scan", counts: pingCounts(uniqueTraps(result.findings)) });
|
package/lib/badge.mjs
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// The README badge `footgun scan` suggests. It claims only what this scan showed, on the day it ran:
|
|
2
|
+
// "no known API traps" when nothing at all was found (check-yourself items included), otherwise just "scanned".
|
|
3
|
+
|
|
4
|
+
/** "clean" only when the scan found nothing, not even a check-yourself item. */
|
|
5
|
+
export function badgeStatus(findings) {
|
|
6
|
+
return findings.length === 0 ? "clean" : "scanned";
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function badgeMarkdown(findings, { base, date }) {
|
|
10
|
+
const status = badgeStatus(findings);
|
|
11
|
+
const alt = status === "clean" ? `No known API traps on ${date} · Footgun` : `Scanned by Footgun on ${date}`;
|
|
12
|
+
return `[](${base}/scan)`;
|
|
13
|
+
}
|
package/lib/report.mjs
CHANGED
|
@@ -44,6 +44,6 @@ export function textReport(findings) {
|
|
|
44
44
|
return out.join("\n");
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
export function jsonReport({ findings, skippedRules, fromCache, version }) {
|
|
48
|
-
return JSON.stringify({ version, fromCache, findings, skippedRules }, null, 2);
|
|
47
|
+
export function jsonReport({ findings, skippedRules, fromCache, version, badge }) {
|
|
48
|
+
return JSON.stringify({ version, fromCache, findings, skippedRules, badge }, null, 2);
|
|
49
49
|
}
|
package/lib/rules.mjs
CHANGED
|
@@ -128,6 +128,13 @@ function isSafe(rule) {
|
|
|
128
128
|
return v;
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
// A line whose trimmed start is a comment marker is not matched (`#!` shebangs fall under `#`).
|
|
132
|
+
// Block comments are not parsed: only lines starting with `/*` or `*` are skipped.
|
|
133
|
+
const COMMENT_START = /^(?:\/\/|#|\/\*|\*|<!--|--)/;
|
|
134
|
+
// A rule whose pattern itself targets comment syntax (contains `<!--`, or starts with a comment marker) is not subject to the skip.
|
|
135
|
+
// For ordinary rules, `#[attr]`, `#include` and Markdown `*` bullets are skipped like any other comment-looking line.
|
|
136
|
+
const COMMENT_RULE = /<!--|^\^?(?:\\s\*|\(\?:|\()*(?:--|#|\/\/|\/\\\*|\\\*)/;
|
|
137
|
+
|
|
131
138
|
export function matchRule(rule, relPath, text) {
|
|
132
139
|
if (!isSafe(rule)) return [];
|
|
133
140
|
if (!rule.files.some((g) => globMatch(g, relPath))) return [];
|
|
@@ -139,10 +146,12 @@ export function matchRule(rule, relPath, text) {
|
|
|
139
146
|
return [];
|
|
140
147
|
}
|
|
141
148
|
if (unless && unless.test(text)) return [];
|
|
149
|
+
const skipComments = !COMMENT_RULE.test(rule.pattern);
|
|
142
150
|
const hits = [];
|
|
143
151
|
const lines = text.split(/\r?\n/);
|
|
144
152
|
for (let i = 0; i < lines.length; i++) {
|
|
145
153
|
if (lines[i].length > MAX_LINE) continue;
|
|
154
|
+
if (skipComments && COMMENT_START.test(lines[i].trimStart())) continue;
|
|
146
155
|
if (re.test(lines[i])) hits.push(i + 1);
|
|
147
156
|
}
|
|
148
157
|
return hits;
|
package/lib/scan.mjs
CHANGED
|
@@ -303,16 +303,16 @@ export async function scanProject(root, rules) {
|
|
|
303
303
|
: [];
|
|
304
304
|
|
|
305
305
|
const findings = [];
|
|
306
|
-
|
|
306
|
+
// one finding per trap per file, at its lowest matching line
|
|
307
|
+
const first = new Map();
|
|
307
308
|
const hitRules = new Set();
|
|
308
309
|
for (const { rel, d, line } of hits) {
|
|
309
310
|
const ri = detects[d].ri;
|
|
310
|
-
const key = `${ri}\0${rel}
|
|
311
|
-
if (seen.has(key)) continue;
|
|
312
|
-
seen.add(key);
|
|
311
|
+
const key = `${ri}\0${rel}`;
|
|
313
312
|
hitRules.add(ri);
|
|
314
|
-
|
|
313
|
+
if (!first.has(key) || line < first.get(key).line) first.set(key, { ri, rel, line });
|
|
315
314
|
}
|
|
315
|
+
for (const { ri, rel, line } of first.values()) findings.push(finding(good[ri], rel, line, false));
|
|
316
316
|
findings.sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : a.line - b.line));
|
|
317
317
|
// a manual finding and the applicable list both need a ref narrower than "any version": a broad ref
|
|
318
318
|
// only shows the package is present, which is enough to run detect rules but not to say the trap applies
|
package/package.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{ "name": "@getfootgun/footgun", "version": "0.2.
|
|
1
|
+
{ "name": "@getfootgun/footgun", "version": "0.2.3", "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" } }
|