leakfix 0.1.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/LICENSE +21 -0
- package/README.md +166 -0
- package/dist/cli.js +162 -0
- package/dist/detect/rules.js +36 -0
- package/dist/detect/scan.js +91 -0
- package/dist/engine/init.js +255 -0
- package/dist/engine/plan.js +53 -0
- package/dist/engine/repo.js +41 -0
- package/dist/engine/rotations.js +173 -0
- package/dist/providers/atlas.js +62 -0
- package/dist/providers/http.js +30 -0
- package/dist/providers/vercel.js +88 -0
- package/package.json +51 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Safayet Hossain
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# leakfix
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/leakfix)
|
|
4
|
+
[](https://github.com/safayet404/leakfix/actions/workflows/ci.yml)
|
|
5
|
+
|
|
6
|
+
**Leaked a secret? leakfix replaces it without taking your app down.**
|
|
7
|
+
|
|
8
|
+
Finding a leaked `.env` is the easy part. GitHub and others will tell you about
|
|
9
|
+
it, and some providers revoke the key on the spot, which takes your app down.
|
|
10
|
+
The hard part is the cleanup: mint a new credential, update the deployment,
|
|
11
|
+
redeploy, check the app still works, and only then kill the old one. leakfix does
|
|
12
|
+
that for you, and undoes everything if any step fails.
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
$ leakfix scan
|
|
16
|
+
Found 3 secret(s), 3 committed to git:
|
|
17
|
+
● .env:5 MONGODB_URI mongodb+srv://app:••••••@cluster0.mongodb.net/app ← committed to git
|
|
18
|
+
● .env:8 JWT_SECRET k3J••••••Qa ← committed to git
|
|
19
|
+
● .env:10 JWT_REFRESH_SECRET p9X••••••7w ← committed to git
|
|
20
|
+
|
|
21
|
+
$ leakfix rotate
|
|
22
|
+
Rotation plan: MONGODB_URI (MongoDB Atlas user app), JWT_SECRET, JWT_REFRESH_SECRET
|
|
23
|
+
1. Create database user app-lf20261001 with the same roles as app
|
|
24
|
+
2. Generate a new random JWT_SECRET
|
|
25
|
+
3. Generate a new random JWT_REFRESH_SECRET
|
|
26
|
+
4. Set MONGODB_URI on Vercel project api (production)
|
|
27
|
+
5. Set JWT_SECRET on Vercel project api (production)
|
|
28
|
+
6. Set JWT_REFRESH_SECRET on Vercel project api (production)
|
|
29
|
+
7. Redeploy api to production and wait until it is ready
|
|
30
|
+
8. Health check https://api.example.com/health
|
|
31
|
+
9. Delete leaked database user app (after everything above succeeded)
|
|
32
|
+
Dry run. Run again with --yes to execute.
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## How a rotation stays safe
|
|
36
|
+
|
|
37
|
+
1. **New credential next to the old one.** For MongoDB, leakfix creates a new
|
|
38
|
+
database user with the same roles, so both work for a moment.
|
|
39
|
+
2. **Switch.** It updates every affected environment variable, then does one redeploy.
|
|
40
|
+
3. **Verify.** It waits for the deployment to be ready and calls your health URL.
|
|
41
|
+
4. **Revoke.** Only then does it delete the leaked credential.
|
|
42
|
+
|
|
43
|
+
If anything fails before step 4, leakfix undoes the completed steps in reverse,
|
|
44
|
+
redeploys the previous configuration, and removes the credential it created.
|
|
45
|
+
Production never points at something that doesn't exist. If step 4 itself
|
|
46
|
+
fails, leakfix does **not** roll back to the leaked credential. It tells you what
|
|
47
|
+
to finish by hand.
|
|
48
|
+
|
|
49
|
+
Every step is written to `leakfix-audit.jsonl`. The log never contains secret values.
|
|
50
|
+
|
|
51
|
+
## Supported (v0.1)
|
|
52
|
+
|
|
53
|
+
| Secret | Rotation |
|
|
54
|
+
|---|---|
|
|
55
|
+
| MongoDB Atlas connection string | ✅ new database user, same roles; old user deleted |
|
|
56
|
+
| JWT signing secrets | ✅ new random secret (users sign in again) |
|
|
57
|
+
| Deployment | ✅ Vercel (env vars + production redeploy) |
|
|
58
|
+
| SMTP passwords, other API keys | 📝 detected, with instructions to rotate by hand |
|
|
59
|
+
|
|
60
|
+
Planned: Render and Railway deployments, Stripe, SendGrid and Resend keys, a GitHub App that
|
|
61
|
+
reacts to pushes and opens the clean-up PR, and an approval mode ("rotate?" → one click).
|
|
62
|
+
|
|
63
|
+
## Setup
|
|
64
|
+
|
|
65
|
+
Needs Node.js 22 or newer. Nothing to install:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
npx leakfix scan # in your repo: which secrets are committed?
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Or install it: `npm i -g leakfix`.
|
|
72
|
+
|
|
73
|
+
Then let leakfix find the rest:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
npx leakfix init
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
✓ Committed secrets to rotate: MONGODB_URI, JWT_SECRET, JWT_REFRESH_SECRET
|
|
81
|
+
✓ Vercel token works (signed in as you)
|
|
82
|
+
✓ Vercel project: my-api (deploys you/my-api)
|
|
83
|
+
✓ Set for production on Vercel: MONGODB_URI, JWT_SECRET, JWT_REFRESH_SECRET
|
|
84
|
+
✓ Health check: https://my-api.vercel.app/health → 200
|
|
85
|
+
✓ Atlas project: my-project (cluster Cluster0)
|
|
86
|
+
✓ Atlas service account can manage database user app
|
|
87
|
+
Wrote ./leakfix.config.json (no secrets in it; safe to commit)
|
|
88
|
+
Ready. Next: leakfix rotate shows the plan; leakfix rotate --yes runs it.
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
`init` only reads. It finds the Vercel project from `.vercel/project.json` or the
|
|
92
|
+
git remote, the Atlas project from the cluster in your connection string, and a
|
|
93
|
+
health route on the project's domain. Anything missing comes with the exact
|
|
94
|
+
fix: where to create the token, which Atlas role to pick, and which IP range to
|
|
95
|
+
allow (a `/24`, so a home ISP changing your IP doesn't break it).
|
|
96
|
+
|
|
97
|
+
The config it writes looks like this, if you'd rather write it yourself:
|
|
98
|
+
|
|
99
|
+
```json
|
|
100
|
+
{
|
|
101
|
+
"vercel": { "project": "my-api", "teamId": "team_xxx" },
|
|
102
|
+
"atlas": { "groupId": "<Atlas project id>" },
|
|
103
|
+
"healthUrl": "https://my-api.vercel.app/api/health"
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Credentials come only from the environment, never from the repo:
|
|
108
|
+
|
|
109
|
+
| Variable | Where to get it | Needs |
|
|
110
|
+
|---|---|---|
|
|
111
|
+
| `LEAKFIX_VERCEL_TOKEN` | vercel.com/account/tokens | access to the project's team |
|
|
112
|
+
| `LEAKFIX_ATLAS_CLIENT_ID` / `LEAKFIX_ATLAS_CLIENT_SECRET` | Atlas → Organization → Access Manager → Service Accounts | permission to manage database users in the project |
|
|
113
|
+
|
|
114
|
+
Then:
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
leakfix init # find projects, check access, write leakfix.config.json
|
|
118
|
+
leakfix rotate # dry run: prints the plan
|
|
119
|
+
leakfix rotate --yes # do it
|
|
120
|
+
leakfix fix-repo # stop tracking .env files, add .gitignore entries and .env.example
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
To mark a fake value as intentional, put `leakfix:allow` in a comment on the same
|
|
124
|
+
line, or `leakfix:allow-file` in the first lines of a file full of test fixtures.
|
|
125
|
+
|
|
126
|
+
`fix-repo` doesn't rewrite history. The old file stays in past commits,
|
|
127
|
+
which is why rotating is what actually protects you.
|
|
128
|
+
|
|
129
|
+
## Block leaks in pull requests
|
|
130
|
+
|
|
131
|
+
Add leakfix to any repo's CI. The check fails when a secret is committed, before it
|
|
132
|
+
reaches `main`:
|
|
133
|
+
|
|
134
|
+
```yaml
|
|
135
|
+
# .github/workflows/leakfix.yml
|
|
136
|
+
name: leakfix
|
|
137
|
+
on: [push, pull_request]
|
|
138
|
+
jobs:
|
|
139
|
+
scan:
|
|
140
|
+
runs-on: ubuntu-latest
|
|
141
|
+
steps:
|
|
142
|
+
- uses: actions/checkout@v4
|
|
143
|
+
- uses: safayet404/leakfix@main
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Or run it in Docker, with nothing to install:
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
docker build -t leakfix https://github.com/safayet404/leakfix.git
|
|
150
|
+
docker run --rm -v "$PWD:/repo" leakfix scan /repo
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## Development
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
pnpm install
|
|
157
|
+
pnpm dev scan ../my-api # run from source
|
|
158
|
+
pnpm test # runs whole rotations against an in-memory Atlas + Vercel, including failures
|
|
159
|
+
pnpm typecheck
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
CI runs the tests on Node 22 and 24, builds the Docker image, and scans this
|
|
163
|
+
repo with it on every push.
|
|
164
|
+
|
|
165
|
+
Releases: bump `version` in package.json, then push a matching tag (`git tag v0.2.0 && git push --tags`).
|
|
166
|
+
CI publishes to npm through Trusted Publishing (no stored token, signed provenance) and creates the GitHub release.
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// leakfix: rotate leaked secrets without downtime.
|
|
3
|
+
//
|
|
4
|
+
// leakfix scan [dir] find secrets and whether git tracks them
|
|
5
|
+
// leakfix init [dir] find the Vercel/Atlas projects, check access, write the config
|
|
6
|
+
// leakfix rotate [dir] show the rotation plan (dry run)
|
|
7
|
+
// leakfix rotate [dir] --yes run it
|
|
8
|
+
// leakfix fix-repo [dir] untrack .env files, ignore them, add .env.example
|
|
9
|
+
//
|
|
10
|
+
// Credentials come from the environment, never from the repo:
|
|
11
|
+
// LEAKFIX_VERCEL_TOKEN, LEAKFIX_ATLAS_CLIENT_ID, LEAKFIX_ATLAS_CLIENT_SECRET
|
|
12
|
+
// Project settings live in leakfix.config.json (see README).
|
|
13
|
+
import { execFileSync } from "node:child_process";
|
|
14
|
+
import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { join, resolve } from "node:path";
|
|
16
|
+
import { parseArgs } from "node:util";
|
|
17
|
+
import { scanRepo } from "./detect/scan.js";
|
|
18
|
+
import { execute } from "./engine/plan.js";
|
|
19
|
+
import { init } from "./engine/init.js";
|
|
20
|
+
import { fixRepo } from "./engine/repo.js";
|
|
21
|
+
import { buildPlans, describeFinding } from "./engine/rotations.js";
|
|
22
|
+
const c = {
|
|
23
|
+
red: (s) => `\x1b[31m${s}\x1b[0m`, green: (s) => `\x1b[32m${s}\x1b[0m`,
|
|
24
|
+
yellow: (s) => `\x1b[33m${s}\x1b[0m`, dim: (s) => `\x1b[2m${s}\x1b[0m`, bold: (s) => `\x1b[1m${s}\x1b[0m`,
|
|
25
|
+
};
|
|
26
|
+
const configFile = (root, path) => (path ? resolve(path) : join(root, "leakfix.config.json"));
|
|
27
|
+
function loadConfig(root, path) {
|
|
28
|
+
const file = configFile(root, path);
|
|
29
|
+
if (!existsSync(file))
|
|
30
|
+
return {};
|
|
31
|
+
return JSON.parse(readFileSync(file, "utf8"));
|
|
32
|
+
}
|
|
33
|
+
/** "owner/repo" of the origin remote, if it is a GitHub-style URL. */
|
|
34
|
+
function gitRepo(root) {
|
|
35
|
+
try {
|
|
36
|
+
const url = execFileSync("git", ["-C", root, "remote", "get-url", "origin"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
37
|
+
return /[:/]([^/:]+\/[^/]+?)(?:\.git)?$/.exec(url)?.[1];
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const credentials = () => ({
|
|
44
|
+
vercelToken: process.env.LEAKFIX_VERCEL_TOKEN,
|
|
45
|
+
atlasClientId: process.env.LEAKFIX_ATLAS_CLIENT_ID,
|
|
46
|
+
atlasClientSecret: process.env.LEAKFIX_ATLAS_CLIENT_SECRET,
|
|
47
|
+
});
|
|
48
|
+
async function main() {
|
|
49
|
+
const { values, positionals } = parseArgs({
|
|
50
|
+
allowPositionals: true,
|
|
51
|
+
options: {
|
|
52
|
+
yes: { type: "boolean", short: "y", default: false },
|
|
53
|
+
config: { type: "string", short: "c" },
|
|
54
|
+
help: { type: "boolean", short: "h", default: false },
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
const [command = "scan", dir = "."] = positionals;
|
|
58
|
+
const root = resolve(dir);
|
|
59
|
+
if (values.help || !["scan", "init", "rotate", "fix-repo"].includes(command)) {
|
|
60
|
+
// The usage text is the comment block at the top of this file (kept in the build).
|
|
61
|
+
const lines = readFileSync(new URL(import.meta.url), "utf8").split("\n").slice(1);
|
|
62
|
+
const usage = lines.slice(0, lines.findIndex((l) => !l.startsWith("//")));
|
|
63
|
+
console.log(usage.map((l) => l.replace(/^\/\/ ?/, "")).join("\n"));
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const findings = scanRepo(root);
|
|
67
|
+
const leaked = findings.filter((f) => f.tracked);
|
|
68
|
+
if (command === "scan") {
|
|
69
|
+
if (!findings.length)
|
|
70
|
+
return console.log(c.green("No secrets found."));
|
|
71
|
+
console.log(c.bold(`Found ${findings.length} secret(s), ${leaked.length} committed to git:\n`));
|
|
72
|
+
for (const f of findings)
|
|
73
|
+
console.log(` ${f.tracked ? c.red("●") : c.dim("○")} ${describeFinding(f)} ${c.dim(f.kind)}`);
|
|
74
|
+
if (leaked.length) {
|
|
75
|
+
console.log(`\n${c.red("Committed secrets must be treated as leaked, even after you delete the file.")}`);
|
|
76
|
+
console.log(`Next: ${c.bold("leakfix rotate")} to replace them, then ${c.bold("leakfix fix-repo")} to stop tracking .env files.`);
|
|
77
|
+
}
|
|
78
|
+
process.exitCode = leaked.length ? 1 : 0;
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (command === "fix-repo") {
|
|
82
|
+
const fix = fixRepo(root, findings);
|
|
83
|
+
if (!fix.untracked.length)
|
|
84
|
+
return console.log(c.green("No tracked .env files."));
|
|
85
|
+
console.log(`Stopped tracking: ${fix.untracked.join(", ")}`);
|
|
86
|
+
if (fix.gitignoreAdded.length)
|
|
87
|
+
console.log(`Added to .gitignore: ${fix.gitignoreAdded.join(" ")}`);
|
|
88
|
+
if (fix.example)
|
|
89
|
+
console.log(`Created ${fix.example} (variable names only)`);
|
|
90
|
+
console.log(c.yellow("\nThe files stay in your git history. Rotating the secrets (leakfix rotate) is what actually protects you."));
|
|
91
|
+
console.log(`Review with ${c.bold("git status")}, then commit and push.`);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (command === "init") {
|
|
95
|
+
const existing = loadConfig(root, values.config);
|
|
96
|
+
console.log(c.bold("leakfix init") + c.dim(" (reads only; changes nothing on Vercel or Atlas)\n"));
|
|
97
|
+
const result = await init({ root, leaked, existing, creds: credentials(), gitRepo: gitRepo(root) });
|
|
98
|
+
for (const check of result.checks) {
|
|
99
|
+
const mark = { ok: c.green("✓"), warn: c.yellow("!"), todo: c.red("✗") }[check.status];
|
|
100
|
+
console.log(`${mark} ${check.label}`);
|
|
101
|
+
for (const line of check.fix ?? [])
|
|
102
|
+
console.log(c.dim(` → ${line}`));
|
|
103
|
+
}
|
|
104
|
+
if (JSON.stringify(result.config) !== JSON.stringify(existing)) {
|
|
105
|
+
const file = configFile(root, values.config);
|
|
106
|
+
writeFileSync(file, JSON.stringify(result.config, null, 2) + "\n");
|
|
107
|
+
console.log(`\nWrote ${file} ${c.dim("(no secrets in it; safe to commit)")}`);
|
|
108
|
+
}
|
|
109
|
+
console.log(result.ready
|
|
110
|
+
? `\n${c.green("Ready.")} Next: ${c.bold("leakfix rotate")} shows the plan; ${c.bold("leakfix rotate --yes")} runs it.`
|
|
111
|
+
: `\n${c.red("Not ready yet.")} Fix the ✗ items, then run ${c.bold("leakfix init")} again.`);
|
|
112
|
+
process.exitCode = result.ready ? 0 : 1;
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
// rotate
|
|
116
|
+
const cfg = loadConfig(root, values.config);
|
|
117
|
+
const { plans, manual } = buildPlans(leaked, cfg, credentials());
|
|
118
|
+
if (!leaked.length)
|
|
119
|
+
return console.log(c.green("No committed secrets to rotate."));
|
|
120
|
+
for (const plan of plans) {
|
|
121
|
+
console.log(c.bold(`\nRotation plan: ${plan.target}`));
|
|
122
|
+
plan.steps.forEach((s, i) => console.log(` ${i + 1}. ${s.title}${s.final ? c.dim(" (after everything above succeeded)") : ""}`));
|
|
123
|
+
}
|
|
124
|
+
if (manual.length) {
|
|
125
|
+
console.log(c.bold(`\nNeeds you (${manual.length}):`));
|
|
126
|
+
for (const m of manual)
|
|
127
|
+
console.log(` ${c.yellow("!")} ${describeFinding(m.finding)}\n ${c.dim(m.reason)}\n → ${m.howTo}`);
|
|
128
|
+
}
|
|
129
|
+
if (!plans.length)
|
|
130
|
+
return;
|
|
131
|
+
if (!values.yes) {
|
|
132
|
+
console.log(c.dim(`\nDry run. Run again with --yes to execute.`));
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
const auditFile = join(root, "leakfix-audit.jsonl");
|
|
136
|
+
let failed = false;
|
|
137
|
+
for (const plan of plans) {
|
|
138
|
+
console.log(c.bold(`\nRotating: ${plan.target}`));
|
|
139
|
+
const result = await execute(plan, (e) => {
|
|
140
|
+
appendFileSync(auditFile, JSON.stringify(e) + "\n");
|
|
141
|
+
const mark = { ok: c.green("✓"), failed: c.red("✗"), undone: c.yellow("↺"), "undo-failed": c.red("‼"), skipped: c.dim("–") }[e.status];
|
|
142
|
+
console.log(` ${mark} ${e.step}${e.error ? c.dim(` (${e.error})`) : ""}`);
|
|
143
|
+
});
|
|
144
|
+
if (!result.ok && result.rolledBack) {
|
|
145
|
+
failed = true;
|
|
146
|
+
console.log(c.red(` Rotation rolled back. Production is still on the previous credentials.`));
|
|
147
|
+
}
|
|
148
|
+
else if (!result.ok) {
|
|
149
|
+
failed = true;
|
|
150
|
+
console.log(c.yellow(` Production now uses the new credentials, but: ${result.error}`));
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
console.log(c.green(` Done. The leaked credentials no longer work.`));
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
console.log(c.dim(`\nAudit log: ${auditFile} (contains no secret values)`));
|
|
157
|
+
process.exitCode = failed ? 1 : 0;
|
|
158
|
+
}
|
|
159
|
+
main().catch((err) => {
|
|
160
|
+
console.error(c.red(err instanceof Error ? err.message : String(err)));
|
|
161
|
+
process.exitCode = 2;
|
|
162
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// What leakfix looks for. Each rule recognises one kind of credential that leakfix
|
|
2
|
+
// knows how to rotate (or, for "generic", at least report).
|
|
3
|
+
//
|
|
4
|
+
// Detection is deliberately simple and explainable: env-style KEY=value lines
|
|
5
|
+
// and connection strings. Broad pattern libraries (gitleaks, trufflehog) find
|
|
6
|
+
// far more; leakfix's job is what happens *after* a leak is found.
|
|
7
|
+
export const RULES = [
|
|
8
|
+
{
|
|
9
|
+
kind: "mongodb-uri",
|
|
10
|
+
description: "MongoDB connection string with a username and password",
|
|
11
|
+
value: /mongodb(?:\+srv)?:\/\/[^\s:@/'"]+:[^\s@/'"]+@[^\s'"]+/,
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
kind: "jwt-secret",
|
|
15
|
+
description: "Secret used to sign JSON Web Tokens",
|
|
16
|
+
key: /^(?:[A-Z0-9_]*_)?JWT(?:_[A-Z0-9]+)*_SECRET$|^(?:ACCESS|REFRESH)_TOKEN_SECRET$/,
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
kind: "smtp-password",
|
|
20
|
+
description: "SMTP / email account password",
|
|
21
|
+
key: /^(?:SMTP|MAIL|EMAIL)_(?:PASS|PASSWORD)$/,
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
kind: "vercel-token",
|
|
25
|
+
description: "Vercel access token",
|
|
26
|
+
key: /^VERCEL_(?:ACCESS_)?TOKEN$/,
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
kind: "generic",
|
|
30
|
+
description: "Variable whose name suggests a secret",
|
|
31
|
+
key: /(?:SECRET|PASSWORD|PASSWD|PRIVATE_KEY|API_KEY|ACCESS_KEY|TOKEN)$/,
|
|
32
|
+
},
|
|
33
|
+
];
|
|
34
|
+
/** Values that are obviously placeholders, not real secrets. */
|
|
35
|
+
// Also shell expansions ($VAR, ${VAR}, $(command)): the value is produced at run time, not written down.
|
|
36
|
+
export const PLACEHOLDER = /^(?:|changeme|change_me|your[_-].*|<.*>|\$\{.*\}|\$\(.*\)|\$[A-Za-z_]\w*|x+|\*+|example.*|dummy|test|secret|password|null|undefined)$/i;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { readFileSync, statSync } from "node:fs";
|
|
3
|
+
import { join, basename } from "node:path";
|
|
4
|
+
import { PLACEHOLDER, RULES } from "./rules.js";
|
|
5
|
+
/** Put this in a comment on a line to tell leakfix the value is intentional (test fixtures, docs). */
|
|
6
|
+
export const ALLOW = "leakfix:allow";
|
|
7
|
+
/** In the first lines of a file: skip the whole file (test fixtures full of fake secrets). */
|
|
8
|
+
export const ALLOW_FILE = "leakfix:allow-file";
|
|
9
|
+
const ENV_LINE = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/;
|
|
10
|
+
function unquote(raw) {
|
|
11
|
+
let v = raw.trim();
|
|
12
|
+
const hash = v.search(/\s+#/); // trailing comment on unquoted values
|
|
13
|
+
if (!/^["']/.test(v) && hash >= 0)
|
|
14
|
+
v = v.slice(0, hash);
|
|
15
|
+
if (/^(["']).*\1$/.test(v))
|
|
16
|
+
v = v.slice(1, -1);
|
|
17
|
+
return v.trim();
|
|
18
|
+
}
|
|
19
|
+
/** Scan text (one file) for secrets. */
|
|
20
|
+
export function scanText(text, file, tracked = false) {
|
|
21
|
+
const findings = [];
|
|
22
|
+
const lines = text.split(/\r?\n/);
|
|
23
|
+
lines.forEach((lineText, i) => {
|
|
24
|
+
if (lineText.includes(ALLOW))
|
|
25
|
+
return; // explicitly marked as a fake / intended value
|
|
26
|
+
const env = ENV_LINE.exec(lineText);
|
|
27
|
+
if (env) {
|
|
28
|
+
const key = env[1];
|
|
29
|
+
const value = unquote(env[2]);
|
|
30
|
+
if (!value || PLACEHOLDER.test(value))
|
|
31
|
+
return;
|
|
32
|
+
const byValue = RULES.find((r) => r.value?.test(value));
|
|
33
|
+
const byKey = RULES.find((r) => r.key?.test(key));
|
|
34
|
+
const rule = byValue ?? byKey;
|
|
35
|
+
if (rule)
|
|
36
|
+
findings.push({ kind: rule.kind, file, line: i + 1, key, value, tracked });
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
// Connection strings can also sit in code or config files.
|
|
40
|
+
for (const rule of RULES) {
|
|
41
|
+
const m = rule.value?.exec(lineText);
|
|
42
|
+
if (m)
|
|
43
|
+
findings.push({ kind: rule.kind, file, line: i + 1, value: m[0], tracked });
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
return findings;
|
|
47
|
+
}
|
|
48
|
+
function git(cwd, args) {
|
|
49
|
+
return execFileSync("git", ["-C", cwd, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
50
|
+
}
|
|
51
|
+
const SKIP = /(?:^|\/)(?:node_modules|\.git|dist|build|\.next|vendor)\//;
|
|
52
|
+
const LIKELY = /(?:^|\/)\.env(?:\.[\w.-]+)?$|\.(?:js|ts|mjs|cjs|json|ya?ml|toml|php|py|env)$/;
|
|
53
|
+
/**
|
|
54
|
+
* Scan a git repository's tracked files plus any .env files lying around.
|
|
55
|
+
* Untracked .env files are reported too (tracked=false) because they show
|
|
56
|
+
* which secrets exist, but only tracked ones count as leaked.
|
|
57
|
+
*/
|
|
58
|
+
export function scanRepo(root) {
|
|
59
|
+
const tracked = new Set(git(root, ["ls-files"]).split("\n").filter(Boolean));
|
|
60
|
+
// Untracked .env files, whether git-ignored or not.
|
|
61
|
+
const untrackedEnv = [
|
|
62
|
+
...git(root, ["ls-files", "--others", "--exclude-standard"]).split("\n"),
|
|
63
|
+
...git(root, ["ls-files", "--others", "--ignored", "--exclude-standard"]).split("\n"),
|
|
64
|
+
].filter((f) => f && /(?:^|\/)\.env(?:\.[\w.-]+)?$/.test(f));
|
|
65
|
+
const findings = [];
|
|
66
|
+
for (const file of [...tracked, ...untrackedEnv]) {
|
|
67
|
+
if (SKIP.test(file) || !LIKELY.test(file) || basename(file) === ".env.example")
|
|
68
|
+
continue;
|
|
69
|
+
const path = join(root, file);
|
|
70
|
+
try {
|
|
71
|
+
if (statSync(path).size > 1_000_000)
|
|
72
|
+
continue;
|
|
73
|
+
const text = readFileSync(path, "utf8");
|
|
74
|
+
// A whole file of intentional fakes (test fixtures) can opt out near its top.
|
|
75
|
+
if (text.split("\n", 5).some((l) => l.includes(ALLOW_FILE)))
|
|
76
|
+
continue;
|
|
77
|
+
findings.push(...scanText(text, file, tracked.has(file)));
|
|
78
|
+
}
|
|
79
|
+
catch { /* deleted or unreadable */ }
|
|
80
|
+
}
|
|
81
|
+
return findings;
|
|
82
|
+
}
|
|
83
|
+
/** Mask a secret for display: keep a hint of the start and end only. */
|
|
84
|
+
export function mask(value) {
|
|
85
|
+
const uri = /^(mongodb(?:\+srv)?:\/\/)([^:]+):[^@]+@(.+)$/.exec(value);
|
|
86
|
+
if (uri)
|
|
87
|
+
return `${uri[1]}${uri[2]}:••••••@${uri[3]}`;
|
|
88
|
+
if (value.length <= 8)
|
|
89
|
+
return "••••••";
|
|
90
|
+
return `${value.slice(0, 3)}••••••${value.slice(-2)}`;
|
|
91
|
+
}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
// `leakfix init`: find everything a rotation needs, check that it works, and say
|
|
2
|
+
// exactly what to do about anything missing. Nothing here changes any service.
|
|
3
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
4
|
+
import { basename, join } from "node:path";
|
|
5
|
+
import { AtlasClient, parseMongoUri } from "../providers/atlas.js";
|
|
6
|
+
import { HttpError, request } from "../providers/http.js";
|
|
7
|
+
import { VercelClient } from "../providers/vercel.js";
|
|
8
|
+
const HEALTH_PATHS = ["/health", "/api/health", "/healthz", "/api/healthz"];
|
|
9
|
+
export async function init(input) {
|
|
10
|
+
const { leaked, existing, creds, fetchImpl = fetch } = input;
|
|
11
|
+
const checks = [];
|
|
12
|
+
const config = structuredClone(existing);
|
|
13
|
+
const done = () => ({ checks, config, ready: !checks.some((c) => c.status === "todo") });
|
|
14
|
+
const keys = [...new Set(leaked.filter((f) => f.key && (f.kind === "mongodb-uri" || f.kind === "jwt-secret")).map((f) => f.key))];
|
|
15
|
+
const mongo = leaked.find((f) => f.key && f.kind === "mongodb-uri");
|
|
16
|
+
if (!keys.length) {
|
|
17
|
+
checks.push({ status: "ok", label: "No committed secrets that leakfix rotates automatically. `leakfix scan` lists everything it found." });
|
|
18
|
+
return done();
|
|
19
|
+
}
|
|
20
|
+
checks.push({ status: "ok", label: `Committed secrets to rotate: ${keys.join(", ")}` });
|
|
21
|
+
await checkVercel(checks, config, keys, input, fetchImpl);
|
|
22
|
+
if (mongo)
|
|
23
|
+
await checkAtlas(checks, config, mongo, creds, fetchImpl);
|
|
24
|
+
return done();
|
|
25
|
+
}
|
|
26
|
+
async function checkVercel(checks, config, keys, input, fetchImpl) {
|
|
27
|
+
const { root, creds, gitRepo } = input;
|
|
28
|
+
if (!creds.vercelToken) {
|
|
29
|
+
checks.push({
|
|
30
|
+
status: "todo", label: "No Vercel token (LEAKFIX_VERCEL_TOKEN)",
|
|
31
|
+
fix: [
|
|
32
|
+
"Create one at https://vercel.com/account/settings/tokens (an expiry of 1 day is enough).",
|
|
33
|
+
`Then, in this terminal: ${hiddenExport("LEAKFIX_VERCEL_TOKEN")}`,
|
|
34
|
+
],
|
|
35
|
+
});
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
const me = await new VercelClient(creds.vercelToken, undefined, fetchImpl).user();
|
|
40
|
+
checks.push({ status: "ok", label: `Vercel token works (signed in as ${me.username})` });
|
|
41
|
+
}
|
|
42
|
+
catch (err) {
|
|
43
|
+
checks.push({ status: "todo", label: `Vercel rejected the token (${errText(err)})`, fix: ["Create a new one at https://vercel.com/account/settings/tokens"] });
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
// Which project? Config first, then the folder's `vercel link`, then the git remote.
|
|
47
|
+
if (!config.vercel) {
|
|
48
|
+
const found = linkedProject(root) ?? await projectForRepo(creds.vercelToken, gitRepo, basename(root), fetchImpl);
|
|
49
|
+
if ("project" in found) {
|
|
50
|
+
config.vercel = { project: found.project, ...(found.teamId ? { teamId: found.teamId } : {}) };
|
|
51
|
+
checks.push({ status: "ok", label: `Vercel project: ${found.project}${found.via ? ` (${found.via})` : ""}` });
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
checks.push({
|
|
55
|
+
status: "todo", label: "Could not tell which Vercel project deploys this repo",
|
|
56
|
+
fix: [
|
|
57
|
+
`Set "vercel": { "project": "<name>" } in leakfix.config.json${found.names.length ? `. Your projects: ${found.names.join(", ")}` : ""}`,
|
|
58
|
+
"For a team project, add its \"teamId\" too.",
|
|
59
|
+
],
|
|
60
|
+
});
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
checks.push({ status: "ok", label: `Vercel project: ${config.vercel.project} (from leakfix.config.json)` });
|
|
66
|
+
}
|
|
67
|
+
const vercel = new VercelClient(creds.vercelToken, config.vercel.teamId, fetchImpl);
|
|
68
|
+
const project = config.vercel.project;
|
|
69
|
+
try {
|
|
70
|
+
const missing = [];
|
|
71
|
+
for (const key of keys)
|
|
72
|
+
if (!(await vercel.findEnv(project, key)).length)
|
|
73
|
+
missing.push(key);
|
|
74
|
+
if (missing.length) {
|
|
75
|
+
checks.push({
|
|
76
|
+
status: "todo", label: `Not set for production on Vercel: ${missing.join(", ")}`,
|
|
77
|
+
fix: [`leakfix replaces existing values. Add them under Project → Settings → Environment Variables (Production), or remove them from the rotation.`],
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
checks.push({ status: "ok", label: `Set for production on Vercel: ${keys.join(", ")}` });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
checks.push({ status: "todo", label: `Cannot read ${project}'s environment variables (${errText(err)})`, fix: ["Check the project name and that the token can access its team."] });
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
// A health URL lets leakfix notice a broken deploy before revoking the old credential.
|
|
89
|
+
if (config.healthUrl) {
|
|
90
|
+
const status = await probe(config.healthUrl, fetchImpl);
|
|
91
|
+
checks.push(status && status < 300
|
|
92
|
+
? { status: "ok", label: `Health check: ${config.healthUrl} → ${status}` }
|
|
93
|
+
: { status: "todo", label: `Health check ${config.healthUrl} answered ${status ?? "nothing"}`, fix: ["Fix the URL in leakfix.config.json, or remove healthUrl."] });
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
let domains = [];
|
|
97
|
+
try {
|
|
98
|
+
domains = await vercel.domains(project);
|
|
99
|
+
}
|
|
100
|
+
catch { /* fall through to the warning */ }
|
|
101
|
+
for (const domain of domains) {
|
|
102
|
+
for (const path of HEALTH_PATHS) {
|
|
103
|
+
const url = `https://${domain}${path}`;
|
|
104
|
+
const status = await probe(url, fetchImpl);
|
|
105
|
+
if (status && status < 300) {
|
|
106
|
+
config.healthUrl = url;
|
|
107
|
+
checks.push({
|
|
108
|
+
status: "ok", label: `Health check: ${url} → ${status}`,
|
|
109
|
+
fix: ["Make sure this route fails when the database is unreachable; otherwise it can't catch a bad credential."],
|
|
110
|
+
});
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
checks.push({
|
|
116
|
+
status: "warn", label: `No health URL found${domains.length ? ` on ${domains.join(", ")}` : ""}`,
|
|
117
|
+
fix: [
|
|
118
|
+
"Without one, leakfix only knows the deploy built, not that the app can reach its database.",
|
|
119
|
+
"Add a route that pings the database, then set \"healthUrl\" in leakfix.config.json.",
|
|
120
|
+
],
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
async function checkAtlas(checks, config, mongo, creds, fetchImpl) {
|
|
124
|
+
let parts;
|
|
125
|
+
try {
|
|
126
|
+
parts = parseMongoUri(mongo.value);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
checks.push({ status: "warn", label: `${mongo.key} has no username/password: nothing to rotate on Atlas` });
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const host = parts.rest.split(/[/?]/)[0];
|
|
133
|
+
if (!host.endsWith(".mongodb.net")) {
|
|
134
|
+
checks.push({ status: "warn", label: `${mongo.key} points at ${host}, not MongoDB Atlas: rotate this one by hand` });
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (!creds.atlasClientId || !creds.atlasClientSecret) {
|
|
138
|
+
const ip = await publicIp(fetchImpl);
|
|
139
|
+
checks.push({
|
|
140
|
+
status: "todo", label: "No MongoDB Atlas service account (LEAKFIX_ATLAS_CLIENT_ID / LEAKFIX_ATLAS_CLIENT_SECRET)",
|
|
141
|
+
fix: [
|
|
142
|
+
"Atlas → your organization → Applications → Service Accounts → Create service account.",
|
|
143
|
+
`Give it the role "Project Database Access Admin" on the project that has the cluster ${host}.`,
|
|
144
|
+
`Add this computer to its API Access List: ${ip ? `${range(ip)} (your IP is ${ip}; the range survives it changing)` : "your public IP"}.`,
|
|
145
|
+
`Then, in this terminal: export LEAKFIX_ATLAS_CLIENT_ID=<client id>; ${hiddenExport("LEAKFIX_ATLAS_CLIENT_SECRET")}`,
|
|
146
|
+
],
|
|
147
|
+
});
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
const atlas = new AtlasClient(creds.atlasClientId, creds.atlasClientSecret, fetchImpl);
|
|
151
|
+
let groupId = config.atlas?.groupId;
|
|
152
|
+
try {
|
|
153
|
+
if (!groupId) {
|
|
154
|
+
for (const project of await atlas.listProjects()) {
|
|
155
|
+
const clusters = await atlas.listClusters(project.id);
|
|
156
|
+
const cluster = clusters.find((c) => [c.connectionStrings?.standardSrv, c.connectionStrings?.standard].some((s) => s?.includes(host)));
|
|
157
|
+
if (cluster) {
|
|
158
|
+
groupId = project.id;
|
|
159
|
+
config.atlas = { groupId };
|
|
160
|
+
checks.push({ status: "ok", label: `Atlas project: ${project.name} (cluster ${cluster.name})` });
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (!groupId) {
|
|
165
|
+
checks.push({
|
|
166
|
+
status: "todo", label: `The service account can't see a project with the cluster ${host}`,
|
|
167
|
+
fix: [`Give it the role "Project Database Access Admin" on that project (Atlas → Project → Project Settings → Applications).`],
|
|
168
|
+
});
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
await atlas.getUser(groupId, parts.username);
|
|
173
|
+
checks.push({ status: "ok", label: `Atlas service account can manage database user ${parts.username}` });
|
|
174
|
+
}
|
|
175
|
+
catch (err) {
|
|
176
|
+
checks.push(atlasProblem(err, parts.username));
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
function atlasProblem(err, username) {
|
|
180
|
+
const text = errText(err);
|
|
181
|
+
const ip = /IP address ([\d.]+) is not allowed/.exec(text)?.[1];
|
|
182
|
+
if (ip) {
|
|
183
|
+
return {
|
|
184
|
+
status: "todo", label: `Atlas blocks this computer's IP (${ip})`,
|
|
185
|
+
fix: [`Add ${range(ip)} to the service account's API Access List (Organization → Applications → Service Accounts → your account). The range keeps working when your ISP changes your IP.`],
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
if (err instanceof HttpError && err.status === 401) {
|
|
189
|
+
return { status: "todo", label: "Atlas rejected the service account credentials", fix: ["Check LEAKFIX_ATLAS_CLIENT_ID / LEAKFIX_ATLAS_CLIENT_SECRET, or create a new secret for the service account."] };
|
|
190
|
+
}
|
|
191
|
+
if (err instanceof HttpError && err.status === 404) {
|
|
192
|
+
return { status: "warn", label: `Atlas has no database user ${username}: it may already be deleted (then the leaked password is dead already)` };
|
|
193
|
+
}
|
|
194
|
+
if (err instanceof HttpError && err.status === 403) {
|
|
195
|
+
return { status: "todo", label: `The service account may not manage database users (${text})`, fix: [`Give it "Project Database Access Admin" on the project (not "Project Data Access Admin").`] };
|
|
196
|
+
}
|
|
197
|
+
return { status: "todo", label: `Atlas: ${text}` };
|
|
198
|
+
}
|
|
199
|
+
/** `vercel link` leaves .vercel/project.json with the project and its owner. */
|
|
200
|
+
function linkedProject(root) {
|
|
201
|
+
const file = join(root, ".vercel", "project.json");
|
|
202
|
+
if (!existsSync(file))
|
|
203
|
+
return undefined;
|
|
204
|
+
try {
|
|
205
|
+
const { projectId, orgId } = JSON.parse(readFileSync(file, "utf8"));
|
|
206
|
+
if (!projectId)
|
|
207
|
+
return undefined;
|
|
208
|
+
return { project: projectId, ...(orgId?.startsWith("team_") ? { teamId: orgId } : {}), via: ".vercel/project.json" };
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
return undefined;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
/** Look through every project the token can see for one that deploys this repo. */
|
|
215
|
+
async function projectForRepo(token, gitRepo, folder, fetchImpl) {
|
|
216
|
+
const personal = new VercelClient(token, undefined, fetchImpl);
|
|
217
|
+
const scopes = [undefined, ...(await personal.teams().catch(() => [])).map((t) => t.id)];
|
|
218
|
+
const all = [];
|
|
219
|
+
for (const teamId of scopes) {
|
|
220
|
+
const projects = await new VercelClient(token, teamId, fetchImpl).projects().catch(() => []);
|
|
221
|
+
all.push(...projects.map((p) => ({ p, teamId })));
|
|
222
|
+
}
|
|
223
|
+
const pick = (hit, via) => hit && { project: hit.p.name, ...(hit.teamId ? { teamId: hit.teamId } : {}), via };
|
|
224
|
+
const [org, repo] = (gitRepo ?? "").toLowerCase().split("/");
|
|
225
|
+
const byRepo = repo ? all.filter(({ p }) => p.link?.repo?.toLowerCase() === repo && (!org || p.link?.org?.toLowerCase() === org)) : [];
|
|
226
|
+
if (byRepo.length === 1)
|
|
227
|
+
return pick(byRepo[0], `deploys ${gitRepo}`);
|
|
228
|
+
const byName = all.filter(({ p }) => p.name.toLowerCase() === folder.toLowerCase());
|
|
229
|
+
if (byName.length === 1)
|
|
230
|
+
return pick(byName[0], "same name as this folder");
|
|
231
|
+
return { names: all.map(({ p }) => p.name).slice(0, 15) };
|
|
232
|
+
}
|
|
233
|
+
async function probe(url, fetchImpl) {
|
|
234
|
+
try {
|
|
235
|
+
const res = await fetchImpl(url, { signal: AbortSignal.timeout(15_000) });
|
|
236
|
+
await res.body?.cancel();
|
|
237
|
+
return res.status;
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
return undefined;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
async function publicIp(fetchImpl) {
|
|
244
|
+
try {
|
|
245
|
+
return (await request(fetchImpl, "ipify", "https://api.ipify.org?format=json")).ip;
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
return undefined;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
/** 113.212.108.26 → 113.212.108.0/24: home ISPs often move you within this range. */
|
|
252
|
+
const range = (ip) => `${ip.split(".").slice(0, 3).join(".")}.0/24`;
|
|
253
|
+
/** Reads a secret without echoing it or saving it in shell history (works in bash and zsh). */
|
|
254
|
+
const hiddenExport = (name) => `stty -echo; printf '${name}: '; read ${name}; stty echo; echo; export ${name}`;
|
|
255
|
+
const errText = (err) => (err instanceof Error ? err.message : String(err));
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// A rotation is a list of small, reversible steps. The engine runs them in
|
|
2
|
+
// order; if one fails, it undoes the completed ones in reverse, so a half-done
|
|
3
|
+
// rotation never leaves the app pointing at a credential that doesn't exist.
|
|
4
|
+
export async function execute(plan, log = () => { }) {
|
|
5
|
+
const events = [];
|
|
6
|
+
const emit = (step, status, error) => {
|
|
7
|
+
const e = { at: new Date().toISOString(), target: plan.target, step, status, ...(error ? { error } : {}) };
|
|
8
|
+
events.push(e);
|
|
9
|
+
log(e);
|
|
10
|
+
};
|
|
11
|
+
const done = [];
|
|
12
|
+
const finalErrors = [];
|
|
13
|
+
for (const step of plan.steps) {
|
|
14
|
+
try {
|
|
15
|
+
await step.run();
|
|
16
|
+
emit(step.title, "ok");
|
|
17
|
+
done.push(step);
|
|
18
|
+
}
|
|
19
|
+
catch (err) {
|
|
20
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
21
|
+
emit(step.title, "failed", message);
|
|
22
|
+
// Past the point of no return the app already runs on the new credential
|
|
23
|
+
// and passed its checks. Rolling back would put it back on the leaked
|
|
24
|
+
// one, so record the failure, try the remaining final steps, and report.
|
|
25
|
+
if (step.final) {
|
|
26
|
+
finalErrors.push(`${step.title}: ${message}`);
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
// Roll back everything that can be rolled back, newest first.
|
|
30
|
+
for (const prev of [...done].reverse()) {
|
|
31
|
+
if (prev.final || !prev.undo)
|
|
32
|
+
continue;
|
|
33
|
+
try {
|
|
34
|
+
await prev.undo();
|
|
35
|
+
emit(prev.title, "undone");
|
|
36
|
+
}
|
|
37
|
+
catch (undoErr) {
|
|
38
|
+
emit(prev.title, "undo-failed", undoErr instanceof Error ? undoErr.message : String(undoErr));
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
for (const rest of plan.steps.slice(plan.steps.indexOf(step) + 1))
|
|
42
|
+
emit(rest.title, "skipped");
|
|
43
|
+
return { target: plan.target, ok: false, rolledBack: true, error: message, events };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (finalErrors.length) {
|
|
47
|
+
return {
|
|
48
|
+
target: plan.target, ok: false, rolledBack: false, events,
|
|
49
|
+
error: `Rotated, but finish these by hand: ${finalErrors.join("; ")}`,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
return { target: plan.target, ok: true, rolledBack: false, events };
|
|
53
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Stop tracking leaked env files: untrack them, ignore them, and leave an
|
|
2
|
+
// .env.example with the variable names only (never the values).
|
|
3
|
+
import { execFileSync } from "node:child_process";
|
|
4
|
+
import { existsSync, readFileSync, writeFileSync, appendFileSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
const ENV_FILE = /(?:^|\/)\.env(?:\.[\w.-]+)?$/;
|
|
7
|
+
function git(root, args) {
|
|
8
|
+
return execFileSync("git", ["-C", root, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
|
9
|
+
}
|
|
10
|
+
export function fixRepo(root, findings) {
|
|
11
|
+
const files = [...new Set(findings.filter((f) => f.tracked && ENV_FILE.test(f.file)).map((f) => f.file))];
|
|
12
|
+
const result = { untracked: [], gitignoreAdded: [] };
|
|
13
|
+
if (!files.length)
|
|
14
|
+
return result;
|
|
15
|
+
for (const file of files) {
|
|
16
|
+
git(root, ["rm", "--cached", "--quiet", "--", file]);
|
|
17
|
+
result.untracked.push(file);
|
|
18
|
+
}
|
|
19
|
+
const gi = join(root, ".gitignore");
|
|
20
|
+
const current = existsSync(gi) ? readFileSync(gi, "utf8") : "";
|
|
21
|
+
const want = [".env", ".env.*", "!.env.example"].filter((p) => !current.split(/\r?\n/).includes(p));
|
|
22
|
+
if (want.length) {
|
|
23
|
+
appendFileSync(gi, `${current && !current.endsWith("\n") ? "\n" : ""}# secrets (added by leakfix)\n${want.join("\n")}\n`);
|
|
24
|
+
result.gitignoreAdded = want;
|
|
25
|
+
}
|
|
26
|
+
const example = join(root, ".env.example");
|
|
27
|
+
if (!existsSync(example)) {
|
|
28
|
+
const keys = new Set();
|
|
29
|
+
for (const file of files) {
|
|
30
|
+
for (const line of readFileSync(join(root, file), "utf8").split(/\r?\n/)) {
|
|
31
|
+
const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line);
|
|
32
|
+
if (m)
|
|
33
|
+
keys.add(m[1]);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
writeFileSync(example, [...keys].map((k) => `${k}=`).join("\n") + "\n");
|
|
37
|
+
result.example = ".env.example";
|
|
38
|
+
}
|
|
39
|
+
git(root, ["add", "--", ".gitignore", ...(result.example ? [".env.example"] : [])]);
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// Turns findings into one rotation plan per deployment target.
|
|
2
|
+
//
|
|
3
|
+
// Zero-downtime order, the core idea of leakfix:
|
|
4
|
+
// 1. prepare mint a NEW credential next to the leaked one (both work)
|
|
5
|
+
// 2. switch point the app's environment at the new credential
|
|
6
|
+
// 3. redeploy one production redeploy for all switched variables
|
|
7
|
+
// 4. verify health check against the new deployment
|
|
8
|
+
// 5. revoke delete the leaked credential (point of no return)
|
|
9
|
+
// If anything before step 5 fails, every completed step is undone and the app
|
|
10
|
+
// keeps running on the old credential.
|
|
11
|
+
import { randomBytes } from "node:crypto";
|
|
12
|
+
import { mask } from "../detect/scan.js";
|
|
13
|
+
import { AtlasClient, buildMongoUri, parseMongoUri } from "../providers/atlas.js";
|
|
14
|
+
import { request } from "../providers/http.js";
|
|
15
|
+
import { VercelClient } from "../providers/vercel.js";
|
|
16
|
+
const MANUAL_HOWTO = {
|
|
17
|
+
"smtp-password": "Create a new app password with your email provider, delete the old one, and update the variable.",
|
|
18
|
+
"vercel-token": "Delete the token at vercel.com/account/tokens and create a new one.",
|
|
19
|
+
generic: "Revoke this credential where it was issued, create a new one, and update the variable.",
|
|
20
|
+
"mongodb-uri": "Change the database user's password in MongoDB Atlas → Database Access, then update the variable.",
|
|
21
|
+
"jwt-secret": "Generate a new random secret (e.g. `openssl rand -hex 32`) and update the variable; users will need to sign in again.",
|
|
22
|
+
};
|
|
23
|
+
function today() {
|
|
24
|
+
return new Date().toISOString().slice(0, 10).replaceAll("-", "");
|
|
25
|
+
}
|
|
26
|
+
function mongoRotation(f, cfg, atlas) {
|
|
27
|
+
const old = parseMongoUri(f.value);
|
|
28
|
+
const newUser = `${old.username.replace(/-lf\d{8}(?:-\d+)?$/, "")}-lf${today()}`;
|
|
29
|
+
const newPassword = randomBytes(24).toString("base64url");
|
|
30
|
+
const groupId = cfg.atlas.groupId;
|
|
31
|
+
return {
|
|
32
|
+
envKey: f.key,
|
|
33
|
+
oldValue: f.value,
|
|
34
|
+
label: `${f.key} (MongoDB Atlas user ${old.username})`,
|
|
35
|
+
newValue: () => buildMongoUri(old, newUser, newPassword),
|
|
36
|
+
prepare: {
|
|
37
|
+
title: `Create database user ${newUser} with the same roles as ${old.username}`,
|
|
38
|
+
async run() {
|
|
39
|
+
const current = await atlas.getUser(groupId, old.username);
|
|
40
|
+
await atlas.createUser({
|
|
41
|
+
groupId, databaseName: current.databaseName, username: newUser, password: newPassword,
|
|
42
|
+
roles: current.roles, ...(current.scopes?.length ? { scopes: current.scopes } : {}),
|
|
43
|
+
description: `Created by leakfix to replace leaked user ${old.username}`,
|
|
44
|
+
});
|
|
45
|
+
},
|
|
46
|
+
undo: () => atlas.deleteUser(groupId, newUser),
|
|
47
|
+
},
|
|
48
|
+
revoke: {
|
|
49
|
+
title: `Delete leaked database user ${old.username}`,
|
|
50
|
+
run: () => atlas.deleteUser(groupId, old.username),
|
|
51
|
+
final: true,
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
function jwtRotation(f) {
|
|
56
|
+
const value = randomBytes(48).toString("base64url");
|
|
57
|
+
return {
|
|
58
|
+
envKey: f.key,
|
|
59
|
+
oldValue: f.value,
|
|
60
|
+
label: `${f.key} (JWT signing secret)`,
|
|
61
|
+
newValue: () => value,
|
|
62
|
+
prepare: { title: `Generate a new random ${f.key}`, run: async () => { } },
|
|
63
|
+
// Nothing to revoke: once the app runs with the new secret, tokens signed
|
|
64
|
+
// with the leaked one stop verifying. Users sign in again.
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
export function buildPlans(findings, cfg, creds, fetchImpl = fetch) {
|
|
68
|
+
const manual = [];
|
|
69
|
+
const rotations = [];
|
|
70
|
+
const atlas = creds.atlasClientId && creds.atlasClientSecret
|
|
71
|
+
? new AtlasClient(creds.atlasClientId, creds.atlasClientSecret, fetchImpl) : undefined;
|
|
72
|
+
// One finding per variable name (the same key often appears in .env and .env.local).
|
|
73
|
+
const seen = new Set();
|
|
74
|
+
for (const f of findings) {
|
|
75
|
+
const id = f.key ?? f.value;
|
|
76
|
+
if (seen.has(id))
|
|
77
|
+
continue;
|
|
78
|
+
seen.add(id);
|
|
79
|
+
const howTo = MANUAL_HOWTO[f.kind] ?? MANUAL_HOWTO.generic;
|
|
80
|
+
if (!f.key) {
|
|
81
|
+
manual.push({ finding: f, reason: "secret is written directly in code, not in an environment variable", howTo: `Move it to an environment variable, then: ${howTo}` });
|
|
82
|
+
}
|
|
83
|
+
else if (!cfg.vercel || !creds.vercelToken) {
|
|
84
|
+
manual.push({ finding: f, reason: "no deployment configured (set vercel.project and LEAKFIX_VERCEL_TOKEN)", howTo });
|
|
85
|
+
}
|
|
86
|
+
else if (f.kind === "mongodb-uri") {
|
|
87
|
+
if (!atlas || !cfg.atlas)
|
|
88
|
+
manual.push({ finding: f, reason: "no MongoDB Atlas access configured", howTo });
|
|
89
|
+
else
|
|
90
|
+
rotations.push(mongoRotation(f, cfg, atlas));
|
|
91
|
+
}
|
|
92
|
+
else if (f.kind === "jwt-secret") {
|
|
93
|
+
rotations.push(jwtRotation(f));
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
manual.push({ finding: f, reason: `leakfix cannot rotate ${f.kind} credentials automatically yet`, howTo });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (!rotations.length || !cfg.vercel || !creds.vercelToken)
|
|
100
|
+
return { plans: [], manual };
|
|
101
|
+
const project = cfg.vercel.project;
|
|
102
|
+
const vercel = new VercelClient(creds.vercelToken, cfg.vercel.teamId, fetchImpl);
|
|
103
|
+
const steps = [];
|
|
104
|
+
for (const r of rotations)
|
|
105
|
+
steps.push(r.prepare);
|
|
106
|
+
for (const r of rotations) {
|
|
107
|
+
const previous = [];
|
|
108
|
+
steps.push({
|
|
109
|
+
title: `Set ${r.envKey} on Vercel project ${project} (production)`,
|
|
110
|
+
async run() {
|
|
111
|
+
const entries = await vercel.findEnv(project, r.envKey);
|
|
112
|
+
if (!entries.length)
|
|
113
|
+
throw new Error(`${r.envKey} is not set for production on Vercel project ${project}`);
|
|
114
|
+
// Remember what to restore on rollback. Never restore an empty value:
|
|
115
|
+
// if the platform hides the current one, use the leaked value from the repo.
|
|
116
|
+
for (const e of entries) {
|
|
117
|
+
const value = e.value || r.oldValue;
|
|
118
|
+
if (!value)
|
|
119
|
+
throw new Error(`Cannot read the current value of ${r.envKey}; refusing to change it without a way back`);
|
|
120
|
+
previous.push({ id: e.id, value });
|
|
121
|
+
}
|
|
122
|
+
for (const e of entries)
|
|
123
|
+
await vercel.setEnv(project, e.id, r.newValue());
|
|
124
|
+
},
|
|
125
|
+
async undo() {
|
|
126
|
+
for (const p of previous)
|
|
127
|
+
await vercel.setEnv(project, p.id, p.value);
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
let rolledBack = false;
|
|
132
|
+
steps.push({
|
|
133
|
+
title: `Redeploy ${project} to production and wait until it is ready`,
|
|
134
|
+
async run() {
|
|
135
|
+
const d = await vercel.redeploy(project);
|
|
136
|
+
await vercel.waitUntilReady(d.id);
|
|
137
|
+
},
|
|
138
|
+
// The env was restored by the previous steps' undo, which run after this
|
|
139
|
+
// one; a fresh redeploy is triggered once at the end of the rollback.
|
|
140
|
+
async undo() {
|
|
141
|
+
rolledBack = true;
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
if (cfg.healthUrl) {
|
|
145
|
+
const url = cfg.healthUrl;
|
|
146
|
+
steps.push({
|
|
147
|
+
title: `Health check ${url}`,
|
|
148
|
+
async run() {
|
|
149
|
+
await request(fetchImpl, "Health check", url, { method: "GET" });
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
for (const r of rotations)
|
|
154
|
+
if (r.revoke)
|
|
155
|
+
steps.push(r.revoke);
|
|
156
|
+
// Rollback tail. The first step's undo runs last, after every env variable is
|
|
157
|
+
// restored: redeploy first (so production is back on the old credential),
|
|
158
|
+
// and only then delete the credential minted in step 1.
|
|
159
|
+
const first = steps[0];
|
|
160
|
+
const originalUndo = first.undo;
|
|
161
|
+
first.undo = async () => {
|
|
162
|
+
if (rolledBack) {
|
|
163
|
+
const d = await vercel.redeploy(project);
|
|
164
|
+
await vercel.waitUntilReady(d.id);
|
|
165
|
+
}
|
|
166
|
+
await originalUndo?.();
|
|
167
|
+
};
|
|
168
|
+
const label = rotations.map((r) => r.label).join(", ");
|
|
169
|
+
return { plans: [{ target: label, steps }], manual };
|
|
170
|
+
}
|
|
171
|
+
export function describeFinding(f) {
|
|
172
|
+
return `${f.file}:${f.line} ${f.key ?? "(inline)"} ${mask(f.value)}${f.tracked ? " ← committed to git" : ""}`;
|
|
173
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// MongoDB Atlas Administration API v2.
|
|
2
|
+
// Auth: a Service Account (OAuth 2.0 client credentials) with Project Owner
|
|
3
|
+
// (or a role that can manage database users) on the project.
|
|
4
|
+
import { request } from "./http.js";
|
|
5
|
+
const BASE = "https://cloud.mongodb.com";
|
|
6
|
+
const ACCEPT = "application/vnd.atlas.2023-01-01+json";
|
|
7
|
+
export class AtlasClient {
|
|
8
|
+
clientId;
|
|
9
|
+
clientSecret;
|
|
10
|
+
fetchImpl;
|
|
11
|
+
token;
|
|
12
|
+
constructor(clientId, clientSecret, fetchImpl = fetch) {
|
|
13
|
+
this.clientId = clientId;
|
|
14
|
+
this.clientSecret = clientSecret;
|
|
15
|
+
this.fetchImpl = fetchImpl;
|
|
16
|
+
}
|
|
17
|
+
async auth() {
|
|
18
|
+
if (this.token && this.token.expires > Date.now() + 60_000)
|
|
19
|
+
return this.token.value;
|
|
20
|
+
const basic = Buffer.from(`${this.clientId}:${this.clientSecret}`).toString("base64");
|
|
21
|
+
const res = await request(this.fetchImpl, "Atlas", `${BASE}/api/oauth/token`, {
|
|
22
|
+
method: "POST",
|
|
23
|
+
headers: { Authorization: `Basic ${basic}`, "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
|
|
24
|
+
body: "grant_type=client_credentials",
|
|
25
|
+
});
|
|
26
|
+
this.token = { value: res.access_token, expires: Date.now() + res.expires_in * 1000 };
|
|
27
|
+
return this.token.value;
|
|
28
|
+
}
|
|
29
|
+
async call(method, path, json) {
|
|
30
|
+
return request(this.fetchImpl, "Atlas", `${BASE}/api/atlas/v2${path}`, {
|
|
31
|
+
method,
|
|
32
|
+
headers: { Authorization: `Bearer ${await this.auth()}`, Accept: ACCEPT, ...(json ? { "Content-Type": ACCEPT } : {}) },
|
|
33
|
+
...(json !== undefined ? { body: JSON.stringify(json) } : {}),
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
/** Projects ("groups") the service account can see. */
|
|
37
|
+
async listProjects() {
|
|
38
|
+
return (await this.call("GET", "/groups?itemsPerPage=500")).results;
|
|
39
|
+
}
|
|
40
|
+
async listClusters(groupId) {
|
|
41
|
+
return (await this.call("GET", `/groups/${groupId}/clusters?itemsPerPage=500`)).results;
|
|
42
|
+
}
|
|
43
|
+
getUser(groupId, username, databaseName = "admin") {
|
|
44
|
+
return this.call("GET", `/groups/${groupId}/databaseUsers/${databaseName}/${encodeURIComponent(username)}`);
|
|
45
|
+
}
|
|
46
|
+
createUser(user) {
|
|
47
|
+
return this.call("POST", `/groups/${user.groupId}/databaseUsers`, user);
|
|
48
|
+
}
|
|
49
|
+
deleteUser(groupId, username, databaseName = "admin") {
|
|
50
|
+
return this.call("DELETE", `/groups/${groupId}/databaseUsers/${databaseName}/${encodeURIComponent(username)}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** Split mongodb[+srv]://user:pass@host/... into its parts. */
|
|
54
|
+
export function parseMongoUri(uri) {
|
|
55
|
+
const m = /^(mongodb(?:\+srv)?:\/\/)([^:]+):([^@]+)@(.+)$/.exec(uri);
|
|
56
|
+
if (!m)
|
|
57
|
+
throw new Error("Not a MongoDB connection string with credentials");
|
|
58
|
+
return { scheme: m[1], username: decodeURIComponent(m[2]), password: decodeURIComponent(m[3]), rest: m[4] };
|
|
59
|
+
}
|
|
60
|
+
export function buildMongoUri(parts, username, password) {
|
|
61
|
+
return `${parts.scheme}${encodeURIComponent(username)}:${encodeURIComponent(password)}@${parts.rest}`;
|
|
62
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** Small fetch wrapper: JSON in/out, readable errors that never include secrets. */
|
|
2
|
+
export class HttpError extends Error {
|
|
3
|
+
status;
|
|
4
|
+
service;
|
|
5
|
+
constructor(status, service, message) {
|
|
6
|
+
super(`${service} ${status}: ${message}`);
|
|
7
|
+
this.status = status;
|
|
8
|
+
this.service = service;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export async function request(fetchImpl, service, url, init = {}) {
|
|
12
|
+
const { json, headers, ...rest } = init;
|
|
13
|
+
const res = await fetchImpl(url, {
|
|
14
|
+
...rest,
|
|
15
|
+
headers: { ...(json !== undefined ? { "Content-Type": "application/json" } : {}), ...headers },
|
|
16
|
+
...(json !== undefined ? { body: JSON.stringify(json) } : {}),
|
|
17
|
+
});
|
|
18
|
+
const text = await res.text();
|
|
19
|
+
if (!res.ok) {
|
|
20
|
+
let detail = text.slice(0, 300);
|
|
21
|
+
try {
|
|
22
|
+
const body = JSON.parse(text);
|
|
23
|
+
detail = body.detail ?? body.error?.message ?? body.message ?? body.errorCode ?? detail;
|
|
24
|
+
}
|
|
25
|
+
catch { /* not JSON */ }
|
|
26
|
+
throw new HttpError(res.status, service, String(detail));
|
|
27
|
+
}
|
|
28
|
+
return (text ? JSON.parse(text) : undefined);
|
|
29
|
+
}
|
|
30
|
+
export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Vercel REST API: environment variables and redeploys.
|
|
2
|
+
// Auth: an access token with access to the project's team.
|
|
3
|
+
import { request, sleep } from "./http.js";
|
|
4
|
+
const BASE = "https://api.vercel.com";
|
|
5
|
+
export class VercelClient {
|
|
6
|
+
token;
|
|
7
|
+
teamId;
|
|
8
|
+
fetchImpl;
|
|
9
|
+
pollMs;
|
|
10
|
+
constructor(token, teamId, fetchImpl = fetch, pollMs = 5000) {
|
|
11
|
+
this.token = token;
|
|
12
|
+
this.teamId = teamId;
|
|
13
|
+
this.fetchImpl = fetchImpl;
|
|
14
|
+
this.pollMs = pollMs;
|
|
15
|
+
}
|
|
16
|
+
url(path, query = {}) {
|
|
17
|
+
const q = new URLSearchParams({ ...query, ...(this.teamId ? { teamId: this.teamId } : {}) });
|
|
18
|
+
const qs = q.toString();
|
|
19
|
+
return `${BASE}${path}${qs ? `?${qs}` : ""}`;
|
|
20
|
+
}
|
|
21
|
+
headers() {
|
|
22
|
+
return { Authorization: `Bearer ${this.token}` };
|
|
23
|
+
}
|
|
24
|
+
/** Who the token belongs to: a cheap way to check that it works. */
|
|
25
|
+
async user() {
|
|
26
|
+
return (await request(this.fetchImpl, "Vercel", this.url("/v2/user"), { headers: this.headers() })).user;
|
|
27
|
+
}
|
|
28
|
+
async teams() {
|
|
29
|
+
return (await request(this.fetchImpl, "Vercel", this.url("/v2/teams"), { headers: this.headers() })).teams;
|
|
30
|
+
}
|
|
31
|
+
async projects() {
|
|
32
|
+
return (await request(this.fetchImpl, "Vercel", this.url("/v9/projects", { limit: "100" }), { headers: this.headers() })).projects;
|
|
33
|
+
}
|
|
34
|
+
/** The project's own domains (not redirects), e.g. my-api.vercel.app. */
|
|
35
|
+
async domains(project) {
|
|
36
|
+
const res = await request(this.fetchImpl, "Vercel", this.url(`/v9/projects/${encodeURIComponent(project)}/domains`), { headers: this.headers() });
|
|
37
|
+
return res.domains.filter((d) => !d.redirect).map((d) => d.name);
|
|
38
|
+
}
|
|
39
|
+
async listEnv(project) {
|
|
40
|
+
const res = await request(this.fetchImpl, "Vercel", this.url(`/v10/projects/${encodeURIComponent(project)}/env`, { decrypt: "true" }), { headers: this.headers() });
|
|
41
|
+
return res.envs;
|
|
42
|
+
}
|
|
43
|
+
/** Production entries for a variable name (one key can have several targets). */
|
|
44
|
+
async findEnv(project, key) {
|
|
45
|
+
const all = await this.listEnv(project);
|
|
46
|
+
return all.filter((e) => e.key === key && [e.target].flat().includes("production"));
|
|
47
|
+
}
|
|
48
|
+
async setEnv(project, envId, value) {
|
|
49
|
+
await request(this.fetchImpl, "Vercel", this.url(`/v9/projects/${encodeURIComponent(project)}/env/${envId}`), {
|
|
50
|
+
method: "PATCH", headers: this.headers(), json: { value },
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
async latestProductionDeployment(project) {
|
|
54
|
+
const res = await request(this.fetchImpl, "Vercel", this.url("/v7/deployments", { projectId: project, target: "production", state: "READY", limit: "1" }), { headers: this.headers() });
|
|
55
|
+
const d = res.deployments[0];
|
|
56
|
+
if (!d)
|
|
57
|
+
throw new Error(`No ready production deployment found for ${project}`);
|
|
58
|
+
return { id: d.uid, url: d.url };
|
|
59
|
+
}
|
|
60
|
+
/** Redeploy the current production deployment so it picks up new env values. */
|
|
61
|
+
async redeploy(project) {
|
|
62
|
+
const latest = await this.latestProductionDeployment(project);
|
|
63
|
+
const current = await request(this.fetchImpl, "Vercel", this.url(`/v13/deployments/${latest.id}`), { headers: this.headers() });
|
|
64
|
+
// A deployment built from Git must be redeployed from the same commit: naming only the
|
|
65
|
+
// old deployment makes Vercel fail with git_info_fail.
|
|
66
|
+
const g = current.gitSource;
|
|
67
|
+
const source = g?.type && g.repoId && g.sha
|
|
68
|
+
? { gitSource: { type: g.type, repoId: g.repoId, ref: g.ref, sha: g.sha } }
|
|
69
|
+
: { deploymentId: latest.id };
|
|
70
|
+
return request(this.fetchImpl, "Vercel", this.url("/v13/deployments", { forceNew: "1" }), {
|
|
71
|
+
method: "POST", headers: this.headers(), json: { name: project, project, target: "production", ...source },
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
async waitUntilReady(deploymentId, timeoutMs = 10 * 60_000) {
|
|
75
|
+
const deadline = Date.now() + timeoutMs;
|
|
76
|
+
while (Date.now() < deadline) {
|
|
77
|
+
const d = await request(this.fetchImpl, "Vercel", this.url(`/v13/deployments/${deploymentId}`), { headers: this.headers() });
|
|
78
|
+
if (d.readyState === "READY")
|
|
79
|
+
return d;
|
|
80
|
+
if (d.readyState === "ERROR" || d.readyState === "CANCELED") {
|
|
81
|
+
const why = [d.errorCode, d.errorMessage].filter(Boolean).join(": ");
|
|
82
|
+
throw new Error(`Deployment ${deploymentId} ended in ${d.readyState}${why ? ` (${why})` : ""}; build logs: https://vercel.com/deployments/${deploymentId}`);
|
|
83
|
+
}
|
|
84
|
+
await sleep(this.pollMs);
|
|
85
|
+
}
|
|
86
|
+
throw new Error(`Deployment ${deploymentId} was not ready after ${Math.round(timeoutMs / 60000)} min`);
|
|
87
|
+
}
|
|
88
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "leakfix",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Rotate leaked secrets automatically: detect, mint a new credential, redeploy, revoke the old one.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"secrets",
|
|
7
|
+
"secret-rotation",
|
|
8
|
+
"leaked-secrets",
|
|
9
|
+
"security",
|
|
10
|
+
"devsecops",
|
|
11
|
+
"mongodb-atlas",
|
|
12
|
+
"vercel",
|
|
13
|
+
"jwt",
|
|
14
|
+
"cli",
|
|
15
|
+
"github-action"
|
|
16
|
+
],
|
|
17
|
+
"author": "Safayet Hossain",
|
|
18
|
+
"homepage": "https://github.com/safayet404/leakfix#readme",
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/safayet404/leakfix.git"
|
|
22
|
+
},
|
|
23
|
+
"bugs": "https://github.com/safayet404/leakfix/issues",
|
|
24
|
+
"type": "module",
|
|
25
|
+
"bin": {
|
|
26
|
+
"leakfix": "dist/cli.js"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"dist",
|
|
30
|
+
"README.md",
|
|
31
|
+
"LICENSE"
|
|
32
|
+
],
|
|
33
|
+
"scripts": {
|
|
34
|
+
"dev": "tsx src/cli.ts",
|
|
35
|
+
"build": "tsc -p tsconfig.json",
|
|
36
|
+
"test": "vitest run",
|
|
37
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
38
|
+
"prepublishOnly": "pnpm typecheck && pnpm test && pnpm build"
|
|
39
|
+
},
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">=22"
|
|
42
|
+
},
|
|
43
|
+
"license": "MIT",
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@types/node": "^26.6.3",
|
|
46
|
+
"tsx": "^4.23.15",
|
|
47
|
+
"typescript": "^7.0.2",
|
|
48
|
+
"vitest": "^5.0.3"
|
|
49
|
+
},
|
|
50
|
+
"packageManager": "pnpm@11.21.0"
|
|
51
|
+
}
|