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 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 };