deployscout 1.0.1
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/Project.md +206 -0
- package/package.json +40 -0
- package/readme.md +151 -0
- package/src/cli.js +80 -0
- package/src/explain.js +87 -0
- package/src/rules/cors.js +269 -0
- package/src/rules/hardcoded-url.js +204 -0
- package/src/scanner.js +27 -0
package/Project.md
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# DeployScout — PROJECT.md
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
## 1. The problem (the 30-second pitch)
|
|
7
|
+
|
|
8
|
+
MERN apps routinely work perfectly in local dev and break the moment
|
|
9
|
+
they're deployed — not because of bad code, but because of **environment
|
|
10
|
+
assumptions** that only become visible in a different environment.
|
|
11
|
+
|
|
12
|
+
Examples that have personally happened to me, building and deploying a
|
|
13
|
+
real app (Affinity, a MERN chat app):
|
|
14
|
+
|
|
15
|
+
- Hardcoded `localhost` URLs in API calls and socket connections
|
|
16
|
+
- CORS configured with a dev-only origin (`http://localhost:3000`)
|
|
17
|
+
hardcoded, instead of reading from an environment variable
|
|
18
|
+
- Cookies set with `sameSite: "strict"`, which silently breaks
|
|
19
|
+
authentication the moment frontend and backend are on different domains
|
|
20
|
+
- A monorepo where the deploy host couldn't find the entry file because
|
|
21
|
+
the root directory wasn't configured
|
|
22
|
+
|
|
23
|
+
None of these are *logic* bugs. The code is "correct" — it just makes an
|
|
24
|
+
assumption (same origin, same machine, same `localhost`) that's true in
|
|
25
|
+
dev and false in prod. **Generic linters and code reviewers don't catch
|
|
26
|
+
this category of bug**, because there's nothing syntactically wrong with
|
|
27
|
+
the code. ESLint won't flag `origin: 'http://localhost:3000'` — it's
|
|
28
|
+
perfectly valid JavaScript. The problem only exists in the *relationship*
|
|
29
|
+
between this code and where it's about to be deployed.
|
|
30
|
+
|
|
31
|
+
**This is the gap DeployScout fills**: it doesn't check if your code is
|
|
32
|
+
well-written. It checks if your code's environment assumptions will
|
|
33
|
+
survive contact with a real deployment.
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## 2. Why this isn't "just another linter" (the question to expect)
|
|
38
|
+
|
|
39
|
+
Tools that already exist, and exactly what they don't cover:
|
|
40
|
+
|
|
41
|
+
| Tool | What it does | What it misses |
|
|
42
|
+
|---|---|---|
|
|
43
|
+
| ESLint | Syntax, style, logic bugs | Has no concept of "this string will be a different value in production" |
|
|
44
|
+
| gitleaks / truffleHog | Finds committed secrets via pattern matching | Doesn't understand CORS, cookies, or deployment config at all |
|
|
45
|
+
| npm audit / Snyk | Dependency vulnerabilities | Unrelated problem space entirely |
|
|
46
|
+
| CodeRabbit / Qodo | General AI code review (bugs, style, security) | Broad and generic — not specialized in dev-vs-prod environment mismatches, and doesn't reason about *why* a specific config breaks in *your* specific deployment context |
|
|
47
|
+
|
|
48
|
+
**The honest differentiator:** existing tools can tell you "this is a
|
|
49
|
+
string" or "this matches a secret pattern." None of them understand that
|
|
50
|
+
`cors({ origin: 'http://localhost:3000' })` is *structurally fine* but
|
|
51
|
+
*semantically broken* for production — that requires understanding what
|
|
52
|
+
CORS does, what `sameSite` cookies require, and why `localhost` never
|
|
53
|
+
matches a deployed origin. That's domain reasoning, not pattern matching,
|
|
54
|
+
which is why this tool uses AST analysis (to understand code structure)
|
|
55
|
+
combined with an LLM explanation step (to reason about *why* it matters
|
|
56
|
+
in context) — not just regex.
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## 3. Explicit scope boundaries
|
|
61
|
+
|
|
62
|
+
**This tool does NOT:**
|
|
63
|
+
- Act as a general code reviewer (that's CodeRabbit's job)
|
|
64
|
+
- Scan for dependency vulnerabilities (that's `npm audit`/Snyk's job)
|
|
65
|
+
- Scan git history for committed secrets (that's `gitleaks`'s job)
|
|
66
|
+
- Monitor live/deployed apps or logs (out of scope for all phases — this
|
|
67
|
+
is a pre-deploy static check, not an APM tool)
|
|
68
|
+
- Support non-MERN stacks in v1 (Next.js, Django, etc. — explicitly out
|
|
69
|
+
of scope until the core engine is proven)
|
|
70
|
+
|
|
71
|
+
Knowing what this *isn't* is as important as what it is — it's what
|
|
72
|
+
keeps the pitch sharp instead of "an AI tool that does everything."
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## 4. Functional & non-functional requirements
|
|
77
|
+
|
|
78
|
+
### Functional
|
|
79
|
+
| ID | Requirement | Status |
|
|
80
|
+
|---|---|---|
|
|
81
|
+
| FR1 | Detect hardcoded `localhost` URLs in API calls/sockets | Not started |
|
|
82
|
+
| FR2 | Detect missing `.env.example` vs. used env vars | Not started |
|
|
83
|
+
| FR3 | Detect CORS misconfiguration (`cors` package, hardcoded origin) | **Built — Phase 1** |
|
|
84
|
+
| FR3b | Detect CORS misconfiguration (manual `res.header(...)` pattern) | Planned — Phase 1.5 |
|
|
85
|
+
| FR4 | Detect risky cookie config (`sameSite: strict` + cross-origin intent) | Not started |
|
|
86
|
+
| FR5 | Detect case-sensitivity import risks (Windows/Mac vs. Linux) | Not started |
|
|
87
|
+
| FR6 | Detect entry-point/monorepo root mismatches | Not started |
|
|
88
|
+
| FR7 | LLM-generated plain-English explanation per finding | Not started |
|
|
89
|
+
| FR8 | Structured terminal report output | **Built — Phase 1** |
|
|
90
|
+
| FR9 | Non-zero exit code on critical findings (CI-ready) | **Built — Phase 1** |
|
|
91
|
+
|
|
92
|
+
### Non-functional
|
|
93
|
+
| ID | Requirement |
|
|
94
|
+
|---|---|
|
|
95
|
+
| NFR1 | Works on any MERN repo structure without manual config |
|
|
96
|
+
| NFR2 | Static analysis runs with zero API calls; LLM only called on actual findings |
|
|
97
|
+
| NFR3 | Low false-positive rate is prioritized over coverage breadth in v1 |
|
|
98
|
+
| NFR4 | Full scan completes in under ~10 seconds for a typical repo |
|
|
99
|
+
| NFR5 | Only the flagged snippet + minimal context is sent to the LLM, never the full file/repo |
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
## 5. Architecture
|
|
104
|
+
|
|
105
|
+
```
|
|
106
|
+
repo to scan
|
|
107
|
+
│
|
|
108
|
+
▼
|
|
109
|
+
scanner.js ──────► finds candidate .js/.jsx files (fast-glob, ignores node_modules)
|
|
110
|
+
│
|
|
111
|
+
▼
|
|
112
|
+
rules/*.js ──────► one file per concern (cors.js, cookies.js, env-vars.js, ...)
|
|
113
|
+
│ each rule: parse file → AST (@babel/parser)
|
|
114
|
+
│ → traverse (@babel/traverse)
|
|
115
|
+
│ → return findings[]
|
|
116
|
+
▼
|
|
117
|
+
cli.js ──────► wires scanner + all rules together, formats output (chalk)
|
|
118
|
+
│
|
|
119
|
+
▼
|
|
120
|
+
explain.js ──────► (Phase 2) takes findings, calls LLM for context-aware
|
|
121
|
+
explanations beyond the rule's built-in message
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
**Why AST instead of regex:** regex can't reliably distinguish "a string
|
|
125
|
+
that happens to contain `localhost`" from "the actual `origin` property
|
|
126
|
+
of an actual CORS config object." It also can't resolve a variable
|
|
127
|
+
reference back to its declaration. AST parsing gives the tool real
|
|
128
|
+
understanding of code *structure*, not just text patterns — this was
|
|
129
|
+
proven necessary almost immediately: the real Affinity bug used
|
|
130
|
+
`cors(corsOption)` (a variable reference), not `cors({...})` (an inline
|
|
131
|
+
object), and only AST + scope resolution could trace that correctly.
|
|
132
|
+
|
|
133
|
+
---
|
|
134
|
+
|
|
135
|
+
## 6. Decisions log (the "why did you do it this way" answers)
|
|
136
|
+
|
|
137
|
+
Keeping this section honest and current is what makes this document
|
|
138
|
+
actually useful in an interview — it's the paper trail of real
|
|
139
|
+
engineering judgment calls, not just a feature list.
|
|
140
|
+
|
|
141
|
+
- **Decided to scope Phase 1 to ONE rule (CORS, `cors` package pattern
|
|
142
|
+
only), built completely end-to-end, rather than partial coverage of
|
|
143
|
+
many rules.** Reasoning: a fully working narrow slice proves the
|
|
144
|
+
architecture; a half-built wide slice proves nothing and is hard to
|
|
145
|
+
debug.
|
|
146
|
+
|
|
147
|
+
- **Decided NOT to flag `origin: process.env.X || 'http://localhost:3000'`
|
|
148
|
+
in v1**, even though the `localhost` fallback is still technically
|
|
149
|
+
present and still a real risk. Reasoning: this value is a
|
|
150
|
+
`LogicalExpression`, not a plain `StringLiteral` — handling it correctly
|
|
151
|
+
requires recursively checking both sides of the `||`, which is a known,
|
|
152
|
+
deliberate v1 limitation, not an oversight. Documented here so it can
|
|
153
|
+
be explained honestly rather than discovered as a "bug" later.
|
|
154
|
+
|
|
155
|
+
- **Decided to resolve variable references** (`cors(corsOption)` →
|
|
156
|
+
trace back to `const corsOption = {...}`) rather than only matching
|
|
157
|
+
inline objects (`cors({...})`). Reasoning: the real-world bug that
|
|
158
|
+
motivated this whole project used the variable pattern, not the inline
|
|
159
|
+
one — building the simpler version first would have meant the tool
|
|
160
|
+
couldn't catch its own origin story's bug.
|
|
161
|
+
|
|
162
|
+
- **Decided to use regex/static-analysis first, LLM only for explanation,
|
|
163
|
+
not detection.** Reasoning: deterministic detection means reliable,
|
|
164
|
+
fast, free-to-run core checks; the LLM's job is narrowly to explain
|
|
165
|
+
*why* something matters in plain English, not to decide *if* something
|
|
166
|
+
is wrong — keeps cost and false-positive risk down.
|
|
167
|
+
|
|
168
|
+
- **Found a second real CORS pattern (manual `res.header(...)` calls,
|
|
169
|
+
not the `cors` package) while building test fixtures.** Decided to
|
|
170
|
+
defer this to Phase 1.5 rather than build both patterns simultaneously,
|
|
171
|
+
to keep Phase 1 finishable and provably complete before expanding.
|
|
172
|
+
|
|
173
|
+
---
|
|
174
|
+
|
|
175
|
+
## 7. Phase plan
|
|
176
|
+
|
|
177
|
+
| Phase | Scope | Status |
|
|
178
|
+
|---|---|---|
|
|
179
|
+
| **Phase 1** | CLI scans a repo, detects CORS issues (`cors` package, hardcoded localhost origin, inline + variable reference), prints terminal report, exits non-zero on critical | ✅ Built, tested on real positive case |
|
|
180
|
+
| **Phase 1.5** | Add second CORS pattern: manual `res.header("Access-Control-Allow-Origin", ...)` detection | Planned |
|
|
181
|
+
| **Phase 2** | `explain.js` — LLM call (Gemini) to generate context-aware explanations per finding | Planned |
|
|
182
|
+
| **Phase 3** | Additional rules: env vars, cookies, case-sensitivity, entry-point mismatch | Planned |
|
|
183
|
+
| **Phase 4** | GitHub Action surface (same engine, CI-integrated) | Planned |
|
|
184
|
+
| **Phase 5** | VS Code extension (inline flagging) | Stretch goal |
|
|
185
|
+
|
|
186
|
+
---
|
|
187
|
+
|
|
188
|
+
## 8. Validation so far
|
|
189
|
+
|
|
190
|
+
- ✅ Tested against a real, lived bug (Affinity's actual broken CORS
|
|
191
|
+
config) — correctly flagged as `CRITICAL` with accurate line number
|
|
192
|
+
and explanation
|
|
193
|
+
- ⏳ Negative case (clean config using `process.env.FRONTEND_URL`)
|
|
194
|
+
pending verification — expected to NOT flag, confirming the rule's
|
|
195
|
+
actual decision boundary
|
|
196
|
+
|
|
197
|
+
---
|
|
198
|
+
|
|
199
|
+
## 9. One-line resume/pitch version
|
|
200
|
+
|
|
201
|
+
> Built a static analysis CLI that catches dev-to-prod environment bugs
|
|
202
|
+
> (CORS misconfig, hardcoded URLs, cookie cross-origin issues) using AST
|
|
203
|
+
> parsing — not regex — to understand code structure and resolve variable
|
|
204
|
+
> references, with LLM-generated, context-aware explanations for each
|
|
205
|
+
> finding. Built after personally losing hours to these exact bugs
|
|
206
|
+
> deploying a real MERN app.
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "deployscout",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "Catches dev-to-prod environment bugs before they bite you",
|
|
5
|
+
"main": "src/cli.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"deployscout": "src/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"src/",
|
|
11
|
+
"readme.md",
|
|
12
|
+
"Project.md"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"test": "jest"
|
|
16
|
+
},
|
|
17
|
+
"jest": {
|
|
18
|
+
"transformIgnorePatterns": [
|
|
19
|
+
"node_modules/(?!(@babel/parser|@babel/traverse)/)"
|
|
20
|
+
]
|
|
21
|
+
},
|
|
22
|
+
"keywords": [],
|
|
23
|
+
"author": "",
|
|
24
|
+
"license": "ISC",
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@babel/parser": "^7.23.9",
|
|
27
|
+
"@babel/traverse": "^7.23.9",
|
|
28
|
+
"axios": "^1.18.1",
|
|
29
|
+
"chalk": "^4.1.2",
|
|
30
|
+
"commander": "^15.0.0",
|
|
31
|
+
"dotenv": "^17.4.2",
|
|
32
|
+
"express": "^5.2.1",
|
|
33
|
+
"fast-glob": "^3.3.3",
|
|
34
|
+
"fs": "^0.0.1-security",
|
|
35
|
+
"path": "^0.12.7"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"jest": "^30.4.2"
|
|
39
|
+
}
|
|
40
|
+
}
|
package/readme.md
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# DeployScout
|
|
2
|
+
|
|
3
|
+
> Catch dev-to-prod environment bugs before they bite you.
|
|
4
|
+
|
|
5
|
+
MERN apps routinely work perfectly in local development and break the moment they're deployed — not because of bad code, but because of **environment assumptions** that only become visible in a different environment. A hardcoded `localhost` URL, a CORS config scoped to your dev machine, a cookie setting that silently breaks cross-origin auth in production.
|
|
6
|
+
|
|
7
|
+
Generic linters won't catch these. They have no concept of "this string is fine locally and completely wrong in production." DeployScout does.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## What it catches
|
|
12
|
+
|
|
13
|
+
### CORS misconfiguration
|
|
14
|
+
- `cors()` package called with a hardcoded `localhost` origin — via both inline config objects and variable references
|
|
15
|
+
- Manual `res.header("Access-Control-Allow-Origin", "http://localhost:...")` calls
|
|
16
|
+
|
|
17
|
+
### Hardcoded localhost URLs
|
|
18
|
+
- `axios.get/post/put/delete/patch("http://localhost:...")` calls
|
|
19
|
+
- `fetch("http://localhost:...")` calls
|
|
20
|
+
- `io("http://localhost:...")` socket connections
|
|
21
|
+
- Variable declarations with URL-like names pointing to localhost (`BASE_URL`, `API_URL`, `BACKEND_HOST`, etc.)
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## Why AST, not regex
|
|
26
|
+
|
|
27
|
+
DeployScout uses `@babel/parser` and `@babel/traverse` to parse files into an Abstract Syntax Tree rather than pattern-matching on raw text. This means it:
|
|
28
|
+
|
|
29
|
+
- **Resolves variable references** — detects `cors(corsOption)` where `corsOption` is declared elsewhere in the file, not just `cors({ origin: '...' })` inline
|
|
30
|
+
- **Understands code structure** — distinguishes an actual CORS config object from a string that happens to contain `localhost` in a comment or unrelated variable
|
|
31
|
+
- **Avoids false positives** — the hardcoded-URL variable check only flags variables whose *name* suggests a URL or endpoint (`URL`, `API`, `BASE`, `HOST`, `ENDPOINT`, `SERVER`, `BACKEND`), not every string in the codebase
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## Installation
|
|
36
|
+
|
|
37
|
+
Run directly without installing:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
npx deployscout <path-to-repo>
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Or install globally:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
npm install -g deployscout
|
|
47
|
+
deployscout <path-to-repo>
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## Usage
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
# Scan a repo
|
|
56
|
+
deployscout ./my-mern-app
|
|
57
|
+
|
|
58
|
+
# Scan a specific subfolder
|
|
59
|
+
deployscout ./my-mern-app/backend
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
DeployScout walks all `.js` and `.jsx` files under the given path, skipping `node_modules`, `dist`, and `build` automatically.
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## Example output
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
Scanning /path/to/my-mern-app...
|
|
70
|
+
|
|
71
|
+
Found 2 issue(s):
|
|
72
|
+
|
|
73
|
+
CRITICAL backend/index.js:8
|
|
74
|
+
origin: 'http://localhost:3000',
|
|
75
|
+
CORS origin is hardcoded to "http://localhost:3000". This will reject every
|
|
76
|
+
real production frontend URL — the deployed frontend's origin will never
|
|
77
|
+
match "localhost", so every cross-origin request will be blocked by the browser.
|
|
78
|
+
AI: The corsOption origin is set to http://localhost:3000, which is only valid
|
|
79
|
+
during local development. Once deployed, your frontend will run on a different
|
|
80
|
+
domain and all cross-origin requests will be blocked. Fix: origin: process.env.FRONTEND_URL
|
|
81
|
+
|
|
82
|
+
CRITICAL frontend/src/hooks/useGetUsers.js:12
|
|
83
|
+
const res = await axios.get("http://localhost:8080/api/v1/user/");
|
|
84
|
+
This axios.get() call is hardcoded to "http://localhost:8080". In production,
|
|
85
|
+
your backend will be hosted at a different domain — this call will fail with
|
|
86
|
+
a connection error once deployed.
|
|
87
|
+
AI: Replace with: const res = await axios.get(`${process.env.REACT_APP_BACKEND_URL}/api/v1/user/`);
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
DeployScout exits with a **non-zero status code** when critical findings are present, making it usable in CI pipelines.
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
## AI-powered explanations (optional)
|
|
95
|
+
|
|
96
|
+
If a `GEMINI_API_KEY` is set, DeployScout enriches each finding with a context-specific explanation and suggested fix generated by Gemini — referencing the actual variable names and code patterns in your file, not a generic template.
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
# .env or environment
|
|
100
|
+
GEMINI_API_KEY=your_key_here
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Get a free API key at [aistudio.google.com/apikey](https://aistudio.google.com/apikey) — no credit card required.
|
|
104
|
+
|
|
105
|
+
If `GEMINI_API_KEY` is not set, DeployScout still runs and prints built-in explanations for every finding. The AI layer is an enhancement, never a dependency.
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
## Exit codes
|
|
110
|
+
|
|
111
|
+
| Code | Meaning |
|
|
112
|
+
|---|---|
|
|
113
|
+
| `0` | No issues found, or only warnings |
|
|
114
|
+
| `1` | One or more critical findings detected |
|
|
115
|
+
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
## Project layout
|
|
119
|
+
|
|
120
|
+
```
|
|
121
|
+
src/
|
|
122
|
+
├── cli.js entry point, wires everything together
|
|
123
|
+
├── scanner.js walks the repo, finds candidate .js/.jsx files
|
|
124
|
+
├── explain.js optional Gemini API explanation layer
|
|
125
|
+
└── rules/
|
|
126
|
+
├── cors.js CORS misconfiguration detection
|
|
127
|
+
└── hardcoded-url.js hardcoded localhost URL detection
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
## Current scope and known limitations
|
|
133
|
+
|
|
134
|
+
DeployScout is currently scoped to MERN (MongoDB / Express / React / Node) apps. It deliberately does not:
|
|
135
|
+
|
|
136
|
+
- Act as a general code reviewer
|
|
137
|
+
- Scan for dependency vulnerabilities (`npm audit` covers this)
|
|
138
|
+
- Detect committed secrets (`gitleaks` covers this)
|
|
139
|
+
- Support non-JS stacks in the current version
|
|
140
|
+
|
|
141
|
+
Known detection limitations in the current version:
|
|
142
|
+
|
|
143
|
+
- `process.env.FRONTEND_URL || 'http://localhost:3000'` (a `||` fallback with a localhost default) is not flagged — the value is a logical expression, not a plain string literal, and the fallback risk is considered a known v1 gap
|
|
144
|
+
- Variable-based CORS config (`cors(corsOption)`) is resolved one level deep; deeply nested or dynamically constructed config objects are not followed
|
|
145
|
+
- Manual CORS header checks assume the Express response object is named `res` or `response`
|
|
146
|
+
|
|
147
|
+
---
|
|
148
|
+
|
|
149
|
+
## Background
|
|
150
|
+
|
|
151
|
+
DeployScout was built after personally losing hours to the exact bugs it now detects — deploying a real MERN chat app and hitting CORS errors, hardcoded localhost failures, and cookie cross-origin issues one after another. The tool exists because none of the existing linters or code reviewers understand the relationship between local dev assumptions and production deployment reality.
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
require("dotenv").config();
|
|
3
|
+
|
|
4
|
+
const { Command } = require("commander");
|
|
5
|
+
const chalk = require("chalk");
|
|
6
|
+
const path = require("path");
|
|
7
|
+
|
|
8
|
+
const { findCandidateFiles } = require("./scanner");
|
|
9
|
+
const corsRule = require("./rules/cors");
|
|
10
|
+
const hardcodedUrlRule = require("./rules/hardcoded-url");
|
|
11
|
+
const { explainFinding } = require("./explain");
|
|
12
|
+
|
|
13
|
+
const program = new Command();
|
|
14
|
+
|
|
15
|
+
program
|
|
16
|
+
.name("deployscout")
|
|
17
|
+
.description("Catches dev-to-prod environment bugs before they bite you")
|
|
18
|
+
.argument("<repoPath>", "path to the repo you want to scan")
|
|
19
|
+
.action(async (repoPath) => {
|
|
20
|
+
const absolutePath = path.resolve(repoPath);
|
|
21
|
+
|
|
22
|
+
console.log(chalk.bold(`\nScanning ${absolutePath}...\n`));
|
|
23
|
+
|
|
24
|
+
const files = await findCandidateFiles(absolutePath);
|
|
25
|
+
|
|
26
|
+
if (files.length === 0) {
|
|
27
|
+
console.log(chalk.yellow("No JS/JSX files found to scan."));
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let allFindings = [];
|
|
32
|
+
|
|
33
|
+
for (const file of files) {
|
|
34
|
+
const corsFindings = corsRule.checkFile(file);
|
|
35
|
+
const urlFindings = hardcodedUrlRule.checkFile(file);
|
|
36
|
+
allFindings = allFindings.concat(corsFindings, urlFindings);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (allFindings.length === 0) {
|
|
40
|
+
console.log(chalk.green("✔ No issues found.\n"));
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
console.log(chalk.bold(`Found ${allFindings.length} issue(s):\n`));
|
|
45
|
+
|
|
46
|
+
if (!process.env.GEMINI_API_KEY) {
|
|
47
|
+
console.log(
|
|
48
|
+
chalk.dim(
|
|
49
|
+
" (No GEMINI_API_KEY set — showing built-in explanations only. " +
|
|
50
|
+
"See .env.example to enable AI-generated context-specific fixes.)\n"
|
|
51
|
+
)
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
for (const finding of allFindings) {
|
|
56
|
+
const relativePath = path.relative(absolutePath, finding.file);
|
|
57
|
+
const badge =
|
|
58
|
+
finding.severity === "critical"
|
|
59
|
+
? chalk.bgRed.white.bold(" CRITICAL ")
|
|
60
|
+
: chalk.bgYellow.black.bold(" WARNING ");
|
|
61
|
+
|
|
62
|
+
console.log(`${badge} ${chalk.underline(`${relativePath}:${finding.line}`)}`);
|
|
63
|
+
console.log(` ${chalk.dim(finding.snippet)}`);
|
|
64
|
+
console.log(` ${finding.message}`);
|
|
65
|
+
|
|
66
|
+
const aiExplanation = await explainFinding(finding);
|
|
67
|
+
if (aiExplanation) {
|
|
68
|
+
console.log(` ${chalk.cyan("AI:")} ${aiExplanation}`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
console.log("");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const hasCritical = allFindings.some((f) => f.severity === "critical");
|
|
75
|
+
if (hasCritical) {
|
|
76
|
+
process.exitCode = 1;
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
program.parse();
|
package/src/explain.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
|
|
3
|
+
const GEMINI_MODEL = process.env.GEMINI_MODEL || "gemini-2.5-flash";
|
|
4
|
+
const GEMINI_URL = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}:generateContent`;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Reads a small, bounded window of lines around the flagged line, so the
|
|
8
|
+
* LLM has just enough real context to give a specific fix — without
|
|
9
|
+
* sending the whole file. (NFR5: only the flagged snippet + minimal
|
|
10
|
+
* context goes to the LLM, never the full file/repo.)
|
|
11
|
+
*/
|
|
12
|
+
function getContextWindow(filePath, line, windowSize = 2) {
|
|
13
|
+
const code = fs.readFileSync(filePath, "utf-8");
|
|
14
|
+
const lines = code.split("\n");
|
|
15
|
+
|
|
16
|
+
const start = Math.max(0, line - 1 - windowSize);
|
|
17
|
+
const end = Math.min(lines.length, line + windowSize);
|
|
18
|
+
|
|
19
|
+
return lines.slice(start, end).join("\n");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function buildPrompt(finding, contextWindow) {
|
|
23
|
+
return `You are a senior backend engineer reviewing a deployment-readiness flag found by a static analyzer in a MERN (MongoDB/Express/React/Node) app.
|
|
24
|
+
|
|
25
|
+
Issue type: CORS misconfiguration
|
|
26
|
+
Severity: ${finding.severity}
|
|
27
|
+
Flagged line: ${finding.snippet}
|
|
28
|
+
|
|
29
|
+
Surrounding code for context:
|
|
30
|
+
${contextWindow}
|
|
31
|
+
|
|
32
|
+
Static analyzer's built-in explanation: ${finding.message}
|
|
33
|
+
|
|
34
|
+
In 2-3 sentences, explain concretely why this will break once the app is deployed — reference the actual code shown above, not a generic explanation. Then give ONE line of suggested fixed code, using an environment variable, that matches the existing variable/property names from the snippet above. Keep the total response under 80 words. Do not use markdown formatting, just plain text.`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Calls the Gemini API to generate a context-specific explanation and fix
|
|
39
|
+
* suggestion for a single finding. Returns null on ANY failure (missing
|
|
40
|
+
* key, network error, bad response) so the caller can gracefully fall
|
|
41
|
+
* back to the rule's built-in message — this layer must never crash the
|
|
42
|
+
* tool or block its core function.
|
|
43
|
+
*
|
|
44
|
+
* @param {object} finding - a single finding object from a rule's checkFile()
|
|
45
|
+
* @returns {Promise<string|null>} AI-generated explanation, or null
|
|
46
|
+
*/
|
|
47
|
+
async function explainFinding(finding) {
|
|
48
|
+
const apiKey = process.env.GEMINI_API_KEY;
|
|
49
|
+
if (!apiKey) return null; // caller already warns once, not per-finding
|
|
50
|
+
|
|
51
|
+
let contextWindow;
|
|
52
|
+
try {
|
|
53
|
+
contextWindow = getContextWindow(finding.file, finding.line);
|
|
54
|
+
} catch (err) {
|
|
55
|
+
return null; // couldn't re-read the file for context, skip silently
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const prompt = buildPrompt(finding, contextWindow);
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
const response = await fetch(GEMINI_URL, {
|
|
62
|
+
method: "POST",
|
|
63
|
+
headers: {
|
|
64
|
+
"Content-Type": "application/json",
|
|
65
|
+
"x-goog-api-key": apiKey,
|
|
66
|
+
},
|
|
67
|
+
body: JSON.stringify({
|
|
68
|
+
contents: [{ role: "user", parts: [{ text: prompt }] }],
|
|
69
|
+
}),
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
if (!response.ok) {
|
|
73
|
+
// e.g. 429 rate limit, 401 invalid key, 500 server error
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const data = await response.json();
|
|
78
|
+
const text = data?.candidates?.[0]?.content?.parts?.[0]?.text;
|
|
79
|
+
|
|
80
|
+
return text ? text.trim() : null;
|
|
81
|
+
} catch (err) {
|
|
82
|
+
// network failure, DNS issue, etc. — fail silently, caller falls back
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
module.exports = { explainFinding };
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const parser = require("@babel/parser");
|
|
3
|
+
const traverse = require("@babel/traverse").default;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Detects risky CORS configuration in two patterns:
|
|
7
|
+
*
|
|
8
|
+
* 1. The `cors` npm package:
|
|
9
|
+
* cors({ origin: 'http://localhost:3000', credentials: true })
|
|
10
|
+
*
|
|
11
|
+
* 2. Manual header-setting (Express's res.header / res.set):
|
|
12
|
+
* res.header("Access-Control-Allow-Origin", "http://localhost:3000")
|
|
13
|
+
*
|
|
14
|
+
* These are structurally unrelated AST shapes, so they're detected by
|
|
15
|
+
* two separate checks inside the same CallExpression visitor.
|
|
16
|
+
*
|
|
17
|
+
* What counts as "risky" for the origin check, in both patterns:
|
|
18
|
+
* - origin is a hardcoded string literal (not process.env.SOMETHING)
|
|
19
|
+
* - that string literal contains "localhost"
|
|
20
|
+
*
|
|
21
|
+
* @param {string} filePath - absolute path to the file being checked
|
|
22
|
+
* @returns {object[]} list of findings, each with file/line/message
|
|
23
|
+
*/
|
|
24
|
+
function checkFile(filePath) {
|
|
25
|
+
const findings = [];
|
|
26
|
+
const code = fs.readFileSync(filePath, "utf-8");
|
|
27
|
+
|
|
28
|
+
let ast;
|
|
29
|
+
try {
|
|
30
|
+
ast = parser.parse(code, {
|
|
31
|
+
sourceType: "unambiguous", // handles both CommonJS and ESM files
|
|
32
|
+
plugins: ["jsx"], // in case a .jsx file slips through
|
|
33
|
+
});
|
|
34
|
+
} catch (err) {
|
|
35
|
+
// file isn't valid JS (or uses syntax we don't support yet) — skip it
|
|
36
|
+
return findings;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
traverse(ast, {
|
|
40
|
+
CallExpression(path) {
|
|
41
|
+
checkCorsPackagePattern(path, code, filePath, findings);
|
|
42
|
+
checkManualHeaderPattern(path, code, filePath, findings);
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
return findings;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Pattern 1: the `cors` npm package, e.g. cors({ origin: '...' })
|
|
51
|
+
* or cors(corsOption) where corsOption is a variable.
|
|
52
|
+
*/
|
|
53
|
+
function checkCorsPackagePattern(path, code, filePath, findings) {
|
|
54
|
+
const callee = path.node.callee;
|
|
55
|
+
|
|
56
|
+
// Only interested in calls literally named "cors"
|
|
57
|
+
// e.g. cors({...}) -- NOT app.use(cors()) directly, but the
|
|
58
|
+
// inner cors(...) call itself, which is what holds the config.
|
|
59
|
+
const isCorsCall = callee.type === "Identifier" && callee.name === "cors";
|
|
60
|
+
if (!isCorsCall) return;
|
|
61
|
+
|
|
62
|
+
const args = path.node.arguments;
|
|
63
|
+
if (args.length === 0) return; // cors() with no args -- nothing to check
|
|
64
|
+
|
|
65
|
+
let configArg = args[0];
|
|
66
|
+
|
|
67
|
+
// Handle the common pattern:
|
|
68
|
+
// const corsOption = { origin: '...', credentials: true };
|
|
69
|
+
// app.use(cors(corsOption));
|
|
70
|
+
// Here cors() is called with an Identifier (variable name), not an
|
|
71
|
+
// inline object -- so we resolve the variable to find its declaration.
|
|
72
|
+
if (configArg.type === "Identifier") {
|
|
73
|
+
const binding = path.scope.getBinding(configArg.name);
|
|
74
|
+
if (!binding || binding.path.node.type !== "VariableDeclarator") return;
|
|
75
|
+
|
|
76
|
+
const init = binding.path.node.init;
|
|
77
|
+
if (!init || init.type !== "ObjectExpression") return;
|
|
78
|
+
|
|
79
|
+
configArg = init;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (configArg.type !== "ObjectExpression") return; // not a config object, skip
|
|
83
|
+
|
|
84
|
+
// Walk the properties of the config object looking for `origin`
|
|
85
|
+
for (const prop of configArg.properties) {
|
|
86
|
+
if (prop.type !== "ObjectProperty") continue;
|
|
87
|
+
|
|
88
|
+
const keyName = prop.key.name || prop.key.value; // handles origin: vs "origin":
|
|
89
|
+
if (keyName !== "origin") continue;
|
|
90
|
+
|
|
91
|
+
const value = prop.value;
|
|
92
|
+
|
|
93
|
+
if (value.type === "StringLiteral") {
|
|
94
|
+
pushOriginFinding({
|
|
95
|
+
findings,
|
|
96
|
+
filePath,
|
|
97
|
+
code,
|
|
98
|
+
line: prop.loc.start.line,
|
|
99
|
+
originValue: value.value,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
// If it's not a StringLiteral (e.g. it's `process.env.FRONTEND_URL`),
|
|
103
|
+
// we consider that safe for this rule and don't flag it.
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Pattern 2: manual header-setting via res.header(...) or res.set(...)
|
|
109
|
+
* e.g. res.header("Access-Control-Allow-Origin", "http://localhost:3000")
|
|
110
|
+
*
|
|
111
|
+
* v1 scope: only checks the origin header. Missing
|
|
112
|
+
* Access-Control-Allow-Headers and overly-restrictive
|
|
113
|
+
* Access-Control-Allow-Methods are deliberately deferred — see
|
|
114
|
+
* PROJECT.md decisions log.
|
|
115
|
+
*/
|
|
116
|
+
function checkManualHeaderPattern(path, code, filePath, findings) {
|
|
117
|
+
const callee = path.node.callee;
|
|
118
|
+
|
|
119
|
+
// We're looking for res.header(...) or res.set(...) -- a member call
|
|
120
|
+
// where the object is named "res" (or "response") and the method is
|
|
121
|
+
// "header" or "set".
|
|
122
|
+
const isMemberCall = callee.type === "MemberExpression";
|
|
123
|
+
if (!isMemberCall) return;
|
|
124
|
+
|
|
125
|
+
const objectName = callee.object.name;
|
|
126
|
+
const methodName = callee.property.name;
|
|
127
|
+
|
|
128
|
+
const isResLike = objectName === "res" || objectName === "response";
|
|
129
|
+
const isHeaderMethod = methodName === "header" || methodName === "set";
|
|
130
|
+
if (!isResLike || !isHeaderMethod) return;
|
|
131
|
+
|
|
132
|
+
const args = path.node.arguments;
|
|
133
|
+
if (args.length < 2) return; // need both header name and value
|
|
134
|
+
|
|
135
|
+
const headerNameArg = args[0];
|
|
136
|
+
const headerValueArg = args[1];
|
|
137
|
+
|
|
138
|
+
if (headerNameArg.type !== "StringLiteral") return;
|
|
139
|
+
if (headerNameArg.value !== "Access-Control-Allow-Origin") return;
|
|
140
|
+
|
|
141
|
+
if (headerValueArg.type === "StringLiteral") {
|
|
142
|
+
pushOriginFinding({
|
|
143
|
+
findings,
|
|
144
|
+
filePath,
|
|
145
|
+
code,
|
|
146
|
+
line: path.node.loc.start.line,
|
|
147
|
+
originValue: headerValueArg.value,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Shared finding-builder for both patterns, so the message wording and
|
|
154
|
+
* severity logic stay consistent regardless of which pattern triggered it.
|
|
155
|
+
*/
|
|
156
|
+
function pushOriginFinding({ findings, filePath, code, line, originValue }) {
|
|
157
|
+
const isLocalhost = originValue.includes("localhost");
|
|
158
|
+
findings.push({
|
|
159
|
+
file: filePath,
|
|
160
|
+
line,
|
|
161
|
+
issueType: "CORS misconfiguration", // ← add this line
|
|
162
|
+
severity: isLocalhost ? "critical" : "warning",
|
|
163
|
+
message: isLocalhost
|
|
164
|
+
? `CORS origin is hardcoded to "${originValue}"...`
|
|
165
|
+
: `CORS origin is a hardcoded string...`,
|
|
166
|
+
snippet: code.split("\n")[line - 1].trim(),
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
module.exports = { checkFile };
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
//__________________________phse 1
|
|
174
|
+
// const fs = require("fs");
|
|
175
|
+
// const parser = require("@babel/parser");
|
|
176
|
+
// const traverse = require("@babel/traverse").default;
|
|
177
|
+
|
|
178
|
+
// /**
|
|
179
|
+
// * Detects risky CORS configuration passed to the `cors` npm package,
|
|
180
|
+
// * e.g. cors({ origin: 'http://localhost:3000', credentials: true })
|
|
181
|
+
// *
|
|
182
|
+
// * What counts as "risky" for this rule:
|
|
183
|
+
// * - origin is a hardcoded string literal (not process.env.SOMETHING)
|
|
184
|
+
// * - that string literal contains "localhost"
|
|
185
|
+
// *
|
|
186
|
+
// * @param {string} filePath - absolute path to the file being checked
|
|
187
|
+
// * @returns {object[]} list of findings, each with file/line/message
|
|
188
|
+
// */
|
|
189
|
+
// function checkFile(filePath) {
|
|
190
|
+
// const findings = [];
|
|
191
|
+
// const code = fs.readFileSync(filePath, "utf-8");
|
|
192
|
+
|
|
193
|
+
// let ast;
|
|
194
|
+
// try {
|
|
195
|
+
// ast = parser.parse(code, {
|
|
196
|
+
// sourceType: "unambiguous", // handles both CommonJS and ESM files
|
|
197
|
+
// plugins: ["jsx"], // in case a .jsx file slips through
|
|
198
|
+
// });
|
|
199
|
+
// } catch (err) {
|
|
200
|
+
// // file isn't valid JS (or uses syntax we don't support yet) — skip it
|
|
201
|
+
// return findings;
|
|
202
|
+
// }
|
|
203
|
+
|
|
204
|
+
// traverse(ast, {
|
|
205
|
+
// // We're looking for any function call: someName(...)
|
|
206
|
+
// CallExpression(path) {
|
|
207
|
+
// const callee = path.node.callee;
|
|
208
|
+
|
|
209
|
+
// // Only interested in calls literally named "cors"
|
|
210
|
+
// // e.g. cors({...}) -- NOT app.use(cors()) directly, but the
|
|
211
|
+
// // inner cors(...) call itself, which is what holds the config.
|
|
212
|
+
// const isCorsCall =
|
|
213
|
+
// callee.type === "Identifier" && callee.name === "cors";
|
|
214
|
+
|
|
215
|
+
// if (!isCorsCall) return;
|
|
216
|
+
|
|
217
|
+
// const args = path.node.arguments;
|
|
218
|
+
// if (args.length === 0) return; // cors() with no args -- nothing to check
|
|
219
|
+
|
|
220
|
+
// let configArg = args[0];
|
|
221
|
+
|
|
222
|
+
// // Handle the common pattern:
|
|
223
|
+
// // const corsOption = { origin: '...', credentials: true };
|
|
224
|
+
// // app.use(cors(corsOption));
|
|
225
|
+
// // Here cors() is called with an Identifier (variable name), not an
|
|
226
|
+
// // inline object -- so we resolve the variable to find its declaration.
|
|
227
|
+
// if (configArg.type === "Identifier") {
|
|
228
|
+
// const binding = path.scope.getBinding(configArg.name);
|
|
229
|
+
// if (!binding || binding.path.node.type !== "VariableDeclarator") return;
|
|
230
|
+
|
|
231
|
+
// const init = binding.path.node.init;
|
|
232
|
+
// if (!init || init.type !== "ObjectExpression") return;
|
|
233
|
+
|
|
234
|
+
// configArg = init;
|
|
235
|
+
// }
|
|
236
|
+
|
|
237
|
+
// if (configArg.type !== "ObjectExpression") return; // not a config object, skip
|
|
238
|
+
|
|
239
|
+
// // Walk the properties of the config object looking for `origin`
|
|
240
|
+
// for (const prop of configArg.properties) {
|
|
241
|
+
// if (prop.type !== "ObjectProperty") continue;
|
|
242
|
+
|
|
243
|
+
// const keyName = prop.key.name || prop.key.value; // handles origin: vs "origin":
|
|
244
|
+
// if (keyName !== "origin") continue;
|
|
245
|
+
|
|
246
|
+
// const value = prop.value;
|
|
247
|
+
|
|
248
|
+
// if (value.type === "StringLiteral") {
|
|
249
|
+
// const isLocalhost = value.value.includes("localhost");
|
|
250
|
+
// findings.push({
|
|
251
|
+
// file: filePath,
|
|
252
|
+
// line: prop.loc.start.line,
|
|
253
|
+
// severity: isLocalhost ? "critical" : "warning",
|
|
254
|
+
// message: isLocalhost
|
|
255
|
+
// ? `CORS origin is hardcoded to "${value.value}". This will reject every real production frontend URL — the deployed frontend's origin will never match "localhost", so every cross-origin request will be blocked by the browser.`
|
|
256
|
+
// : `CORS origin is a hardcoded string ("${value.value}") instead of an environment variable. This works until your deployed frontend URL changes or differs per environment.`,
|
|
257
|
+
// snippet: code.split("\n")[prop.loc.start.line - 1].trim(),
|
|
258
|
+
// });
|
|
259
|
+
// }
|
|
260
|
+
// // If it's not a StringLiteral (e.g. it's `process.env.FRONTEND_URL`),
|
|
261
|
+
// // we consider that safe for this rule and don't flag it.
|
|
262
|
+
// }
|
|
263
|
+
// },
|
|
264
|
+
// });
|
|
265
|
+
|
|
266
|
+
// return findings;
|
|
267
|
+
// }
|
|
268
|
+
|
|
269
|
+
// module.exports = { checkFile };
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const parser = require("@babel/parser");
|
|
3
|
+
const traverse = require("@babel/traverse").default;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Detects hardcoded "localhost" URLs in two patterns:
|
|
7
|
+
*
|
|
8
|
+
* 1. Function-call arguments (Phase 3):
|
|
9
|
+
* axios.get('http://localhost:8080/api/...')
|
|
10
|
+
* axios.post('http://localhost:8080/api/...', body)
|
|
11
|
+
* fetch('http://localhost:8080/api/...')
|
|
12
|
+
* io('http://localhost:8080', { query: {...} })
|
|
13
|
+
*
|
|
14
|
+
* 2. Variable declarations whose name suggests a URL/endpoint (Phase 3.5):
|
|
15
|
+
* export const BASE_URL = "http://localhost:8080"
|
|
16
|
+
* const API_URL = "http://localhost:8080"
|
|
17
|
+
*
|
|
18
|
+
* Pattern 2 requires the variable name to contain a URL-related keyword
|
|
19
|
+
* (URL, API, BASE, HOST, ENDPOINT, SERVER, BACKEND) — a deliberate
|
|
20
|
+
* heuristic to avoid flagging unrelated variables that happen to hold a
|
|
21
|
+
* string containing "localhost". See PROJECT.md decisions log.
|
|
22
|
+
*
|
|
23
|
+
* What counts as "risky" for both patterns:
|
|
24
|
+
* - the value is a hardcoded string literal (or a template literal
|
|
25
|
+
* with no ${...} interpolation)
|
|
26
|
+
* - that string contains "localhost"
|
|
27
|
+
*
|
|
28
|
+
* @param {string} filePath - absolute path to the file being checked
|
|
29
|
+
* @returns {object[]} list of findings, each with file/line/message
|
|
30
|
+
*/
|
|
31
|
+
function checkFile(filePath) {
|
|
32
|
+
const findings = [];
|
|
33
|
+
const code = fs.readFileSync(filePath, "utf-8");
|
|
34
|
+
|
|
35
|
+
let ast;
|
|
36
|
+
try {
|
|
37
|
+
ast = parser.parse(code, {
|
|
38
|
+
sourceType: "unambiguous",
|
|
39
|
+
plugins: ["jsx"],
|
|
40
|
+
});
|
|
41
|
+
} catch (err) {
|
|
42
|
+
return findings;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
traverse(ast, {
|
|
46
|
+
CallExpression(path) {
|
|
47
|
+
checkAxiosCall(path, code, filePath, findings);
|
|
48
|
+
checkFetchCall(path, code, filePath, findings);
|
|
49
|
+
checkSocketIoCall(path, code, filePath, findings);
|
|
50
|
+
},
|
|
51
|
+
VariableDeclarator(path) {
|
|
52
|
+
checkUrlVariableDeclaration(path, code, filePath, findings);
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
return findings;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* axios.get(url), axios.post(url, body), axios.put(url, body), etc.
|
|
61
|
+
*/
|
|
62
|
+
function checkAxiosCall(path, code, filePath, findings) {
|
|
63
|
+
const callee = path.node.callee;
|
|
64
|
+
if (callee.type !== "MemberExpression") return;
|
|
65
|
+
|
|
66
|
+
const objectName = callee.object.name;
|
|
67
|
+
const methodName = callee.property.name;
|
|
68
|
+
|
|
69
|
+
const HTTP_METHODS = ["get", "post", "put", "delete", "patch"];
|
|
70
|
+
if (objectName !== "axios" || !HTTP_METHODS.includes(methodName)) return;
|
|
71
|
+
|
|
72
|
+
const urlArg = path.node.arguments[0];
|
|
73
|
+
if (!urlArg) return;
|
|
74
|
+
|
|
75
|
+
flagIfHardcodedLocalhost({
|
|
76
|
+
argNode: urlArg,
|
|
77
|
+
source: `axios.${methodName}() call`,
|
|
78
|
+
path,
|
|
79
|
+
code,
|
|
80
|
+
filePath,
|
|
81
|
+
findings,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* fetch(url, options)
|
|
87
|
+
*/
|
|
88
|
+
function checkFetchCall(path, code, filePath, findings) {
|
|
89
|
+
const callee = path.node.callee;
|
|
90
|
+
if (callee.type !== "Identifier" || callee.name !== "fetch") return;
|
|
91
|
+
|
|
92
|
+
const urlArg = path.node.arguments[0];
|
|
93
|
+
if (!urlArg) return;
|
|
94
|
+
|
|
95
|
+
flagIfHardcodedLocalhost({
|
|
96
|
+
argNode: urlArg,
|
|
97
|
+
source: "fetch() call",
|
|
98
|
+
path,
|
|
99
|
+
code,
|
|
100
|
+
filePath,
|
|
101
|
+
findings,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* io(url, options) — socket.io-client connection
|
|
107
|
+
*/
|
|
108
|
+
function checkSocketIoCall(path, code, filePath, findings) {
|
|
109
|
+
const callee = path.node.callee;
|
|
110
|
+
if (callee.type !== "Identifier" || callee.name !== "io") return;
|
|
111
|
+
|
|
112
|
+
const urlArg = path.node.arguments[0];
|
|
113
|
+
if (!urlArg) return;
|
|
114
|
+
|
|
115
|
+
flagIfHardcodedLocalhost({
|
|
116
|
+
argNode: urlArg,
|
|
117
|
+
source: "io() call",
|
|
118
|
+
path,
|
|
119
|
+
code,
|
|
120
|
+
filePath,
|
|
121
|
+
findings,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Keywords that suggest a variable name represents a URL/endpoint.
|
|
126
|
+
// Substring match, case-insensitive. A deliberate heuristic — see
|
|
127
|
+
// PROJECT.md decisions log for the reasoning and known limitations.
|
|
128
|
+
const URL_LIKE_NAME_KEYWORDS = [
|
|
129
|
+
"URL",
|
|
130
|
+
"API",
|
|
131
|
+
"BASE",
|
|
132
|
+
"HOST",
|
|
133
|
+
"ENDPOINT",
|
|
134
|
+
"SERVER",
|
|
135
|
+
"BACKEND",
|
|
136
|
+
];
|
|
137
|
+
|
|
138
|
+
function isUrlLikeVariableName(name) {
|
|
139
|
+
const upperName = name.toUpperCase();
|
|
140
|
+
return URL_LIKE_NAME_KEYWORDS.some((keyword) => upperName.includes(keyword));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* const BASE_URL = "http://localhost:8080"
|
|
145
|
+
* export const API_URL = "http://localhost:8080"
|
|
146
|
+
*
|
|
147
|
+
* Only flags if BOTH:
|
|
148
|
+
* - the variable name matches the URL-like keyword heuristic
|
|
149
|
+
* - the assigned value is a hardcoded localhost string
|
|
150
|
+
*/
|
|
151
|
+
function checkUrlVariableDeclaration(path, code, filePath, findings) {
|
|
152
|
+
const id = path.node.id;
|
|
153
|
+
if (id.type !== "Identifier") return; // skip destructuring, etc.
|
|
154
|
+
|
|
155
|
+
if (!isUrlLikeVariableName(id.name)) return;
|
|
156
|
+
|
|
157
|
+
const init = path.node.init;
|
|
158
|
+
if (!init) return; // declared but not initialized, e.g. `let x;`
|
|
159
|
+
|
|
160
|
+
flagIfHardcodedLocalhost({
|
|
161
|
+
argNode: init,
|
|
162
|
+
source: `"${id.name}" declaration`,
|
|
163
|
+
path,
|
|
164
|
+
code,
|
|
165
|
+
filePath,
|
|
166
|
+
findings,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Shared check: is this argument/value node a hardcoded string literal
|
|
172
|
+
* containing "localhost"? If so, push a finding. Handles plain string
|
|
173
|
+
* literals directly; template literals with no interpolated expressions
|
|
174
|
+
* are also checked, since that's a common stylistic choice that's
|
|
175
|
+
* otherwise identical in risk to a plain string.
|
|
176
|
+
*/
|
|
177
|
+
function flagIfHardcodedLocalhost({ argNode, source, path, code, filePath, findings }) {
|
|
178
|
+
let urlValue = null;
|
|
179
|
+
|
|
180
|
+
if (argNode.type === "StringLiteral") {
|
|
181
|
+
urlValue = argNode.value;
|
|
182
|
+
} else if (
|
|
183
|
+
argNode.type === "TemplateLiteral" &&
|
|
184
|
+
argNode.expressions.length === 0
|
|
185
|
+
) {
|
|
186
|
+
urlValue = argNode.quasis.map((q) => q.value.cooked).join("");
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (urlValue === null) return; // not a static string we can check
|
|
190
|
+
if (!urlValue.includes("localhost")) return;
|
|
191
|
+
|
|
192
|
+
const line = path.node.loc.start.line;
|
|
193
|
+
|
|
194
|
+
findings.push({
|
|
195
|
+
file: filePath,
|
|
196
|
+
line,
|
|
197
|
+
issueType: "Hardcoded localhost URL",
|
|
198
|
+
severity: "critical",
|
|
199
|
+
message: `This ${source} is hardcoded to "${urlValue}". In production, your backend will be hosted at a different domain — "localhost" will never resolve to it, so this will fail with a connection error once deployed.`,
|
|
200
|
+
snippet: code.split("\n")[line - 1].trim(),
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
module.exports = { checkFile };
|
package/src/scanner.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
const fg = require("fast-glob");
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Walks the given repo root and returns a list of candidate JS/JSX files
|
|
5
|
+
* to scan for issues. Skips node_modules, build output, and hidden folders.
|
|
6
|
+
*
|
|
7
|
+
* @param {string} repoPath - absolute or relative path to the repo root
|
|
8
|
+
* @returns {Promise<string[]>} list of absolute file paths
|
|
9
|
+
*/
|
|
10
|
+
async function findCandidateFiles(repoPath) {
|
|
11
|
+
const patterns = ["**/*.js", "**/*.jsx"];
|
|
12
|
+
|
|
13
|
+
const files = await fg(patterns, {
|
|
14
|
+
cwd: repoPath,
|
|
15
|
+
absolute: true,
|
|
16
|
+
ignore: [
|
|
17
|
+
"**/node_modules/**",
|
|
18
|
+
"**/dist/**",
|
|
19
|
+
"**/build/**",
|
|
20
|
+
"**/.git/**",
|
|
21
|
+
],
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
return files;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
module.exports = { findCandidateFiles };
|