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 +21 -0
- package/README.md +219 -0
- package/dist/checks/env.js +156 -0
- package/dist/checks/gitignore.js +56 -0
- package/dist/checks/packageJson.js +34 -0
- package/dist/checks/readme.js +47 -0
- package/dist/checks/secrets.js +105 -0
- package/dist/checks/todos.js +72 -0
- package/dist/cli.js +151 -0
- package/dist/config.js +59 -0
- package/dist/fixers/agentFiles.js +23 -0
- package/dist/fixers/envExample.js +27 -0
- package/dist/fixers/gitignore.js +29 -0
- package/dist/generators/agentsMd.js +66 -0
- package/dist/generators/claudeMd.js +35 -0
- package/dist/generators/cursorRules.js +21 -0
- package/dist/index.js +6 -0
- package/dist/scanner.js +116 -0
- package/dist/types.js +1 -0
- package/dist/utils/files.js +100 -0
- package/dist/utils/framework.js +68 -0
- package/dist/utils/report.js +108 -0
- package/package.json +67 -0
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
|
+
[](https://github.com/formalness/shipready/actions/workflows/ci.yml)
|
|
6
|
+
[](https://www.npmjs.com/package/shipready)
|
|
7
|
+
[](./LICENSE)
|
|
8
|
+
[](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
|
+
}
|