shipready 1.4.0 → 1.5.1
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 +97 -8
- package/dist/checks/secrets.js +11 -0
- package/dist/checks/staged.js +55 -0
- package/dist/checks/todos.js +4 -0
- package/dist/cli.js +88 -16
- package/dist/fixers/agentFiles.js +6 -3
- package/dist/fixers/envExample.js +11 -3
- package/dist/fixers/gitignore.js +10 -5
- package/dist/scanner.js +11 -4
- package/dist/utils/sarif.js +85 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
[](https://github.com/formalness/shipready/actions/workflows/ci.yml)
|
|
6
6
|
[](https://www.npmjs.com/package/shipready)
|
|
7
|
+
[](https://github.com/formalness/shipready/tree/main/tests)
|
|
7
8
|
[](./LICENSE)
|
|
8
9
|
[](https://nodejs.org)
|
|
9
10
|
|
|
@@ -37,9 +38,10 @@ shipready check
|
|
|
37
38
|
## Usage
|
|
38
39
|
|
|
39
40
|
```bash
|
|
40
|
-
shipready check
|
|
41
|
-
shipready
|
|
42
|
-
shipready
|
|
41
|
+
shipready check [path] # scan a project (current dir by default)
|
|
42
|
+
shipready staged [path] # fast secrets-only scan of staged files (pre-commit)
|
|
43
|
+
shipready init [path] # generate AI-agent instruction files
|
|
44
|
+
shipready fix [path] # apply safe automatic fixes (--dry-run to preview)
|
|
43
45
|
```
|
|
44
46
|
|
|
45
47
|
## Commands
|
|
@@ -52,6 +54,7 @@ Scans the project and prints a report with a 0-100 score.
|
|
|
52
54
|
| --- | --- |
|
|
53
55
|
| `-v, --verbose` | show file and line locations for every finding |
|
|
54
56
|
| `--json` | output the raw structured report as JSON (great for CI) |
|
|
57
|
+
| `--sarif` | output SARIF 2.1.0 for **GitHub code scanning** — findings become native PR alerts |
|
|
55
58
|
| `--fix` | apply safe fixes, re-scan, and show the score before/after |
|
|
56
59
|
| `--history` | also scan the **full git history** (all branches) for secrets that were committed and later removed |
|
|
57
60
|
| `--verify` | check detected keys against provider APIs to see if they are **live right now** |
|
|
@@ -74,6 +77,27 @@ For 12+ providers (OpenAI, Anthropic, GitHub, GitLab, Stripe, SendGrid, Google,
|
|
|
74
77
|
|
|
75
78
|
Verification requests contain only the key itself, go directly to the provider's official API host, and never mutate remote state. Nothing is ever sent to any third party.
|
|
76
79
|
|
|
80
|
+
### `shipready staged [path]`
|
|
81
|
+
|
|
82
|
+
Fast, secrets-only scan of the files **staged for commit** — built for pre-commit hooks. It reads content from the git index (`git show :file`), so partially staged files are checked exactly as they would be committed, not as they sit on disk.
|
|
83
|
+
|
|
84
|
+
- High-confidence secrets **block the commit** (exit code 1)
|
|
85
|
+
- Medium-confidence findings warn but do not block
|
|
86
|
+
- TODO/console.log are deliberately not checked — hooks must be fast and only stop real dangers
|
|
87
|
+
|
|
88
|
+
Hook setup with [husky](https://typicode.github.io/husky/):
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
npx husky init
|
|
92
|
+
echo "npx shipready staged" > .husky/pre-commit
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Or plain git:
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
echo 'npx shipready staged' > .git/hooks/pre-commit && chmod +x .git/hooks/pre-commit
|
|
99
|
+
```
|
|
100
|
+
|
|
77
101
|
### `shipready init [path]`
|
|
78
102
|
|
|
79
103
|
Generates AI-agent instruction files based on your detected framework, package manager, and scripts:
|
|
@@ -94,6 +118,21 @@ Applies safe automatic fixes:
|
|
|
94
118
|
|
|
95
119
|
It never deletes user code and never overwrites files without `--force`. A summary of changed files is printed at the end.
|
|
96
120
|
|
|
121
|
+
Pass `--dry-run` to preview exactly what would change — nothing is written to disk:
|
|
122
|
+
|
|
123
|
+
```txt
|
|
124
|
+
shipready fix --dry-run
|
|
125
|
+
|
|
126
|
+
+ would create .env.example
|
|
127
|
+
| # Environment variables used by this project.
|
|
128
|
+
| API_URL=
|
|
129
|
+
+ would create .gitignore
|
|
130
|
+
| .env
|
|
131
|
+
| node_modules
|
|
132
|
+
|
|
133
|
+
2 files would change. Run without --dry-run to apply.
|
|
134
|
+
```
|
|
135
|
+
|
|
97
136
|
## Example output
|
|
98
137
|
|
|
99
138
|
```txt
|
|
@@ -194,7 +233,14 @@ If shipready flags a line you know is safe (a demo value, an already-revoked key
|
|
|
194
233
|
const DEMO_TOKEN = "ghp_thisIsADocumentationExample000000"; // shipready-ignore
|
|
195
234
|
```
|
|
196
235
|
|
|
197
|
-
The
|
|
236
|
+
The marker works for **all checks** — secrets, `console.log`, `TODO`/`FIXME`, and `debugger` alike:
|
|
237
|
+
|
|
238
|
+
```js
|
|
239
|
+
console.log("startup banner"); // shipready-ignore
|
|
240
|
+
// TODO: legacy, tracked in JIRA-123 - shipready-ignore
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
For project-wide exceptions, prefer `secretAllowlist` in the config (see below) so the suppression is reviewable in one place.
|
|
198
244
|
|
|
199
245
|
## Configuration
|
|
200
246
|
|
|
@@ -251,6 +297,33 @@ Example with options:
|
|
|
251
297
|
version: "1.0.3"
|
|
252
298
|
```
|
|
253
299
|
|
|
300
|
+
### GitHub code scanning (SARIF)
|
|
301
|
+
|
|
302
|
+
Turn shipready findings into **native code scanning alerts** on pull requests:
|
|
303
|
+
|
|
304
|
+
```yaml
|
|
305
|
+
# .github/workflows/shipready-sarif.yml
|
|
306
|
+
name: shipready code scanning
|
|
307
|
+
on: [push, pull_request]
|
|
308
|
+
permissions:
|
|
309
|
+
security-events: write
|
|
310
|
+
jobs:
|
|
311
|
+
scan:
|
|
312
|
+
runs-on: ubuntu-latest
|
|
313
|
+
steps:
|
|
314
|
+
- uses: actions/checkout@v4
|
|
315
|
+
- run: npx shipready check --sarif > shipready.sarif || true
|
|
316
|
+
- uses: github/codeql-action/upload-sarif@v3
|
|
317
|
+
with:
|
|
318
|
+
sarif_file: shipready.sarif
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
Findings appear in the repository's **Security → Code scanning** tab and as inline PR annotations, with stable fingerprints so alerts track across pushes.
|
|
322
|
+
|
|
323
|
+
### Pre-commit hook
|
|
324
|
+
|
|
325
|
+
Catch secrets **before** they enter history at all — see [`shipready staged`](#shipready-staged-path) above.
|
|
326
|
+
|
|
254
327
|
### Manual setup
|
|
255
328
|
|
|
256
329
|
`shipready check` exits with code `1` when errors are found, so it works in any CI:
|
|
@@ -259,7 +332,7 @@ Example with options:
|
|
|
259
332
|
- run: npx shipready check --verbose
|
|
260
333
|
```
|
|
261
334
|
|
|
262
|
-
Use `--json` to feed the report into other tooling.
|
|
335
|
+
Use `--json` to feed the report into other tooling, or `--sarif` for anything that speaks SARIF.
|
|
263
336
|
|
|
264
337
|
## Scoring
|
|
265
338
|
|
|
@@ -290,12 +363,28 @@ pnpm dev # run the CLI from source (tsx)
|
|
|
290
363
|
|
|
291
364
|
See [CONTRIBUTING.md](./CONTRIBUTING.md) for project structure and how to add new checks or secret patterns.
|
|
292
365
|
|
|
366
|
+
## How does it compare to gitleaks?
|
|
367
|
+
|
|
368
|
+
Measured head-to-head on a corpus of 7 real-format leaks and 10 false-positive baits (gitleaks v8.24.3):
|
|
369
|
+
|
|
370
|
+
| | shipready | gitleaks |
|
|
371
|
+
| --- | --- | --- |
|
|
372
|
+
| Real leaks caught | **7 / 7** | 4 / 7 |
|
|
373
|
+
| Error-level false positives | **0** | 1 |
|
|
374
|
+
| Newer key formats (`sk-proj-`, `sk-ant-`) | ✓ | missed |
|
|
375
|
+
| Committed `.env.backup` | ✓ | partial |
|
|
376
|
+
|
|
377
|
+
gitleaks remains more battle-tested for deep git-history forensics with hundreds of rules. shipready covers the leaks that actually happen in modern AI-assisted projects — plus README, env hygiene, and code hygiene that gitleaks doesn't check at all. Full methodology, fairness notes, and reproduction steps: [BENCHMARK.md](./BENCHMARK.md).
|
|
378
|
+
|
|
293
379
|
## Roadmap
|
|
294
380
|
|
|
381
|
+
- [x] `shipready staged` for pre-commit hooks
|
|
382
|
+
- [x] SARIF report output for GitHub code scanning
|
|
383
|
+
- [x] Git history scanning (`--history`)
|
|
384
|
+
- [x] Live key verification (`--verify`)
|
|
385
|
+
- [x] Benchmark against gitleaks with published numbers ([BENCHMARK.md](./BENCHMARK.md))
|
|
386
|
+
- [ ] Framework-aware scoring (Next.js vs Vite vs Python have different needs)
|
|
295
387
|
- [ ] Optional AI-powered fix suggestions (bring your own key)
|
|
296
|
-
- [ ] `shipready check --staged` for pre-commit hooks
|
|
297
|
-
- [ ] Markdown/SARIF report output for GitHub code scanning
|
|
298
|
-
- [ ] More frameworks and secret providers
|
|
299
388
|
|
|
300
389
|
## License
|
|
301
390
|
|
package/dist/checks/secrets.js
CHANGED
|
@@ -232,6 +232,17 @@ const PATTERNS = [
|
|
|
232
232
|
group: 1,
|
|
233
233
|
minEntropy: 3.8,
|
|
234
234
|
},
|
|
235
|
+
{
|
|
236
|
+
// Unquoted dotenv-style assignment (KEY=value at line start). Catches
|
|
237
|
+
// committed .env backups where values carry no quotes - a gap found by
|
|
238
|
+
// benchmarking against gitleaks. Anchored to line start so ordinary
|
|
239
|
+
// code assignments (always quoted) don't double-match.
|
|
240
|
+
kind: "Hardcoded credential",
|
|
241
|
+
re: /^[A-Z0-9_]*(?:API_?KEY|SECRET(?:_KEY)?|ACCESS_TOKEN|AUTH_TOKEN|PASSWORD|PASSWD|CREDENTIALS?)[A-Z0-9_]*=([A-Za-z0-9+/_.=-]{16,})\s*$/,
|
|
242
|
+
confidence: "medium",
|
|
243
|
+
group: 1,
|
|
244
|
+
minEntropy: 3.8,
|
|
245
|
+
},
|
|
235
246
|
];
|
|
236
247
|
/** Words and shapes that indicate a placeholder, not a real secret. */
|
|
237
248
|
const PLACEHOLDER_VALUE_RE = /your[-_ ]?|example|sample|placeholder|change[-_ ]?me|dummy|fake|insert|replace|redacted|deadbeef|lorem|goes[-_ ]?here|test[-_ ]?key|x{4,}|\*{3,}|\.\.\.|123456|abcdef/i;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { scanContentForSecrets } from "./secrets.js";
|
|
3
|
+
/**
|
|
4
|
+
* Pre-commit mode: scans only the files staged in the git index.
|
|
5
|
+
* Reads content via `git show :<file>` so partially staged files are
|
|
6
|
+
* checked exactly as they would be committed, not as they sit on disk.
|
|
7
|
+
* Secrets-only by design - a pre-commit hook must be fast and only
|
|
8
|
+
* block on real dangers, not TODO comments.
|
|
9
|
+
*/
|
|
10
|
+
/** Extensions and paths we skip in staged mode (binary/vendor noise). */
|
|
11
|
+
const STAGED_SKIP_RE = /(^|\/)(node_modules|vendor|dist|build|\.next|out|coverage)(\/|$)|\.(lock|min\.js|min\.css|map|png|jpg|jpeg|gif|webp|svg|ico|pdf|zip|gz|woff2?|ttf|eot|mp3|mp4)$|(^|\/)(package-lock\.json|pnpm-lock\.yaml|yarn\.lock|bun\.lockb?)$/i;
|
|
12
|
+
/** Lists files staged for commit (added/copied/modified/renamed). */
|
|
13
|
+
export function listStagedFiles(root) {
|
|
14
|
+
try {
|
|
15
|
+
const out = execFileSync("git", ["diff", "--cached", "--name-only", "--diff-filter=ACMR", "-z"], { cwd: root, encoding: "utf8" });
|
|
16
|
+
return out.split("\0").filter((f) => f.length > 0 && !STAGED_SKIP_RE.test(f));
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return [];
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/** Reads a file's staged (index) content, not the working-tree version. */
|
|
23
|
+
export function readStagedContent(root, file) {
|
|
24
|
+
try {
|
|
25
|
+
const buf = execFileSync("git", ["show", `:${file}`], {
|
|
26
|
+
cwd: root,
|
|
27
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
28
|
+
});
|
|
29
|
+
// Binary sniff: NUL byte in the first 512 bytes.
|
|
30
|
+
const head = buf.subarray(0, 512);
|
|
31
|
+
for (const byte of head) {
|
|
32
|
+
if (byte === 0)
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
return buf.toString("utf8");
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** Scans all staged files for secrets. */
|
|
42
|
+
export function scanStaged(root, allowlist = []) {
|
|
43
|
+
const files = listStagedFiles(root);
|
|
44
|
+
const secrets = [];
|
|
45
|
+
for (const file of files) {
|
|
46
|
+
// .env files are expected to hold real values; committing one at all is
|
|
47
|
+
// the problem, and the gitignore check covers that. But since it IS
|
|
48
|
+
// being committed here, flag everything inside it too.
|
|
49
|
+
const content = readStagedContent(root, file);
|
|
50
|
+
if (content === null)
|
|
51
|
+
continue;
|
|
52
|
+
secrets.push(...scanContentForSecrets(content, file, allowlist));
|
|
53
|
+
}
|
|
54
|
+
return { files, secrets };
|
|
55
|
+
}
|
package/dist/checks/todos.js
CHANGED
|
@@ -13,6 +13,10 @@ export function scanContentForTodos(content, file) {
|
|
|
13
13
|
const lines = content.split("\n");
|
|
14
14
|
for (let i = 0; i < lines.length; i++) {
|
|
15
15
|
const line = lines[i];
|
|
16
|
+
// Same suppression marker the secret scanner honors - one convention
|
|
17
|
+
// for all checks, so intentional console.log/TODO lines stay quiet.
|
|
18
|
+
if (line.includes("shipready-ignore"))
|
|
19
|
+
continue;
|
|
16
20
|
const marker = line.match(MARKER_RE);
|
|
17
21
|
if (marker) {
|
|
18
22
|
found.push({ kind: "marker", label: marker[1], file, line: i + 1 });
|
package/dist/cli.js
CHANGED
|
@@ -9,17 +9,27 @@ import { fixAgentFiles } from "./fixers/agentFiles.js";
|
|
|
9
9
|
import { fixEnvExample } from "./fixers/envExample.js";
|
|
10
10
|
import { fixGitignore } from "./fixers/gitignore.js";
|
|
11
11
|
import { renderReport } from "./utils/report.js";
|
|
12
|
-
|
|
12
|
+
import { toSarif } from "./utils/sarif.js";
|
|
13
|
+
import { scanStaged } from "./checks/staged.js";
|
|
14
|
+
import { isGitRepo } from "./checks/history.js";
|
|
15
|
+
function printFixResults(results, showPreview = false) {
|
|
13
16
|
for (const r of results) {
|
|
17
|
+
const verb = (v) => (r.dryRun ? `would ${v.replace(/ed$/, "e")}` : v);
|
|
14
18
|
if (r.action === "created") {
|
|
15
|
-
console.log(`${pc.green("+")} created ${pc.bold(r.file)}`);
|
|
19
|
+
console.log(`${pc.green("+")} ${verb("created")} ${pc.bold(r.file)}`);
|
|
16
20
|
}
|
|
17
21
|
else if (r.action === "updated") {
|
|
18
|
-
console.log(`${pc.yellow("~")} updated ${pc.bold(r.file)}`);
|
|
22
|
+
console.log(`${pc.yellow("~")} ${verb("updated")} ${pc.bold(r.file)}`);
|
|
19
23
|
}
|
|
20
24
|
else {
|
|
21
25
|
console.log(`${pc.dim("-")} skipped ${r.file}${r.reason ? pc.dim(` (${r.reason})`) : ""}`);
|
|
22
26
|
}
|
|
27
|
+
if (showPreview && r.preview && r.action !== "skipped") {
|
|
28
|
+
const lines = r.preview.trimEnd().split("\n");
|
|
29
|
+
for (const l of lines) {
|
|
30
|
+
console.log(pc.dim(" | ") + pc.green(l));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
23
33
|
}
|
|
24
34
|
}
|
|
25
35
|
/** Resolves and validates the target directory argument. */
|
|
@@ -34,14 +44,14 @@ function resolveRoot(dir) {
|
|
|
34
44
|
return root;
|
|
35
45
|
}
|
|
36
46
|
/** Applies all safe fixes for the given root; returns the results. */
|
|
37
|
-
async function applyFixes(root, force) {
|
|
47
|
+
async function applyFixes(root, force, dryRun = false) {
|
|
38
48
|
const config = loadConfig(root);
|
|
39
49
|
const project = await detectProject(root, config);
|
|
40
50
|
const { envUsages } = scanFiles(root, project.sourceFiles, config.secretAllowlist);
|
|
41
51
|
return [
|
|
42
|
-
fixEnvExample(root, envUsages, force),
|
|
43
|
-
fixGitignore(root),
|
|
44
|
-
...fixAgentFiles(root, project, force),
|
|
52
|
+
fixEnvExample(root, envUsages, force, dryRun),
|
|
53
|
+
fixGitignore(root, dryRun),
|
|
54
|
+
...fixAgentFiles(root, project, force, dryRun),
|
|
45
55
|
];
|
|
46
56
|
}
|
|
47
57
|
/** Reads the CLI's own version from its package.json (works from dist/ at runtime). */
|
|
@@ -68,14 +78,16 @@ export function buildProgram() {
|
|
|
68
78
|
.argument("[path]", "project directory to scan (defaults to current directory)")
|
|
69
79
|
.option("-v, --verbose", "show file locations for every finding")
|
|
70
80
|
.option("--json", "output the raw report as JSON")
|
|
81
|
+
.option("--sarif", "output the report as SARIF 2.1.0 for GitHub code scanning")
|
|
71
82
|
.option("--fix", "apply safe fixes, then re-scan and show the improved report")
|
|
72
83
|
.option("--history", "also scan the full git history for leaked secrets")
|
|
73
84
|
.option("--verify", "check detected keys against provider APIs to see if they are live")
|
|
74
85
|
.action(async (dir, opts) => {
|
|
75
86
|
try {
|
|
76
87
|
const root = resolveRoot(dir);
|
|
88
|
+
const machineOutput = Boolean(opts.json || opts.sarif);
|
|
77
89
|
const scanOpts = { history: opts.history, verify: opts.verify };
|
|
78
|
-
if (opts.verify && !
|
|
90
|
+
if (opts.verify && !machineOutput) {
|
|
79
91
|
console.log(pc.dim("\n Verifying detected keys against provider APIs..."));
|
|
80
92
|
}
|
|
81
93
|
let report = await runScan(root, scanOpts);
|
|
@@ -83,7 +95,7 @@ export function buildProgram() {
|
|
|
83
95
|
const before = report.score;
|
|
84
96
|
const results = await applyFixes(root, false);
|
|
85
97
|
report = await runScan(root, scanOpts);
|
|
86
|
-
if (!
|
|
98
|
+
if (!machineOutput) {
|
|
87
99
|
console.log("");
|
|
88
100
|
console.log(pc.bold(pc.magenta("shipready check --fix")));
|
|
89
101
|
console.log("");
|
|
@@ -94,7 +106,10 @@ export function buildProgram() {
|
|
|
94
106
|
(delta > 0 ? pc.green(` (+${delta})`) : pc.dim(" (no change)")));
|
|
95
107
|
}
|
|
96
108
|
}
|
|
97
|
-
if (opts.
|
|
109
|
+
if (opts.sarif) {
|
|
110
|
+
console.log(toSarif(report, ownVersion()));
|
|
111
|
+
}
|
|
112
|
+
else if (opts.json) {
|
|
98
113
|
console.log(JSON.stringify(report, null, 2));
|
|
99
114
|
}
|
|
100
115
|
else {
|
|
@@ -140,19 +155,28 @@ export function buildProgram() {
|
|
|
140
155
|
.description("Apply safe automatic fixes (.env.example, .gitignore, agent files)")
|
|
141
156
|
.argument("[path]", "project directory (defaults to current directory)")
|
|
142
157
|
.option("-f, --force", "overwrite existing files")
|
|
158
|
+
.option("--dry-run", "show what would change without writing anything")
|
|
143
159
|
.action(async (dir, opts) => {
|
|
144
160
|
try {
|
|
145
161
|
const root = resolveRoot(dir);
|
|
162
|
+
const dryRun = opts.dryRun ?? false;
|
|
146
163
|
console.log("");
|
|
147
|
-
console.log(pc.bold(pc.magenta(
|
|
164
|
+
console.log(pc.bold(pc.magenta(`shipready fix${dryRun ? " --dry-run" : ""}`)));
|
|
148
165
|
console.log("");
|
|
149
|
-
const results = await applyFixes(root, opts.force ?? false);
|
|
150
|
-
printFixResults(results);
|
|
166
|
+
const results = await applyFixes(root, opts.force ?? false, dryRun);
|
|
167
|
+
printFixResults(results, dryRun);
|
|
151
168
|
const changed = results.filter((r) => r.action !== "skipped").length;
|
|
152
169
|
console.log("");
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
170
|
+
if (dryRun) {
|
|
171
|
+
console.log(changed > 0
|
|
172
|
+
? pc.yellow(`${changed} file${changed > 1 ? "s" : ""} would change. Run without --dry-run to apply.`)
|
|
173
|
+
: pc.dim("Nothing to fix - everything already in place."));
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
console.log(changed > 0
|
|
177
|
+
? pc.green(`${changed} file${changed > 1 ? "s" : ""} changed.`)
|
|
178
|
+
: pc.dim("Nothing to fix - everything already in place."));
|
|
179
|
+
}
|
|
156
180
|
console.log("");
|
|
157
181
|
}
|
|
158
182
|
catch (err) {
|
|
@@ -160,6 +184,54 @@ export function buildProgram() {
|
|
|
160
184
|
process.exitCode = 1;
|
|
161
185
|
}
|
|
162
186
|
});
|
|
187
|
+
program
|
|
188
|
+
.command("staged")
|
|
189
|
+
.description("Fast secrets-only scan of files staged for commit (pre-commit hook)")
|
|
190
|
+
.argument("[path]", "project directory (defaults to current directory)")
|
|
191
|
+
.action(async (dir) => {
|
|
192
|
+
try {
|
|
193
|
+
const root = resolveRoot(dir);
|
|
194
|
+
if (!isGitRepo(root)) {
|
|
195
|
+
console.error(pc.red("shipready staged: not a git repository"));
|
|
196
|
+
process.exitCode = 1;
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
const config = loadConfig(root);
|
|
200
|
+
const { files, secrets } = scanStaged(root, config.secretAllowlist);
|
|
201
|
+
if (files.length === 0) {
|
|
202
|
+
console.log(pc.dim("shipready staged: no staged files to scan."));
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
const high = secrets.filter((s) => s.confidence !== "medium");
|
|
206
|
+
const medium = secrets.filter((s) => s.confidence === "medium");
|
|
207
|
+
if (secrets.length === 0) {
|
|
208
|
+
console.log(pc.green("✓") +
|
|
209
|
+
` shipready staged: ${files.length} file${files.length > 1 ? "s" : ""} clean.`);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
console.log("");
|
|
213
|
+
for (const s of high) {
|
|
214
|
+
console.log(`${pc.red("✗")} ${s.kind}: ${s.masked} ${pc.dim(`${s.file}:${s.line}`)}`);
|
|
215
|
+
}
|
|
216
|
+
for (const s of medium) {
|
|
217
|
+
console.log(`${pc.yellow("⚠")} ${s.kind}: ${s.masked} ${pc.dim(`${s.file}:${s.line}`)} ${pc.dim("(low confidence)")}`);
|
|
218
|
+
}
|
|
219
|
+
console.log("");
|
|
220
|
+
if (high.length > 0) {
|
|
221
|
+
console.log(pc.red(`Blocked: ${high.length} secret${high.length > 1 ? "s" : ""} in staged files. ` +
|
|
222
|
+
`Remove them (or add // shipready-ignore for intentional values) and retry.`));
|
|
223
|
+
// Only high-confidence findings block the commit; medium is a warning.
|
|
224
|
+
process.exitCode = 1;
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
console.log(pc.yellow("Warning only - commit not blocked."));
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
catch (err) {
|
|
231
|
+
console.error(pc.red(`shipready staged failed: ${err.message}`));
|
|
232
|
+
process.exitCode = 1;
|
|
233
|
+
}
|
|
234
|
+
});
|
|
163
235
|
return program;
|
|
164
236
|
}
|
|
165
237
|
/** CLI entry: parses argv and runs the matching command. */
|
|
@@ -8,7 +8,7 @@ const TARGETS = [
|
|
|
8
8
|
{ file: ".cursor/rules/shipready.md", generate: generateCursorRules },
|
|
9
9
|
];
|
|
10
10
|
/** Generates all agent instruction files, honoring --force. */
|
|
11
|
-
export function fixAgentFiles(root, project, force) {
|
|
11
|
+
export function fixAgentFiles(root, project, force, dryRun = false) {
|
|
12
12
|
const results = [];
|
|
13
13
|
for (const { file, generate } of TARGETS) {
|
|
14
14
|
if (fileExists(root, file) && !force) {
|
|
@@ -16,8 +16,11 @@ export function fixAgentFiles(root, project, force) {
|
|
|
16
16
|
continue;
|
|
17
17
|
}
|
|
18
18
|
const existed = fileExists(root, file);
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
if (!dryRun) {
|
|
20
|
+
writeTextFile(root, file, generate(project));
|
|
21
|
+
}
|
|
22
|
+
// Agent files are long; the dry-run preview stays terse on purpose.
|
|
23
|
+
results.push({ file, action: existed ? "updated" : "created", dryRun });
|
|
21
24
|
}
|
|
22
25
|
return results;
|
|
23
26
|
}
|
|
@@ -12,7 +12,7 @@ export function buildEnvExample(usages) {
|
|
|
12
12
|
return lines.join("\n");
|
|
13
13
|
}
|
|
14
14
|
/** Creates .env.example if missing (or with force). */
|
|
15
|
-
export function fixEnvExample(root, usages, force) {
|
|
15
|
+
export function fixEnvExample(root, usages, force, dryRun = false) {
|
|
16
16
|
const file = ".env.example";
|
|
17
17
|
const names = [...new Set(usages.map((u) => u.name))];
|
|
18
18
|
if (names.length === 0) {
|
|
@@ -22,6 +22,14 @@ export function fixEnvExample(root, usages, force) {
|
|
|
22
22
|
return { file, action: "skipped", reason: "already exists (use --force)" };
|
|
23
23
|
}
|
|
24
24
|
const existed = fileExists(root, file);
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
const content = buildEnvExample(usages);
|
|
26
|
+
if (!dryRun) {
|
|
27
|
+
writeTextFile(root, file, content);
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
file,
|
|
31
|
+
action: existed ? "updated" : "created",
|
|
32
|
+
dryRun,
|
|
33
|
+
preview: content,
|
|
34
|
+
};
|
|
27
35
|
}
|
package/dist/fixers/gitignore.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { missingIgnoreEntries } from "../checks/gitignore.js";
|
|
2
2
|
import { readTextFile, writeTextFile } from "../utils/files.js";
|
|
3
3
|
/** Adds missing important entries to .gitignore (creates it if absent). */
|
|
4
|
-
export function fixGitignore(root) {
|
|
4
|
+
export function fixGitignore(root, dryRun = false) {
|
|
5
5
|
const file = ".gitignore";
|
|
6
6
|
const existing = readTextFile(root, file);
|
|
7
7
|
if (existing === null) {
|
|
@@ -15,8 +15,10 @@ export function fixGitignore(root) {
|
|
|
15
15
|
".next",
|
|
16
16
|
"",
|
|
17
17
|
].join("\n");
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
if (!dryRun) {
|
|
19
|
+
writeTextFile(root, file, content);
|
|
20
|
+
}
|
|
21
|
+
return { file, action: "created", dryRun, preview: content };
|
|
20
22
|
}
|
|
21
23
|
const missing = missingIgnoreEntries(existing);
|
|
22
24
|
if (missing.length === 0) {
|
|
@@ -24,6 +26,9 @@ export function fixGitignore(root) {
|
|
|
24
26
|
}
|
|
25
27
|
const suffix = existing.endsWith("\n") ? "" : "\n";
|
|
26
28
|
const addition = `${suffix}\n# Added by shipready\n${missing.join("\n")}\n`;
|
|
27
|
-
|
|
28
|
-
|
|
29
|
+
if (!dryRun) {
|
|
30
|
+
writeTextFile(root, file, existing + addition);
|
|
31
|
+
}
|
|
32
|
+
// Preview shows only what gets appended, not the whole file.
|
|
33
|
+
return { file, action: "updated", dryRun, preview: addition.trimStart() };
|
|
29
34
|
}
|
package/dist/scanner.js
CHANGED
|
@@ -42,11 +42,18 @@ export function scanFiles(root, files, secretAllowlist = []) {
|
|
|
42
42
|
* an example is just as dangerous - but hygiene noise is skipped.
|
|
43
43
|
*/
|
|
44
44
|
const HYGIENE_EXEMPT_RE = /(?:^|\/)(?:examples?|demos?|benchmarks?|scripts?|playground)\//;
|
|
45
|
+
/**
|
|
46
|
+
* Standard env file names that legitimately hold real values and are
|
|
47
|
+
* expected to be gitignored - the env check owns those. Anything else
|
|
48
|
+
* that merely starts with ".env" (.env.backup, .env.old, .env.prod)
|
|
49
|
+
* is a copy someone made and MUST be scanned: benchmark testing showed
|
|
50
|
+
* gitleaks catches committed .env backups while we used to skip them.
|
|
51
|
+
*/
|
|
52
|
+
const STANDARD_ENV_RE = /^\.env(?:\.(?:local|development|production|test|staging))?(?:\.local)?$/;
|
|
45
53
|
for (const file of files) {
|
|
46
54
|
const base = path.basename(file);
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const isEnvFile = base.startsWith(".env");
|
|
55
|
+
const isStandardEnv = STANDARD_ENV_RE.test(base);
|
|
56
|
+
const isEnvTemplate = base === ".env.example" || base === ".env.sample" || base === ".env.template";
|
|
50
57
|
const ext = path.extname(file).toLowerCase();
|
|
51
58
|
const isCode = CODE_ONLY.has(ext);
|
|
52
59
|
if (isProbablyBinary(root, file))
|
|
@@ -60,7 +67,7 @@ export function scanFiles(root, files, secretAllowlist = []) {
|
|
|
60
67
|
todos.push(...scanContentForTodos(content, file));
|
|
61
68
|
}
|
|
62
69
|
}
|
|
63
|
-
if (!
|
|
70
|
+
if (!isStandardEnv && !isEnvTemplate) {
|
|
64
71
|
secrets.push(...scanContentForSecrets(content, file, secretAllowlist));
|
|
65
72
|
}
|
|
66
73
|
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/** Maps shipready severity to a SARIF level. */
|
|
2
|
+
function toLevel(severity) {
|
|
3
|
+
if (severity === "error")
|
|
4
|
+
return "error";
|
|
5
|
+
if (severity === "warning")
|
|
6
|
+
return "warning";
|
|
7
|
+
return "note";
|
|
8
|
+
}
|
|
9
|
+
/** Human-friendly rule names derived from rule ids ("secrets.detected-item" -> "SecretsDetectedItem"). */
|
|
10
|
+
function ruleName(ruleId) {
|
|
11
|
+
return ruleId
|
|
12
|
+
.split(/[.-]/)
|
|
13
|
+
.filter(Boolean)
|
|
14
|
+
.map((p) => p[0].toUpperCase() + p.slice(1))
|
|
15
|
+
.join("");
|
|
16
|
+
}
|
|
17
|
+
/** Simple stable fingerprint so GitHub tracks alerts across pushes. */
|
|
18
|
+
function fingerprint(f) {
|
|
19
|
+
const raw = `${f.rule}|${f.file ?? ""}|${f.message}`;
|
|
20
|
+
let hash = 0;
|
|
21
|
+
for (let i = 0; i < raw.length; i++) {
|
|
22
|
+
hash = (hash * 31 + raw.charCodeAt(i)) | 0;
|
|
23
|
+
}
|
|
24
|
+
return (hash >>> 0).toString(16);
|
|
25
|
+
}
|
|
26
|
+
/** Converts a shipready report to a SARIF 2.1.0 log string. */
|
|
27
|
+
export function toSarif(report, version) {
|
|
28
|
+
const rules = new Map();
|
|
29
|
+
const results = [];
|
|
30
|
+
for (const check of report.results) {
|
|
31
|
+
for (const f of check.findings) {
|
|
32
|
+
// Success/info summary rows aren't actionable alerts.
|
|
33
|
+
if (f.severity === "success")
|
|
34
|
+
continue;
|
|
35
|
+
if (f.severity === "info" && !f.file)
|
|
36
|
+
continue;
|
|
37
|
+
if (!rules.has(f.rule)) {
|
|
38
|
+
rules.set(f.rule, {
|
|
39
|
+
id: f.rule,
|
|
40
|
+
name: ruleName(f.rule),
|
|
41
|
+
shortDescription: { text: f.rule },
|
|
42
|
+
defaultConfiguration: { level: toLevel(f.severity) },
|
|
43
|
+
helpUri: "https://github.com/formalness/shipready#readme",
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
results.push({
|
|
47
|
+
ruleId: f.rule,
|
|
48
|
+
level: toLevel(f.severity),
|
|
49
|
+
message: { text: f.message },
|
|
50
|
+
locations: [
|
|
51
|
+
{
|
|
52
|
+
physicalLocation: {
|
|
53
|
+
artifactLocation: {
|
|
54
|
+
// SARIF requires a location; findings without a file anchor
|
|
55
|
+
// to the repo root via package.json-ish convention.
|
|
56
|
+
uri: f.file ?? ".",
|
|
57
|
+
uriBaseId: "%SRCROOT%",
|
|
58
|
+
},
|
|
59
|
+
...(f.line && f.line > 0 ? { region: { startLine: f.line } } : {}),
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
partialFingerprints: { shipready: fingerprint(f) },
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const log = {
|
|
68
|
+
$schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
|
|
69
|
+
version: "2.1.0",
|
|
70
|
+
runs: [
|
|
71
|
+
{
|
|
72
|
+
tool: {
|
|
73
|
+
driver: {
|
|
74
|
+
name: "shipready",
|
|
75
|
+
version,
|
|
76
|
+
informationUri: "https://github.com/formalness/shipready",
|
|
77
|
+
rules: [...rules.values()],
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
results,
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
};
|
|
84
|
+
return JSON.stringify(log, null, 2);
|
|
85
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "shipready",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.1",
|
|
4
4
|
"description": "Pre-flight check for your repo before shipping. Scans AI-coded projects for secrets, env issues, debug leftovers, and generates AI-agent instruction files.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|