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 +85 -28
- package/dist/checks/history.js +146 -0
- package/dist/checks/secrets.js +311 -42
- package/dist/checks/verify.js +106 -0
- package/dist/cli.js +9 -3
- package/dist/scanner.js +17 -1
- package/dist/utils/entropy.js +42 -0
- package/dist/utils/report.js +93 -29
- package/package.json +1 -1
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
✓
|
|
90
|
-
|
|
91
|
-
✗ .env
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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** |
|
|
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
|
-
|
|
|
126
|
-
|
|
|
127
|
-
|
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/checks/secrets.js
CHANGED
|
@@ -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: "
|
|
17
|
-
re:
|
|
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: "
|
|
21
|
-
re:
|
|
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
|
-
|
|
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(?:
|
|
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
|
-
/**
|
|
38
|
-
const
|
|
39
|
-
/**
|
|
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
|
-
|
|
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
|
-
//
|
|
57
|
-
if (
|
|
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
|
|
60
|
-
const match = line.match(re);
|
|
61
|
-
if (match)
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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: `${
|
|
344
|
+
message: `${high.length} potential secret${high.length > 1 ? "s" : ""} detected`,
|
|
93
345
|
});
|
|
94
|
-
for (const s of
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/utils/report.js
CHANGED
|
@@ -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(
|
|
129
|
+
lines.push(...box([title, meta]));
|
|
65
130
|
lines.push("");
|
|
66
|
-
lines.push(
|
|
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
|
-
|
|
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
|
-
|
|
72
|
-
|
|
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(`
|
|
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("
|
|
93
|
-
steps.forEach((step, i) => lines.push(`${i + 1}
|
|
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.
|
|
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",
|