codevet-cli 1.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/.gitleaks.toml +91 -0
- package/LICENSE +21 -0
- package/README.md +171 -0
- package/dist/config.js +44 -0
- package/dist/detectors/detectStack.js +28 -0
- package/dist/fixLibrary/templates.js +311 -0
- package/dist/index.js +354 -0
- package/dist/promptConfirm.js +12 -0
- package/dist/report.js +155 -0
- package/dist/resolveTarget.js +119 -0
- package/dist/scanners/bearerScanner.js +108 -0
- package/dist/scanners/ensureBinary.js +101 -0
- package/dist/scanners/gitleaksScanner.js +109 -0
- package/dist/scanners/hygieneScanner.js +156 -0
- package/dist/scanners/npmAuditScanner.js +118 -0
- package/dist/scanners/npmFixActions.js +50 -0
- package/dist/scanners/pipAuditScanner.js +73 -0
- package/dist/scanners/pythonAuditScanner.js +66 -0
- package/package.json +57 -0
- package/scripts/generateTemplates.mjs +48 -0
- package/scripts/postPrComment.mjs +113 -0
- package/scripts/postinstall.mjs +20 -0
- package/scripts/runTests.mjs +39 -0
package/.gitleaks.toml
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
title = "codevet-gitleaks-config"
|
|
2
|
+
|
|
3
|
+
[extend]
|
|
4
|
+
useDefault = true
|
|
5
|
+
|
|
6
|
+
# --no-git mode (used so CodeVet works even outside a git repo, and catches
|
|
7
|
+
# uncommitted files) scans the raw filesystem and does NOT respect
|
|
8
|
+
# .gitignore. Without this, build caches, dependency folders, and other
|
|
9
|
+
# directories that can never actually leak via git get scanned anyway,
|
|
10
|
+
# producing pure noise. Confirmed in real testing: a Next.js .next/ build
|
|
11
|
+
# cache produced 6 false "Generic API Key" findings before this was added.
|
|
12
|
+
[allowlist]
|
|
13
|
+
paths = [
|
|
14
|
+
'''(^|[/\\])node_modules([/\\]|$)''',
|
|
15
|
+
'''(^|[/\\])\.next([/\\]|$)''',
|
|
16
|
+
'''(^|[/\\])\.nuxt([/\\]|$)''',
|
|
17
|
+
'''(^|[/\\])dist([/\\]|$)''',
|
|
18
|
+
'''(^|[/\\])build([/\\]|$)''',
|
|
19
|
+
'''(^|[/\\])out([/\\]|$)''',
|
|
20
|
+
'''(^|[/\\])coverage([/\\]|$)''',
|
|
21
|
+
'''(^|[/\\])\.turbo([/\\]|$)''',
|
|
22
|
+
'''(^|[/\\])\.git([/\\]|$)''',
|
|
23
|
+
'''(^|[/\\])target([/\\]|$)''',
|
|
24
|
+
'''(^|[/\\])vendor([/\\]|$)''',
|
|
25
|
+
'''(^|[/\\])__pycache__([/\\]|$)''',
|
|
26
|
+
'''(^|[/\\])\.venv([/\\]|$)''',
|
|
27
|
+
'''(^|[/\\])venv([/\\]|$)''',
|
|
28
|
+
'''(^|[/\\])\.cache([/\\]|$)''',
|
|
29
|
+
# Confirmed as a real false-positive source in testing: a single
|
|
30
|
+
# Angular project's .angular/cache/ produced ~90 "Generic API Key"
|
|
31
|
+
# false positives from webpack .pack binary cache blobs — their
|
|
32
|
+
# compressed/hashed content trips generic entropy-based secret
|
|
33
|
+
# detection, but they're compiler cache artifacts, never application
|
|
34
|
+
# source. Angular commits nothing sensitive here by design.
|
|
35
|
+
'''(^|[/\\])\.angular([/\\]|$)''',
|
|
36
|
+
# Yarn 2+ (Berry) commits vendored CLI binaries under .yarn/releases/
|
|
37
|
+
# and .yarn/cache/ by design (the "zero-installs" pattern) — these are
|
|
38
|
+
# third-party tool code, not application secrets, and the same
|
|
39
|
+
# generic-entropy false-positive risk applies as with webpack packs.
|
|
40
|
+
'''(^|[/\\])\.yarn([/\\]|$)''',
|
|
41
|
+
# Webpack/build-tool cache ".pack" files are binary blobs regardless of
|
|
42
|
+
# which directory they end up in — matched by extension as a safety net
|
|
43
|
+
# beyond the .angular directory rule above, since other bundlers may use
|
|
44
|
+
# differently-named cache folders we haven't seen a false positive from
|
|
45
|
+
# yet. Real secrets essentially never live in a compiled binary cache
|
|
46
|
+
# file, so this trade-off favors eliminating noise.
|
|
47
|
+
'''\.pack$''',
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
[[rules]]
|
|
51
|
+
id = "generic-connection-string-credentials"
|
|
52
|
+
description = "Hardcoded username/password embedded in a database or service connection string"
|
|
53
|
+
regex = '''(?i)(postgres(ql)?|mysql|mongodb(\+srv)?|redis|amqp|rabbitmq):\/\/[^:\/\s"']+:[^@\/\s"']+@[^\/\s"']+'''
|
|
54
|
+
tags = ["credentials", "connection-string"]
|
|
55
|
+
|
|
56
|
+
# Real leaked credentials live in application source/config files, not prose
|
|
57
|
+
# documentation — confirmed in real testing: 15 false positives came from
|
|
58
|
+
# *.md reference/skill docs containing illustrative example connection
|
|
59
|
+
# strings, not actual secrets. Markdown docs are excluded from this rule
|
|
60
|
+
# specifically (not globally) since a real secret pasted into a README is
|
|
61
|
+
# still worth catching by the default rules.
|
|
62
|
+
[[rules.allowlists]]
|
|
63
|
+
paths = [
|
|
64
|
+
'''\.md$''',
|
|
65
|
+
'''(^|[/\\])references([/\\]|$)''',
|
|
66
|
+
'''(^|[/\\])docs?([/\\]|$)''',
|
|
67
|
+
'''(^|[/\\])examples?([/\\]|$)''',
|
|
68
|
+
]
|
|
69
|
+
regexes = [
|
|
70
|
+
'''://(user|username|admin|test|example|localhost|127\.0\.0\.1):(password|pass|test|example|changeme)@''',
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
# Supabase's service role key bypasses Row Level Security entirely — it
|
|
74
|
+
# must never reach client code. Two real, statically-detectable patterns:
|
|
75
|
+
# (1) the key itself assigned to a SERVICE_ROLE-named variable, and
|
|
76
|
+
# (2) the naming mistake alone (NEXT_PUBLIC_/VITE_ prefix on a
|
|
77
|
+
# SERVICE_ROLE var), which guarantees the bundler ships it to the browser
|
|
78
|
+
# even before any real value is filled in.
|
|
79
|
+
[[rules]]
|
|
80
|
+
id = "supabase-service-role-key-assignment"
|
|
81
|
+
description = "Supabase service_role key assigned in code — this key bypasses Row Level Security and must never reach client-side code"
|
|
82
|
+
regex = '''(?i)(SUPABASE[_-]?SERVICE[_-]?ROLE[_-]?KEY|service_role_key)\s*[:=]\s*['"]eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+['"]'''
|
|
83
|
+
tags = ["credentials", "supabase"]
|
|
84
|
+
|
|
85
|
+
[[rules]]
|
|
86
|
+
id = "supabase-service-role-key-exposed-to-client"
|
|
87
|
+
description = "A SUPABASE service_role env var is prefixed for client-side exposure (NEXT_PUBLIC_/VITE_/REACT_APP_) — this ships the key straight into the browser bundle regardless of its actual value"
|
|
88
|
+
regex = '''(?i)(NEXT_PUBLIC_|VITE_|REACT_APP_)[A-Z_]*SERVICE[_-]?ROLE[_-]?KEY'''
|
|
89
|
+
tags = ["credentials", "supabase", "misconfiguration"]
|
|
90
|
+
|
|
91
|
+
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 CodeVet Contributors
|
|
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
|
|
7
|
+
deal in the Software without restriction, including without limitation the
|
|
8
|
+
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
|
9
|
+
sell 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
|
|
13
|
+
all 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
|
|
20
|
+
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
|
21
|
+
DEALINGS IN THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# CodeVet
|
|
2
|
+
|
|
3
|
+
**Vet your code before it ships.** A free, open-source security co-pilot for
|
|
4
|
+
developers who aren't security experts — students, solo/indie developers,
|
|
5
|
+
and small teams who can't justify an enterprise AppSec platform yet.
|
|
6
|
+
|
|
7
|
+
CodeVet doesn't invent its own detection logic. It orchestrates trusted,
|
|
8
|
+
independently-maintained tools (`gitleaks`, `npm audit`, `pip-audit`,
|
|
9
|
+
`bearer`) and translates their output into plain language: what's wrong,
|
|
10
|
+
how severe it is, why it matters, and — where possible — the exact working
|
|
11
|
+
fix code, not just a description.
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
$ npx codevet scan
|
|
15
|
+
|
|
16
|
+
CodeVet — scanning .
|
|
17
|
+
|
|
18
|
+
Detected: node (package.json)
|
|
19
|
+
|
|
20
|
+
Checking for exposed secrets...
|
|
21
|
+
✖ 1 exposed secret(s) found: .env:12 — not gitignored
|
|
22
|
+
|
|
23
|
+
Checking dependencies for known vulnerabilities...
|
|
24
|
+
✖ axios — HIGH — MITM via proxy config prototype pollution — fix: axios@1.19.0
|
|
25
|
+
|
|
26
|
+
Checking for missing security middleware...
|
|
27
|
+
CRITICAL Table 'payments' may be missing Row Level Security
|
|
28
|
+
✖ HIGH No rate limiting found
|
|
29
|
+
○ MODERATE No security headers configured
|
|
30
|
+
Suggested fix — security/helmet.config.js: [real, ready-to-paste code]
|
|
31
|
+
|
|
32
|
+
Checking personal data flow (via bearer)...
|
|
33
|
+
✔ No personal-data-flow risks found.
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
npm install -g codevet-cli # global — run `codevet` from any project
|
|
40
|
+
# or
|
|
41
|
+
npm install --save-dev codevet-cli # per-project — run via `npx codevet`
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Use
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
codevet scan # scan the current directory
|
|
48
|
+
codevet scan ./some/folder # scan a specific local folder
|
|
49
|
+
codevet scan https://github.com/user/repo # review a repo before you trust it
|
|
50
|
+
codevet fix # safely upgrade flagged dependencies
|
|
51
|
+
codevet fix --force # allow major-version upgrades
|
|
52
|
+
codevet remove-dependency <name> # explicitly uninstall a flagged package
|
|
53
|
+
codevet clean ./some-repo-you-kept # remove a repo CodeVet cloned earlier
|
|
54
|
+
codevet config status # see which checks are enabled
|
|
55
|
+
codevet config disable <check> # turn a check off for this project
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Full command reference: [`docs/HELP.md`](./docs/HELP.md).
|
|
59
|
+
|
|
60
|
+
## What it checks today
|
|
61
|
+
|
|
62
|
+
| Check | Tool | Severity model |
|
|
63
|
+
|---|---|---|
|
|
64
|
+
| Leaked secrets — API keys, credentials, connection strings, Supabase service-role-key exposure | `gitleaks` (extended ruleset) | Every finding is real and confirmed |
|
|
65
|
+
| Dependency vulnerabilities (Node) | `npm audit` | CRITICAL/HIGH/MODERATE/LOW from the advisory database. Skipped (not failed) on pnpm-managed projects — a confirmed bug in npm itself, run `pnpm audit` directly for those |
|
|
66
|
+
| Dependency vulnerabilities (Python) | `pip-audit` | Unranked — PyPA's database has no severity field; prioritize by whether a fix exists |
|
|
67
|
+
| Missing security middleware — no helmet, no rate limiting, wide-open CORS, error responses leaking internals, missing Supabase Row Level Security | CodeVet's own heuristic scanner | CRITICAL/HIGH/MODERATE, each with a `Verify:` note on how the check could be wrong |
|
|
68
|
+
| Personal data flow — logging or transmitting PII, secrets, etc. | `bearer` | CRITICAL/HIGH/MODERATE/LOW. **No native Windows build** — unavailable there, everything else still works |
|
|
69
|
+
|
|
70
|
+
**CRITICAL** means fix it before anyone else touches the code. **HIGH**
|
|
71
|
+
means fix before launch. **MODERATE** is real but rarely the sole cause of
|
|
72
|
+
an incident. See [`docs/SECURITY-CHECKLIST.md`](./docs/SECURITY-CHECKLIST.md)
|
|
73
|
+
for the full severity-annotated checklist this is built around.
|
|
74
|
+
|
|
75
|
+
## Reviewing an unfamiliar repo before you trust it
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
codevet scan https://github.com/someone/some-repo
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Clones to a temp folder, scans it, and asks before keeping anything if
|
|
82
|
+
issues are found. Decline and nothing is left on disk. Accept and it's
|
|
83
|
+
moved next to where you ran the command, with a provenance marker so
|
|
84
|
+
`codevet clean` can safely identify and remove it later.
|
|
85
|
+
|
|
86
|
+
## Running automatically on every PR
|
|
87
|
+
|
|
88
|
+
```yaml
|
|
89
|
+
# .github/workflows/codevet.yml
|
|
90
|
+
name: CodeVet
|
|
91
|
+
on: [pull_request]
|
|
92
|
+
jobs:
|
|
93
|
+
scan:
|
|
94
|
+
runs-on: ubuntu-latest
|
|
95
|
+
steps:
|
|
96
|
+
- uses: actions/checkout@v4
|
|
97
|
+
- uses: i-akb25/codevet@v1
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Posts results as a PR comment. Fails the check on critical/high findings
|
|
101
|
+
by default — see [`docs/HELP.md`](./docs/HELP.md) to make it advisory-only.
|
|
102
|
+
|
|
103
|
+
## Works inside your AI coding CLI
|
|
104
|
+
|
|
105
|
+
CodeVet ships as an Agent Skill — ask Claude Code, Cursor, or OpenCode to
|
|
106
|
+
"scan this project with CodeVet" and it runs directly in conversation. See
|
|
107
|
+
[`AGENTS.md`](./AGENTS.md).
|
|
108
|
+
|
|
109
|
+
## What it doesn't do (see [`TERMS.md`](./TERMS.md) for the full scope)
|
|
110
|
+
|
|
111
|
+
Not a malware/antivirus scanner. Not a replacement for a professional
|
|
112
|
+
security audit on anything handling real user data or payments at scale.
|
|
113
|
+
A clean scan means the checks CodeVet currently runs found nothing — not a
|
|
114
|
+
certification.
|
|
115
|
+
|
|
116
|
+
## About the Socket.dev supply-chain score
|
|
117
|
+
|
|
118
|
+
CodeVet's automated Socket.dev score flags Install scripts, Network access,
|
|
119
|
+
Shell access, and Filesystem access as "supply chain risk." These are all
|
|
120
|
+
expected and necessary — CodeVet downloads vetted, official scanner
|
|
121
|
+
binaries (gitleaks, bearer) at install time or on first use, invokes them
|
|
122
|
+
via the shell to run a scan, and reads your project's files to scan them.
|
|
123
|
+
That's the whole job. Automated scoring can't distinguish that from
|
|
124
|
+
something malicious without deeper context — see
|
|
125
|
+
[`PRIVACY.md`](./PRIVACY.md) for exactly what each of these does and
|
|
126
|
+
where any data goes.
|
|
127
|
+
|
|
128
|
+
## Why this exists
|
|
129
|
+
|
|
130
|
+
Every existing free security tool — Semgrep, Trivy, GitGuardian — is built
|
|
131
|
+
for teams that already have security expertise. Nobody was serving the
|
|
132
|
+
developer who's never heard of a CVE and just wants to know if it's safe
|
|
133
|
+
to ship. That gap is what CodeVet is for. More in
|
|
134
|
+
[`docs/ABOUT.md`](./docs/ABOUT.md).
|
|
135
|
+
|
|
136
|
+
## Repository structure
|
|
137
|
+
|
|
138
|
+
See [`INFO.md`](./INFO.md) for a complete, file-by-file breakdown of this
|
|
139
|
+
repo — what everything is, who needs to touch it, and how to use it.
|
|
140
|
+
|
|
141
|
+
## Contributing
|
|
142
|
+
|
|
143
|
+
See [`CONTRIBUTING.md`](./CONTRIBUTING.md) — this project deliberately
|
|
144
|
+
avoids "vibe coding": every change is type-checked, tested against a real
|
|
145
|
+
reproduced scenario, and dependency-audited before merge.
|
|
146
|
+
|
|
147
|
+
## Security
|
|
148
|
+
|
|
149
|
+
Found a vulnerability in CodeVet itself? See [`SECURITY.md`](./SECURITY.md)
|
|
150
|
+
for private disclosure — please don't open a public issue for it.
|
|
151
|
+
|
|
152
|
+
## Privacy
|
|
153
|
+
|
|
154
|
+
CodeVet runs entirely locally — no account, no telemetry, no CodeVet
|
|
155
|
+
server. See [`PRIVACY.md`](./PRIVACY.md) for exactly what data each check
|
|
156
|
+
touches and where it goes (mainly: `npm audit` talks to the npm registry,
|
|
157
|
+
same as running it yourself).
|
|
158
|
+
|
|
159
|
+
## Author
|
|
160
|
+
|
|
161
|
+
**Anurag Kumar Bharti**
|
|
162
|
+
Software Engineer
|
|
163
|
+
|
|
164
|
+
- **Portfolio:** [https://ace-akb.vercel.app](https://ace-akb.vercel.app)
|
|
165
|
+
- **GitHub:** [https://github.com/i-akb25](https://github.com/i-akb25)
|
|
166
|
+
- **LinkedIn:** [https://linkedin.com/in/anuragkumarbharti](https://linkedin.com/in/anuragkumarbharti)
|
|
167
|
+
- **Email:** anuragbhartiee25@gmail.com
|
|
168
|
+
|
|
169
|
+
## License
|
|
170
|
+
|
|
171
|
+
MIT — see [`LICENSE`](./LICENSE).
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
const DEFAULT_CONFIG = { secrets: true, dependencies: true, hygiene: true, dataFlow: true };
|
|
5
|
+
function configPath(projectRoot) {
|
|
6
|
+
return join(projectRoot, ".codevet", "config.json");
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Loads per-project scanner toggles. IMPORTANT: only call this for a
|
|
10
|
+
* project the user actually owns/controls locally. Never call it on a
|
|
11
|
+
* freshly-cloned, not-yet-reviewed target — a malicious repo could ship
|
|
12
|
+
* its own .codevet/config.json set to "disabled" and bypass every check
|
|
13
|
+
* just by being cloned. Untrusted-clone scans always use the full default
|
|
14
|
+
* config, ignoring whatever the repo itself contains.
|
|
15
|
+
*/
|
|
16
|
+
export async function loadConfig(projectRoot) {
|
|
17
|
+
const path = configPath(projectRoot);
|
|
18
|
+
if (!existsSync(path))
|
|
19
|
+
return { ...DEFAULT_CONFIG };
|
|
20
|
+
try {
|
|
21
|
+
const raw = await readFile(path, "utf-8");
|
|
22
|
+
return { ...DEFAULT_CONFIG, ...JSON.parse(raw) };
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return { ...DEFAULT_CONFIG };
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export async function saveConfig(projectRoot, config) {
|
|
29
|
+
const dir = join(projectRoot, ".codevet");
|
|
30
|
+
await mkdir(dir, { recursive: true });
|
|
31
|
+
await writeFile(configPath(projectRoot), JSON.stringify(config, null, 2));
|
|
32
|
+
await ensureGitignored(projectRoot);
|
|
33
|
+
}
|
|
34
|
+
async function ensureGitignored(projectRoot) {
|
|
35
|
+
const gitignorePath = join(projectRoot, ".gitignore");
|
|
36
|
+
const existing = existsSync(gitignorePath)
|
|
37
|
+
? await readFile(gitignorePath, "utf-8")
|
|
38
|
+
: "";
|
|
39
|
+
if (existing.split("\n").some((line) => line.trim() === ".codevet/")) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const separator = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
|
|
43
|
+
await writeFile(gitignorePath, existing + separator + ".codevet/\n");
|
|
44
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
const SIGNALS = [
|
|
4
|
+
{ stack: "node", file: "package.json" },
|
|
5
|
+
{ stack: "python", file: "requirements.txt" },
|
|
6
|
+
{ stack: "python", file: "pyproject.toml" },
|
|
7
|
+
{ stack: "android", file: "build.gradle" },
|
|
8
|
+
{ stack: "android", file: "build.gradle.kts" },
|
|
9
|
+
{ stack: "ios", file: "Podfile" },
|
|
10
|
+
];
|
|
11
|
+
/**
|
|
12
|
+
* Detects which stacks are present at the root of a project directory.
|
|
13
|
+
* A repo can match more than one stack — that's expected, not an error.
|
|
14
|
+
*/
|
|
15
|
+
export function detectStack(projectRoot) {
|
|
16
|
+
const found = new Map();
|
|
17
|
+
for (const signal of SIGNALS) {
|
|
18
|
+
if (found.has(signal.stack))
|
|
19
|
+
continue;
|
|
20
|
+
if (existsSync(join(projectRoot, signal.file))) {
|
|
21
|
+
found.set(signal.stack, signal.file);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return Array.from(found.entries()).map(([stack, matchedOn]) => ({
|
|
25
|
+
stack,
|
|
26
|
+
matchedOn,
|
|
27
|
+
}));
|
|
28
|
+
}
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Real, working code templates shown as suggested fixes. Each one is
|
|
3
|
+
* plain, dependency-minimal Express middleware — matches the "no
|
|
4
|
+
* unnecessary abstraction" engineering rule. These are not generated per
|
|
5
|
+
* finding; they're static, reviewed content, versioned here so updates
|
|
6
|
+
* propagate to every future scan instead of going stale in someone's repo.
|
|
7
|
+
*/
|
|
8
|
+
export const HELMET_CONFIG = `// security/helmet.config.js
|
|
9
|
+
const helmet = require('helmet');
|
|
10
|
+
|
|
11
|
+
module.exports = helmet({
|
|
12
|
+
contentSecurityPolicy: {
|
|
13
|
+
directives: {
|
|
14
|
+
defaultSrc: ["'self'"],
|
|
15
|
+
scriptSrc: ["'self'"],
|
|
16
|
+
objectSrc: ["'none'"],
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
hsts: { maxAge: 31536000, includeSubDomains: true },
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// Usage: app.use(require('./security/helmet.config'));
|
|
23
|
+
`;
|
|
24
|
+
export const CORS_CONFIG = `// security/cors.config.js
|
|
25
|
+
const cors = require('cors');
|
|
26
|
+
|
|
27
|
+
// Replace with your actual allowed origins — never use '*' once you have
|
|
28
|
+
// real users, since it lets any website read your API's responses.
|
|
29
|
+
const ALLOWED_ORIGINS = (process.env.ALLOWED_ORIGINS || '').split(',').filter(Boolean);
|
|
30
|
+
|
|
31
|
+
module.exports = cors({
|
|
32
|
+
origin(origin, callback) {
|
|
33
|
+
if (!origin || ALLOWED_ORIGINS.includes(origin)) {
|
|
34
|
+
callback(null, true);
|
|
35
|
+
} else {
|
|
36
|
+
callback(new Error('Not allowed by CORS'));
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
credentials: true,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// Usage: app.use(require('./security/cors.config'));
|
|
43
|
+
// .env: ALLOWED_ORIGINS=https://yourapp.com,https://staging.yourapp.com
|
|
44
|
+
`;
|
|
45
|
+
export const ACCOUNT_BACKOFF = `// security/accountBackoff.js
|
|
46
|
+
// Per-account exponential backoff — complements a per-IP rate limiter,
|
|
47
|
+
// since an attacker distributing attempts across many IPs would otherwise
|
|
48
|
+
// bypass IP-based limiting entirely. Uses exponential backoff (delay
|
|
49
|
+
// doubles each failed attempt) rather than a hard lockout, since hard
|
|
50
|
+
// lockouts let an attacker lock a real user out of their own account (a
|
|
51
|
+
// denial-of-service against your own users).
|
|
52
|
+
//
|
|
53
|
+
// const { checkAccountBackoff, recordFailedAttempt, recordSuccessfulAttempt } = require('./security/accountBackoff');
|
|
54
|
+
//
|
|
55
|
+
// app.post('/login', async (req, res) => {
|
|
56
|
+
// const { email } = req.body;
|
|
57
|
+
// const backoff = checkAccountBackoff(email);
|
|
58
|
+
// if (backoff.blocked) {
|
|
59
|
+
// return res.status(429).json({ error: \`Try again in \${backoff.retryAfterSeconds}s\` });
|
|
60
|
+
// }
|
|
61
|
+
// const valid = await verifyPassword(...);
|
|
62
|
+
// if (!valid) {
|
|
63
|
+
// recordFailedAttempt(email);
|
|
64
|
+
// return res.status(401).json({ error: 'Invalid credentials' });
|
|
65
|
+
// }
|
|
66
|
+
// recordSuccessfulAttempt(email);
|
|
67
|
+
// // ...issue token
|
|
68
|
+
// });
|
|
69
|
+
//
|
|
70
|
+
// In-memory by default — fine for a single instance. For multi-instance
|
|
71
|
+
// deployments, swap the Map for Redis (same interface, different backing
|
|
72
|
+
// store) so backoff state is shared across instances.
|
|
73
|
+
|
|
74
|
+
const attempts = new Map();
|
|
75
|
+
|
|
76
|
+
const BASE_DELAY_MS = parseInt(process.env.AUTH_BACKOFF_BASE_MS ?? '1000', 10);
|
|
77
|
+
const MAX_DELAY_MS = parseInt(process.env.AUTH_BACKOFF_MAX_MS ?? '300000', 10);
|
|
78
|
+
const RESET_AFTER_MS = parseInt(process.env.AUTH_BACKOFF_RESET_MS ?? '3600000', 10);
|
|
79
|
+
|
|
80
|
+
function checkAccountBackoff(accountKey) {
|
|
81
|
+
const record = attempts.get(accountKey);
|
|
82
|
+
if (!record) return { blocked: false };
|
|
83
|
+
|
|
84
|
+
const elapsedSinceLastAttempt = Date.now() - record.lastAttemptAt;
|
|
85
|
+
if (elapsedSinceLastAttempt > RESET_AFTER_MS) {
|
|
86
|
+
attempts.delete(accountKey);
|
|
87
|
+
return { blocked: false };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const requiredDelay = Math.min(BASE_DELAY_MS * 2 ** record.failCount, MAX_DELAY_MS);
|
|
91
|
+
|
|
92
|
+
if (elapsedSinceLastAttempt < requiredDelay) {
|
|
93
|
+
return { blocked: true, retryAfterSeconds: Math.ceil((requiredDelay - elapsedSinceLastAttempt) / 1000) };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return { blocked: false };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function recordFailedAttempt(accountKey) {
|
|
100
|
+
const record = attempts.get(accountKey) ?? { failCount: 0, lastAttemptAt: 0 };
|
|
101
|
+
record.failCount += 1;
|
|
102
|
+
record.lastAttemptAt = Date.now();
|
|
103
|
+
attempts.set(accountKey, record);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function recordSuccessfulAttempt(accountKey) {
|
|
107
|
+
attempts.delete(accountKey);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
module.exports = { checkAccountBackoff, recordFailedAttempt, recordSuccessfulAttempt };
|
|
111
|
+
`;
|
|
112
|
+
export const RATE_LIMITER = `// security/rateLimiter.middleware.js
|
|
113
|
+
const rateLimit = require('express-rate-limit');
|
|
114
|
+
|
|
115
|
+
// Stricter limits on auth routes, looser on general API traffic — apply
|
|
116
|
+
// the right one per route rather than one blanket limiter for everything.
|
|
117
|
+
function makeLimiter({ windowMs, max }) {
|
|
118
|
+
return rateLimit({
|
|
119
|
+
windowMs,
|
|
120
|
+
max,
|
|
121
|
+
standardHeaders: true,
|
|
122
|
+
legacyHeaders: false,
|
|
123
|
+
message: { error: 'Too many requests, please try again later.' },
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
module.exports = {
|
|
128
|
+
authLimiter: makeLimiter({
|
|
129
|
+
windowMs: Number(process.env.AUTH_RATE_LIMIT_WINDOW_MS) || 15 * 60 * 1000,
|
|
130
|
+
max: Number(process.env.AUTH_RATE_LIMIT_MAX) || 5,
|
|
131
|
+
}),
|
|
132
|
+
publicLimiter: makeLimiter({
|
|
133
|
+
windowMs: Number(process.env.PUBLIC_RATE_LIMIT_WINDOW_MS) || 15 * 60 * 1000,
|
|
134
|
+
max: Number(process.env.PUBLIC_RATE_LIMIT_MAX) || 100,
|
|
135
|
+
}),
|
|
136
|
+
authenticatedLimiter: makeLimiter({
|
|
137
|
+
windowMs: Number(process.env.AUTHED_RATE_LIMIT_WINDOW_MS) || 15 * 60 * 1000,
|
|
138
|
+
max: Number(process.env.AUTHED_RATE_LIMIT_MAX) || 300,
|
|
139
|
+
}),
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
// Usage: app.use('/login', authLimiter); app.use('/api', publicLimiter);
|
|
143
|
+
// Thresholds are env-configurable, not hardcoded, per the checklist.
|
|
144
|
+
`;
|
|
145
|
+
export const ERROR_HANDLER = `// security/errorHandler.middleware.js
|
|
146
|
+
// Must be registered LAST, after all routes.
|
|
147
|
+
module.exports = function errorHandler(err, req, res, next) {
|
|
148
|
+
// Full details go to server-side logs — never to the client.
|
|
149
|
+
console.error(err);
|
|
150
|
+
|
|
151
|
+
// The client only ever sees a generic message, never a stack trace,
|
|
152
|
+
// file path, or raw database error.
|
|
153
|
+
res.status(err.statusCode || 500).json({
|
|
154
|
+
error: 'Something went wrong. Please try again.',
|
|
155
|
+
});
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
// Usage: app.use(errorHandler); // after every other app.use()/route
|
|
159
|
+
`;
|
|
160
|
+
export const HASH_PASSWORD = `// security/auth/hashPassword.js
|
|
161
|
+
const argon2 = require('argon2');
|
|
162
|
+
|
|
163
|
+
async function hashPassword(plainPassword) {
|
|
164
|
+
return argon2.hash(plainPassword);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function verifyPassword(plainPassword, hash) {
|
|
168
|
+
return argon2.verify(hash, plainPassword);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
module.exports = { hashPassword, verifyPassword };
|
|
172
|
+
`;
|
|
173
|
+
export const JWT_TOKEN = `// security/auth/generateToken.js
|
|
174
|
+
const jwt = require('jsonwebtoken');
|
|
175
|
+
|
|
176
|
+
const SECRET = process.env.JWT_SECRET;
|
|
177
|
+
if (!SECRET) {
|
|
178
|
+
throw new Error('JWT_SECRET is not set — refusing to start without it.');
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function generateToken(payload, expiresIn = '15m') {
|
|
182
|
+
return jwt.sign(payload, SECRET, { expiresIn });
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function verifyToken(token) {
|
|
186
|
+
return jwt.verify(token, SECRET); // throws on invalid/expired
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
module.exports = { generateToken, verifyToken };
|
|
190
|
+
// Pair a short-lived access token with a separate, longer-lived refresh
|
|
191
|
+
// token stored server-side (or httpOnly cookie) — never a single
|
|
192
|
+
// long-lived token for everything.
|
|
193
|
+
`;
|
|
194
|
+
export const VALIDATION_SCHEMA = `// security/validation/authSchemas.js
|
|
195
|
+
const { z } = require('zod');
|
|
196
|
+
|
|
197
|
+
// Strict schema: type, length, and format all enforced. Anything that
|
|
198
|
+
// doesn't match is REJECTED, not silently sanitized/escaped.
|
|
199
|
+
const signupSchema = z.object({
|
|
200
|
+
email: z.string().email().max(254),
|
|
201
|
+
password: z.string().min(12).max(128),
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const loginSchema = z.object({
|
|
205
|
+
email: z.string().email().max(254),
|
|
206
|
+
password: z.string().min(1).max(128),
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
module.exports = { signupSchema, loginSchema };
|
|
210
|
+
// Usage: const data = signupSchema.parse(req.body); // throws on invalid input
|
|
211
|
+
`;
|
|
212
|
+
export const ENV_EXAMPLE = `# .env.example — copy to .env and fill in real values. Never commit .env itself.
|
|
213
|
+
JWT_SECRET=
|
|
214
|
+
ALLOWED_ORIGINS=https://yourapp.com
|
|
215
|
+
AUTH_RATE_LIMIT_MAX=5
|
|
216
|
+
PUBLIC_RATE_LIMIT_MAX=100
|
|
217
|
+
DATABASE_URL=
|
|
218
|
+
`;
|
|
219
|
+
export const SECURITY_CHECK_WORKFLOW = `# .github/workflows/security-check.yml
|
|
220
|
+
name: security-check
|
|
221
|
+
on: [push, pull_request]
|
|
222
|
+
jobs:
|
|
223
|
+
audit:
|
|
224
|
+
runs-on: ubuntu-latest
|
|
225
|
+
steps:
|
|
226
|
+
- uses: actions/checkout@v4
|
|
227
|
+
- uses: actions/setup-node@v4
|
|
228
|
+
with: { node-version: 20 }
|
|
229
|
+
- run: npm ci
|
|
230
|
+
- run: npm audit --audit-level=high
|
|
231
|
+
- uses: gitleaks/gitleaks-action@v2
|
|
232
|
+
env:
|
|
233
|
+
GITHUB_TOKEN: \${{ secrets.GITHUB_TOKEN }}
|
|
234
|
+
`;
|
|
235
|
+
export const PRE_COMMIT_HOOK = `#!/usr/bin/env sh
|
|
236
|
+
# .husky/pre-commit — requires: npm install -D husky && npx husky init
|
|
237
|
+
npx gitleaks protect --staged --no-banner || {
|
|
238
|
+
echo "Blocked: gitleaks found a potential secret in your staged changes.";
|
|
239
|
+
exit 1;
|
|
240
|
+
}
|
|
241
|
+
`;
|
|
242
|
+
export const FILE_UPLOAD = `// security/fileUpload.middleware.js
|
|
243
|
+
const multer = require('multer');
|
|
244
|
+
const path = require('node:path');
|
|
245
|
+
const crypto = require('node:crypto');
|
|
246
|
+
|
|
247
|
+
const ALLOWED_MIME_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'application/pdf']);
|
|
248
|
+
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
|
|
249
|
+
|
|
250
|
+
// Store outside the web root — never somewhere a webserver would execute
|
|
251
|
+
// an uploaded file (e.g. never inside a directory served with script
|
|
252
|
+
// execution enabled).
|
|
253
|
+
const UPLOAD_DIR = path.join(process.cwd(), 'private-uploads');
|
|
254
|
+
|
|
255
|
+
const storage = multer.diskStorage({
|
|
256
|
+
destination: UPLOAD_DIR,
|
|
257
|
+
filename(req, file, cb) {
|
|
258
|
+
// Never trust the original filename — generate a random one instead.
|
|
259
|
+
cb(null, crypto.randomBytes(16).toString('hex'));
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
const upload = multer({
|
|
264
|
+
storage,
|
|
265
|
+
limits: { fileSize: MAX_FILE_SIZE },
|
|
266
|
+
fileFilter(req, file, cb) {
|
|
267
|
+
if (!ALLOWED_MIME_TYPES.has(file.mimetype)) {
|
|
268
|
+
return cb(new Error('Unsupported file type'));
|
|
269
|
+
}
|
|
270
|
+
cb(null, true);
|
|
271
|
+
},
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
// IMPORTANT: fileFilter only checks the client-provided mimetype header,
|
|
275
|
+
// which can be spoofed. After upload, verify actual file content with a
|
|
276
|
+
// library like 'file-type' (reads magic bytes) before trusting it.
|
|
277
|
+
|
|
278
|
+
module.exports = { upload, UPLOAD_DIR };
|
|
279
|
+
// Usage: app.post('/upload', upload.single('file'), uploadHandler);
|
|
280
|
+
`;
|
|
281
|
+
export const RLS_ENABLE = `-- Enable Row Level Security on a table, then add a policy.
|
|
282
|
+
-- Without RLS, the public anon key can read/write this table directly —
|
|
283
|
+
-- this is the #1 cause of drained/defaced Supabase-backed apps.
|
|
284
|
+
|
|
285
|
+
ALTER TABLE your_table_name ENABLE ROW LEVEL SECURITY;
|
|
286
|
+
|
|
287
|
+
-- Example: users can only read their own rows.
|
|
288
|
+
CREATE POLICY "Users can read their own rows"
|
|
289
|
+
ON your_table_name
|
|
290
|
+
FOR SELECT
|
|
291
|
+
USING (auth.uid() = user_id);
|
|
292
|
+
|
|
293
|
+
-- Repeat ALTER TABLE ... ENABLE ROW LEVEL SECURITY for every table —
|
|
294
|
+
-- there are no exceptions. A table with RLS enabled but no policies
|
|
295
|
+
-- denies all access by default, which is a safe starting point.
|
|
296
|
+
`;
|
|
297
|
+
export const FIX_TEMPLATES = {
|
|
298
|
+
helmet: { id: "helmet", title: "security/helmet.config.js", code: HELMET_CONFIG },
|
|
299
|
+
cors: { id: "cors", title: "security/cors.config.js", code: CORS_CONFIG },
|
|
300
|
+
rateLimit: { id: "rateLimit", title: "security/rateLimiter.middleware.js", code: RATE_LIMITER },
|
|
301
|
+
accountBackoff: { id: "accountBackoff", title: "security/accountBackoff.js", code: ACCOUNT_BACKOFF },
|
|
302
|
+
errorHandler: { id: "errorHandler", title: "security/errorHandler.middleware.js", code: ERROR_HANDLER },
|
|
303
|
+
hashPassword: { id: "hashPassword", title: "security/auth/hashPassword.js", code: HASH_PASSWORD },
|
|
304
|
+
jwt: { id: "jwt", title: "security/auth/generateToken.js", code: JWT_TOKEN },
|
|
305
|
+
validation: { id: "validation", title: "security/validation/authSchemas.js", code: VALIDATION_SCHEMA },
|
|
306
|
+
envExample: { id: "envExample", title: ".env.example", code: ENV_EXAMPLE },
|
|
307
|
+
ciWorkflow: { id: "ciWorkflow", title: ".github/workflows/security-check.yml", code: SECURITY_CHECK_WORKFLOW },
|
|
308
|
+
preCommit: { id: "preCommit", title: ".husky/pre-commit", code: PRE_COMMIT_HOOK },
|
|
309
|
+
fileUpload: { id: "fileUpload", title: "security/fileUpload.middleware.js", code: FILE_UPLOAD },
|
|
310
|
+
rlsEnable: { id: "rlsEnable", title: "enable-rls.sql", code: RLS_ENABLE },
|
|
311
|
+
};
|