shipready 1.2.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 +50 -20
- package/dist/checks/history.js +146 -0
- package/dist/checks/secrets.js +23 -4
- package/dist/checks/verify.js +106 -0
- package/dist/cli.js +9 -3
- package/dist/scanner.js +17 -1
- 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
|
|
@@ -166,6 +186,16 @@ False-positive protection:
|
|
|
166
186
|
- **Bundle guard**: single-line minified blobs are skipped entirely
|
|
167
187
|
- Matched values are always masked — shipready never prints a full secret
|
|
168
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.
|
|
198
|
+
|
|
169
199
|
## Configuration
|
|
170
200
|
|
|
171
201
|
Optional `shipready.config.json` in your project root:
|
|
@@ -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
|
@@ -300,6 +300,8 @@ export function scanContentForSecrets(content, file, allowlist = []) {
|
|
|
300
300
|
line: i + 1,
|
|
301
301
|
masked: maskSecret(value),
|
|
302
302
|
confidence,
|
|
303
|
+
// Raw value stays in memory only; used by --verify, never printed.
|
|
304
|
+
raw: value,
|
|
303
305
|
});
|
|
304
306
|
break; // one finding per line is enough
|
|
305
307
|
}
|
|
@@ -319,6 +321,22 @@ export function checkSecrets(secretFindings) {
|
|
|
319
321
|
});
|
|
320
322
|
return { name: "secrets", findings };
|
|
321
323
|
}
|
|
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
|
+
};
|
|
322
340
|
if (high.length > 0) {
|
|
323
341
|
findings.push({
|
|
324
342
|
severity: "error",
|
|
@@ -329,7 +347,7 @@ export function checkSecrets(secretFindings) {
|
|
|
329
347
|
findings.push({
|
|
330
348
|
severity: "error",
|
|
331
349
|
rule: "secrets.detected-item",
|
|
332
|
-
message: `${s.kind}: ${s.masked}`,
|
|
350
|
+
message: `${s.kind}: ${s.masked}${verifyNote(s)}`,
|
|
333
351
|
file: s.file,
|
|
334
352
|
line: s.line,
|
|
335
353
|
});
|
|
@@ -343,9 +361,10 @@ export function checkSecrets(secretFindings) {
|
|
|
343
361
|
});
|
|
344
362
|
for (const s of medium) {
|
|
345
363
|
findings.push({
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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)}`,
|
|
349
368
|
file: s.file,
|
|
350
369
|
line: s.line,
|
|
351
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
|
];
|
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",
|