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 CHANGED
@@ -4,6 +4,7 @@
4
4
 
5
5
  [![CI](https://github.com/formalness/shipready/actions/workflows/ci.yml/badge.svg)](https://github.com/formalness/shipready/actions/workflows/ci.yml)
6
6
  [![npm version](https://img.shields.io/npm/v/shipready.svg)](https://www.npmjs.com/package/shipready)
7
+ [![tests](https://img.shields.io/badge/tests-179%20passing-brightgreen.svg)](https://github.com/formalness/shipready/tree/main/tests)
7
8
  [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)
8
9
  [![node >= 18](https://img.shields.io/badge/node-%3E%3D18-brightgreen.svg)](https://nodejs.org)
9
10
 
@@ -37,9 +38,10 @@ shipready check
37
38
  ## Usage
38
39
 
39
40
  ```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
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 scanner skips any line containing `shipready-ignore`. For project-wide exceptions, prefer `secretAllowlist` in the config (see below) so the suppression is reviewable in one place.
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
 
@@ -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
+ }
@@ -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
- function printFixResults(results) {
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 && !opts.json) {
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 (!opts.json) {
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.json) {
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("shipready fix")));
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
- console.log(changed > 0
154
- ? pc.green(`${changed} file${changed > 1 ? "s" : ""} changed.`)
155
- : pc.dim("Nothing to fix - everything already in place."));
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
- writeTextFile(root, file, generate(project));
20
- results.push({ file, action: existed ? "updated" : "created" });
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
- writeTextFile(root, file, buildEnvExample(usages));
26
- return { file, action: existed ? "updated" : "created" };
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
  }
@@ -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
- writeTextFile(root, file, content);
19
- return { file, action: "created" };
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
- writeTextFile(root, file, existing + addition);
28
- return { file, action: "updated" };
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
- // Never flag .env files themselves for secrets/todos; they are expected
48
- // to contain real values and are handled by the env check.
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 (!isEnvFile && base !== ".env.example" && base !== ".env.sample") {
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.4.0",
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",