shipready 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 shipready contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,219 @@
1
+ # shipready
2
+
3
+ > Pre-flight check for your repo before shipping.
4
+
5
+ [![CI](https://github.com/formalness/shipready/actions/workflows/ci.yml/badge.svg)](https://github.com/formalness/shipready/actions/workflows/ci.yml)
6
+ [![npm version](https://img.shields.io/npm/v/shipready.svg)](https://www.npmjs.com/package/shipready)
7
+ [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)
8
+ [![node >= 18](https://img.shields.io/badge/node-%3E%3D18-brightgreen.svg)](https://nodejs.org)
9
+
10
+ shipready is a CLI for developers who build with AI and vibe-coding tools. It scans your project for the things AI-generated code tends to leave behind — hardcoded secrets, missing `.env.example` files, debug logs, unfinished TODOs, and broken repo hygiene — and helps you fix them before you ship.
11
+
12
+ **No API key required. Works fully offline. No telemetry.**
13
+
14
+ ## Why shipready exists
15
+
16
+ AI coding tools are great at producing working code fast, but they routinely:
17
+
18
+ - paste API keys directly into source files
19
+ - forget `.env.example` while sprinkling `process.env.X` everywhere
20
+ - leave `console.log`, `debugger`, and `TODO` breadcrumbs all over
21
+ - skip README setup instructions entirely
22
+ - ignore `.gitignore` hygiene
23
+
24
+ shipready catches all of that in seconds, scores your repo, and can auto-fix the safe stuff.
25
+
26
+ ## Installation
27
+
28
+ ```bash
29
+ # Run directly
30
+ npx shipready check
31
+
32
+ # Or install globally
33
+ npm install -g shipready
34
+ shipready check
35
+ ```
36
+
37
+ ## Usage
38
+
39
+ ```bash
40
+ shipready check [path] # scan a project (current dir by default)
41
+ shipready init [path] # generate AI-agent instruction files
42
+ shipready fix [path] # apply safe automatic fixes
43
+ ```
44
+
45
+ ## Commands
46
+
47
+ ### `shipready check [path]`
48
+
49
+ Scans the project and prints a report with a 0-100 score.
50
+
51
+ | Flag | Effect |
52
+ | --- | --- |
53
+ | `-v, --verbose` | show file and line locations for every finding |
54
+ | `--json` | output the raw structured report as JSON (great for CI) |
55
+ | `--fix` | apply safe fixes, re-scan, and show the score before/after |
56
+
57
+ Exits with code `1` when errors are found, so you can use it in CI pipelines.
58
+
59
+ ### `shipready init [path]`
60
+
61
+ Generates AI-agent instruction files based on your detected framework, package manager, and scripts:
62
+
63
+ - `AGENTS.md`
64
+ - `CLAUDE.md`
65
+ - `.cursor/rules/shipready.md`
66
+
67
+ Existing files are never overwritten unless you pass `--force`.
68
+
69
+ ### `shipready fix [path]`
70
+
71
+ Applies safe automatic fixes:
72
+
73
+ - creates `.env.example` from detected `process.env.X` usages (keys only, no values)
74
+ - adds missing entries to `.gitignore` (`.env`, `node_modules`, `dist`, ...)
75
+ - generates the AI-agent files above if missing
76
+
77
+ It never deletes user code and never overwrites files without `--force`. A summary of changed files is printed at the end.
78
+
79
+ ## Example output
80
+
81
+ ```txt
82
+ shipready report
83
+
84
+ Project: Next.js
85
+ Package manager: pnpm
86
+
87
+ Summary:
88
+ ✓ package.json found
89
+ ✓ README.md found
90
+ ✗ .env.example missing
91
+ ✗ .env is not ignored
92
+ ⚠ 4 TODO/FIXME comments found
93
+ ⚠ 2 console.log calls found
94
+ ✓ No obvious secrets found
95
+
96
+ Score: 72/100
97
+
98
+ Recommended next steps:
99
+ 1. Add .env to .gitignore
100
+ 2. Create .env.example (run: shipready fix)
101
+ 3. Remove debug logs before shipping
102
+ ```
103
+
104
+ ## What it checks
105
+
106
+ | Check | What it looks for |
107
+ | --- | --- |
108
+ | **package.json** | Existence, package manager (lockfiles), framework, `dev`/`build`/`test`/`lint` scripts |
109
+ | **README** | Existence, installation/usage/license sections, not-too-empty |
110
+ | **Env safety** | `.env` gitignored, `.env.example` present and complete, no real values in examples |
111
+ | **Secrets** | 18 token patterns, all masked in output (see table below) |
112
+ | **Debug leftovers** | `TODO`, `FIXME`, `HACK`, `XXX`, `console.log`, `debugger`, `throw new Error("Not implemented")` |
113
+ | **.gitignore** | Existence and important entries (`.env`, `node_modules`, `dist`, `build`, `.next`) |
114
+
115
+ Ignored during scans: `node_modules`, `.git`, `dist`, `build`, `.next`, `out`, `coverage`, `.cache`.
116
+
117
+ ## Detected secrets
118
+
119
+ | Type | Recognized by |
120
+ | --- | --- |
121
+ | OpenAI | `sk-`, `sk-proj-` |
122
+ | Anthropic | `sk-ant-` |
123
+ | Google / Gemini | `AIza...` |
124
+ | GitHub | `ghp_`, `github_pat_` |
125
+ | Stripe (live) | `sk_live_`, `rk_live_` |
126
+ | Slack | `xoxb-`, `xoxp-` |
127
+ | AWS | `AKIA...` |
128
+ | Supabase | `sbp_...` |
129
+ | Vercel | `vercel_...` |
130
+ | npm | `npm_...` |
131
+ | SendGrid | `SG.xxx.xxx` |
132
+ | Twilio | `AC` / `SK` + 32 hex |
133
+ | Telegram bot | `123456789:AA...` |
134
+ | Database URL with password | `postgres://user:pass@host` (also mysql, mongodb, redis, amqp) |
135
+ | GCP service account | `"private_key_id"` in JSON |
136
+ | Private key block | `-----BEGIN ... PRIVATE KEY-----` |
137
+ | JWT | `eyJ...` |
138
+ | Generic credential | `API_KEY=`, `SECRET=`, `PASSWORD=` assignments |
139
+
140
+ Placeholders (`your-api-key`, `changeme`, `xxxx`, ...) are ignored. Matched values are always masked — shipready never prints a full secret.
141
+
142
+ ## Configuration
143
+
144
+ Optional `shipready.config.json` in your project root:
145
+
146
+ ```json
147
+ {
148
+ "ignore": ["fixtures/**", "docs/**"],
149
+ "disableRules": ["todos", "readme.weak"],
150
+ "secretAllowlist": ["not-a-real-key-used-in-tests"]
151
+ }
152
+ ```
153
+
154
+ | Field | Purpose |
155
+ | --- | --- |
156
+ | `ignore` | Extra glob patterns excluded from scanning (on top of the built-in ignores) |
157
+ | `disableRules` | Rule ids (`readme.weak`) or whole checks (`todos`) to disable; disabled rules don't affect the score |
158
+ | `secretAllowlist` | Substrings marking known false positives; matching lines are not reported |
159
+
160
+ ## Using in CI
161
+
162
+ `shipready check` exits with code `1` when errors are found:
163
+
164
+ ```yaml
165
+ # .github/workflows/quality.yml
166
+ name: Quality gate
167
+ on: [push, pull_request]
168
+ jobs:
169
+ shipready:
170
+ runs-on: ubuntu-latest
171
+ steps:
172
+ - uses: actions/checkout@v4
173
+ - uses: actions/setup-node@v4
174
+ with:
175
+ node-version: 20
176
+ - run: npx shipready check --verbose
177
+ ```
178
+
179
+ Use `--json` to feed the report into other tooling.
180
+
181
+ ## Scoring
182
+
183
+ Every project starts at 100 and loses points for issues:
184
+
185
+ | Issue | Deduction |
186
+ | --- | --- |
187
+ | Missing package.json | -20 |
188
+ | Missing README | -15 |
189
+ | Weak README | -8 |
190
+ | Missing build script | -8 |
191
+ | Missing test script | -6 |
192
+ | Missing .env.example (when env vars exist) | -10 |
193
+ | .env not gitignored | -15 |
194
+ | Possible secret | -25 each (max -50) |
195
+ | TODO/debug leftovers | -2 each (max -15) |
196
+
197
+ The score never goes below 0.
198
+
199
+ ## Development
200
+
201
+ ```bash
202
+ pnpm install
203
+ pnpm build # compile TypeScript to dist/
204
+ pnpm test # run Vitest suite
205
+ pnpm dev # run the CLI from source (tsx)
206
+ ```
207
+
208
+ See [CONTRIBUTING.md](./CONTRIBUTING.md) for project structure and how to add new checks or secret patterns.
209
+
210
+ ## Roadmap
211
+
212
+ - [ ] Optional AI-powered fix suggestions (bring your own key)
213
+ - [ ] `shipready check --staged` for pre-commit hooks
214
+ - [ ] Markdown/SARIF report output for GitHub code scanning
215
+ - [ ] More frameworks and secret providers
216
+
217
+ ## License
218
+
219
+ [MIT](./LICENSE)
@@ -0,0 +1,156 @@
1
+ import { fileExists, readTextFile } from "../utils/files.js";
2
+ const ENV_USAGE_RE = /process\.env\.([A-Z][A-Z0-9_]*)/g;
3
+ const IMPORT_META_ENV_RE = /import\.meta\.env\.([A-Z][A-Z0-9_]*)/g;
4
+ /** Built-in variables that don't need to be in .env.example. */
5
+ const BUILTIN_VARS = new Set([
6
+ "NODE_ENV",
7
+ "CI",
8
+ "PORT",
9
+ "HOME",
10
+ "PATH",
11
+ "PWD",
12
+ "TZ",
13
+ "MODE",
14
+ "DEV",
15
+ "PROD",
16
+ "SSR",
17
+ "BASE_URL",
18
+ ]);
19
+ /** Extracts `process.env` / `import.meta.env` variable usages from source code. */
20
+ export function extractEnvUsages(content, file) {
21
+ const usages = [];
22
+ const lines = content.split("\n");
23
+ for (let i = 0; i < lines.length; i++) {
24
+ for (const re of [ENV_USAGE_RE, IMPORT_META_ENV_RE]) {
25
+ re.lastIndex = 0;
26
+ let match;
27
+ while ((match = re.exec(lines[i])) !== null) {
28
+ const name = match[1];
29
+ if (!BUILTIN_VARS.has(name)) {
30
+ usages.push({ name, file, line: i + 1 });
31
+ }
32
+ }
33
+ }
34
+ }
35
+ return usages;
36
+ }
37
+ /** Parses variable names from a dotenv-format file. */
38
+ export function parseEnvKeys(content) {
39
+ const keys = [];
40
+ for (const raw of content.split("\n")) {
41
+ const line = raw.trim();
42
+ if (!line || line.startsWith("#"))
43
+ continue;
44
+ const eq = line.indexOf("=");
45
+ if (eq <= 0)
46
+ continue;
47
+ const key = line.slice(0, eq).trim().replace(/^export\s+/, "");
48
+ if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
49
+ keys.push(key);
50
+ }
51
+ return keys;
52
+ }
53
+ /** Values in .env.example that look like real secrets rather than placeholders. */
54
+ export function findRealLookingExampleValues(content) {
55
+ const suspicious = [];
56
+ for (const raw of content.split("\n")) {
57
+ const line = raw.trim();
58
+ if (!line || line.startsWith("#"))
59
+ continue;
60
+ const eq = line.indexOf("=");
61
+ if (eq <= 0)
62
+ continue;
63
+ const key = line.slice(0, eq).trim();
64
+ let value = line.slice(eq + 1).trim().replace(/^["']|["']$/g, "");
65
+ if (!value)
66
+ continue;
67
+ const lower = value.toLowerCase();
68
+ const placeholder = lower.includes("your") ||
69
+ lower.includes("example") ||
70
+ lower.includes("placeholder") ||
71
+ lower.includes("changeme") ||
72
+ lower.includes("xxx") ||
73
+ lower.includes("<") ||
74
+ lower === "true" ||
75
+ lower === "false" ||
76
+ /^\d+$/.test(value) ||
77
+ value.length < 8;
78
+ // Long, high-entropy-looking values that aren't placeholders
79
+ if (!placeholder && /^[A-Za-z0-9+/_.-]{20,}$/.test(value)) {
80
+ suspicious.push(key);
81
+ }
82
+ }
83
+ return suspicious;
84
+ }
85
+ /** Checks .env / .env.example / gitignore safety around env vars. */
86
+ export function checkEnv(root, envUsages) {
87
+ const findings = [];
88
+ const hasEnv = fileExists(root, ".env");
89
+ const exampleContent = readTextFile(root, ".env.example") ?? readTextFile(root, ".env.sample");
90
+ const hasExample = exampleContent !== null;
91
+ const gitignore = readTextFile(root, ".gitignore") ?? "";
92
+ const usedVars = [...new Set(envUsages.map((u) => u.name))];
93
+ // .env should be gitignored if it exists
94
+ if (hasEnv) {
95
+ const ignored = gitignore
96
+ .split("\n")
97
+ .map((l) => l.trim())
98
+ .some((l) => l === ".env" || l === ".env*" || l === "*.env");
99
+ if (ignored) {
100
+ findings.push({
101
+ severity: "success",
102
+ rule: "env.ignored",
103
+ message: ".env is ignored by git",
104
+ });
105
+ }
106
+ else {
107
+ findings.push({
108
+ severity: "error",
109
+ rule: "env.not-ignored",
110
+ message: ".env is not ignored by git",
111
+ });
112
+ }
113
+ }
114
+ // .env.example presence
115
+ if (usedVars.length > 0 && !hasExample) {
116
+ findings.push({
117
+ severity: "error",
118
+ rule: "env.example-missing",
119
+ message: `.env.example missing (${usedVars.length} env var${usedVars.length > 1 ? "s" : ""} used in code)`,
120
+ });
121
+ }
122
+ else if (hasExample) {
123
+ findings.push({
124
+ severity: "success",
125
+ rule: "env.example-found",
126
+ message: ".env.example found",
127
+ });
128
+ // Are all used vars documented?
129
+ const exampleKeys = new Set(parseEnvKeys(exampleContent));
130
+ const undocumented = usedVars.filter((v) => !exampleKeys.has(v));
131
+ if (undocumented.length > 0) {
132
+ findings.push({
133
+ severity: "warning",
134
+ rule: "env.example-incomplete",
135
+ message: `.env.example missing variables: ${undocumented.join(", ")}`,
136
+ });
137
+ }
138
+ // Real-looking secrets in the example file?
139
+ const leaky = findRealLookingExampleValues(exampleContent);
140
+ if (leaky.length > 0) {
141
+ findings.push({
142
+ severity: "warning",
143
+ rule: "env.example-real-values",
144
+ message: `.env.example has real-looking values for: ${leaky.join(", ")}`,
145
+ });
146
+ }
147
+ }
148
+ else if (usedVars.length === 0) {
149
+ findings.push({
150
+ severity: "info",
151
+ rule: "env.none-used",
152
+ message: "No environment variables detected in code",
153
+ });
154
+ }
155
+ return { name: "env", findings };
156
+ }
@@ -0,0 +1,56 @@
1
+ import { readTextFile } from "../utils/files.js";
2
+ export const IMPORTANT_IGNORES = [
3
+ ".env",
4
+ ".env.local",
5
+ "node_modules",
6
+ "dist",
7
+ "build",
8
+ ".next",
9
+ ];
10
+ /** Returns which important entries are missing from gitignore content. */
11
+ export function missingIgnoreEntries(content) {
12
+ const lines = content
13
+ .split("\n")
14
+ .map((l) => l.trim())
15
+ .filter((l) => l && !l.startsWith("#"));
16
+ const covers = (entry) => lines.some((l) => {
17
+ const normalized = l.replace(/\/+$/, "").replace(/^\//, "");
18
+ if (normalized === entry)
19
+ return true;
20
+ // ".env*" covers ".env" and ".env.local"
21
+ if (normalized === ".env*" && entry.startsWith(".env"))
22
+ return true;
23
+ // "node_modules/" style already normalized above
24
+ return false;
25
+ });
26
+ return IMPORTANT_IGNORES.filter((e) => !covers(e));
27
+ }
28
+ /** Checks .gitignore existence and important entries. */
29
+ export function checkGitignore(root) {
30
+ const findings = [];
31
+ const content = readTextFile(root, ".gitignore");
32
+ if (content === null) {
33
+ findings.push({
34
+ severity: "error",
35
+ rule: "gitignore.missing",
36
+ message: ".gitignore missing",
37
+ });
38
+ return { name: "gitignore", findings };
39
+ }
40
+ const missing = missingIgnoreEntries(content);
41
+ if (missing.length > 0) {
42
+ findings.push({
43
+ severity: "warning",
44
+ rule: "gitignore.incomplete",
45
+ message: `.gitignore missing entries: ${missing.join(", ")}`,
46
+ });
47
+ }
48
+ else {
49
+ findings.push({
50
+ severity: "success",
51
+ rule: "gitignore.complete",
52
+ message: ".gitignore covers important entries",
53
+ });
54
+ }
55
+ return { name: "gitignore", findings };
56
+ }
@@ -0,0 +1,34 @@
1
+ const IMPORTANT_SCRIPTS = ["dev", "build", "test", "lint"];
2
+ /** Checks package.json existence and important scripts. */
3
+ export function checkPackageJson(project) {
4
+ const findings = [];
5
+ if (!project.hasPackageJson) {
6
+ findings.push({
7
+ severity: "error",
8
+ rule: "package-json.missing",
9
+ message: "package.json missing",
10
+ });
11
+ return { name: "package.json", findings };
12
+ }
13
+ findings.push({
14
+ severity: "success",
15
+ rule: "package-json.found",
16
+ message: "package.json found",
17
+ });
18
+ const missing = IMPORTANT_SCRIPTS.filter((s) => !project.scripts[s]);
19
+ if (missing.length > 0) {
20
+ findings.push({
21
+ severity: "warning",
22
+ rule: "scripts.missing",
23
+ message: `Missing script${missing.length > 1 ? "s" : ""}: ${missing.join(", ")}`,
24
+ });
25
+ }
26
+ else {
27
+ findings.push({
28
+ severity: "success",
29
+ rule: "scripts.found",
30
+ message: "All important scripts present (dev, build, test, lint)",
31
+ });
32
+ }
33
+ return { name: "package.json", findings };
34
+ }
@@ -0,0 +1,47 @@
1
+ import { readTextFile } from "../utils/files.js";
2
+ const SECTION_KEYWORDS = [
3
+ "installation",
4
+ "install",
5
+ "usage",
6
+ "quickstart",
7
+ "quick start",
8
+ "getting started",
9
+ "environment variables",
10
+ "development",
11
+ "build",
12
+ "license",
13
+ ];
14
+ /** Minimum character count for a README to not be considered "too empty". */
15
+ const MIN_README_LENGTH = 200;
16
+ /** Minimum number of keyword hits for a README to be considered useful. */
17
+ const MIN_KEYWORD_HITS = 2;
18
+ /** Checks README existence and quality heuristics. */
19
+ export function checkReadme(root) {
20
+ const findings = [];
21
+ const content = readTextFile(root, "README.md") ??
22
+ readTextFile(root, "readme.md") ??
23
+ readTextFile(root, "Readme.md");
24
+ if (content === null) {
25
+ findings.push({
26
+ severity: "error",
27
+ rule: "readme.missing",
28
+ message: "README.md missing",
29
+ });
30
+ return { name: "README", findings };
31
+ }
32
+ findings.push({
33
+ severity: "success",
34
+ rule: "readme.found",
35
+ message: "README.md found",
36
+ });
37
+ const lower = content.toLowerCase();
38
+ const hits = SECTION_KEYWORDS.filter((k) => lower.includes(k)).length;
39
+ if (content.trim().length < MIN_README_LENGTH || hits < MIN_KEYWORD_HITS) {
40
+ findings.push({
41
+ severity: "warning",
42
+ rule: "readme.weak",
43
+ message: "README looks thin - add installation and usage instructions",
44
+ });
45
+ }
46
+ return { name: "README", findings };
47
+ }
@@ -0,0 +1,105 @@
1
+ const PATTERNS = [
2
+ { kind: "Anthropic key", re: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/ },
3
+ { kind: "OpenAI key", re: /\bsk-(?:proj-)?[A-Za-z0-9_-]{16,}\b/ },
4
+ { kind: "Google/Gemini key", re: /\bAIza[A-Za-z0-9_-]{30,}\b/ },
5
+ { kind: "GitHub token", re: /\b(?:ghp_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})\b/ },
6
+ { kind: "Stripe live key", re: /\b(?:sk|rk)_live_[A-Za-z0-9]{16,}\b/ },
7
+ { kind: "Slack token", re: /\bxox[bp]-[A-Za-z0-9-]{10,}\b/ },
8
+ { kind: "AWS access key", re: /\bAKIA[A-Z0-9]{16}\b/ },
9
+ { kind: "Supabase personal token", re: /\bsbp_[a-f0-9]{40}\b/ },
10
+ { kind: "Vercel token", re: /\bvercel_[A-Za-z0-9]{24,}\b/ },
11
+ { kind: "npm token", re: /\bnpm_[A-Za-z0-9]{36}\b/ },
12
+ { kind: "SendGrid key", re: /\bSG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}\b/ },
13
+ { kind: "Twilio credential", re: /\b(?:AC|SK)[a-f0-9]{32}\b/ },
14
+ { kind: "Telegram bot token", re: /\b\d{8,10}:[A-Za-z0-9_-]{35}\b/ },
15
+ {
16
+ kind: "Database URL with password",
17
+ re: /\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|rediss|amqp):\/\/[^:\s"'@/]+:[^@\s"']+@/,
18
+ },
19
+ {
20
+ kind: "Private key block",
21
+ re: /-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/,
22
+ },
23
+ {
24
+ kind: "GCP service account key",
25
+ re: /"private_key_id"\s*:\s*"[a-f0-9]{20,}"/,
26
+ },
27
+ {
28
+ kind: "JWT",
29
+ re: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/,
30
+ },
31
+ {
32
+ // Credential-style assignment with a long opaque value
33
+ kind: "Hardcoded credential",
34
+ re: /\b(?:API_KEY|APIKEY|SECRET|SECRET_KEY|ACCESS_TOKEN|AUTH_TOKEN|PASSWORD)\s*[=:]\s*["']?[A-Za-z0-9+/_-]{16,}["']?/i,
35
+ },
36
+ ];
37
+ /** Values that indicate a placeholder, not a real secret. */
38
+ const PLACEHOLDER_RE = /your[-_ ]?|example|placeholder|changeme|change[-_ ]me|dummy|fake|test[-_ ]?key|<[^>]*>|x{4,}|\*{4,}|123456|abcdef/i;
39
+ /** Masks a secret, keeping a short prefix and suffix. */
40
+ export function maskSecret(secret) {
41
+ if (secret.length <= 8)
42
+ return "*".repeat(secret.length);
43
+ // Keep a recognizable prefix (e.g. "sk-proj-ab") and last 4 chars.
44
+ const prefixMatch = secret.match(/^(sk-ant-|sk-proj-|sk-|sk_live_|rk_live_|ghp_|github_pat_|AIza|xoxb-|xoxp-|AKIA|sbp_|vercel_|npm_|SG\.|AC|SK|eyJ)/);
45
+ const prefixLen = (prefixMatch?.[0].length ?? 0) + 2;
46
+ const prefix = secret.slice(0, Math.min(prefixLen, secret.length - 4));
47
+ const suffix = secret.slice(-4);
48
+ return `${prefix}...${suffix}`;
49
+ }
50
+ /** Scans a single file's content for potential secrets. */
51
+ export function scanContentForSecrets(content, file, allowlist = []) {
52
+ const found = [];
53
+ const lines = content.split("\n");
54
+ for (let i = 0; i < lines.length; i++) {
55
+ const line = lines[i];
56
+ // Skip obvious placeholder lines and comments about examples
57
+ if (PLACEHOLDER_RE.test(line))
58
+ continue;
59
+ for (const { kind, re } of PATTERNS) {
60
+ const match = line.match(re);
61
+ if (match) {
62
+ // User-configured false positives (substring match on the value or line)
63
+ if (allowlist.some((a) => match[0].includes(a) || line.includes(a))) {
64
+ continue;
65
+ }
66
+ found.push({
67
+ kind,
68
+ file,
69
+ line: i + 1,
70
+ masked: maskSecret(match[0]),
71
+ });
72
+ break; // one finding per line is enough
73
+ }
74
+ }
75
+ }
76
+ return found;
77
+ }
78
+ /** Builds the CheckResult from all secret findings. */
79
+ export function checkSecrets(secretFindings) {
80
+ const findings = [];
81
+ if (secretFindings.length === 0) {
82
+ findings.push({
83
+ severity: "success",
84
+ rule: "secrets.none",
85
+ message: "No obvious secrets found",
86
+ });
87
+ }
88
+ else {
89
+ findings.push({
90
+ severity: "error",
91
+ rule: "secrets.detected",
92
+ message: `${secretFindings.length} potential secret${secretFindings.length > 1 ? "s" : ""} detected`,
93
+ });
94
+ for (const s of secretFindings) {
95
+ findings.push({
96
+ severity: "error",
97
+ rule: "secrets.detected-item",
98
+ message: `${s.kind}: ${s.masked}`,
99
+ file: s.file,
100
+ line: s.line,
101
+ });
102
+ }
103
+ }
104
+ return { name: "secrets", findings };
105
+ }