shipready 1.1.0 → 1.3.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/README.md CHANGED
@@ -53,9 +53,27 @@ Scans the project and prints a report with a 0-100 score.
53
53
  | `-v, --verbose` | show file and line locations for every finding |
54
54
  | `--json` | output the raw structured report as JSON (great for CI) |
55
55
  | `--fix` | apply safe fixes, re-scan, and show the score before/after |
56
+ | `--history` | also scan the **full git history** (all branches) for secrets that were committed and later removed |
57
+ | `--verify` | check detected keys against provider APIs to see if they are **live right now** |
56
58
 
57
59
  Exits with code `1` when errors are found, so you can use it in CI pipelines.
58
60
 
61
+ #### `--history`: secrets buried in old commits
62
+
63
+ Deleting a leaked key from your code does not delete it from git history — anyone who clones the repo can still read it. `shipready check --history` scans every added line in every commit on every branch, dedupes findings, and skips anything still present in the working tree (the regular scan already covers those). Each finding shows the abbreviated commit hash so you know where to look.
64
+
65
+ If something is found: rotate the key, then purge it with [git filter-repo](https://github.com/newren/git-filter-repo) or [BFG](https://rtyley.github.io/bfg-repo-cleaner/).
66
+
67
+ #### `--verify`: is the key actually live?
68
+
69
+ For 12+ providers (OpenAI, Anthropic, GitHub, GitLab, Stripe, SendGrid, Google, npm, Hugging Face, Figma, and more) shipready can make a single read-only "who am I" request to the provider's API:
70
+
71
+ - `[VERIFIED ACTIVE]` — the key works right now; this upgrades the finding to an error and jumps to the top of next steps
72
+ - `[not active - rotate anyway]` — the provider rejected it (revoked or fake)
73
+ - no marker — the provider has no safe verification endpoint, or the network was unavailable
74
+
75
+ 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
+
59
77
  ### `shipready init [path]`
60
78
 
61
79
  Generates AI-agent instruction files based on your detected framework, package manager, and scripts:
@@ -79,26 +97,28 @@ It never deletes user code and never overwrites files without `--force`. A summa
79
97
  ## Example output
80
98
 
81
99
  ```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
100
+ ╭──────────────────────────────────────────────────────────╮
101
+ │ shipready v1.3.0 │
102
+ project Next.js · pm pnpm │
103
+ ╰──────────────────────────────────────────────────────────╯
104
+
105
+ Score ████████████████████░░░░░░░░ 72/100 almost there
106
+
107
+ package.json ok
108
+ README ok
109
+ Env safety .env.example missing (3 env vars used in code)
110
+ ✗ .env is not ignored by git
111
+ Secrets No obvious secrets found
112
+ Git history 1 secret buried in git history (removed from code but still exposed)
113
+ ⚠ Code hygiene 4 TODO/FIXME comments found
114
+ ⚠ 2 console.log calls found
115
+ ✓ .gitignore ok
116
+
117
+ Next steps
118
+ 1. Purge leaked secrets from git history (BFG or git filter-repo) and rotate them
119
+ 2. Add .env to .gitignore
120
+ 3. Create .env.example (run: shipready fix)
121
+ 4. Remove debug logs and debugger statements before shipping
102
122
  ```
103
123
 
104
124
  ## What it checks
@@ -108,7 +128,7 @@ Recommended next steps:
108
128
  | **package.json** | Existence, package manager (lockfiles), framework, `dev`/`build`/`test`/`lint` scripts |
109
129
  | **README** | Existence, installation/usage/license sections, not-too-empty |
110
130
  | **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) |
131
+ | **Secrets** | 30+ token patterns with entropy analysis and confidence levels, all masked in output (see table below) |
112
132
  | **Debug leftovers** | `TODO`, `FIXME`, `HACK`, `XXX`, `console.log`, `debugger`, `throw new Error("Not implemented")` |
113
133
  | **.gitignore** | Existence and important entries (`.env`, `node_modules`, `dist`, `build`, `.next`) |
114
134
 
@@ -116,28 +136,65 @@ Ignored during scans: `node_modules`, `.git`, `dist`, `build`, `.next`, `out`, `
116
136
 
117
137
  ## Detected secrets
118
138
 
139
+ The scanner combines provider-specific patterns with **Shannon entropy analysis** so that placeholders, examples, and templated values never pollute the report.
140
+
141
+ **High confidence** (reported as errors):
142
+
119
143
  | Type | Recognized by |
120
144
  | --- | --- |
121
145
  | OpenAI | `sk-`, `sk-proj-` |
122
146
  | Anthropic | `sk-ant-` |
123
147
  | Google / Gemini | `AIza...` |
124
- | GitHub | `ghp_`, `github_pat_` |
125
- | Stripe (live) | `sk_live_`, `rk_live_` |
126
- | Slack | `xoxb-`, `xoxp-` |
127
- | AWS | `AKIA...` |
148
+ | GitHub | `ghp_`, `gho_`, `ghu_`, `ghs_`, `ghr_`, `github_pat_` |
149
+ | GitLab | `glpat-...` |
150
+ | Stripe (live) | `sk_live_`, `rk_live_`, `whsec_` |
151
+ | Slack | `xoxb-`, `xoxp-`, `xapp-`, webhook URLs |
152
+ | Discord | bot tokens, webhook URLs |
153
+ | AWS | `AKIA...` key IDs, `aws_secret_access_key` assignments |
128
154
  | Supabase | `sbp_...` |
129
155
  | Vercel | `vercel_...` |
130
156
  | npm | `npm_...` |
131
157
  | SendGrid | `SG.xxx.xxx` |
132
158
  | Twilio | `AC` / `SK` + 32 hex |
133
159
  | Telegram bot | `123456789:AA...` |
160
+ | DigitalOcean | `dop_v1_`, `doo_v1_`, `dor_v1_` |
161
+ | Hugging Face | `hf_...` |
162
+ | Shopify | `shpat_`, `shpss_`, `shpca_` |
163
+ | Mailchimp | 32 hex + `-usNN` |
164
+ | Airtable | `pat...` |
165
+ | Fly.io | `fo1_...` |
166
+ | Cloudflare | `CLOUDFLARE_API_TOKEN=` assignments |
167
+ | Heroku | `HEROKU_API_KEY=` UUIDs |
134
168
  | Database URL with password | `postgres://user:pass@host` (also mysql, mongodb, redis, amqp) |
135
169
  | GCP service account | `"private_key_id"` in JSON |
136
170
  | Private key block | `-----BEGIN ... PRIVATE KEY-----` |
137
- | JWT | `eyJ...` |
138
- | Generic credential | `API_KEY=`, `SECRET=`, `PASSWORD=` assignments |
139
171
 
140
- Placeholders (`your-api-key`, `changeme`, `xxxx`, ...) are ignored. Matched values are always masked — shipready never prints a full secret.
172
+ **Medium confidence** (reported as warnings, so real keys never hide among noise):
173
+
174
+ | Type | Recognized by |
175
+ | --- | --- |
176
+ | Stripe (test) | `sk_test_`, `pk_test_` |
177
+ | JWT | `eyJ...` with valid structure |
178
+ | Generic credential | `API_KEY=`, `SECRET=`, `PASSWORD=` assignments — only when the value has high entropy |
179
+ | Any finding in test/fixture/mock files | automatically downgraded |
180
+
181
+ False-positive protection:
182
+
183
+ - **Entropy gate**: generic assignments are only flagged when the value is statistically random (real keys are; `changeme` is not)
184
+ - **Placeholder detection**: `your-api-key`, `xxxx`, `<token>`, `${VAR}`, `process.env.X`, and template literals are skipped
185
+ - **Repeat/sequence filter**: `aaaa...`, `1234...` never match
186
+ - **Bundle guard**: single-line minified blobs are skipped entirely
187
+ - Matched values are always masked — shipready never prints a full secret
188
+
189
+ ### Suppressing a single finding
190
+
191
+ If shipready flags a line you know is safe (a demo value, an already-revoked key in docs), add the `shipready-ignore` marker to that line:
192
+
193
+ ```js
194
+ const DEMO_TOKEN = "ghp_thisIsADocumentationExample000000"; // shipready-ignore
195
+ ```
196
+
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.
141
198
 
142
199
  ## Configuration
143
200
 
@@ -0,0 +1,146 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { scanContentForSecrets } from "./secrets.js";
3
+ /** Paths in history we never scan (vendor noise, lockfiles, build output). */
4
+ const HISTORY_SKIP_RE = /(^|\/)(node_modules|vendor|dist|build|\.next|out|coverage)(\/|$)|\.(lock|min\.js|min\.css|map|png|jpg|jpeg|gif|webp|ico|pdf|zip|gz|woff2?)$|(^|\/)(package-lock\.json|pnpm-lock\.yaml|yarn\.lock|bun\.lockb?)$/i;
5
+ /**
6
+ * Parses `git log -p --unified=0` output into added lines.
7
+ * Exported for testing.
8
+ */
9
+ export function extractAddedLines(diffText) {
10
+ const added = [];
11
+ let commit = "";
12
+ let file = "";
13
+ for (const line of diffText.split("\n")) {
14
+ if (line.startsWith("commit ")) {
15
+ commit = line.slice(7, 14); // abbreviated hash
16
+ continue;
17
+ }
18
+ if (line.startsWith("+++ b/")) {
19
+ file = line.slice(6);
20
+ continue;
21
+ }
22
+ if (line.startsWith("+++")) {
23
+ // +++ /dev/null (file deletion)
24
+ file = "";
25
+ continue;
26
+ }
27
+ if (line.startsWith("+") && !line.startsWith("+++") && file) {
28
+ const content = line.slice(1);
29
+ // Skip empty lines and vendor/binary-ish paths early.
30
+ if (content.trim().length === 0)
31
+ continue;
32
+ if (HISTORY_SKIP_RE.test(file))
33
+ continue;
34
+ // Minified blobs are noise, mirroring the working-tree scanner.
35
+ if (content.length > 10000)
36
+ continue;
37
+ added.push({ commit, file, content });
38
+ }
39
+ }
40
+ return added;
41
+ }
42
+ /** True when the directory is inside a git repository. */
43
+ export function isGitRepo(root) {
44
+ try {
45
+ execFileSync("git", ["rev-parse", "--is-inside-work-tree"], {
46
+ cwd: root,
47
+ stdio: ["ignore", "pipe", "ignore"],
48
+ });
49
+ return true;
50
+ }
51
+ catch {
52
+ return false;
53
+ }
54
+ }
55
+ /**
56
+ * Scans the entire git history (all branches) for secrets in added lines.
57
+ * Findings that still exist in the working tree are excluded - the regular
58
+ * scanner already reports those; this check surfaces the ones hiding only
59
+ * in old commits.
60
+ */
61
+ export function scanGitHistory(root, allowlist = [], workingTreeSecrets = []) {
62
+ let diffText;
63
+ try {
64
+ diffText = execFileSync("git", ["log", "--all", "-p", "--unified=0", "--no-color", "--diff-filter=AM"], { cwd: root, maxBuffer: 512 * 1024 * 1024, encoding: "utf8" });
65
+ }
66
+ catch {
67
+ return [];
68
+ }
69
+ const addedLines = extractAddedLines(diffText);
70
+ const currentMasks = new Set(workingTreeSecrets.map((s) => `${s.file}::${s.masked}`));
71
+ const seen = new Set();
72
+ const found = [];
73
+ for (const { commit, file, content } of addedLines) {
74
+ const hits = scanContentForSecrets(content, file, allowlist);
75
+ for (const hit of hits) {
76
+ // Skip if the same secret is already reported from the working tree.
77
+ if (currentMasks.has(`${hit.file}::${hit.masked}`))
78
+ continue;
79
+ // Dedupe repeats across commits (same value in the same file).
80
+ const key = `${hit.file}::${hit.masked}::${hit.kind}`;
81
+ if (seen.has(key))
82
+ continue;
83
+ seen.add(key);
84
+ found.push({
85
+ ...hit,
86
+ line: 0,
87
+ source: "history",
88
+ commit,
89
+ });
90
+ }
91
+ }
92
+ return found;
93
+ }
94
+ /** Builds the CheckResult for git-history findings. */
95
+ export function checkHistory(historySecrets, scanned) {
96
+ const findings = [];
97
+ if (!scanned) {
98
+ findings.push({
99
+ severity: "info",
100
+ rule: "history.skipped",
101
+ message: "Not a git repository - history scan skipped",
102
+ });
103
+ return { name: "git-history", findings };
104
+ }
105
+ if (historySecrets.length === 0) {
106
+ findings.push({
107
+ severity: "success",
108
+ rule: "history.clean",
109
+ message: "No secrets found in git history",
110
+ });
111
+ return { name: "git-history", findings };
112
+ }
113
+ const high = historySecrets.filter((s) => s.confidence !== "medium");
114
+ const medium = historySecrets.filter((s) => s.confidence === "medium");
115
+ if (high.length > 0) {
116
+ findings.push({
117
+ severity: "error",
118
+ rule: "history.secret",
119
+ message: `${high.length} secret${high.length > 1 ? "s" : ""} buried in git history (removed from code but still exposed)`,
120
+ });
121
+ for (const s of high) {
122
+ findings.push({
123
+ severity: "error",
124
+ rule: "history.secret-item",
125
+ message: `${s.kind}: ${s.masked} (commit ${s.commit})`,
126
+ file: s.file,
127
+ });
128
+ }
129
+ }
130
+ if (medium.length > 0) {
131
+ findings.push({
132
+ severity: "warning",
133
+ rule: "history.possible",
134
+ message: `${medium.length} possible secret${medium.length > 1 ? "s" : ""} in git history (lower confidence)`,
135
+ });
136
+ for (const s of medium) {
137
+ findings.push({
138
+ severity: "warning",
139
+ rule: "history.possible-item",
140
+ message: `${s.kind}: ${s.masked} (commit ${s.commit})`,
141
+ file: s.file,
142
+ });
143
+ }
144
+ }
145
+ return { name: "git-history", findings };
146
+ }
@@ -1,47 +1,263 @@
1
+ import { hasLongRepeat, hasSequentialRun, shannonEntropy, } from "../utils/entropy.js";
2
+ /**
3
+ * Ordered pattern list: most specific first. The first matching pattern
4
+ * wins for a given line.
5
+ */
1
6
  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
7
  {
16
- kind: "Database URL with password",
17
- re: /\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|rediss|amqp):\/\/[^:\s"'@/]+:[^@\s"']+@/,
8
+ kind: "Private key block",
9
+ re: /-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP |ENCRYPTED )?PRIVATE KEY(?: BLOCK)?-----/,
10
+ confidence: "high",
11
+ skipValueChecks: true,
18
12
  },
19
13
  {
20
- kind: "Private key block",
21
- re: /-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/,
14
+ kind: "AWS access key ID",
15
+ re: /\b(?:AKIA|ASIA|ABIA|ACCA)[A-Z0-9]{16}\b/,
16
+ confidence: "high",
17
+ },
18
+ {
19
+ kind: "AWS secret access key",
20
+ re: /\baws_?secret_?access_?key\b.{0,10}[=:]\s*["']?([A-Za-z0-9/+=]{40})\b/i,
21
+ confidence: "high",
22
+ group: 1,
23
+ minEntropy: 3.5,
24
+ },
25
+ {
26
+ kind: "GitHub token",
27
+ re: /\b(?:gh[pousr]_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{20,})\b/,
28
+ confidence: "high",
29
+ },
30
+ {
31
+ kind: "GitLab token",
32
+ re: /\bglpat-[A-Za-z0-9_-]{20,}\b/,
33
+ confidence: "high",
34
+ },
35
+ { kind: "Anthropic key", re: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/, confidence: "high" },
36
+ {
37
+ kind: "OpenAI key",
38
+ re: /\bsk-(?:proj-|svcacct-|None-)?[A-Za-z0-9_-]{16,}\b/,
39
+ confidence: "high",
40
+ minEntropy: 3.2,
41
+ },
42
+ {
43
+ kind: "Google/Gemini key",
44
+ re: /\bAIza[A-Za-z0-9_-]{30,}\b/,
45
+ confidence: "high",
46
+ },
47
+ {
48
+ kind: "Stripe live key",
49
+ re: /\b(?:sk|rk)_live_[A-Za-z0-9]{16,}\b/,
50
+ confidence: "high",
51
+ },
52
+ {
53
+ kind: "Stripe webhook secret",
54
+ re: /\bwhsec_[A-Za-z0-9]{24,}\b/,
55
+ confidence: "high",
56
+ },
57
+ {
58
+ kind: "Stripe test key",
59
+ re: /\b(?:sk|rk)_test_[A-Za-z0-9]{16,}\b/,
60
+ confidence: "medium",
61
+ },
62
+ {
63
+ kind: "Slack token",
64
+ re: /\bxox[abpsr]-[A-Za-z0-9-]{10,}\b/,
65
+ confidence: "high",
66
+ },
67
+ {
68
+ kind: "Slack webhook URL",
69
+ re: /hooks\.slack\.com\/services\/T[A-Z0-9]{5,}\/B[A-Z0-9]{5,}\/[A-Za-z0-9]{18,}/,
70
+ confidence: "high",
71
+ },
72
+ {
73
+ kind: "SendGrid key",
74
+ re: /\bSG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}\b/,
75
+ confidence: "high",
76
+ },
77
+ {
78
+ kind: "Twilio credential",
79
+ re: /\b(?:AC|SK)[a-f0-9]{32}\b/,
80
+ confidence: "high",
81
+ },
82
+ {
83
+ kind: "Telegram bot token",
84
+ re: /\b\d{8,10}:[A-Za-z0-9_-]{35}\b/,
85
+ confidence: "high",
86
+ },
87
+ { kind: "npm token", re: /\bnpm_[A-Za-z0-9]{36}\b/, confidence: "high" },
88
+ {
89
+ kind: "Supabase personal token",
90
+ re: /\bsbp_[a-f0-9]{40}\b/,
91
+ confidence: "high",
92
+ },
93
+ {
94
+ kind: "Vercel token",
95
+ re: /\bvercel_[A-Za-z0-9]{24,}\b/,
96
+ confidence: "high",
97
+ },
98
+ {
99
+ kind: "DigitalOcean token",
100
+ re: /\bdo[pors]_v1_[a-f0-9]{64}\b/,
101
+ confidence: "high",
102
+ },
103
+ {
104
+ kind: "Shopify token",
105
+ re: /\bshp(?:at|ca|pa|ss)_[a-fA-F0-9]{32}\b/,
106
+ confidence: "high",
107
+ },
108
+ {
109
+ kind: "Mailchimp key",
110
+ re: /\b[a-f0-9]{32}-us\d{1,2}\b/,
111
+ confidence: "high",
112
+ },
113
+ {
114
+ kind: "Mailgun key",
115
+ re: /\bkey-[a-f0-9]{32}\b/,
116
+ confidence: "high",
117
+ },
118
+ {
119
+ kind: "Airtable token",
120
+ re: /\bpat[A-Za-z0-9]{14}\.[a-f0-9]{64}\b/,
121
+ confidence: "high",
122
+ },
123
+ {
124
+ kind: "Notion token",
125
+ re: /\b(?:secret_[A-Za-z0-9]{43}|ntn_[A-Za-z0-9]{40,})\b/,
126
+ confidence: "high",
127
+ },
128
+ {
129
+ kind: "Linear key",
130
+ re: /\blin_api_[A-Za-z0-9]{40,}\b/,
131
+ confidence: "high",
132
+ },
133
+ {
134
+ kind: "Figma token",
135
+ re: /\bfigd_[A-Za-z0-9_-]{40,}\b/,
136
+ confidence: "high",
137
+ },
138
+ {
139
+ kind: "Hugging Face token",
140
+ re: /\bhf_[A-Za-z0-9]{30,}\b/,
141
+ confidence: "high",
142
+ },
143
+ { kind: "Groq key", re: /\bgsk_[A-Za-z0-9]{30,}\b/, confidence: "high" },
144
+ {
145
+ kind: "Replicate token",
146
+ re: /\br8_[A-Za-z0-9]{30,}\b/,
147
+ confidence: "high",
148
+ },
149
+ {
150
+ kind: "Perplexity key",
151
+ re: /\bpplx-[A-Za-z0-9]{40,}\b/,
152
+ confidence: "high",
153
+ },
154
+ {
155
+ kind: "Databricks token",
156
+ re: /\bdapi[a-f0-9]{32}\b/,
157
+ confidence: "high",
158
+ },
159
+ {
160
+ kind: "Doppler token",
161
+ re: /\bdp\.pt\.[A-Za-z0-9]{40,}\b/,
162
+ confidence: "high",
163
+ },
164
+ {
165
+ kind: "Postman key",
166
+ re: /\bPMAK-[a-f0-9]{24}-[a-f0-9]{34}\b/,
167
+ confidence: "high",
168
+ },
169
+ {
170
+ kind: "PyPI token",
171
+ re: /\bpypi-AgEIcHlwaS5vcmc[A-Za-z0-9_-]{50,}\b/,
172
+ confidence: "high",
173
+ },
174
+ {
175
+ kind: "Sentry token",
176
+ re: /\bsntrys_[A-Za-z0-9_+/=.-]{40,}\b/,
177
+ confidence: "high",
22
178
  },
23
179
  {
24
180
  kind: "GCP service account key",
25
181
  re: /"private_key_id"\s*:\s*"[a-f0-9]{20,}"/,
182
+ confidence: "high",
183
+ skipValueChecks: true,
184
+ },
185
+ {
186
+ kind: "Discord webhook URL",
187
+ re: /discord(?:app)?\.com\/api\/webhooks\/\d{15,20}\/[A-Za-z0-9_-]{60,}/,
188
+ confidence: "high",
189
+ },
190
+ {
191
+ kind: "Discord bot token",
192
+ re: /\b[MNO][A-Za-z\d_-]{23,25}\.[A-Za-z\d_-]{6}\.[A-Za-z\d_-]{27,}\b/,
193
+ confidence: "medium",
194
+ minEntropy: 4.0,
195
+ },
196
+ {
197
+ // Placeholder checks run against the password only (group 1), so
198
+ // documentation hosts like db.example.com don't suppress real leaks.
199
+ kind: "Database URL with password",
200
+ re: /\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|rediss|amqp):\/\/[^:\s"'@/]+:([^@\s"']+)@/,
201
+ confidence: "high",
202
+ group: 1,
203
+ },
204
+ {
205
+ kind: "Basic auth in URL",
206
+ re: /https?:\/\/[^:\s"'/@]{3,}:([^@\s"']{8,})@/,
207
+ confidence: "medium",
208
+ group: 1,
209
+ minEntropy: 3.0,
26
210
  },
27
211
  {
28
212
  kind: "JWT",
29
213
  re: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/,
214
+ confidence: "medium",
215
+ minEntropy: 4.0,
30
216
  },
31
217
  {
32
- // Credential-style assignment with a long opaque value
218
+ kind: "Authorization header",
219
+ re: /\b(?:authorization|x-api-key)["']?\s*[:=]\s*["']?(?:Bearer|Basic|token)\s+([A-Za-z0-9+/_.=-]{20,})\b/i,
220
+ confidence: "medium",
221
+ group: 1,
222
+ minEntropy: 3.5,
223
+ },
224
+ {
225
+ // Credential-style assignment with a long opaque quoted value
33
226
  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,
227
+ re: /\b[A-Z0-9_]*(?:API_?KEY|SECRET(?:_KEY)?|ACCESS_TOKEN|AUTH_TOKEN|PASSWORD|PASSWD|CREDENTIALS?)[A-Z0-9_]*\s*[=:]\s*["']([A-Za-z0-9+/_.=-]{16,})["']/i,
228
+ confidence: "medium",
229
+ group: 1,
230
+ minEntropy: 3.8,
35
231
  },
36
232
  ];
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. */
233
+ /** Words and shapes that indicate a placeholder, not a real secret. */
234
+ 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;
235
+ /**
236
+ * Line-level markers for documentation/example lines. Deliberately narrow:
237
+ * broad words like "example" would hide real keys on lines that merely
238
+ * mention example.com. Value-level checks handle those instead.
239
+ */
240
+ const PLACEHOLDER_LINE_RE = /\b(?:do not commit|for docs only|shipready-ignore)\b|<[A-Z_][A-Z0-9_ -]*>/i;
241
+ /** Paths whose findings are downgraded to medium confidence. */
242
+ const TEST_PATH_RE = /(^|[/\\])(tests?|__tests__|__mocks__|spec|specs|fixtures?|mocks?|examples?|samples?|docs?)([/\\]|$)|\.(test|spec)\.[a-z]+$/i;
243
+ /** True when a matched value looks like a placeholder or templated string. */
244
+ function isPlaceholderValue(value) {
245
+ if (PLACEHOLDER_VALUE_RE.test(value))
246
+ return true;
247
+ if (value.includes("${") || value.includes("{{") || value.includes("%s")) {
248
+ return true;
249
+ }
250
+ if (hasLongRepeat(value))
251
+ return true;
252
+ if (hasSequentialRun(value))
253
+ return true;
254
+ return false;
255
+ }
256
+ /** Masks a secret, keeping a short recognizable prefix and last 4 chars. */
40
257
  export function maskSecret(secret) {
41
258
  if (secret.length <= 8)
42
259
  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)/);
260
+ const prefixMatch = secret.match(/^(sk-ant-|sk-proj-|sk-svcacct-|sk-|sk_live_|sk_test_|rk_live_|rk_test_|whsec_|gh[pousr]_|github_pat_|glpat-|AIza|xox[abpsr]-|AKIA|ASIA|sbp_|vercel_|npm_|SG\.|dop_v1_|doo_v1_|dor_v1_|dos_v1_|shpat_|shpca_|shppa_|shpss_|hf_|gsk_|r8_|pplx-|lin_api_|figd_|secret_|ntn_|dapi|dp\.pt\.|PMAK-|pypi-|sntrys_|key-|glsa_|AC|SK|eyJ|pat)/);
45
261
  const prefixLen = (prefixMatch?.[0].length ?? 0) + 2;
46
262
  const prefix = secret.slice(0, Math.min(prefixLen, secret.length - 4));
47
263
  const suffix = secret.slice(-4);
@@ -51,26 +267,43 @@ export function maskSecret(secret) {
51
267
  export function scanContentForSecrets(content, file, allowlist = []) {
52
268
  const found = [];
53
269
  const lines = content.split("\n");
270
+ const isTestPath = TEST_PATH_RE.test(file);
54
271
  for (let i = 0; i < lines.length; i++) {
55
272
  const line = lines[i];
56
- // Skip obvious placeholder lines and comments about examples
57
- if (PLACEHOLDER_RE.test(line))
273
+ // Bundled/minified single-line blobs are noise, not user code.
274
+ if (line.length > 10000)
275
+ continue;
276
+ // Documentation/example lines are not real leaks.
277
+ if (PLACEHOLDER_LINE_RE.test(line))
58
278
  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))) {
279
+ for (const pattern of PATTERNS) {
280
+ const match = line.match(pattern.re);
281
+ if (!match)
282
+ continue;
283
+ const value = match[pattern.group ?? 0];
284
+ if (!pattern.skipValueChecks) {
285
+ if (isPlaceholderValue(value))
286
+ continue;
287
+ if (pattern.minEntropy !== undefined &&
288
+ shannonEntropy(value) < pattern.minEntropy) {
64
289
  continue;
65
290
  }
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
291
  }
292
+ // User-configured false positives (substring match on the value or line)
293
+ if (allowlist.some((a) => match[0].includes(a) || line.includes(a))) {
294
+ continue;
295
+ }
296
+ const confidence = isTestPath ? "medium" : pattern.confidence;
297
+ found.push({
298
+ kind: pattern.kind,
299
+ file,
300
+ line: i + 1,
301
+ masked: maskSecret(value),
302
+ confidence,
303
+ // Raw value stays in memory only; used by --verify, never printed.
304
+ raw: value,
305
+ });
306
+ break; // one finding per line is enough
74
307
  }
75
308
  }
76
309
  return found;
@@ -78,24 +311,60 @@ export function scanContentForSecrets(content, file, allowlist = []) {
78
311
  /** Builds the CheckResult from all secret findings. */
79
312
  export function checkSecrets(secretFindings) {
80
313
  const findings = [];
314
+ const high = secretFindings.filter((s) => s.confidence !== "medium");
315
+ const medium = secretFindings.filter((s) => s.confidence === "medium");
81
316
  if (secretFindings.length === 0) {
82
317
  findings.push({
83
318
  severity: "success",
84
319
  rule: "secrets.none",
85
320
  message: "No obvious secrets found",
86
321
  });
322
+ return { name: "secrets", findings };
87
323
  }
88
- else {
324
+ const verifiedActive = secretFindings.filter((s) => s.verified === "active");
325
+ if (verifiedActive.length > 0) {
326
+ findings.push({
327
+ severity: "error",
328
+ rule: "secrets.verified-active",
329
+ message: `${verifiedActive.length} key${verifiedActive.length > 1 ? "s" : ""} VERIFIED ACTIVE - rotate immediately`,
330
+ });
331
+ }
332
+ /** Suffix describing the live verification result, when available. */
333
+ const verifyNote = (s) => {
334
+ if (s.verified === "active")
335
+ return " [VERIFIED ACTIVE]";
336
+ if (s.verified === "inactive")
337
+ return " [not active - rotate anyway]";
338
+ return "";
339
+ };
340
+ if (high.length > 0) {
89
341
  findings.push({
90
342
  severity: "error",
91
343
  rule: "secrets.detected",
92
- message: `${secretFindings.length} potential secret${secretFindings.length > 1 ? "s" : ""} detected`,
344
+ message: `${high.length} potential secret${high.length > 1 ? "s" : ""} detected`,
93
345
  });
94
- for (const s of secretFindings) {
346
+ for (const s of high) {
95
347
  findings.push({
96
348
  severity: "error",
97
349
  rule: "secrets.detected-item",
98
- message: `${s.kind}: ${s.masked}`,
350
+ message: `${s.kind}: ${s.masked}${verifyNote(s)}`,
351
+ file: s.file,
352
+ line: s.line,
353
+ });
354
+ }
355
+ }
356
+ if (medium.length > 0) {
357
+ findings.push({
358
+ severity: "warning",
359
+ rule: "secrets.possible",
360
+ message: `${medium.length} possible secret${medium.length > 1 ? "s" : ""} detected (lower confidence)`,
361
+ });
362
+ for (const s of medium) {
363
+ findings.push({
364
+ // A verified-active key is an error no matter the pattern confidence.
365
+ severity: s.verified === "active" ? "error" : "warning",
366
+ rule: s.verified === "active" ? "secrets.detected-item" : "secrets.possible-item",
367
+ message: `${s.kind}: ${s.masked}${verifyNote(s)}`,
99
368
  file: s.file,
100
369
  line: s.line,
101
370
  });
@@ -0,0 +1,106 @@
1
+ const TIMEOUT_MS = 6000;
2
+ /** fetch with an AbortController timeout; never throws. */
3
+ async function timedFetch(url, init) {
4
+ const ctrl = new AbortController();
5
+ const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
6
+ try {
7
+ return await fetch(url, { ...init, signal: ctrl.signal });
8
+ }
9
+ catch {
10
+ return null;
11
+ }
12
+ finally {
13
+ clearTimeout(timer);
14
+ }
15
+ }
16
+ /**
17
+ * Maps an HTTP status to a verification result using a common convention:
18
+ * 401/403 => the key was understood but rejected (inactive/revoked),
19
+ * 2xx => accepted (active),
20
+ * anything else (network, 404, 5xx, rate limit) => unknown.
21
+ */
22
+ function statusToResult(res) {
23
+ if (!res)
24
+ return "unknown";
25
+ if (res.ok)
26
+ return "active";
27
+ if (res.status === 401 || res.status === 403)
28
+ return "inactive";
29
+ return "unknown";
30
+ }
31
+ /**
32
+ * Verifiers keyed by finding.kind. Only providers with a safe, well-known
33
+ * "who am I" endpoint are included. Everything else stays "unknown".
34
+ */
35
+ const VERIFIERS = {
36
+ "OpenAI key": async (raw) => statusToResult(await timedFetch("https://api.openai.com/v1/models", {
37
+ headers: { Authorization: `Bearer ${raw}` },
38
+ })),
39
+ "Anthropic key": async (raw) => {
40
+ // /v1/models requires the key; 401 => bad key, 200 => good.
41
+ const res = await timedFetch("https://api.anthropic.com/v1/models", {
42
+ headers: { "x-api-key": raw, "anthropic-version": "2023-06-01" },
43
+ });
44
+ return statusToResult(res);
45
+ },
46
+ "GitHub token": async (raw) => statusToResult(await timedFetch("https://api.github.com/user", {
47
+ headers: {
48
+ Authorization: `Bearer ${raw}`,
49
+ "User-Agent": "shipready",
50
+ Accept: "application/vnd.github+json",
51
+ },
52
+ })),
53
+ "GitLab token": async (raw) => statusToResult(await timedFetch("https://gitlab.com/api/v4/user", {
54
+ headers: { "PRIVATE-TOKEN": raw },
55
+ })),
56
+ "Stripe live key": async (raw) => statusToResult(await timedFetch("https://api.stripe.com/v1/account", {
57
+ headers: { Authorization: `Bearer ${raw}` },
58
+ })),
59
+ "Stripe test key": async (raw) => statusToResult(await timedFetch("https://api.stripe.com/v1/account", {
60
+ headers: { Authorization: `Bearer ${raw}` },
61
+ })),
62
+ "SendGrid key": async (raw) => statusToResult(await timedFetch("https://api.sendgrid.com/v3/scopes", {
63
+ headers: { Authorization: `Bearer ${raw}` },
64
+ })),
65
+ "Google/Gemini key": async (raw) => statusToResult(await timedFetch(`https://generativelanguage.googleapis.com/v1beta/models?key=${encodeURIComponent(raw)}`, {})),
66
+ "npm token": async (raw) => statusToResult(await timedFetch("https://registry.npmjs.org/-/whoami", {
67
+ headers: { Authorization: `Bearer ${raw}` },
68
+ })),
69
+ "Hugging Face token": async (raw) => statusToResult(await timedFetch("https://huggingface.co/api/whoami-v2", {
70
+ headers: { Authorization: `Bearer ${raw}` },
71
+ })),
72
+ "Figma token": async (raw) => statusToResult(await timedFetch("https://api.figma.com/v1/me", {
73
+ headers: { "X-Figma-Token": raw },
74
+ })),
75
+ "GitHub fine-grained token": async (raw) => statusToResult(await timedFetch("https://api.github.com/user", {
76
+ headers: {
77
+ Authorization: `Bearer ${raw}`,
78
+ "User-Agent": "shipready",
79
+ },
80
+ })),
81
+ };
82
+ /** Number of providers shipready can actively verify. */
83
+ export const VERIFIABLE_KINDS = Object.keys(VERIFIERS);
84
+ /**
85
+ * Verifies findings against provider APIs, mutating each finding's
86
+ * `verified` field in place. Runs with limited concurrency and only for
87
+ * findings whose `kind` has a known verifier and whose `raw` value is present.
88
+ */
89
+ export async function verifySecrets(findings, concurrency = 5) {
90
+ const targets = findings.filter((f) => f.raw && VERIFIERS[f.kind]);
91
+ let cursor = 0;
92
+ async function worker() {
93
+ while (cursor < targets.length) {
94
+ const finding = targets[cursor++];
95
+ try {
96
+ finding.verified = await VERIFIERS[finding.kind](finding.raw);
97
+ }
98
+ catch {
99
+ finding.verified = "unknown";
100
+ }
101
+ }
102
+ }
103
+ await Promise.all(Array.from({ length: Math.min(concurrency, targets.length) }, worker));
104
+ // Findings we didn't verify stay undefined (rendered as no live info).
105
+ return findings;
106
+ }
package/dist/cli.js CHANGED
@@ -69,14 +69,20 @@ export function buildProgram() {
69
69
  .option("-v, --verbose", "show file locations for every finding")
70
70
  .option("--json", "output the raw report as JSON")
71
71
  .option("--fix", "apply safe fixes, then re-scan and show the improved report")
72
+ .option("--history", "also scan the full git history for leaked secrets")
73
+ .option("--verify", "check detected keys against provider APIs to see if they are live")
72
74
  .action(async (dir, opts) => {
73
75
  try {
74
76
  const root = resolveRoot(dir);
75
- let report = await runScan(root);
77
+ const scanOpts = { history: opts.history, verify: opts.verify };
78
+ if (opts.verify && !opts.json) {
79
+ console.log(pc.dim("\n Verifying detected keys against provider APIs..."));
80
+ }
81
+ let report = await runScan(root, scanOpts);
76
82
  if (opts.fix) {
77
83
  const before = report.score;
78
84
  const results = await applyFixes(root, false);
79
- report = await runScan(root);
85
+ report = await runScan(root, scanOpts);
80
86
  if (!opts.json) {
81
87
  console.log("");
82
88
  console.log(pc.bold(pc.magenta("shipready check --fix")));
@@ -92,7 +98,7 @@ export function buildProgram() {
92
98
  console.log(JSON.stringify(report, null, 2));
93
99
  }
94
100
  else {
95
- console.log(renderReport(report, opts.verbose ?? false));
101
+ console.log(renderReport(report, opts.verbose ?? false, ownVersion()));
96
102
  }
97
103
  // Non-zero exit when errors are present, useful for CI.
98
104
  const hasErrors = report.results.some((r) => r.findings.some((f) => f.severity === "error"));
package/dist/scanner.js CHANGED
@@ -4,6 +4,8 @@ import { checkGitignore } from "./checks/gitignore.js";
4
4
  import { checkPackageJson } from "./checks/packageJson.js";
5
5
  import { checkReadme } from "./checks/readme.js";
6
6
  import { checkSecrets, scanContentForSecrets } from "./checks/secrets.js";
7
+ import { checkHistory, isGitRepo, scanGitHistory } from "./checks/history.js";
8
+ import { verifySecrets } from "./checks/verify.js";
7
9
  import { checkTodos, scanContentForTodos } from "./checks/todos.js";
8
10
  import { fileExists, findSourceFiles, isProbablyBinary, readJsonFile, readTextFile, } from "./utils/files.js";
9
11
  import { detectFramework, detectPackageManager } from "./utils/framework.js";
@@ -80,6 +82,8 @@ export function calculateScore(results) {
80
82
  score -= 15;
81
83
  const secretCount = count("secrets.detected-item");
82
84
  score -= Math.min(secretCount * 25, 50);
85
+ const historyCount = count("history.secret-item");
86
+ score -= Math.min(historyCount * 10, 30);
83
87
  const todoCount = count("todos.item");
84
88
  score -= Math.min(todoCount * 2, 15);
85
89
  return Math.max(0, score);
@@ -94,15 +98,27 @@ function applyDisabledRules(results, disableRules) {
94
98
  }));
95
99
  }
96
100
  /** Runs the full scan and returns a structured report. */
97
- export async function runScan(root) {
101
+ export async function runScan(root, options = {}) {
98
102
  const config = loadConfig(root);
99
103
  const project = await detectProject(root, config);
100
104
  const { envUsages, secrets, todos } = scanFiles(root, project.sourceFiles, config.secretAllowlist);
105
+ let historySecrets = [];
106
+ let historyScanned = false;
107
+ if (options.history) {
108
+ historyScanned = isGitRepo(root);
109
+ if (historyScanned) {
110
+ historySecrets = scanGitHistory(root, config.secretAllowlist, secrets);
111
+ }
112
+ }
113
+ if (options.verify) {
114
+ await verifySecrets([...secrets, ...historySecrets]);
115
+ }
101
116
  const rawResults = [
102
117
  checkPackageJson(project),
103
118
  checkReadme(root),
104
119
  checkEnv(root, envUsages),
105
120
  checkSecrets(secrets),
121
+ ...(options.history ? [checkHistory(historySecrets, historyScanned)] : []),
106
122
  checkTodos(todos),
107
123
  checkGitignore(root),
108
124
  ];
@@ -0,0 +1,42 @@
1
+ /**
2
+ * String randomness heuristics used by the secret scanner.
3
+ *
4
+ * Real credentials are generated by CSPRNGs, so they have high Shannon
5
+ * entropy and no human-friendly structure. Placeholders and dictionary
6
+ * words score much lower, which lets us separate the two without
7
+ * hardcoding every possible fake value.
8
+ */
9
+ /** Shannon entropy of a string, in bits per character. */
10
+ export function shannonEntropy(s) {
11
+ if (s.length === 0)
12
+ return 0;
13
+ const freq = new Map();
14
+ for (const ch of s)
15
+ freq.set(ch, (freq.get(ch) ?? 0) + 1);
16
+ let entropy = 0;
17
+ for (const count of freq.values()) {
18
+ const p = count / s.length;
19
+ entropy -= p * Math.log2(p);
20
+ }
21
+ return entropy;
22
+ }
23
+ /** True when the string contains a run of 5+ identical characters (e.g. "aaaaa", "-----"). */
24
+ export function hasLongRepeat(s) {
25
+ return /(.)\1{4,}/.test(s);
26
+ }
27
+ /**
28
+ * True when the string contains a long ascending or descending character
29
+ * run such as "abcdefgh" or "87654321" — a strong placeholder signal.
30
+ */
31
+ export function hasSequentialRun(s, minRun = 8) {
32
+ let asc = 1;
33
+ let desc = 1;
34
+ for (let i = 1; i < s.length; i++) {
35
+ const d = s.charCodeAt(i) - s.charCodeAt(i - 1);
36
+ asc = d === 1 ? asc + 1 : 1;
37
+ desc = d === -1 ? desc + 1 : 1;
38
+ if (asc >= minRun || desc >= minRun)
39
+ return true;
40
+ }
41
+ return false;
42
+ }
@@ -5,6 +5,59 @@ const ICONS = {
5
5
  warning: pc.yellow("\u26a0"),
6
6
  info: pc.cyan("\u2139"),
7
7
  };
8
+ /** Inner width of the report layout (visible characters). */
9
+ const WIDTH = 56;
10
+ /** Visible length of a string, ignoring ANSI escape codes. */
11
+ function visibleLength(s) {
12
+ // eslint-disable-next-line no-control-regex
13
+ return s.replace(/\u001b\[[0-9;]*m/g, "").length;
14
+ }
15
+ /** Pads a string (which may contain ANSI codes) to a visible width. */
16
+ function padVisible(s, width) {
17
+ const len = visibleLength(s);
18
+ return len >= width ? s : s + " ".repeat(width - len);
19
+ }
20
+ /** Draws a rounded box around the given lines. */
21
+ function box(lines) {
22
+ const top = pc.dim("\u256d" + "\u2500".repeat(WIDTH + 2) + "\u256e");
23
+ const bottom = pc.dim("\u2570" + "\u2500".repeat(WIDTH + 2) + "\u256f");
24
+ const body = lines.map((l) => `${pc.dim("\u2502")} ${padVisible(l, WIDTH)} ${pc.dim("\u2502")}`);
25
+ return [top, ...body, bottom];
26
+ }
27
+ /** Renders the score progress bar. */
28
+ function scoreBar(score) {
29
+ const total = 28;
30
+ const filled = Math.max(0, Math.min(total, Math.round((score / 100) * total)));
31
+ const fill = "\u2588".repeat(filled);
32
+ const rest = "\u2591".repeat(total - filled);
33
+ const painted = score >= 85 ? pc.green(fill) : score >= 60 ? pc.yellow(fill) : pc.red(fill);
34
+ return painted + pc.dim(rest);
35
+ }
36
+ function scoreLabel(score) {
37
+ const label = `${score}/100`;
38
+ if (score >= 85)
39
+ return pc.bold(pc.green(label));
40
+ if (score >= 60)
41
+ return pc.bold(pc.yellow(label));
42
+ return pc.bold(pc.red(label));
43
+ }
44
+ function verdict(score) {
45
+ if (score >= 85)
46
+ return pc.green("ready to ship");
47
+ if (score >= 60)
48
+ return pc.yellow("almost there");
49
+ return pc.red("not ready to ship");
50
+ }
51
+ /** Human-friendly labels for check names. */
52
+ const CHECK_LABELS = {
53
+ "package.json": "package.json",
54
+ README: "README",
55
+ env: "Env safety",
56
+ secrets: "Secrets",
57
+ todos: "Code hygiene",
58
+ gitignore: ".gitignore",
59
+ "git-history": "Git history",
60
+ };
8
61
  function colorFor(severity, text) {
9
62
  switch (severity) {
10
63
  case "success":
@@ -17,23 +70,19 @@ function colorFor(severity, text) {
17
70
  return pc.cyan(text);
18
71
  }
19
72
  }
20
- function scoreColor(score) {
21
- const label = `${score}/100`;
22
- if (score >= 85)
23
- return pc.green(label);
24
- if (score >= 60)
25
- return pc.yellow(label);
26
- return pc.red(label);
27
- }
28
73
  /** Builds recommended next steps from findings, highest severity first. */
29
74
  export function nextSteps(report) {
30
75
  const steps = [];
31
76
  const all = report.results.flatMap((r) => r.findings);
32
77
  const has = (rule) => all.some((f) => f.rule === rule);
78
+ if (has("secrets.verified-active"))
79
+ steps.push("A key was VERIFIED ACTIVE - rotate it right now");
33
80
  if (has("package-json.missing"))
34
81
  steps.push("Add a package.json (npm init -y)");
35
82
  if (has("secrets.detected"))
36
83
  steps.push("Rotate and remove hardcoded secrets immediately");
84
+ if (has("history.secret"))
85
+ steps.push("Purge leaked secrets from git history (BFG or git filter-repo) and rotate them");
37
86
  if (has("env.not-ignored"))
38
87
  steps.push("Add .env to .gitignore");
39
88
  if (has("env.example-missing"))
@@ -56,21 +105,45 @@ export function nextSteps(report) {
56
105
  steps.push("Resolve TODO/FIXME comments or track them as issues");
57
106
  return steps;
58
107
  }
108
+ /** Worst severity across a set of findings (error > warning > info > success). */
109
+ function worst(findings) {
110
+ if (findings.some((f) => f.severity === "error"))
111
+ return "error";
112
+ if (findings.some((f) => f.severity === "warning"))
113
+ return "warning";
114
+ if (findings.some((f) => f.severity === "info"))
115
+ return "info";
116
+ return "success";
117
+ }
59
118
  /** Renders the full report to a printable string. */
60
- export function renderReport(report, verbose = false) {
119
+ export function renderReport(report, verbose = false, version) {
61
120
  const lines = [];
62
121
  const { project } = report;
122
+ const title = pc.bold(pc.magenta("shipready")) + (version ? pc.dim(` v${version}`) : "");
123
+ const meta = pc.dim("project ") +
124
+ project.framework +
125
+ pc.dim(" \u00b7 ") +
126
+ pc.dim("pm ") +
127
+ project.packageManager;
63
128
  lines.push("");
64
- lines.push(pc.bold(pc.magenta("shipready report")));
129
+ lines.push(...box([title, meta]));
65
130
  lines.push("");
66
- lines.push(`${pc.dim("Project:")} ${project.framework}`);
67
- lines.push(`${pc.dim("Package manager:")} ${project.packageManager}`);
131
+ lines.push(` ${pc.bold("Score")} ${scoreBar(report.score)} ${scoreLabel(report.score)} ${verdict(report.score)}`);
68
132
  lines.push("");
69
- lines.push(pc.bold("Summary:"));
133
+ // Aligned checks table: one row per check, then its top-level findings.
134
+ const labelWidth = Math.max(...report.results.map((r) => (CHECK_LABELS[r.name] ?? r.name).length));
70
135
  for (const result of report.results) {
71
- for (const finding of summarize(result.findings)) {
72
- lines.push(`${ICONS[finding.severity]} ${finding.message}`);
136
+ const label = CHECK_LABELS[result.name] ?? result.name;
137
+ const summaryFindings = result.findings.filter((f) => !f.file);
138
+ const status = worst(result.findings);
139
+ if (summaryFindings.length === 0) {
140
+ lines.push(` ${ICONS[status]} ${pc.bold(padVisible(label, labelWidth))} ${pc.dim("ok")}`);
141
+ continue;
73
142
  }
143
+ summaryFindings.forEach((finding, i) => {
144
+ const name = i === 0 ? pc.bold(padVisible(label, labelWidth)) : " ".repeat(labelWidth);
145
+ lines.push(` ${ICONS[finding.severity]} ${name} ${finding.message}`);
146
+ });
74
147
  }
75
148
  // Detailed findings with file locations
76
149
  const located = report.results
@@ -78,31 +151,22 @@ export function renderReport(report, verbose = false) {
78
151
  .filter((f) => f.file && f.severity !== "success");
79
152
  if (located.length > 0 && verbose) {
80
153
  lines.push("");
81
- lines.push(pc.bold("Details:"));
154
+ lines.push(` ${pc.bold("Details")}`);
82
155
  for (const f of located) {
83
156
  const loc = f.line ? `${f.file}:${f.line}` : f.file;
84
- lines.push(` ${ICONS[f.severity]} ${pc.dim(loc ?? "")} ${colorFor(f.severity, f.message)}`);
157
+ lines.push(` ${ICONS[f.severity]} ${pc.dim(loc ?? "")} ${colorFor(f.severity, f.message)}`);
85
158
  }
86
159
  }
87
- lines.push("");
88
- lines.push(`${pc.bold("Score:")} ${scoreColor(report.score)}`);
89
160
  const steps = nextSteps(report);
90
161
  if (steps.length > 0) {
91
162
  lines.push("");
92
- lines.push(pc.bold("Recommended next steps:"));
93
- steps.forEach((step, i) => lines.push(`${i + 1}. ${step}`));
163
+ lines.push(` ${pc.bold("Next steps")}`);
164
+ steps.forEach((step, i) => lines.push(` ${pc.dim(`${i + 1}.`)} ${step}`));
94
165
  }
95
166
  if (located.length > 0 && !verbose) {
96
167
  lines.push("");
97
- lines.push(pc.dim("Run with --verbose to see file locations."));
168
+ lines.push(pc.dim(" Run with --verbose to see file locations."));
98
169
  }
99
170
  lines.push("");
100
171
  return lines.join("\n");
101
172
  }
102
- /**
103
- * Collapses per-file findings into summary lines while keeping
104
- * top-level findings (those without a file) as-is.
105
- */
106
- function summarize(findings) {
107
- return findings.filter((f) => !f.file);
108
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shipready",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
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",