shipready 1.6.0 → 1.8.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 +47 -0
- package/dist/checks/gitignore.js +24 -7
- package/dist/checks/packageJson.js +20 -0
- package/dist/cli.js +23 -4
- package/dist/fixers/secrets.js +269 -0
- package/dist/scanner.js +34 -17
- package/dist/utils/framework.js +55 -13
- package/dist/utils/report.js +7 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -242,6 +242,53 @@ console.log("startup banner"); // shipready-ignore
|
|
|
242
242
|
|
|
243
243
|
For project-wide exceptions, prefer `secretAllowlist` in the config (see below) so the suppression is reviewable in one place.
|
|
244
244
|
|
|
245
|
+
## Secret autofix
|
|
246
|
+
|
|
247
|
+
`shipready fix` moves hardcoded secrets out of your code into `.env`:
|
|
248
|
+
|
|
249
|
+
```js
|
|
250
|
+
// before
|
|
251
|
+
const githubToken = "ghp_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX";
|
|
252
|
+
// after
|
|
253
|
+
const githubToken = process.env.GITHUB_TOKEN;
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
The value lands in `.env` (gitignored), a placeholder lands in `.env.example`, and env var names are derived from your identifiers (`stripeApiKey` becomes `STRIPE_API_KEY`). TypeScript gets `process.env.NAME!` to keep strict mode compiling; Python gets `os.environ["NAME"]` with the `import os` added.
|
|
257
|
+
|
|
258
|
+
It never breaks your program - a rewrite only happens when it is provably safe:
|
|
259
|
+
|
|
260
|
+
- only complete quoted string literals are replaced; a password embedded in a database URL is flagged for manual restructuring instead
|
|
261
|
+
- client-side code (`"use client"`, `import.meta.env`) is never rewritten - an env var would still ship to the browser, so shipready tells you to move the call behind a server endpoint
|
|
262
|
+
- after rewriting, the file is re-scanned; if the secret somehow survived, the file is restored untouched
|
|
263
|
+
- identical values across files map to one env var; name collisions get numeric suffixes; existing `.env` entries are never overwritten
|
|
264
|
+
- if nothing in your project loads `.env` (no dotenv/Next.js/Vite), shipready tells you to run with `node --env-file=.env`
|
|
265
|
+
|
|
266
|
+
Use `--dry-run` to preview every change first. Remember to rotate any key that was already pushed - moving it to `.env` does not un-leak it.
|
|
267
|
+
|
|
268
|
+
## Scoring
|
|
269
|
+
|
|
270
|
+
The score starts at 100 and every deduction is itemized right under the score bar - the number is never a black box:
|
|
271
|
+
|
|
272
|
+
| Deduction | Points |
|
|
273
|
+
| --- | --- |
|
|
274
|
+
| No package.json | -20 |
|
|
275
|
+
| No README | -15 |
|
|
276
|
+
| Weak README | -8 |
|
|
277
|
+
| No build script | -8 |
|
|
278
|
+
| No test script | -6 |
|
|
279
|
+
| `.env.example` missing | -10 |
|
|
280
|
+
| `.env` not ignored by git | -15 |
|
|
281
|
+
| Hardcoded secret | -25 each (capped at -50) |
|
|
282
|
+
| Secret in git history | -10 each (capped at -30) |
|
|
283
|
+
| TODO/FIXME comment | -2 each (capped at -15) |
|
|
284
|
+
|
|
285
|
+
Checks adapt to what your repo actually is:
|
|
286
|
+
|
|
287
|
+
- **Monorepos** - workspaces (`package.json` `workspaces`, `pnpm-workspace.yaml`, including `**` globs) and conventional `frontend/` + `backend/` splits are detected; scripts and `.env.example` files in workspace packages count.
|
|
288
|
+
- **Libraries** - packages with a `files` allowlist aren't required to have `dev`/`build` scripts.
|
|
289
|
+
- **Non-JS ecosystems** - Go/Rust/Python/etc. projects get ecosystem-appropriate `.gitignore` expectations, and `.env` is only expected if the code actually reads env vars.
|
|
290
|
+
- **Mixed-language repos** - a FastAPI + React project reads "Vite + Python", not just "Vite".
|
|
291
|
+
|
|
245
292
|
## Configuration
|
|
246
293
|
|
|
247
294
|
Optional `shipready.config.json` in your project root:
|
package/dist/checks/gitignore.js
CHANGED
|
@@ -26,13 +26,30 @@ const ECOSYSTEM_IGNORES = {
|
|
|
26
26
|
unknown: [".env"],
|
|
27
27
|
};
|
|
28
28
|
/** Returns the entries a project of the given framework should ignore. */
|
|
29
|
-
export function importantIgnoresFor(framework) {
|
|
30
|
-
|
|
29
|
+
export function importantIgnoresFor(framework, ctx) {
|
|
30
|
+
// Without context, keep the legacy full list (also the config-less API).
|
|
31
|
+
if (!framework && !ctx)
|
|
31
32
|
return IMPORTANT_IGNORES;
|
|
32
|
-
|
|
33
|
+
if (!framework || isNodeEcosystem(framework)) {
|
|
34
|
+
const entries = [".env", ".env.local", "node_modules"];
|
|
35
|
+
// Build output dirs are only worth ignoring when something builds.
|
|
36
|
+
if (ctx?.hasBuildScript !== false) {
|
|
37
|
+
if (framework === "Next.js")
|
|
38
|
+
entries.push(".next");
|
|
39
|
+
else
|
|
40
|
+
entries.push("dist");
|
|
41
|
+
}
|
|
42
|
+
return entries;
|
|
43
|
+
}
|
|
44
|
+
const base = ECOSYSTEM_IGNORES[framework] ?? [".env"];
|
|
45
|
+
// A repo that reads no env vars gains nothing from ignoring .env -
|
|
46
|
+
// suggesting it to e.g. ripgrep is pure noise.
|
|
47
|
+
if (ctx?.usesEnv === false)
|
|
48
|
+
return base.filter((e) => e !== ".env");
|
|
49
|
+
return base;
|
|
33
50
|
}
|
|
34
51
|
/** Returns which important entries are missing from gitignore content. */
|
|
35
|
-
export function missingIgnoreEntries(content, framework) {
|
|
52
|
+
export function missingIgnoreEntries(content, framework, ctx) {
|
|
36
53
|
const lines = content
|
|
37
54
|
.split("\n")
|
|
38
55
|
.map((l) => l.trim())
|
|
@@ -47,10 +64,10 @@ export function missingIgnoreEntries(content, framework) {
|
|
|
47
64
|
// "node_modules/" style already normalized above
|
|
48
65
|
return false;
|
|
49
66
|
});
|
|
50
|
-
return importantIgnoresFor(framework).filter((e) => !covers(e));
|
|
67
|
+
return importantIgnoresFor(framework, ctx).filter((e) => !covers(e));
|
|
51
68
|
}
|
|
52
69
|
/** Checks .gitignore existence and important entries. */
|
|
53
|
-
export function checkGitignore(root, framework) {
|
|
70
|
+
export function checkGitignore(root, framework, ctx) {
|
|
54
71
|
const findings = [];
|
|
55
72
|
const content = readTextFile(root, ".gitignore");
|
|
56
73
|
if (content === null) {
|
|
@@ -61,7 +78,7 @@ export function checkGitignore(root, framework) {
|
|
|
61
78
|
});
|
|
62
79
|
return { name: "gitignore", findings };
|
|
63
80
|
}
|
|
64
|
-
const missing = missingIgnoreEntries(content, framework);
|
|
81
|
+
const missing = missingIgnoreEntries(content, framework, ctx);
|
|
65
82
|
if (missing.length > 0) {
|
|
66
83
|
findings.push({
|
|
67
84
|
severity: "warning",
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
1
3
|
import { fileExists } from "../utils/files.js";
|
|
2
4
|
import { isNodeEcosystem } from "../utils/framework.js";
|
|
3
5
|
const IMPORTANT_SCRIPTS = ["dev", "build", "test", "lint"];
|
|
@@ -43,9 +45,27 @@ export function checkPackageJson(project) {
|
|
|
43
45
|
message: "package.json found",
|
|
44
46
|
});
|
|
45
47
|
const hasLinterConfig = LINTER_CONFIGS.some((f) => fileExists(project.root, f));
|
|
48
|
+
// Published libraries (a "files" allowlist, not private) ship source, not
|
|
49
|
+
// a running app - requiring dev/build scripts of e.g. express is noise.
|
|
50
|
+
const pkg = project.packageJson;
|
|
51
|
+
const isLibrary = Array.isArray(pkg?.["files"]) && pkg?.["private"] !== true;
|
|
52
|
+
// In monorepos the scripts live in workspace packages, not the root.
|
|
53
|
+
const workspaceScripts = new Set();
|
|
54
|
+
for (const dir of project.workspaceDirs ?? []) {
|
|
55
|
+
try {
|
|
56
|
+
const wsPkg = JSON.parse(fs.readFileSync(path.join(project.root, dir, "package.json"), "utf8"));
|
|
57
|
+
for (const name of Object.keys(wsPkg.scripts ?? {}))
|
|
58
|
+
workspaceScripts.add(name);
|
|
59
|
+
}
|
|
60
|
+
catch { /* skip unreadable workspace */ }
|
|
61
|
+
}
|
|
46
62
|
const missing = IMPORTANT_SCRIPTS.filter((s) => {
|
|
47
63
|
if (project.scripts[s])
|
|
48
64
|
return false;
|
|
65
|
+
if (workspaceScripts.has(s))
|
|
66
|
+
return false;
|
|
67
|
+
if (isLibrary && (s === "dev" || s === "build"))
|
|
68
|
+
return false;
|
|
49
69
|
// A biome/eslint config means linting is set up; the script name is
|
|
50
70
|
// a convention, not a requirement (e.g. Biome repos run "biome check").
|
|
51
71
|
if (s === "lint" && hasLinterConfig)
|
package/dist/cli.js
CHANGED
|
@@ -7,6 +7,7 @@ import { detectProject, runScan, scanFiles } from "./scanner.js";
|
|
|
7
7
|
import { loadConfig } from "./config.js";
|
|
8
8
|
import { fixAgentFiles } from "./fixers/agentFiles.js";
|
|
9
9
|
import { fixEnvExample } from "./fixers/envExample.js";
|
|
10
|
+
import { fixSecrets, projectLoadsDotenv } from "./fixers/secrets.js";
|
|
10
11
|
import { fixGitignore } from "./fixers/gitignore.js";
|
|
11
12
|
import { renderReport } from "./utils/report.js";
|
|
12
13
|
import { toSarif } from "./utils/sarif.js";
|
|
@@ -47,12 +48,30 @@ function resolveRoot(dir) {
|
|
|
47
48
|
async function applyFixes(root, force, dryRun = false) {
|
|
48
49
|
const config = loadConfig(root);
|
|
49
50
|
const project = await detectProject(root, config);
|
|
50
|
-
const { envUsages } = scanFiles(root, project.sourceFiles, config.secretAllowlist);
|
|
51
|
-
|
|
52
|
-
|
|
51
|
+
const { envUsages, secrets } = scanFiles(root, project.sourceFiles, config.secretAllowlist);
|
|
52
|
+
const secretFix = fixSecrets(root, secrets, dryRun);
|
|
53
|
+
const handled = new Set(secretFix.results.map((r) => r.file));
|
|
54
|
+
const results = [
|
|
55
|
+
...secretFix.results,
|
|
56
|
+
...[fixEnvExample(root, envUsages, force, dryRun)].filter((r) => !(r.action === "skipped" && handled.has(r.file))),
|
|
53
57
|
fixGitignore(root, dryRun),
|
|
54
58
|
...fixAgentFiles(root, project, force, dryRun),
|
|
55
59
|
];
|
|
60
|
+
for (const { finding, reason } of secretFix.manual) {
|
|
61
|
+
results.push({
|
|
62
|
+
file: finding.file,
|
|
63
|
+
action: "skipped",
|
|
64
|
+
reason: `secret at line ${finding.line} needs a manual fix: ${reason}`,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
if (secretFix.envAdditions.length > 0 && !projectLoadsDotenv(root)) {
|
|
68
|
+
results.push({
|
|
69
|
+
file: ".env",
|
|
70
|
+
action: "skipped",
|
|
71
|
+
reason: "heads up: nothing loads .env automatically - run with `node --env-file=.env` (Node 20+) or add dotenv",
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
return results;
|
|
56
75
|
}
|
|
57
76
|
/** Reads the CLI's own version from its package.json (works from dist/ at runtime). */
|
|
58
77
|
function ownVersion() {
|
|
@@ -152,7 +171,7 @@ export function buildProgram() {
|
|
|
152
171
|
});
|
|
153
172
|
program
|
|
154
173
|
.command("fix")
|
|
155
|
-
.description("Apply safe automatic fixes (.env.example, .gitignore, agent files)")
|
|
174
|
+
.description("Apply safe automatic fixes (move secrets to .env, .env.example, .gitignore, agent files)")
|
|
156
175
|
.argument("[path]", "project directory (defaults to current directory)")
|
|
157
176
|
.option("-f, --force", "overwrite existing files")
|
|
158
177
|
.option("--dry-run", "show what would change without writing anything")
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { scanContentForSecrets } from "../checks/secrets.js";
|
|
3
|
+
import { fileExists, readTextFile, writeTextFile } from "../utils/files.js";
|
|
4
|
+
/** File extensions where we know how to rewrite a string literal safely. */
|
|
5
|
+
const CODE_EXTENSIONS = new Set([
|
|
6
|
+
".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".py",
|
|
7
|
+
]);
|
|
8
|
+
const QUOTES = new Set(["'", '"', "`"]);
|
|
9
|
+
/**
|
|
10
|
+
* Converts an identifier (stripeKey, api_key, STRIPE_KEY) to
|
|
11
|
+
* SCREAMING_SNAKE_CASE for use as an env var name.
|
|
12
|
+
*/
|
|
13
|
+
function toEnvName(identifier) {
|
|
14
|
+
return identifier
|
|
15
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
|
|
16
|
+
.replace(/[^A-Za-z0-9]+/g, "_")
|
|
17
|
+
.replace(/^_+|_+$/g, "")
|
|
18
|
+
.toUpperCase();
|
|
19
|
+
}
|
|
20
|
+
/** Names too vague to stand alone as env vars. */
|
|
21
|
+
const GENERIC_NAMES = new Set(["KEY", "TOKEN", "SECRET", "PASSWORD", "AUTH", "API_KEY"]);
|
|
22
|
+
/** Derives an env var name from the secret's kind ("GitHub token" -> GITHUB_TOKEN). */
|
|
23
|
+
function nameFromKind(kind) {
|
|
24
|
+
return toEnvName(kind);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Derives an env var name from the code context: the identifier being
|
|
28
|
+
* assigned right before the literal, falling back to the finding's kind.
|
|
29
|
+
*/
|
|
30
|
+
function deriveEnvName(linePrefix, kind) {
|
|
31
|
+
const m = linePrefix.match(/([A-Za-z_$][A-Za-z0-9_$]*)\s*["']?\s*[:=]\s*$/);
|
|
32
|
+
if (m) {
|
|
33
|
+
const name = toEnvName(m[1]);
|
|
34
|
+
// "key" or "token" alone says nothing - qualify it with the kind.
|
|
35
|
+
if (GENERIC_NAMES.has(name))
|
|
36
|
+
return nameFromKind(kind);
|
|
37
|
+
if (name.length >= 3)
|
|
38
|
+
return name;
|
|
39
|
+
}
|
|
40
|
+
return nameFromKind(kind);
|
|
41
|
+
}
|
|
42
|
+
/** Builds the language-appropriate replacement expression. */
|
|
43
|
+
function replacementFor(file, envName) {
|
|
44
|
+
const ext = path.extname(file);
|
|
45
|
+
if (ext === ".py")
|
|
46
|
+
return `os.environ["${envName}"]`;
|
|
47
|
+
// Non-null assertion keeps TS strict mode compiling; at runtime the
|
|
48
|
+
// behavior matches plain JS (undefined if unset).
|
|
49
|
+
if (ext === ".ts" || ext === ".tsx")
|
|
50
|
+
return `process.env.${envName}!`;
|
|
51
|
+
return `process.env.${envName}`;
|
|
52
|
+
}
|
|
53
|
+
/** True when the file is client-bundle code where env vars still leak. */
|
|
54
|
+
function looksLikeClientCode(content) {
|
|
55
|
+
return (/^\s*["']use client["']/m.test(content) || content.includes("import.meta.env"));
|
|
56
|
+
}
|
|
57
|
+
/** Ensures `import os` exists in a Python file, inserting it if needed. */
|
|
58
|
+
function ensurePythonOsImport(lines) {
|
|
59
|
+
if (lines.some((l) => /^\s*(import os\b|from os\b)/.test(l)))
|
|
60
|
+
return;
|
|
61
|
+
// Insert after shebang / encoding comments, before the first real line.
|
|
62
|
+
let insertAt = 0;
|
|
63
|
+
while (insertAt < lines.length &&
|
|
64
|
+
(lines[insertAt].startsWith("#!") || /^#.*coding[:=]/.test(lines[insertAt]))) {
|
|
65
|
+
insertAt++;
|
|
66
|
+
}
|
|
67
|
+
lines.splice(insertAt, 0, "import os");
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Moves hardcoded secrets out of source code into .env, replacing each
|
|
71
|
+
* literal with the language's env accessor.
|
|
72
|
+
*
|
|
73
|
+
* Safety rules - a replacement only happens when ALL hold, otherwise the
|
|
74
|
+
* finding is reported for manual fixing:
|
|
75
|
+
* - the file is a known code type (JS/TS family or Python)
|
|
76
|
+
* - the file is not client-bundle code (env vars still ship to the browser)
|
|
77
|
+
* - the secret is a COMPLETE quoted string literal, not a substring of one
|
|
78
|
+
* (a password inside a URL needs restructuring, not substitution)
|
|
79
|
+
* - after rewriting, a re-scan of the file confirms the raw value is gone;
|
|
80
|
+
* if not, the file is restored untouched
|
|
81
|
+
*/
|
|
82
|
+
export function fixSecrets(root, secrets, dryRun = false) {
|
|
83
|
+
const results = [];
|
|
84
|
+
const manual = [];
|
|
85
|
+
const envAdditions = [];
|
|
86
|
+
// Same raw value everywhere maps to one env var; name collisions with
|
|
87
|
+
// different values get numeric suffixes.
|
|
88
|
+
const valueToName = new Map();
|
|
89
|
+
const nameToValue = new Map();
|
|
90
|
+
// Pre-seed with existing .env so we never clobber an existing entry.
|
|
91
|
+
const existingEnv = readTextFile(root, ".env") ?? "";
|
|
92
|
+
for (const line of existingEnv.split("\n")) {
|
|
93
|
+
const m = line.match(/^([A-Z0-9_]+)=(.*)$/);
|
|
94
|
+
if (m)
|
|
95
|
+
nameToValue.set(m[1], m[2]);
|
|
96
|
+
}
|
|
97
|
+
const claimName = (wanted, value) => {
|
|
98
|
+
const known = valueToName.get(value);
|
|
99
|
+
if (known)
|
|
100
|
+
return known;
|
|
101
|
+
let name = wanted;
|
|
102
|
+
let n = 2;
|
|
103
|
+
while (nameToValue.has(name) && nameToValue.get(name) !== value) {
|
|
104
|
+
name = `${wanted}_${n++}`;
|
|
105
|
+
}
|
|
106
|
+
valueToName.set(value, name);
|
|
107
|
+
nameToValue.set(name, value);
|
|
108
|
+
return name;
|
|
109
|
+
};
|
|
110
|
+
const byFile = new Map();
|
|
111
|
+
for (const f of secrets) {
|
|
112
|
+
if (!f.raw)
|
|
113
|
+
continue;
|
|
114
|
+
const list = byFile.get(f.file) ?? [];
|
|
115
|
+
list.push(f);
|
|
116
|
+
byFile.set(f.file, list);
|
|
117
|
+
}
|
|
118
|
+
for (const [file, findings] of byFile) {
|
|
119
|
+
const ext = path.extname(file);
|
|
120
|
+
if (!CODE_EXTENSIONS.has(ext)) {
|
|
121
|
+
for (const f of findings) {
|
|
122
|
+
manual.push({ finding: f, reason: `unsupported file type (${ext || "no extension"}) - move the value manually` });
|
|
123
|
+
}
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
const original = readTextFile(root, file);
|
|
127
|
+
if (original === null) {
|
|
128
|
+
for (const f of findings)
|
|
129
|
+
manual.push({ finding: f, reason: "file unreadable" });
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (looksLikeClientCode(original)) {
|
|
133
|
+
for (const f of findings) {
|
|
134
|
+
manual.push({
|
|
135
|
+
finding: f,
|
|
136
|
+
reason: "client-side code - an env var would still ship to the browser; move this call behind a server endpoint",
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
const lines = original.split("\n");
|
|
142
|
+
const fixedNames = [];
|
|
143
|
+
let changed = false;
|
|
144
|
+
// Bottom-up so earlier line numbers stay valid if we insert imports.
|
|
145
|
+
const sorted = [...findings].sort((a, b) => b.line - a.line);
|
|
146
|
+
for (const f of sorted) {
|
|
147
|
+
const raw = f.raw;
|
|
148
|
+
const lineIdx = f.line - 1;
|
|
149
|
+
const line = lines[lineIdx];
|
|
150
|
+
if (line === undefined) {
|
|
151
|
+
manual.push({ finding: f, reason: "line not found (file changed since scan)" });
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
const idx = line.indexOf(raw);
|
|
155
|
+
if (idx === -1) {
|
|
156
|
+
manual.push({ finding: f, reason: "value not found on its line (file changed since scan)" });
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
const before = line[idx - 1];
|
|
160
|
+
const after = line[idx + raw.length];
|
|
161
|
+
const isWholeLiteral = before !== undefined && after !== undefined && before === after && QUOTES.has(before);
|
|
162
|
+
if (!isWholeLiteral) {
|
|
163
|
+
manual.push({
|
|
164
|
+
finding: f,
|
|
165
|
+
reason: "secret is embedded inside a larger string (e.g. a URL) - restructure it manually",
|
|
166
|
+
});
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
const envName = claimName(deriveEnvName(line.slice(0, idx - 1), f.kind), raw);
|
|
170
|
+
const replacement = replacementFor(file, envName);
|
|
171
|
+
lines[lineIdx] =
|
|
172
|
+
line.slice(0, idx - 1) + replacement + line.slice(idx + raw.length + 1);
|
|
173
|
+
envAdditions.push({ name: envName, value: raw });
|
|
174
|
+
fixedNames.push(envName);
|
|
175
|
+
changed = true;
|
|
176
|
+
}
|
|
177
|
+
if (!changed)
|
|
178
|
+
continue;
|
|
179
|
+
if (ext === ".py")
|
|
180
|
+
ensurePythonOsImport(lines);
|
|
181
|
+
const updated = lines.join("\n");
|
|
182
|
+
// Verification gate: the raw values must be gone from a fresh scan.
|
|
183
|
+
const stillLeaking = scanContentForSecrets(updated, file).some((s) => findings.some((f) => f.raw === s.raw && fixedNames.length > 0));
|
|
184
|
+
const rawsGone = findings
|
|
185
|
+
.filter((f) => f.raw && fixedNames.length > 0)
|
|
186
|
+
.every((f) => !updated.includes(f.raw) || manual.some((m) => m.finding === f));
|
|
187
|
+
if (stillLeaking || !rawsGone) {
|
|
188
|
+
// Restore and report rather than half-fix.
|
|
189
|
+
for (const f of findings) {
|
|
190
|
+
manual.push({ finding: f, reason: "post-fix verification failed - file left untouched" });
|
|
191
|
+
}
|
|
192
|
+
// Roll back env additions for this file.
|
|
193
|
+
for (const name of fixedNames) {
|
|
194
|
+
const value = nameToValue.get(name);
|
|
195
|
+
if (value !== undefined) {
|
|
196
|
+
valueToName.delete(value);
|
|
197
|
+
nameToValue.delete(name);
|
|
198
|
+
}
|
|
199
|
+
const i = envAdditions.findIndex((e) => e.name === name);
|
|
200
|
+
if (i !== -1)
|
|
201
|
+
envAdditions.splice(i, 1);
|
|
202
|
+
}
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
if (!dryRun)
|
|
206
|
+
writeTextFile(root, file, updated);
|
|
207
|
+
results.push({
|
|
208
|
+
file,
|
|
209
|
+
action: "updated",
|
|
210
|
+
dryRun,
|
|
211
|
+
preview: fixedNames.map((n) => `-> ${replacementFor(file, n)}`).join("\n"),
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
// Write .env and .env.example additions.
|
|
215
|
+
if (envAdditions.length > 0) {
|
|
216
|
+
const uniqueAdditions = envAdditions.filter((e, i) => envAdditions.findIndex((x) => x.name === e.name) === i);
|
|
217
|
+
const newEnvLines = uniqueAdditions
|
|
218
|
+
.filter((e) => !new RegExp(`^${e.name}=`, "m").test(existingEnv))
|
|
219
|
+
.map((e) => `${e.name}=${e.value}`);
|
|
220
|
+
if (newEnvLines.length > 0) {
|
|
221
|
+
const envContent = existingEnv.length > 0
|
|
222
|
+
? existingEnv.replace(/\n*$/, "\n") + newEnvLines.join("\n") + "\n"
|
|
223
|
+
: "# Managed by shipready - do not commit this file.\n" + newEnvLines.join("\n") + "\n";
|
|
224
|
+
if (!dryRun)
|
|
225
|
+
writeTextFile(root, ".env", envContent);
|
|
226
|
+
results.push({
|
|
227
|
+
file: ".env",
|
|
228
|
+
action: existingEnv ? "updated" : "created",
|
|
229
|
+
dryRun,
|
|
230
|
+
preview: newEnvLines.map((l) => l.replace(/=.*/, "=***")).join("\n"),
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
const existingExample = readTextFile(root, ".env.example") ?? "";
|
|
234
|
+
const newExampleLines = uniqueAdditions
|
|
235
|
+
.filter((e) => !new RegExp(`^${e.name}=`, "m").test(existingExample))
|
|
236
|
+
.map((e) => `${e.name}=`);
|
|
237
|
+
if (newExampleLines.length > 0) {
|
|
238
|
+
const exampleContent = existingExample.length > 0
|
|
239
|
+
? existingExample.replace(/\n*$/, "\n") + newExampleLines.join("\n") + "\n"
|
|
240
|
+
: newExampleLines.join("\n") + "\n";
|
|
241
|
+
if (!dryRun)
|
|
242
|
+
writeTextFile(root, ".env.example", exampleContent);
|
|
243
|
+
results.push({
|
|
244
|
+
file: ".env.example",
|
|
245
|
+
action: fileExists(root, ".env.example") && existingExample ? "updated" : "created",
|
|
246
|
+
dryRun,
|
|
247
|
+
preview: newExampleLines.join("\n"),
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return { results, envAdditions, manual };
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* True when the project will pick up .env automatically at runtime; when
|
|
255
|
+
* false the caller should tell the user how to load it.
|
|
256
|
+
*/
|
|
257
|
+
export function projectLoadsDotenv(root) {
|
|
258
|
+
const pkgRaw = readTextFile(root, "package.json");
|
|
259
|
+
if (!pkgRaw)
|
|
260
|
+
return false;
|
|
261
|
+
try {
|
|
262
|
+
const pkg = JSON.parse(pkgRaw);
|
|
263
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
264
|
+
return Boolean(deps["dotenv"] || deps["next"] || deps["vite"] || deps["@dotenvx/dotenvx"]);
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
return false;
|
|
268
|
+
}
|
|
269
|
+
}
|
package/dist/scanner.js
CHANGED
|
@@ -8,7 +8,7 @@ import { checkHistory, isGitRepo, scanGitHistory } from "./checks/history.js";
|
|
|
8
8
|
import { verifySecrets } from "./checks/verify.js";
|
|
9
9
|
import { checkTodos, scanContentForTodos } from "./checks/todos.js";
|
|
10
10
|
import { fileExists, findSourceFiles, isProbablyBinary, readJsonFile, readTextFile, } from "./utils/files.js";
|
|
11
|
-
import { detectFramework, detectPackageManager, findWorkspaceDirs } from "./utils/framework.js";
|
|
11
|
+
import { detectExtraLanguages, detectFramework, detectPackageManager, findWorkspaceDirs } from "./utils/framework.js";
|
|
12
12
|
import { isRuleDisabled, loadConfig } from "./config.js";
|
|
13
13
|
/** File extensions considered code for content scanning (not config/data). */
|
|
14
14
|
const CODE_ONLY = new Set([
|
|
@@ -31,6 +31,7 @@ export async function detectProject(root, config) {
|
|
|
31
31
|
scripts: pkg?.scripts ?? {},
|
|
32
32
|
sourceFiles,
|
|
33
33
|
workspaceDirs,
|
|
34
|
+
extraLanguages: detectExtraLanguages(root, detectFramework(pkg, root, sourceFiles, workspaceDirs)),
|
|
34
35
|
};
|
|
35
36
|
}
|
|
36
37
|
/** Extracts env usages, secrets, and todos from all source files. */
|
|
@@ -75,37 +76,50 @@ export function scanFiles(root, files, secretAllowlist = []) {
|
|
|
75
76
|
}
|
|
76
77
|
return { envUsages, secrets, todos };
|
|
77
78
|
}
|
|
78
|
-
/**
|
|
79
|
-
|
|
80
|
-
|
|
79
|
+
/**
|
|
80
|
+
* Score deductions per the shipready scoring model. Returns the breakdown
|
|
81
|
+
* so users can see exactly where every point went instead of trusting an
|
|
82
|
+
* opaque number.
|
|
83
|
+
*/
|
|
84
|
+
export function calculateScoreBreakdown(results) {
|
|
81
85
|
const all = results.flatMap((r) => r.findings);
|
|
82
86
|
const has = (rule) => all.some((f) => f.rule === rule);
|
|
83
87
|
const count = (rule) => all.filter((f) => f.rule === rule).length;
|
|
88
|
+
const deductions = [];
|
|
89
|
+
const deduct = (points, reason) => {
|
|
90
|
+
if (points > 0)
|
|
91
|
+
deductions.push({ reason, points });
|
|
92
|
+
};
|
|
84
93
|
if (has("package-json.missing"))
|
|
85
|
-
|
|
94
|
+
deduct(20, "no package.json");
|
|
86
95
|
if (has("readme.missing"))
|
|
87
|
-
|
|
96
|
+
deduct(15, "no README");
|
|
88
97
|
if (has("readme.weak"))
|
|
89
|
-
|
|
98
|
+
deduct(8, "weak README");
|
|
90
99
|
// Missing scripts: look at message to determine which ones
|
|
91
100
|
const scriptsFinding = all.find((f) => f.rule === "scripts.missing");
|
|
92
101
|
if (scriptsFinding) {
|
|
93
102
|
if (scriptsFinding.message.includes("build"))
|
|
94
|
-
|
|
103
|
+
deduct(8, "no build script");
|
|
95
104
|
if (scriptsFinding.message.includes("test"))
|
|
96
|
-
|
|
105
|
+
deduct(6, "no test script");
|
|
97
106
|
}
|
|
98
107
|
if (has("env.example-missing"))
|
|
99
|
-
|
|
108
|
+
deduct(10, ".env.example missing");
|
|
100
109
|
if (has("env.not-ignored"))
|
|
101
|
-
|
|
110
|
+
deduct(15, ".env not ignored");
|
|
102
111
|
const secretCount = count("secrets.detected-item");
|
|
103
|
-
|
|
112
|
+
deduct(Math.min(secretCount * 25, 50), secretCount === 1 ? "hardcoded secret" : `hardcoded secrets (${secretCount})`);
|
|
104
113
|
const historyCount = count("history.secret-item");
|
|
105
|
-
|
|
114
|
+
deduct(Math.min(historyCount * 10, 30), historyCount === 1 ? "secret in git history" : `secrets in git history (${historyCount})`);
|
|
106
115
|
const todoCount = count("todos.item");
|
|
107
|
-
|
|
108
|
-
|
|
116
|
+
deduct(Math.min(todoCount * 2, 15), todoCount === 1 ? "TODO comment" : `TODO comments (${todoCount})`);
|
|
117
|
+
const total = deductions.reduce((sum, d) => sum + d.points, 0);
|
|
118
|
+
return { score: Math.max(0, 100 - total), deductions };
|
|
119
|
+
}
|
|
120
|
+
/** Score per the shipready scoring model (see calculateScoreBreakdown). */
|
|
121
|
+
export function calculateScore(results) {
|
|
122
|
+
return calculateScoreBreakdown(results).score;
|
|
109
123
|
}
|
|
110
124
|
/** Removes findings whose rules are disabled by config. */
|
|
111
125
|
function applyDisabledRules(results, disableRules) {
|
|
@@ -139,13 +153,16 @@ export async function runScan(root, options = {}) {
|
|
|
139
153
|
checkSecrets(secrets),
|
|
140
154
|
...(options.history ? [checkHistory(historySecrets, historyScanned)] : []),
|
|
141
155
|
checkTodos(todos),
|
|
142
|
-
checkGitignore(root, project.framework
|
|
156
|
+
checkGitignore(root, project.framework, {
|
|
157
|
+
hasBuildScript: Boolean(project.scripts["build"]),
|
|
158
|
+
usesEnv: envUsages.length > 0,
|
|
159
|
+
}),
|
|
143
160
|
];
|
|
144
161
|
// Disabled rules are removed before scoring so they don't affect the score.
|
|
145
162
|
const results = applyDisabledRules(rawResults, config.disableRules);
|
|
146
163
|
return {
|
|
147
164
|
project,
|
|
148
165
|
results,
|
|
149
|
-
|
|
166
|
+
...calculateScoreBreakdown(results),
|
|
150
167
|
};
|
|
151
168
|
}
|
package/dist/utils/framework.js
CHANGED
|
@@ -38,29 +38,71 @@ export function findWorkspaceDirs(root, pkg) {
|
|
|
38
38
|
catch { /* unreadable yaml: treat as no workspaces */ }
|
|
39
39
|
}
|
|
40
40
|
const dirs = new Set();
|
|
41
|
+
const addPackagesUnder = (base, depth) => {
|
|
42
|
+
const abs = path.join(root, base);
|
|
43
|
+
if (!fs.existsSync(abs))
|
|
44
|
+
return;
|
|
45
|
+
try {
|
|
46
|
+
for (const entry of fs.readdirSync(abs, { withFileTypes: true })) {
|
|
47
|
+
if (!entry.isDirectory() || entry.name === "node_modules")
|
|
48
|
+
continue;
|
|
49
|
+
const rel = path.join(base, entry.name);
|
|
50
|
+
if (fs.existsSync(path.join(root, rel, "package.json")))
|
|
51
|
+
dirs.add(rel);
|
|
52
|
+
// "packages/**/*" style patterns nest one extra level (e.g. astro's
|
|
53
|
+
// packages/integrations/react). Depth-capped to avoid crawling.
|
|
54
|
+
else if (depth > 1)
|
|
55
|
+
addPackagesUnder(rel, depth - 1);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
catch { /* unreadable dir */ }
|
|
59
|
+
};
|
|
41
60
|
for (const pattern of patterns) {
|
|
42
61
|
if (pattern.startsWith("!"))
|
|
43
62
|
continue;
|
|
44
|
-
if (pattern.
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
try {
|
|
50
|
-
for (const entry of fs.readdirSync(abs, { withFileTypes: true })) {
|
|
51
|
-
if (entry.isDirectory() && fs.existsSync(path.join(abs, entry.name, "package.json"))) {
|
|
52
|
-
dirs.add(path.join(base, entry.name));
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
catch { /* unreadable dir */ }
|
|
63
|
+
if (pattern.includes("**")) {
|
|
64
|
+
addPackagesUnder(pattern.slice(0, pattern.indexOf("**")).replace(/\/$/, ""), 3);
|
|
65
|
+
}
|
|
66
|
+
else if (pattern.endsWith("/*")) {
|
|
67
|
+
addPackagesUnder(pattern.slice(0, -2), 1);
|
|
57
68
|
}
|
|
58
69
|
else if (fs.existsSync(path.join(root, pattern, "package.json"))) {
|
|
59
70
|
dirs.add(pattern);
|
|
60
71
|
}
|
|
61
72
|
}
|
|
73
|
+
// Repos without workspace config often still split into conventional
|
|
74
|
+
// app dirs (frontend/ + backend/, client/ + server/). Treating those as
|
|
75
|
+
// pseudo-workspaces makes env/script checks see the whole project.
|
|
76
|
+
if (dirs.size === 0) {
|
|
77
|
+
for (const conventional of ["frontend", "backend", "client", "server", "web", "app", "api"]) {
|
|
78
|
+
if (fs.existsSync(path.join(root, conventional, "package.json"))) {
|
|
79
|
+
dirs.add(conventional);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
62
83
|
return [...dirs].sort();
|
|
63
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* Detects non-JS languages living alongside the primary framework, so a
|
|
87
|
+
* FastAPI + React template reads "Vite + Python" instead of hiding half
|
|
88
|
+
* the project.
|
|
89
|
+
*/
|
|
90
|
+
export function detectExtraLanguages(root, framework) {
|
|
91
|
+
const markers = [
|
|
92
|
+
["pyproject.toml", "Python"],
|
|
93
|
+
["requirements.txt", "Python"],
|
|
94
|
+
["go.mod", "Go"],
|
|
95
|
+
["Cargo.toml", "Rust"],
|
|
96
|
+
["Gemfile", "Ruby"],
|
|
97
|
+
["composer.json", "PHP"],
|
|
98
|
+
];
|
|
99
|
+
const found = new Set();
|
|
100
|
+
for (const [file, lang] of markers) {
|
|
101
|
+
if (lang !== framework && fs.existsSync(path.join(root, file)))
|
|
102
|
+
found.add(lang);
|
|
103
|
+
}
|
|
104
|
+
return [...found];
|
|
105
|
+
}
|
|
64
106
|
/** Detects the package manager based on lockfiles and manifests. */
|
|
65
107
|
export function detectPackageManager(root) {
|
|
66
108
|
// Node ecosystems (lockfile wins over manifest)
|
package/dist/utils/report.js
CHANGED
|
@@ -125,6 +125,7 @@ export function renderReport(report, verbose = false, version) {
|
|
|
125
125
|
: project.packageManager;
|
|
126
126
|
const meta = pc.dim("project ") +
|
|
127
127
|
project.framework +
|
|
128
|
+
(project.extraLanguages.length > 0 ? " + " + project.extraLanguages.join(" + ") : "") +
|
|
128
129
|
pc.dim(" \u00b7 ") +
|
|
129
130
|
pc.dim("pm ") +
|
|
130
131
|
pmLabel;
|
|
@@ -132,6 +133,12 @@ export function renderReport(report, verbose = false, version) {
|
|
|
132
133
|
lines.push(...box([title, meta]));
|
|
133
134
|
lines.push("");
|
|
134
135
|
lines.push(` ${pc.bold("Score")} ${scoreBar(report.score)} ${scoreLabel(report.score)} ${verdict(report.score)}`);
|
|
136
|
+
if (report.deductions.length > 0) {
|
|
137
|
+
lines.push(" " +
|
|
138
|
+
pc.dim(report.deductions
|
|
139
|
+
.map((d) => `-${d.points} ${d.reason}`)
|
|
140
|
+
.join(" \u00b7 ")));
|
|
141
|
+
}
|
|
135
142
|
lines.push("");
|
|
136
143
|
// Aligned checks table: one row per check, then its top-level findings.
|
|
137
144
|
const labelWidth = Math.max(...report.results.map((r) => (CHECK_LABELS[r.name] ?? r.name).length));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "shipready",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.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",
|