@rivi-software/workers-bundle-check 0.0.0-stage → 0.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.
@@ -0,0 +1,48 @@
1
+ import { type ModulePattern } from "./modules.js";
2
+ /** The shape of a kind of secret, by the name reports give it. */
3
+ export type SecretPattern = {
4
+ name: string;
5
+ pattern: RegExp;
6
+ };
7
+ /**
8
+ * The key formats the check looks for. Each has a prefix or a structure of its own, so text that
9
+ * merely looks random does not match. Public keys that apps ship to browsers on purpose, such as
10
+ * Stripe's `pk_` keys and Google's `AIza` API keys, are not in the list.
11
+ */
12
+ export declare const SECRET_PATTERNS: readonly SecretPattern[];
13
+ /**
14
+ * A finding known to be safe, such as a library's example key. It names the finding's
15
+ * fingerprint, the module the value came from, or both, and must match a finding: an entry that
16
+ * matches none is reported, so the list stays current.
17
+ */
18
+ export type AllowedSecret = {
19
+ /** The fingerprint the report gives the finding. */
20
+ fingerprint?: string;
21
+ /** The module the value comes from, as the file's source map says: see {@link ModulePattern}. */
22
+ module?: ModulePattern;
23
+ /** Why it is safe, for whoever reads the configuration next. */
24
+ reason: string;
25
+ };
26
+ /** A value shaped like a secret in a built file. */
27
+ export type SecretFinding = {
28
+ /** The pattern's name. */
29
+ name: string;
30
+ /** The first 16 hexadecimal digits of the value's SHA-256. The value itself is never reported. */
31
+ fingerprint: string;
32
+ /** The line it is on, from 1. */
33
+ line: number;
34
+ /** Its column on the line, from 0. */
35
+ column: number;
36
+ };
37
+ /** Every value in `text` that one of the patterns matches. */
38
+ export declare function findSecrets(text: string, patterns: readonly SecretPattern[]): SecretFinding[];
39
+ /** The fingerprint a finding of `value` gets. */
40
+ export declare function fingerprint(value: string): string;
41
+ /** Whether an allowed entry covers a finding, given the module the finding comes from. */
42
+ export declare function allows(allowed: AllowedSecret, finding: SecretFinding, module?: string): boolean;
43
+ /** Throws when an entry names neither a fingerprint nor a module, or has no reason. */
44
+ export declare function validateAllowedSecrets(allowed: readonly AllowedSecret[]): void;
45
+ /** Throws when a secret pattern has no name, or matches the empty string. */
46
+ export declare function validateSecretPatterns(patterns: readonly SecretPattern[]): void;
47
+ /** An allowed entry as reports name it. */
48
+ export declare function describeAllowed(entry: AllowedSecret): string;
@@ -0,0 +1,111 @@
1
+ import { createHash } from "node:crypto";
2
+ import { describePattern, moduleMatcher } from "./modules.js";
3
+ /**
4
+ * The key formats the check looks for. Each has a prefix or a structure of its own, so text that
5
+ * merely looks random does not match. Public keys that apps ship to browsers on purpose, such as
6
+ * Stripe's `pk_` keys and Google's `AIza` API keys, are not in the list.
7
+ */
8
+ export const SECRET_PATTERNS = [
9
+ {
10
+ name: "private key",
11
+ pattern: /-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY(?: BLOCK)?-----(?:\\+[nr]|\s)+[A-Za-z0-9+/=]{40,}/,
12
+ },
13
+ // AWS's documentation and SDKs use IDs that end in EXAMPLE.
14
+ { name: "AWS access key ID", pattern: /\b(?:AKIA|ASIA)(?![A-Z2-7]{9}EXAMPLE)[A-Z2-7]{16}\b/ },
15
+ { name: "GitHub token", pattern: /\b(?:gh[pousr]_[A-Za-z0-9]{36}|github_pat_\w{82})\b/ },
16
+ { name: "GitLab token", pattern: /\bglpat-[\w-]{20,}/ },
17
+ { name: "npm token", pattern: /\bnpm_[A-Za-z0-9]{36}\b/ },
18
+ { name: "Slack token", pattern: /\bxox[abeprs]-\d+-[A-Za-z0-9-]{10,}/ },
19
+ {
20
+ name: "Slack webhook URL",
21
+ pattern: /\bhooks\.slack\.com\/(?:services|workflows|triggers)\/[\w+/-]{30,}/,
22
+ },
23
+ { name: "Stripe secret key", pattern: /\b[rs]k_(?:live|test)_[A-Za-z0-9]{20,}/ },
24
+ { name: "webhook signing secret", pattern: /\bwhsec_[A-Za-z0-9+/=]{24,}/ },
25
+ { name: "OpenAI API key", pattern: /\bsk-[\w-]{20,}T3BlbkFJ[\w-]{20,}/ },
26
+ { name: "Anthropic API key", pattern: /\bsk-ant-(?:api|admin)\d\d-[\w-]{80,}/ },
27
+ { name: "PostHog personal API key", pattern: /\bphx_[A-Za-z0-9]{20,}/ },
28
+ { name: "PostHog project secret key", pattern: /\bphs_[A-Za-z0-9]{20,}/ },
29
+ { name: "Supabase secret key", pattern: /\bsb_secret_[\w-]{20,}/ },
30
+ { name: "SendGrid API key", pattern: /\bSG\.[\w-]{22}\.[\w-]{43}\b/ },
31
+ { name: "Shopify access token", pattern: /\bshp(?:at|ca|pa|ss)_[a-fA-F0-9]{32}\b/ },
32
+ { name: "Linear API key", pattern: /\blin_api_[A-Za-z0-9]{40}\b/ },
33
+ { name: "Hugging Face token", pattern: /\bhf_[A-Za-z0-9]{34,}\b/ },
34
+ ];
35
+ /** Every value in `text` that one of the patterns matches. */
36
+ export function findSecrets(text, patterns) {
37
+ const findings = [];
38
+ let lines;
39
+ for (const { name, pattern } of patterns) {
40
+ const global = new RegExp(pattern.source, `${pattern.flags.replace(/[gy]/g, "")}g`);
41
+ for (const match of text.matchAll(global)) {
42
+ if (!match[0])
43
+ continue;
44
+ lines ??= lineStarts(text);
45
+ const line = lineOf(lines, match.index);
46
+ findings.push({
47
+ name,
48
+ fingerprint: fingerprint(match[0]),
49
+ line: line + 1,
50
+ column: match.index - lines[line],
51
+ });
52
+ }
53
+ }
54
+ return findings.sort((a, b) => a.line - b.line || a.column - b.column);
55
+ }
56
+ /** The fingerprint a finding of `value` gets. */
57
+ export function fingerprint(value) {
58
+ return createHash("sha256").update(value).digest("hex").slice(0, 16);
59
+ }
60
+ /** Whether an allowed entry covers a finding, given the module the finding comes from. */
61
+ export function allows(allowed, finding, module) {
62
+ return ((allowed.fingerprint === undefined || allowed.fingerprint === finding.fingerprint) &&
63
+ (allowed.module === undefined ||
64
+ (module !== undefined && moduleMatcher(allowed.module)(module))));
65
+ }
66
+ /** Throws when an entry names neither a fingerprint nor a module, or has no reason. */
67
+ export function validateAllowedSecrets(allowed) {
68
+ for (const entry of allowed) {
69
+ if (entry.fingerprint === undefined && entry.module === undefined)
70
+ throw new TypeError(`An allowed secret names neither a fingerprint nor a module (${entry.reason}).`);
71
+ if (!entry.reason?.trim())
72
+ throw new TypeError(`An allowed secret has no reason: ${describeAllowed(entry)}.`);
73
+ }
74
+ }
75
+ /** Throws when a secret pattern has no name, or matches the empty string. */
76
+ export function validateSecretPatterns(patterns) {
77
+ for (const { name, pattern } of patterns) {
78
+ if (!name?.trim() || !(pattern instanceof RegExp))
79
+ throw new TypeError(`A secret pattern needs a name and a regular expression: ${name}.`);
80
+ if (new RegExp(pattern.source, pattern.flags.replace(/[gy]/g, "")).test(""))
81
+ throw new TypeError(`The secret pattern ${name} matches an empty string.`);
82
+ }
83
+ }
84
+ /** An allowed entry as reports name it. */
85
+ export function describeAllowed(entry) {
86
+ return [
87
+ entry.fingerprint === undefined ? undefined : `fingerprint ${entry.fingerprint}`,
88
+ entry.module === undefined ? undefined : `module ${describePattern(entry.module)}`,
89
+ ]
90
+ .filter(Boolean)
91
+ .join(" and ");
92
+ }
93
+ function lineStarts(text) {
94
+ const starts = [0];
95
+ for (let index = text.indexOf("\n"); index >= 0; index = text.indexOf("\n", index + 1))
96
+ starts.push(index + 1);
97
+ return starts;
98
+ }
99
+ /** The 0-based line that holds `index`. */
100
+ function lineOf(starts, index) {
101
+ let low = 0;
102
+ let high = starts.length - 1;
103
+ while (low < high) {
104
+ const middle = Math.ceil((low + high) / 2);
105
+ if (starts[middle] <= index)
106
+ low = middle;
107
+ else
108
+ high = middle - 1;
109
+ }
110
+ return low;
111
+ }
@@ -0,0 +1,52 @@
1
+ /** A version 3 source map, with the fields the check reads. */
2
+ export type SourceMap = {
3
+ sources: (string | null)[];
4
+ mappings: string;
5
+ sourceRoot?: string;
6
+ /** An index map's parts. The check reads only plain maps. */
7
+ sections?: unknown;
8
+ };
9
+ /** A source map with the module path of each of its sources. */
10
+ export type ResolvedSourceMap = {
11
+ /** The map file, or the built file itself when the map is inline. */
12
+ file: string;
13
+ map: SourceMap;
14
+ /** Each source's module path, by its index in `map.sources`: see {@link modulePath}. */
15
+ modules: string[];
16
+ };
17
+ /** A generated position's segment: its column and its source's index, or -1 when it has none. */
18
+ export type Segment = [column: number, source: number];
19
+ /** The segments of each generated line: its column and source index (Base64 VLQ, source map v3). */
20
+ export declare function decodeMappings(mappings: string): Segment[][];
21
+ /**
22
+ * The bytes of generated code each source renders, by source index. A source whose only code is
23
+ * the residue of a dropped module is left out.
24
+ */
25
+ export declare function renderedSources(code: string, mappings: Segment[][]): Map<number, number>;
26
+ /** The source index of the code at a generated line (0-based) and column, or -1. */
27
+ export declare function sourceAt(mappings: Segment[][], line: number, column: number): number;
28
+ /**
29
+ * The source map of a built file: the one its `sourceMappingURL` names, inline or in a file, or
30
+ * else `<file>.map` beside it, which is where a `hidden` map is. Undefined when it has none.
31
+ */
32
+ export declare function readSourceMap(file: string, code: string, cwd: string): Promise<ResolvedSourceMap | undefined>;
33
+ /**
34
+ * The module a source map's source names, as a path relative to `cwd` with `/` between segments,
35
+ * symlinks resolved: `src/server.ts`, `node_modules/.pnpm/msw@2.7.0/node_modules/msw/lib/core/index.mjs`,
36
+ * or `../../node_modules/…` for a module outside `cwd`. A source that names no file, such as a
37
+ * bundler's virtual module, is kept as it is.
38
+ *
39
+ * The source is resolved from the map's directory and `sourceRoot`, as the specification says.
40
+ * Wrangler writes a `sourceRoot` that its own sources do not use, so when no file is there, the
41
+ * source is looked for without it.
42
+ */
43
+ export declare function modulePath(source: string, mapFile: string, sourceRoot: string | undefined, cwd: string): string;
44
+ /** The real path of a file, or undefined when it does not exist. */
45
+ export declare function realPath(file: string): string | undefined;
46
+ /**
47
+ * A path as reports show it: relative to `cwd`, which is a real path, with `/` between segments.
48
+ * A file that exists is named by its real path.
49
+ */
50
+ export declare function display(file: string, cwd: string): string;
51
+ /** `file` relative to `dir`, with `/` between segments. */
52
+ export declare function posixRelative(dir: string, file: string): string;
@@ -0,0 +1,169 @@
1
+ import { existsSync, realpathSync } from "node:fs";
2
+ import { readFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ const BASE64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
6
+ const DIGIT = new Map([...BASE64].map((char, index) => [char, index]));
7
+ /** The segments of each generated line: its column and source index (Base64 VLQ, source map v3). */
8
+ export function decodeMappings(mappings) {
9
+ const lines = [];
10
+ let source = 0;
11
+ for (const line of mappings.split(";")) {
12
+ const segments = [];
13
+ let column = 0;
14
+ for (const segment of line.split(",")) {
15
+ if (!segment)
16
+ continue;
17
+ const fields = [];
18
+ let value = 0;
19
+ let shift = 0;
20
+ for (const char of segment) {
21
+ const digit = DIGIT.get(char);
22
+ if (digit === undefined)
23
+ throw new Error(`The source map's mappings hold "${char}".`);
24
+ value += (digit & 31) * 2 ** shift;
25
+ if (digit & 32)
26
+ shift += 5;
27
+ else {
28
+ fields.push(value % 2 ? -Math.floor(value / 2) : value / 2);
29
+ value = 0;
30
+ shift = 0;
31
+ }
32
+ }
33
+ column += fields[0] ?? 0;
34
+ if (fields.length >= 4) {
35
+ source += fields[1];
36
+ segments.push([column, source]);
37
+ }
38
+ else
39
+ segments.push([column, -1]);
40
+ }
41
+ lines.push(segments);
42
+ }
43
+ return lines;
44
+ }
45
+ /**
46
+ * What Rolldown and esbuild leave of a module whose code was dropped: the call to its lazy
47
+ * initializer, `init_x(` and the `)` that closes it, kept where its importer was.
48
+ */
49
+ const INIT_RESIDUE = /^(?:[\w$]+\(|\)[\s,;)}\]]*)$/;
50
+ /**
51
+ * The bytes of generated code each source renders, by source index. A source whose only code is
52
+ * the residue of a dropped module is left out.
53
+ */
54
+ export function renderedSources(code, mappings) {
55
+ const sizes = new Map();
56
+ const lines = code.split("\n");
57
+ mappings.forEach((segments, line) => {
58
+ const text = lines[line] ?? "";
59
+ segments.forEach(([column, source], position) => {
60
+ if (source < 0)
61
+ return;
62
+ const end = segments[position + 1]?.[0] ?? text.length;
63
+ const rendered = text.slice(column, end).trim();
64
+ if (!rendered || INIT_RESIDUE.test(rendered))
65
+ return;
66
+ sizes.set(source, (sizes.get(source) ?? 0) + rendered.length);
67
+ });
68
+ });
69
+ return sizes;
70
+ }
71
+ /** The source index of the code at a generated line (0-based) and column, or -1. */
72
+ export function sourceAt(mappings, line, column) {
73
+ let found = -1;
74
+ for (const [start, source] of mappings[line] ?? []) {
75
+ if (start > column)
76
+ break;
77
+ found = source;
78
+ }
79
+ return found;
80
+ }
81
+ /** The `sourceMappingURL` comment at the end of a built file. */
82
+ const SOURCE_MAPPING_URL = /\/[/*][#@] sourceMappingURL=([^\s'"*]+)[\s*/]*$/;
83
+ /**
84
+ * The source map of a built file: the one its `sourceMappingURL` names, inline or in a file, or
85
+ * else `<file>.map` beside it, which is where a `hidden` map is. Undefined when it has none.
86
+ */
87
+ export async function readSourceMap(file, code, cwd) {
88
+ // From the last comment, not the file's last few kB: an inline map can be megabytes long.
89
+ const at = code.lastIndexOf("sourceMappingURL=");
90
+ const url = at < 4 ? undefined : SOURCE_MAPPING_URL.exec(code.slice(at - 4))?.[1];
91
+ let found;
92
+ if (url?.startsWith("data:")) {
93
+ const comma = url.indexOf(",");
94
+ const data = url.slice(comma + 1);
95
+ found = {
96
+ file,
97
+ text: /;base64$/.test(url.slice(0, comma))
98
+ ? Buffer.from(data, "base64").toString("utf8")
99
+ : decodeURIComponent(data),
100
+ };
101
+ }
102
+ else {
103
+ const candidates = [`${file}.map`];
104
+ if (url && !/^[a-z][a-z\d+.-]*:/i.test(url))
105
+ candidates.unshift(path.resolve(path.dirname(file), decodeURIComponent(url)));
106
+ const mapFile = candidates.find((candidate) => existsSync(candidate));
107
+ if (mapFile)
108
+ found = { file: mapFile, text: await readFile(mapFile, "utf8") };
109
+ }
110
+ if (!found)
111
+ return undefined;
112
+ let map;
113
+ try {
114
+ map = JSON.parse(found.text);
115
+ }
116
+ catch {
117
+ throw new Error(`${display(found.file, cwd)} is not a source map: it is not JSON.`);
118
+ }
119
+ if (map.sections)
120
+ throw new Error(`${display(found.file, cwd)} is an index map (sections), which the check does not read.`);
121
+ if (!Array.isArray(map.sources) || typeof map.mappings !== "string")
122
+ throw new Error(`${display(found.file, cwd)} is not a source map: it lacks sources or mappings.`);
123
+ return {
124
+ file: found.file,
125
+ map,
126
+ modules: map.sources.map((source) => modulePath(source ?? "", found.file, map.sourceRoot, cwd)),
127
+ };
128
+ }
129
+ /**
130
+ * The module a source map's source names, as a path relative to `cwd` with `/` between segments,
131
+ * symlinks resolved: `src/server.ts`, `node_modules/.pnpm/msw@2.7.0/node_modules/msw/lib/core/index.mjs`,
132
+ * or `../../node_modules/…` for a module outside `cwd`. A source that names no file, such as a
133
+ * bundler's virtual module, is kept as it is.
134
+ *
135
+ * The source is resolved from the map's directory and `sourceRoot`, as the specification says.
136
+ * Wrangler writes a `sourceRoot` that its own sources do not use, so when no file is there, the
137
+ * source is looked for without it.
138
+ */
139
+ export function modulePath(source, mapFile, sourceRoot, cwd) {
140
+ if (source.startsWith("file:"))
141
+ return display(fileURLToPath(source), cwd);
142
+ if (!source || /^[a-z][a-z\d+.-]*:/i.test(source) || source.startsWith("\0"))
143
+ return source.replace(/^\0/, "");
144
+ const base = path.dirname(mapFile);
145
+ const candidates = [path.resolve(base, sourceRoot ?? "", source)];
146
+ if (sourceRoot)
147
+ candidates.push(path.resolve(base, source));
148
+ return display(candidates.find((candidate) => existsSync(candidate)) ?? candidates[0], cwd);
149
+ }
150
+ /** The real path of a file, or undefined when it does not exist. */
151
+ export function realPath(file) {
152
+ try {
153
+ return realpathSync(file);
154
+ }
155
+ catch {
156
+ return undefined;
157
+ }
158
+ }
159
+ /**
160
+ * A path as reports show it: relative to `cwd`, which is a real path, with `/` between segments.
161
+ * A file that exists is named by its real path.
162
+ */
163
+ export function display(file, cwd) {
164
+ return posixRelative(cwd, realPath(file) ?? file);
165
+ }
166
+ /** `file` relative to `dir`, with `/` between segments. */
167
+ export function posixRelative(dir, file) {
168
+ return (path.relative(dir, file) || ".").split(path.sep).join("/");
169
+ }
package/package.json CHANGED
@@ -1,6 +1,56 @@
1
1
  {
2
2
  "name": "@rivi-software/workers-bundle-check",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "description": "Fails CI on what a Cloudflare Worker's build should not contain: a forbidden module or marker in the Worker, a source map its static assets would serve, or a value shaped like a secret",
5
+ "keywords": [
6
+ "cloudflare",
7
+ "cloudflare-workers",
8
+ "wrangler",
9
+ "bundle",
10
+ "source-map",
11
+ "assetsignore",
12
+ "secrets",
13
+ "ci"
14
+ ],
15
+ "license": "MIT",
16
+ "type": "module",
17
+ "exports": {
18
+ ".": {
19
+ "types": "./dist/index.d.ts",
20
+ "default": "./dist/index.js"
21
+ }
22
+ },
23
+ "bin": {
24
+ "workers-bundle-check": "./dist/cli.js"
25
+ },
26
+ "files": [
27
+ "dist"
28
+ ],
29
+ "sideEffects": false,
30
+ "dependencies": {
31
+ "ignore": "^5.3.2",
32
+ "minimatch": "^10.2.5"
33
+ },
34
+ "devDependencies": {
35
+ "vite": "8.3.5",
36
+ "wrangler": "4.156.2"
37
+ },
38
+ "engines": {
39
+ "node": ">=22"
40
+ },
41
+ "repository": {
42
+ "type": "git",
43
+ "url": "git+https://github.com/rivi-software/tools.git",
44
+ "directory": "packages/workers-bundle-check"
45
+ },
46
+ "homepage": "https://github.com/rivi-software/tools/tree/main/packages/workers-bundle-check#readme",
47
+ "bugs": "https://github.com/rivi-software/tools/issues",
48
+ "publishConfig": {
49
+ "access": "public"
50
+ },
51
+ "scripts": {
52
+ "build": "rm -rf dist && tsc -p tsconfig.build.json",
53
+ "check": "tsc --noEmit",
54
+ "test": "vitest run"
55
+ }
6
56
  }