shipready 1.0.3 → 1.2.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 +64 -11
- package/dist/checks/secrets.js +291 -41
- package/dist/utils/entropy.js +42 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -108,7 +108,7 @@ Recommended next steps:
|
|
|
108
108
|
| **package.json** | Existence, package manager (lockfiles), framework, `dev`/`build`/`test`/`lint` scripts |
|
|
109
109
|
| **README** | Existence, installation/usage/license sections, not-too-empty |
|
|
110
110
|
| **Env safety** | `.env` gitignored, `.env.example` present and complete, no real values in examples |
|
|
111
|
-
| **Secrets** |
|
|
111
|
+
| **Secrets** | 30+ token patterns with entropy analysis and confidence levels, all masked in output (see table below) |
|
|
112
112
|
| **Debug leftovers** | `TODO`, `FIXME`, `HACK`, `XXX`, `console.log`, `debugger`, `throw new Error("Not implemented")` |
|
|
113
113
|
| **.gitignore** | Existence and important entries (`.env`, `node_modules`, `dist`, `build`, `.next`) |
|
|
114
114
|
|
|
@@ -116,28 +116,55 @@ Ignored during scans: `node_modules`, `.git`, `dist`, `build`, `.next`, `out`, `
|
|
|
116
116
|
|
|
117
117
|
## Detected secrets
|
|
118
118
|
|
|
119
|
+
The scanner combines provider-specific patterns with **Shannon entropy analysis** so that placeholders, examples, and templated values never pollute the report.
|
|
120
|
+
|
|
121
|
+
**High confidence** (reported as errors):
|
|
122
|
+
|
|
119
123
|
| Type | Recognized by |
|
|
120
124
|
| --- | --- |
|
|
121
125
|
| OpenAI | `sk-`, `sk-proj-` |
|
|
122
126
|
| Anthropic | `sk-ant-` |
|
|
123
127
|
| Google / Gemini | `AIza...` |
|
|
124
|
-
| GitHub | `ghp_`, `github_pat_` |
|
|
125
|
-
|
|
|
126
|
-
|
|
|
127
|
-
|
|
|
128
|
+
| GitHub | `ghp_`, `gho_`, `ghu_`, `ghs_`, `ghr_`, `github_pat_` |
|
|
129
|
+
| GitLab | `glpat-...` |
|
|
130
|
+
| Stripe (live) | `sk_live_`, `rk_live_`, `whsec_` |
|
|
131
|
+
| Slack | `xoxb-`, `xoxp-`, `xapp-`, webhook URLs |
|
|
132
|
+
| Discord | bot tokens, webhook URLs |
|
|
133
|
+
| AWS | `AKIA...` key IDs, `aws_secret_access_key` assignments |
|
|
128
134
|
| Supabase | `sbp_...` |
|
|
129
135
|
| Vercel | `vercel_...` |
|
|
130
136
|
| npm | `npm_...` |
|
|
131
137
|
| SendGrid | `SG.xxx.xxx` |
|
|
132
138
|
| Twilio | `AC` / `SK` + 32 hex |
|
|
133
139
|
| Telegram bot | `123456789:AA...` |
|
|
140
|
+
| DigitalOcean | `dop_v1_`, `doo_v1_`, `dor_v1_` |
|
|
141
|
+
| Hugging Face | `hf_...` |
|
|
142
|
+
| Shopify | `shpat_`, `shpss_`, `shpca_` |
|
|
143
|
+
| Mailchimp | 32 hex + `-usNN` |
|
|
144
|
+
| Airtable | `pat...` |
|
|
145
|
+
| Fly.io | `fo1_...` |
|
|
146
|
+
| Cloudflare | `CLOUDFLARE_API_TOKEN=` assignments |
|
|
147
|
+
| Heroku | `HEROKU_API_KEY=` UUIDs |
|
|
134
148
|
| Database URL with password | `postgres://user:pass@host` (also mysql, mongodb, redis, amqp) |
|
|
135
149
|
| GCP service account | `"private_key_id"` in JSON |
|
|
136
150
|
| Private key block | `-----BEGIN ... PRIVATE KEY-----` |
|
|
137
|
-
| JWT | `eyJ...` |
|
|
138
|
-
| Generic credential | `API_KEY=`, `SECRET=`, `PASSWORD=` assignments |
|
|
139
151
|
|
|
140
|
-
|
|
152
|
+
**Medium confidence** (reported as warnings, so real keys never hide among noise):
|
|
153
|
+
|
|
154
|
+
| Type | Recognized by |
|
|
155
|
+
| --- | --- |
|
|
156
|
+
| Stripe (test) | `sk_test_`, `pk_test_` |
|
|
157
|
+
| JWT | `eyJ...` with valid structure |
|
|
158
|
+
| Generic credential | `API_KEY=`, `SECRET=`, `PASSWORD=` assignments — only when the value has high entropy |
|
|
159
|
+
| Any finding in test/fixture/mock files | automatically downgraded |
|
|
160
|
+
|
|
161
|
+
False-positive protection:
|
|
162
|
+
|
|
163
|
+
- **Entropy gate**: generic assignments are only flagged when the value is statistically random (real keys are; `changeme` is not)
|
|
164
|
+
- **Placeholder detection**: `your-api-key`, `xxxx`, `<token>`, `${VAR}`, `process.env.X`, and template literals are skipped
|
|
165
|
+
- **Repeat/sequence filter**: `aaaa...`, `1234...` never match
|
|
166
|
+
- **Bundle guard**: single-line minified blobs are skipped entirely
|
|
167
|
+
- Matched values are always masked — shipready never prints a full secret
|
|
141
168
|
|
|
142
169
|
## Configuration
|
|
143
170
|
|
|
@@ -159,7 +186,9 @@ Optional `shipready.config.json` in your project root:
|
|
|
159
186
|
|
|
160
187
|
## Using in CI
|
|
161
188
|
|
|
162
|
-
|
|
189
|
+
### GitHub Action (recommended)
|
|
190
|
+
|
|
191
|
+
Add the quality gate to any workflow with a single step:
|
|
163
192
|
|
|
164
193
|
```yaml
|
|
165
194
|
# .github/workflows/quality.yml
|
|
@@ -170,9 +199,33 @@ jobs:
|
|
|
170
199
|
runs-on: ubuntu-latest
|
|
171
200
|
steps:
|
|
172
201
|
- uses: actions/checkout@v4
|
|
173
|
-
- uses:
|
|
202
|
+
- uses: formalness/shipready@v1
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
The job fails when errors (secrets, unignored `.env`, ...) are found.
|
|
206
|
+
|
|
207
|
+
| Input | Default | Purpose |
|
|
208
|
+
| --- | --- | --- |
|
|
209
|
+
| `path` | `.` | Project directory to scan |
|
|
210
|
+
| `verbose` | `true` | Show file/line locations for every finding |
|
|
211
|
+
| `version` | `latest` | shipready version to run (npm tag or exact version) |
|
|
212
|
+
| `args` | `""` | Extra arguments for `shipready check` (e.g. `--json`) |
|
|
213
|
+
|
|
214
|
+
Example with options:
|
|
215
|
+
|
|
216
|
+
```yaml
|
|
217
|
+
- uses: formalness/shipready@v1
|
|
174
218
|
with:
|
|
175
|
-
|
|
219
|
+
path: apps/web
|
|
220
|
+
verbose: "false"
|
|
221
|
+
version: "1.0.3"
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
### Manual setup
|
|
225
|
+
|
|
226
|
+
`shipready check` exits with code `1` when errors are found, so it works in any CI:
|
|
227
|
+
|
|
228
|
+
```yaml
|
|
176
229
|
- run: npx shipready check --verbose
|
|
177
230
|
```
|
|
178
231
|
|
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,41 @@ 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)
|
|
58
275
|
continue;
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
276
|
+
// Documentation/example lines are not real leaks.
|
|
277
|
+
if (PLACEHOLDER_LINE_RE.test(line))
|
|
278
|
+
continue;
|
|
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
|
+
});
|
|
304
|
+
break; // one finding per line is enough
|
|
74
305
|
}
|
|
75
306
|
}
|
|
76
307
|
return found;
|
|
@@ -78,20 +309,23 @@ export function scanContentForSecrets(content, file, allowlist = []) {
|
|
|
78
309
|
/** Builds the CheckResult from all secret findings. */
|
|
79
310
|
export function checkSecrets(secretFindings) {
|
|
80
311
|
const findings = [];
|
|
312
|
+
const high = secretFindings.filter((s) => s.confidence !== "medium");
|
|
313
|
+
const medium = secretFindings.filter((s) => s.confidence === "medium");
|
|
81
314
|
if (secretFindings.length === 0) {
|
|
82
315
|
findings.push({
|
|
83
316
|
severity: "success",
|
|
84
317
|
rule: "secrets.none",
|
|
85
318
|
message: "No obvious secrets found",
|
|
86
319
|
});
|
|
320
|
+
return { name: "secrets", findings };
|
|
87
321
|
}
|
|
88
|
-
|
|
322
|
+
if (high.length > 0) {
|
|
89
323
|
findings.push({
|
|
90
324
|
severity: "error",
|
|
91
325
|
rule: "secrets.detected",
|
|
92
|
-
message: `${
|
|
326
|
+
message: `${high.length} potential secret${high.length > 1 ? "s" : ""} detected`,
|
|
93
327
|
});
|
|
94
|
-
for (const s of
|
|
328
|
+
for (const s of high) {
|
|
95
329
|
findings.push({
|
|
96
330
|
severity: "error",
|
|
97
331
|
rule: "secrets.detected-item",
|
|
@@ -101,5 +335,21 @@ export function checkSecrets(secretFindings) {
|
|
|
101
335
|
});
|
|
102
336
|
}
|
|
103
337
|
}
|
|
338
|
+
if (medium.length > 0) {
|
|
339
|
+
findings.push({
|
|
340
|
+
severity: "warning",
|
|
341
|
+
rule: "secrets.possible",
|
|
342
|
+
message: `${medium.length} possible secret${medium.length > 1 ? "s" : ""} detected (lower confidence)`,
|
|
343
|
+
});
|
|
344
|
+
for (const s of medium) {
|
|
345
|
+
findings.push({
|
|
346
|
+
severity: "warning",
|
|
347
|
+
rule: "secrets.possible-item",
|
|
348
|
+
message: `${s.kind}: ${s.masked}`,
|
|
349
|
+
file: s.file,
|
|
350
|
+
line: s.line,
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
}
|
|
104
354
|
return { name: "secrets", findings };
|
|
105
355
|
}
|
|
@@ -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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "shipready",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.2.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",
|