leakfix 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,6 +5,10 @@
5
5
 
6
6
  **Leaked a secret? leakfix replaces it without taking your app down.**
7
7
 
8
+ ![leakfix finds committed secrets, rolls back a failed rotation, then rotates them with zero downtime](docs/demo.gif)
9
+
10
+ <sub>Recorded against simulated MongoDB Atlas and Vercel APIs (<a href="scripts/demo.ts">scripts/demo.ts</a>); the first deploy fails on purpose to show the rollback.</sub>
11
+
8
12
  Finding a leaked `.env` is the easy part. GitHub and others will tell you about
9
13
  it, and some providers revoke the key on the spot, which takes your app down.
10
14
  The hard part is the cleanup: mint a new credential, update the deployment,
@@ -48,17 +52,20 @@ to finish by hand.
48
52
 
49
53
  Every step is written to `leakfix-audit.jsonl`. The log never contains secret values.
50
54
 
51
- ## Supported (v0.1)
55
+ ## Supported
52
56
 
53
57
  | Secret | Rotation |
54
58
  |---|---|
55
59
  | MongoDB Atlas connection string | ✅ new database user, same roles; old user deleted |
56
60
  | JWT signing secrets | ✅ new random secret (users sign in again) |
57
- | Deployment | ✅ Vercel (env vars + production redeploy) |
58
61
  | SMTP passwords, other API keys | 📝 detected, with instructions to rotate by hand |
59
62
 
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).
63
+ | Deployment | |
64
+ |---|---|
65
+ | Vercel | ✅ env vars + production redeploy (used on a real production app) |
66
+ | Render | 🧪 env vars + deploy (tested against a simulated API; reports from real use welcome) |
67
+
68
+ See the [roadmap](#roadmap) for what's next.
62
69
 
63
70
  ## Setup
64
71
 
@@ -104,11 +111,14 @@ The config it writes looks like this, if you'd rather write it yourself:
104
111
  }
105
112
  ```
106
113
 
114
+ On Render, use `"render": { "serviceId": "srv-..." }` instead of `"vercel"`.
115
+
107
116
  Credentials come only from the environment, never from the repo:
108
117
 
109
118
  | Variable | Where to get it | Needs |
110
119
  |---|---|---|
111
120
  | `LEAKFIX_VERCEL_TOKEN` | vercel.com/account/tokens | access to the project's team |
121
+ | `LEAKFIX_RENDER_API_KEY` | dashboard.render.com → Account Settings → API Keys | the service (instead of the Vercel token) |
112
122
  | `LEAKFIX_ATLAS_CLIENT_ID` / `LEAKFIX_ATLAS_CLIENT_SECRET` | Atlas → Organization → Access Manager → Service Accounts | permission to manage database users in the project |
113
123
 
114
124
  Then:
@@ -126,6 +136,32 @@ line, or `leakfix:allow-file` in the first lines of a file full of test fixtures
126
136
  `fix-repo` doesn't rewrite history. The old file stays in past commits,
127
137
  which is why rotating is what actually protects you.
128
138
 
139
+ ## Roadmap
140
+
141
+ Ordered by how often each one leaks and how much damage it does. Want one sooner,
142
+ or want to build one? Open an issue, or read [CONTRIBUTING.md](CONTRIBUTING.md):
143
+ a new deployment platform is one file.
144
+
145
+ **Deployments**
146
+ - [x] Vercel
147
+ - [x] Render
148
+ - [ ] Railway
149
+ - [ ] Netlify
150
+ - [ ] Fly.io
151
+
152
+ **Secrets**
153
+ - [x] MongoDB Atlas users
154
+ - [x] JWT signing secrets
155
+ - [ ] Stripe secret keys (Stripe can roll a key with an expiry for the old one)
156
+ - [ ] Resend / SendGrid API keys
157
+ - [ ] Postgres users on Neon and Supabase
158
+ - [ ] AWS access keys (IAM)
159
+
160
+ **Workflow**
161
+ - [ ] GitHub App: notices a pushed secret, asks "rotate?", then opens the clean-up PR
162
+ - [ ] Approval mode for teams: plan in a PR comment, rotate on approval
163
+ - [ ] Rotate several deployments that share a secret (e.g. API on Render, web on Vercel)
164
+
129
165
  ## Block leaks in pull requests
130
166
 
131
167
  Add leakfix to any repo's CI. The check fails when a secret is committed, before it
@@ -150,6 +186,11 @@ docker build -t leakfix https://github.com/safayet404/leakfix.git
150
186
  docker run --rm -v "$PWD:/repo" leakfix scan /repo
151
187
  ```
152
188
 
189
+ ## Contributing
190
+
191
+ Issues and pull requests are welcome. [CONTRIBUTING.md](CONTRIBUTING.md) explains how
192
+ to add a deployment platform or a secret type, and how to test it without real accounts.
193
+
153
194
  ## Development
154
195
 
155
196
  ```bash
package/dist/cli.js CHANGED
@@ -2,13 +2,13 @@
2
2
  // leakfix: rotate leaked secrets without downtime.
3
3
  //
4
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
5
+ // leakfix init [dir] find the deployment and Atlas project, check access, write the config
6
6
  // leakfix rotate [dir] show the rotation plan (dry run)
7
7
  // leakfix rotate [dir] --yes run it
8
8
  // leakfix fix-repo [dir] untrack .env files, ignore them, add .env.example
9
9
  //
10
10
  // Credentials come from the environment, never from the repo:
11
- // LEAKFIX_VERCEL_TOKEN, LEAKFIX_ATLAS_CLIENT_ID, LEAKFIX_ATLAS_CLIENT_SECRET
11
+ // LEAKFIX_VERCEL_TOKEN or LEAKFIX_RENDER_API_KEY, LEAKFIX_ATLAS_CLIENT_ID, LEAKFIX_ATLAS_CLIENT_SECRET
12
12
  // Project settings live in leakfix.config.json (see README).
13
13
  import { execFileSync } from "node:child_process";
14
14
  import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
@@ -42,6 +42,7 @@ function gitRepo(root) {
42
42
  }
43
43
  const credentials = () => ({
44
44
  vercelToken: process.env.LEAKFIX_VERCEL_TOKEN,
45
+ renderApiKey: process.env.LEAKFIX_RENDER_API_KEY,
45
46
  atlasClientId: process.env.LEAKFIX_ATLAS_CLIENT_ID,
46
47
  atlasClientSecret: process.env.LEAKFIX_ATLAS_CLIENT_SECRET,
47
48
  });
@@ -4,6 +4,7 @@ import { existsSync, readFileSync } from "node:fs";
4
4
  import { basename, join } from "node:path";
5
5
  import { AtlasClient, parseMongoUri } from "../providers/atlas.js";
6
6
  import { HttpError, request } from "../providers/http.js";
7
+ import { RenderClient } from "../providers/render.js";
7
8
  import { VercelClient } from "../providers/vercel.js";
8
9
  const HEALTH_PATHS = ["/health", "/api/health", "/healthz", "/api/healthz"];
9
10
  export async function init(input) {
@@ -18,7 +19,12 @@ export async function init(input) {
18
19
  return done();
19
20
  }
20
21
  checks.push({ status: "ok", label: `Committed secrets to rotate: ${keys.join(", ")}` });
21
- await checkVercel(checks, config, keys, input, fetchImpl);
22
+ // Render when it's configured, or when only a Render key is available; Vercel otherwise.
23
+ const render = config.render || (!config.vercel && creds.renderApiKey && !creds.vercelToken);
24
+ if (render)
25
+ await checkRender(checks, config, keys, input, fetchImpl);
26
+ else
27
+ await checkVercel(checks, config, keys, input, fetchImpl);
22
28
  if (mongo)
23
29
  await checkAtlas(checks, config, mongo, creds, fetchImpl);
24
30
  return done();
@@ -31,6 +37,7 @@ async function checkVercel(checks, config, keys, input, fetchImpl) {
31
37
  fix: [
32
38
  "Create one at https://vercel.com/account/settings/tokens (an expiry of 1 day is enough).",
33
39
  `Then, in this terminal: ${hiddenExport("LEAKFIX_VERCEL_TOKEN")}`,
40
+ `Deployed on Render instead? Create an API key at https://dashboard.render.com/u/settings#api-keys, then: ${hiddenExport("LEAKFIX_RENDER_API_KEY")}`,
34
41
  ],
35
42
  });
36
43
  return;
@@ -85,7 +92,75 @@ async function checkVercel(checks, config, keys, input, fetchImpl) {
85
92
  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
93
  return;
87
94
  }
88
- // A health URL lets leakfix notice a broken deploy before revoking the old credential.
95
+ let domains = [];
96
+ if (!config.healthUrl)
97
+ try {
98
+ domains = await vercel.domains(project);
99
+ }
100
+ catch { /* fall through to the warning */ }
101
+ await checkHealth(checks, config, domains, fetchImpl);
102
+ }
103
+ async function checkRender(checks, config, keys, input, fetchImpl) {
104
+ const { creds, gitRepo } = input;
105
+ if (!creds.renderApiKey) {
106
+ checks.push({
107
+ status: "todo", label: "No Render API key (LEAKFIX_RENDER_API_KEY)",
108
+ fix: [
109
+ "Create one at https://dashboard.render.com/u/settings#api-keys",
110
+ `Then, in this terminal: ${hiddenExport("LEAKFIX_RENDER_API_KEY")}`,
111
+ ],
112
+ });
113
+ return;
114
+ }
115
+ const render = new RenderClient(creds.renderApiKey, fetchImpl);
116
+ // Which service? Config first, then the one that deploys this repo.
117
+ let services;
118
+ try {
119
+ services = await render.services();
120
+ checks.push({ status: "ok", label: "Render API key works" });
121
+ }
122
+ catch (err) {
123
+ checks.push({ status: "todo", label: `Render rejected the API key (${errText(err)})`, fix: ["Create a new one at https://dashboard.render.com/u/settings#api-keys"] });
124
+ return;
125
+ }
126
+ let service = config.render ? services.find((s) => s.id === config.render.serviceId) : undefined;
127
+ if (config.render) {
128
+ checks.push(service
129
+ ? { status: "ok", label: `Render service: ${service.name} (from leakfix.config.json)` }
130
+ : { status: "todo", label: `The API key can't see Render service ${config.render.serviceId}`, fix: ["Check render.serviceId in leakfix.config.json (it starts with srv-)."] });
131
+ if (!service)
132
+ return;
133
+ }
134
+ else {
135
+ const repo = (gitRepo ?? "").toLowerCase();
136
+ const matches = repo ? services.filter((s) => s.repo?.toLowerCase().replace(/\.git$/, "").endsWith(`/${repo}`)) : [];
137
+ if (matches.length !== 1) {
138
+ checks.push({
139
+ status: "todo", label: matches.length ? `Several Render services deploy ${gitRepo}` : "Could not tell which Render service deploys this repo",
140
+ fix: [`Set "render": { "serviceId": "srv-..." } in leakfix.config.json. Your services: ${(matches.length ? matches : services).map((s) => `${s.name} (${s.id})`).slice(0, 15).join(", ")}`],
141
+ });
142
+ return;
143
+ }
144
+ service = matches[0];
145
+ config.render = { serviceId: service.id };
146
+ checks.push({ status: "ok", label: `Render service: ${service.name} (deploys ${gitRepo})` });
147
+ }
148
+ try {
149
+ const env = await render.listEnv(service.id);
150
+ const missing = keys.filter((k) => !env.some((e) => e.key === k));
151
+ checks.push(missing.length
152
+ ? { status: "todo", label: `Not set on the Render service: ${missing.join(", ")}`, fix: ["leakfix updates the service's own variables. Variables from an environment group must be rotated by hand for now."] }
153
+ : { status: "ok", label: `Set on the Render service: ${keys.join(", ")}` });
154
+ }
155
+ catch (err) {
156
+ checks.push({ status: "todo", label: `Cannot read the service's environment variables (${errText(err)})` });
157
+ return;
158
+ }
159
+ const host = service.serviceDetails?.url?.replace(/^https?:\/\//, "").replace(/\/$/, "");
160
+ await checkHealth(checks, config, host ? [host] : [], fetchImpl);
161
+ }
162
+ /** A health URL lets leakfix notice a broken deploy before revoking the old credential. */
163
+ async function checkHealth(checks, config, domains, fetchImpl) {
89
164
  if (config.healthUrl) {
90
165
  const status = await probe(config.healthUrl, fetchImpl);
91
166
  checks.push(status && status < 300
@@ -93,11 +168,6 @@ async function checkVercel(checks, config, keys, input, fetchImpl) {
93
168
  : { status: "todo", label: `Health check ${config.healthUrl} answered ${status ?? "nothing"}`, fix: ["Fix the URL in leakfix.config.json, or remove healthUrl."] });
94
169
  return;
95
170
  }
96
- let domains = [];
97
- try {
98
- domains = await vercel.domains(project);
99
- }
100
- catch { /* fall through to the warning */ }
101
171
  for (const domain of domains) {
102
172
  for (const path of HEALTH_PATHS) {
103
173
  const url = `https://${domain}${path}`;
@@ -11,8 +11,8 @@
11
11
  import { randomBytes } from "node:crypto";
12
12
  import { mask } from "../detect/scan.js";
13
13
  import { AtlasClient, buildMongoUri, parseMongoUri } from "../providers/atlas.js";
14
+ import { renderTarget, vercelTarget } from "../providers/deploy.js";
14
15
  import { request } from "../providers/http.js";
15
- import { VercelClient } from "../providers/vercel.js";
16
16
  const MANUAL_HOWTO = {
17
17
  "smtp-password": "Create a new app password with your email provider, delete the old one, and update the variable.",
18
18
  "vercel-token": "Delete the token at vercel.com/account/tokens and create a new one.",
@@ -64,8 +64,25 @@ function jwtRotation(f) {
64
64
  // with the leaked one stop verifying. Users sign in again.
65
65
  };
66
66
  }
67
+ /** The configured deployment, or why there is none. */
68
+ export function deployTarget(cfg, creds, fetchImpl = fetch) {
69
+ if (cfg.vercel && cfg.render)
70
+ return "both vercel and render are configured; leakfix updates one deployment at a time";
71
+ if (cfg.vercel) {
72
+ if (!creds.vercelToken)
73
+ return "LEAKFIX_VERCEL_TOKEN is not set";
74
+ return vercelTarget(creds.vercelToken, cfg.vercel.project, cfg.vercel.teamId, fetchImpl);
75
+ }
76
+ if (cfg.render) {
77
+ if (!creds.renderApiKey)
78
+ return "LEAKFIX_RENDER_API_KEY is not set";
79
+ return renderTarget(creds.renderApiKey, cfg.render.serviceId, fetchImpl);
80
+ }
81
+ return "no deployment configured (set vercel or render in leakfix.config.json; `leakfix init` can find it)";
82
+ }
67
83
  export function buildPlans(findings, cfg, creds, fetchImpl = fetch) {
68
84
  const manual = [];
85
+ const target = deployTarget(cfg, creds, fetchImpl);
69
86
  const rotations = [];
70
87
  const atlas = creds.atlasClientId && creds.atlasClientSecret
71
88
  ? new AtlasClient(creds.atlasClientId, creds.atlasClientSecret, fetchImpl) : undefined;
@@ -80,8 +97,8 @@ export function buildPlans(findings, cfg, creds, fetchImpl = fetch) {
80
97
  if (!f.key) {
81
98
  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
99
  }
83
- else if (!cfg.vercel || !creds.vercelToken) {
84
- manual.push({ finding: f, reason: "no deployment configured (set vercel.project and LEAKFIX_VERCEL_TOKEN)", howTo });
100
+ else if (typeof target === "string") {
101
+ manual.push({ finding: f, reason: target, howTo });
85
102
  }
86
103
  else if (f.kind === "mongodb-uri") {
87
104
  if (!atlas || !cfg.atlas)
@@ -96,45 +113,40 @@ export function buildPlans(findings, cfg, creds, fetchImpl = fetch) {
96
113
  manual.push({ finding: f, reason: `leakfix cannot rotate ${f.kind} credentials automatically yet`, howTo });
97
114
  }
98
115
  }
99
- if (!rotations.length || !cfg.vercel || !creds.vercelToken)
116
+ if (!rotations.length || typeof target === "string")
100
117
  return { plans: [], manual };
101
- const project = cfg.vercel.project;
102
- const vercel = new VercelClient(creds.vercelToken, cfg.vercel.teamId, fetchImpl);
103
118
  const steps = [];
104
119
  for (const r of rotations)
105
120
  steps.push(r.prepare);
106
121
  for (const r of rotations) {
107
122
  const previous = [];
108
123
  steps.push({
109
- title: `Set ${r.envKey} on Vercel project ${project} (production)`,
124
+ title: `Set ${r.envKey} on ${target.name} (production)`,
110
125
  async run() {
111
- const entries = await vercel.findEnv(project, r.envKey);
126
+ const entries = await target.findEnv(r.envKey);
112
127
  if (!entries.length)
113
- throw new Error(`${r.envKey} is not set for production on Vercel project ${project}`);
128
+ throw new Error(`${r.envKey} is not set for production on ${target.name}`);
114
129
  // Remember what to restore on rollback. Never restore an empty value:
115
130
  // if the platform hides the current one, use the leaked value from the repo.
116
131
  for (const e of entries) {
117
132
  const value = e.value || r.oldValue;
118
133
  if (!value)
119
134
  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 });
135
+ previous.push({ entry: e, value });
121
136
  }
122
137
  for (const e of entries)
123
- await vercel.setEnv(project, e.id, r.newValue());
138
+ await target.setEnv(r.envKey, e, r.newValue());
124
139
  },
125
140
  async undo() {
126
141
  for (const p of previous)
127
- await vercel.setEnv(project, p.id, p.value);
142
+ await target.setEnv(r.envKey, p.entry, p.value);
128
143
  },
129
144
  });
130
145
  }
131
146
  let rolledBack = false;
132
147
  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
- },
148
+ title: `Redeploy ${target.name} to production and wait until it is ready`,
149
+ run: () => target.redeploy(),
138
150
  // The env was restored by the previous steps' undo, which run after this
139
151
  // one; a fresh redeploy is triggered once at the end of the rollback.
140
152
  async undo() {
@@ -159,10 +171,8 @@ export function buildPlans(findings, cfg, creds, fetchImpl = fetch) {
159
171
  const first = steps[0];
160
172
  const originalUndo = first.undo;
161
173
  first.undo = async () => {
162
- if (rolledBack) {
163
- const d = await vercel.redeploy(project);
164
- await vercel.waitUntilReady(d.id);
165
- }
174
+ if (rolledBack)
175
+ await target.redeploy();
166
176
  await originalUndo?.();
167
177
  };
168
178
  const label = rotations.map((r) => r.label).join(", ");
@@ -0,0 +1,32 @@
1
+ // Where the app runs. A rotation needs three things from the platform: read the
2
+ // current value of a variable, set a new one, and redeploy once. Adding a
3
+ // platform means implementing DeployTarget (see CONTRIBUTING.md).
4
+ import { RenderClient } from "./render.js";
5
+ import { VercelClient } from "./vercel.js";
6
+ export function vercelTarget(token, project, teamId, fetchImpl) {
7
+ const vercel = new VercelClient(token, teamId, fetchImpl);
8
+ return {
9
+ name: `Vercel project ${project}`,
10
+ findEnv: (key) => vercel.findEnv(project, key),
11
+ setEnv: (_key, entry, value) => vercel.setEnv(project, entry.id, value),
12
+ async redeploy() {
13
+ const d = await vercel.redeploy(project);
14
+ await vercel.waitUntilReady(d.id);
15
+ },
16
+ };
17
+ }
18
+ export function renderTarget(apiKey, serviceId, fetchImpl) {
19
+ const render = new RenderClient(apiKey, fetchImpl);
20
+ return {
21
+ name: `Render service ${serviceId}`,
22
+ async findEnv(key) {
23
+ const env = await render.listEnv(serviceId);
24
+ return env.filter((e) => e.key === key).map((e) => ({ id: e.key, value: e.value }));
25
+ },
26
+ setEnv: (key, _entry, value) => render.setEnv(serviceId, key, value),
27
+ async redeploy() {
28
+ const d = await render.deploy(serviceId);
29
+ await render.waitUntilLive(serviceId, d.id);
30
+ },
31
+ };
32
+ }
@@ -0,0 +1,51 @@
1
+ // Render REST API: service environment variables and deploys.
2
+ // Auth: an API key from dashboard.render.com → Account Settings → API Keys.
3
+ import { request, sleep } from "./http.js";
4
+ const BASE = "https://api.render.com/v1";
5
+ const FAILED = ["build_failed", "update_failed", "canceled", "deactivated", "pre_deploy_failed"];
6
+ export class RenderClient {
7
+ apiKey;
8
+ fetchImpl;
9
+ pollMs;
10
+ constructor(apiKey, fetchImpl = fetch, pollMs = 5000) {
11
+ this.apiKey = apiKey;
12
+ this.fetchImpl = fetchImpl;
13
+ this.pollMs = pollMs;
14
+ }
15
+ call(path, init = {}) {
16
+ return request(this.fetchImpl, "Render", `${BASE}${path}`, {
17
+ ...init, headers: { Authorization: `Bearer ${this.apiKey}`, Accept: "application/json" },
18
+ });
19
+ }
20
+ async services() {
21
+ const res = await this.call("/services?limit=100");
22
+ return res.map((r) => r.service);
23
+ }
24
+ async service(serviceId) {
25
+ return this.call(`/services/${serviceId}`);
26
+ }
27
+ /** The service's own variables (not ones inherited from environment groups). */
28
+ async listEnv(serviceId) {
29
+ const res = await this.call(`/services/${serviceId}/env-vars?limit=100`);
30
+ return res.map((r) => r.envVar);
31
+ }
32
+ async setEnv(serviceId, key, value) {
33
+ await this.call(`/services/${serviceId}/env-vars/${encodeURIComponent(key)}`, { method: "PUT", json: { value } });
34
+ }
35
+ async deploy(serviceId) {
36
+ return this.call(`/services/${serviceId}/deploys`, { method: "POST", json: { clearCache: "do_not_clear" } });
37
+ }
38
+ async waitUntilLive(serviceId, deployId, timeoutMs = 15 * 60_000) {
39
+ const deadline = Date.now() + timeoutMs;
40
+ while (Date.now() < deadline) {
41
+ const d = await this.call(`/services/${serviceId}/deploys/${deployId}`);
42
+ if (d.status === "live")
43
+ return;
44
+ if (FAILED.includes(d.status)) {
45
+ throw new Error(`Deploy ${deployId} ended as ${d.status}; logs: https://dashboard.render.com/web/${serviceId}/deploys/${deployId}`);
46
+ }
47
+ await sleep(this.pollMs);
48
+ }
49
+ throw new Error(`Deploy ${deployId} was not live after ${Math.round(timeoutMs / 60000)} min`);
50
+ }
51
+ }
package/package.json CHANGED
@@ -1,18 +1,19 @@
1
1
  {
2
2
  "name": "leakfix",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Rotate leaked secrets automatically: detect, mint a new credential, redeploy, revoke the old one.",
5
5
  "keywords": [
6
- "secrets",
7
- "secret-rotation",
8
- "leaked-secrets",
9
- "security",
6
+ "cli",
10
7
  "devsecops",
11
- "mongodb-atlas",
12
- "vercel",
8
+ "github-action",
13
9
  "jwt",
14
- "cli",
15
- "github-action"
10
+ "leaked-secrets",
11
+ "mongodb-atlas",
12
+ "render",
13
+ "secret-rotation",
14
+ "secrets",
15
+ "security",
16
+ "vercel"
16
17
  ],
17
18
  "author": "Safayet Hossain",
18
19
  "homepage": "https://github.com/safayet404/leakfix#readme",