@hoanghainh/komagu 2.0.1 → 2.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/CHANGELOG.md +13 -0
- package/assess/lsa/knowledge/injection-patterns.md +8 -1
- package/assess/lsa/knowledge/stacks/nextjs.md +62 -0
- package/assess/lsa/prompts/_shared-rules.md +1 -0
- package/assess/lsa/scripts/detect_stack.py +5 -1
- package/assess/lsa/scripts/find_injection.py +34 -5
- package/assess/lsa/scripts/lsa_common.py +9 -2
- package/assess/lsa/scripts/make_batches.py +2 -1
- package/assess/lsa/scripts/map_routes.py +10 -2
- package/assess/lsa/scripts/nextjs_routes.py +236 -0
- package/assess/lsa/templates/audit-config.json +1 -1
- package/assess/lsa/tests/test_default_scope.py +86 -0
- package/assess/lsa/tests/test_js_tagged_sql.py +112 -0
- package/assess/lsa/tests/test_nextjs.py +198 -0
- package/operate/cli/__init__.py +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `@hoanghainh/komagu` (published as `komainu-guard` up to v1.7.0) are documented here. From v1.2.2 on, entries are generated by [release-please](https://github.com/googleapis/release-please) from [Conventional Commits](https://www.conventionalcommits.org/). Earlier entries are copied from the [GitHub Releases](https://github.com/hoanghainh1188/komainu/releases).
|
|
4
4
|
|
|
5
|
+
## [2.1.0](https://github.com/hoanghainh1188/komagu/compare/v2.0.1...v2.1.0) (2026-11-01)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
* **assess:** detect Next.js and map App Router routes and server actions ([#330](https://github.com/hoanghainh1188/komagu/issues/330)) ([d494891](https://github.com/hoanghainh1188/komagu/commit/d494891218d0f2c7961c325271daa82950d06923))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Bug Fixes
|
|
14
|
+
|
|
15
|
+
* **assess:** exclude framework build output from default scan scope ([#327](https://github.com/hoanghainh1188/komagu/issues/327)) ([317049e](https://github.com/hoanghainh1188/komagu/commit/317049ee4c50c0e6a64ba004d87f214c7299d646))
|
|
16
|
+
* **assess:** stop flagging parameterizing sql tagged templates as injection ([#329](https://github.com/hoanghainh1188/komagu/issues/329)) ([3456655](https://github.com/hoanghainh1188/komagu/commit/3456655c7bd62f7a68d6b8d3499d0b06268d7db8))
|
|
17
|
+
|
|
5
18
|
## [2.0.1](https://github.com/hoanghainh1188/komagu/compare/v2.0.0...v2.0.1) (2026-10-31)
|
|
6
19
|
|
|
7
20
|
|
|
@@ -57,7 +57,9 @@ Parameterization Cheat Sheet; CWE-89, CWE-564, CWE-943.
|
|
|
57
57
|
`selectall_arrayref`, `selectrow_*`.
|
|
58
58
|
- **ColdFusion**: `<cfquery>` body, `queryExecute("..." & x)`.
|
|
59
59
|
- **Node**: `connection.query`, `pool.query`, `sequelize.query`,
|
|
60
|
-
`knex.raw`, `db.collection(...).find/aggregate/$where
|
|
60
|
+
`knex.raw`, `db.collection(...).find/aggregate/$where`, Prisma
|
|
61
|
+
`$queryRawUnsafe` / `$executeRawUnsafe`, and raw fragments inside tagged
|
|
62
|
+
templates: Drizzle / postgres.js `sql.raw(x)`, `sql.unsafe(x)`, `Prisma.raw(x)`.
|
|
61
63
|
- **Python**: `cursor.execute`, `executemany`, `engine.execute`,
|
|
62
64
|
`Model.objects.raw`, `extra(where=...)`.
|
|
63
65
|
- **Ruby**: `find_by_sql`, `where("...#{}")`, `order(params[:sort])`,
|
|
@@ -80,6 +82,7 @@ Parameterization Cheat Sheet; CWE-89, CWE-564, CWE-943.
|
|
|
80
82
|
| Regex "no quotes" / blacklist (`select`, `union`) | No | Treat as unsanitized; mention in notes. |
|
|
81
83
|
| `magic_quotes_gpc` | No | Removed in PHP 5.4; never rely on it. |
|
|
82
84
|
| ORM entity methods (`find($id)`, `Where(x => x.Id == id)`) | Yes | Not raw. |
|
|
85
|
+
| JS tagged templates: Drizzle / postgres.js / Slonik `` sql`... ${x}` ``, `` Prisma.sql`...` ``, `` prisma.$queryRaw`...` `` | Yes | `${x}` is sent as a bound parameter. Not the same as an untagged template passed to `db.execute(...)` or `$queryRawUnsafe(...)`, and not `sql.raw(x)` / `Prisma.raw(x)` inside the template, which insert raw text. |
|
|
83
86
|
|
|
84
87
|
## 4. NoSQL specifics
|
|
85
88
|
|
|
@@ -115,6 +118,10 @@ A string that is built but **never sent to any sink** is not a finding: verdict
|
|
|
115
118
|
(`"IN (" + String.join(",", Collections.nCopies(n, "?")) + ")"`) — safe if
|
|
116
119
|
values are bound afterwards.
|
|
117
120
|
- Log or error messages that contain SQL keywords.
|
|
121
|
+
- `sql-raw-fragment` whose argument is a schema object or constant
|
|
122
|
+
(`sql.raw(`'${seq.seqName}'`)` with `seq = pgSequence("customer_code_seq")`)
|
|
123
|
+
— not attacker-controlled. The pre-scan marks these `priority: low` when no
|
|
124
|
+
request input is in the file.
|
|
118
125
|
- MyBatis `#{}` (safe) vs `${}` (unsafe) — do not confuse.
|
|
119
126
|
- Session-only values (`$_SESSION['uid']` set by the server at login) — not
|
|
120
127
|
attacker-controlled, but note if the session value itself came from an
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Next.js Knowledge Profile (App Router, Pages Router, Server Actions)
|
|
2
|
+
|
|
3
|
+
## Stack Identification
|
|
4
|
+
- **Markers:** `next.config.(js|mjs|ts)`, `"next"` in `package.json`, an `app/` or `src/app/` tree with
|
|
5
|
+
`route.ts` / `page.tsx`, a `pages/api/` tree, imports from `next/server`, `next/navigation`, `next/headers`.
|
|
6
|
+
- **Auth libraries:** Auth.js / NextAuth (`auth()`, `getServerSession`), Better Auth (`getSession`,
|
|
7
|
+
`getSessionCookie`), Clerk (`auth()`, `currentUser()`), Lucia, iron-session, or a project wrapper such as
|
|
8
|
+
`withAuth(handler, { permission })`.
|
|
9
|
+
- **Data access:** Drizzle (`db.query.x.findFirst`, `db.select().from(x).where(...)`), Prisma
|
|
10
|
+
(`prisma.x.findUnique`), postgres.js, `@vercel/postgres`, Mongoose.
|
|
11
|
+
|
|
12
|
+
## Entry points (what the pre-scan inventories)
|
|
13
|
+
| Kind | File | URL | Notes |
|
|
14
|
+
|---|---|---|---|
|
|
15
|
+
| Route handler | `app/**/route.ts` | folder path; `(group)` and `@slot` dropped, `[id]` → `{id}`, `[...x]` / `[[...x]]` → `{x*}` | One route per exported `GET`/`POST`/... function or `export const GET = wrap(...)`. `_folder` is private (not routed). |
|
|
16
|
+
| Pages API | `pages/api/**.ts` | `/api/...`, `index` dropped | One handler for all methods; method from `req.method` checks. |
|
|
17
|
+
| Server action | file starting with `"use server"`, or a function whose body starts with `"use server"` | `action:<file>#<name>` | **Every exported function is a public POST endpoint**, callable with any arguments, even if the UI never calls it that way. |
|
|
18
|
+
| Page | `app/**/page.tsx` with a dynamic segment or `searchParams` | folder path | A GET that renders data loaded by id. |
|
|
19
|
+
|
|
20
|
+
## Where the guard lives
|
|
21
|
+
1. **`middleware.ts` / `proxy.ts` (Next.js 16):** runs before routes listed in `config.matcher`. Usually only
|
|
22
|
+
checks that a session cookie exists; it rarely checks roles or ownership, and a matcher gap leaves routes
|
|
23
|
+
open. Never accept middleware alone as an ownership check.
|
|
24
|
+
2. **Per handler:** `const session = await auth(); if (!session) return 401`, or a wrapper (`withAuth`).
|
|
25
|
+
Check the wrapper once and reuse the result for every route that uses it.
|
|
26
|
+
3. **Server actions:** need their own check inside the action. Layout or page checks do **not** protect an
|
|
27
|
+
action, because the action can be called directly.
|
|
28
|
+
|
|
29
|
+
## BOLA / IDOR Patterns
|
|
30
|
+
- **Vulnerable:** id from `params` / `searchParams` / action argument / JSON body used without the user or
|
|
31
|
+
tenant in the query:
|
|
32
|
+
```ts
|
|
33
|
+
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
34
|
+
const { id } = await params;
|
|
35
|
+
const order = await db.query.orders.findFirst({ where: eq(orders.id, id) }); // any user's order
|
|
36
|
+
return Response.json(order);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
"use server";
|
|
40
|
+
export async function cancelOrder(orderId: number) {
|
|
41
|
+
const session = await auth(); // authenticated, but...
|
|
42
|
+
await db.update(orders).set({ status: "cancelled" }).where(eq(orders.id, orderId)); // ...not owner-scoped
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
- **Protected:** the session user or tenant is part of the lookup, or the loaded row is compared to it, and
|
|
46
|
+
a miss returns 404:
|
|
47
|
+
```ts
|
|
48
|
+
const order = await db.query.orders.findFirst({
|
|
49
|
+
where: and(eq(orders.id, id), eq(orders.tenantId, session.user.tenantId)),
|
|
50
|
+
});
|
|
51
|
+
if (!order) return new Response(null, { status: 404 });
|
|
52
|
+
```
|
|
53
|
+
- **Mass assignment:** `db.update(users).set(await req.json())` or spreading `formData` into `.set({...})`
|
|
54
|
+
lets a user write `role`, `tenantId`, `ownerId`.
|
|
55
|
+
- **BFLA:** admin routes guarded only by "is logged in" (middleware cookie check, `auth()` without a role or
|
|
56
|
+
permission check).
|
|
57
|
+
|
|
58
|
+
## SQL Injection Patterns
|
|
59
|
+
- **Safe:** Drizzle query builder, Drizzle / postgres.js / Prisma tagged templates (`` sql`... ${x}` ``,
|
|
60
|
+
`` prisma.$queryRaw`...` ``): `${x}` is a bound parameter.
|
|
61
|
+
- **Vulnerable:** `sql.raw(x)`, `sql.unsafe(x)`, `Prisma.raw(x)`, `$queryRawUnsafe(`...${x}`)`, or an untagged
|
|
62
|
+
template passed to `db.execute(...)`, with `x` from the request.
|
|
@@ -84,6 +84,7 @@ Batch files list the right files in `knowledge_files`. If they do not:
|
|
|
84
84
|
| `classic-asp` | `knowledge/stacks/classic-asp.md` |
|
|
85
85
|
| `perl` | `knowledge/stacks/perl-cgi.md` |
|
|
86
86
|
| `coldfusion` | `knowledge/stacks/coldfusion.md` |
|
|
87
|
+
| `node-nextjs` | `knowledge/stacks/nextjs.md` |
|
|
87
88
|
| `node`, `python`, `ruby` | `knowledge/stacks/node-python-ruby.md` |
|
|
88
89
|
| `sql-procedures` | `knowledge/injection-patterns.md` §1 (stored procedures) |
|
|
89
90
|
|
|
@@ -61,6 +61,8 @@ RULES = [
|
|
|
61
61
|
("go-gorilla", "go", "go.md", [("text", r"github\.com/gorilla/mux|mux\.NewRouter")]),
|
|
62
62
|
("python-fastapi", "python", "node-python-ruby.md", [("text", r"from\s+fastapi\s+import|import\s+fastapi|FastAPI\s*\(")]),
|
|
63
63
|
("node-nestjs", "typescript", "node-python-ruby.md", [("text", r"@nestjs/(common|core|swagger)")]),
|
|
64
|
+
("node-nextjs", "typescript", "nextjs.md", [("name", r"(^|/)next\.config\.(js|mjs|cjs|ts|mts)$"),
|
|
65
|
+
("text", r"from\s+['\"]next/(server|navigation|headers|cache)['\"]")]),
|
|
64
66
|
]
|
|
65
67
|
|
|
66
68
|
DATA_ACCESS = [
|
|
@@ -85,6 +87,8 @@ DATA_ACCESS = [
|
|
|
85
87
|
("SQLAlchemy (Python)", r"sqlalchemy\.|create_engine|SessionLocal"),
|
|
86
88
|
("Prisma (Node/TS)", r"PrismaClient|@prisma/client"),
|
|
87
89
|
("TypeORM (Node/TS)", r"typeorm|@Entity|getRepository"),
|
|
90
|
+
("Drizzle ORM (Node/TS)", r"drizzle-orm"),
|
|
91
|
+
("postgres.js (Node/TS)", r"from\s+['\"]postgres['\"]|require\(\s*['\"]postgres['\"]\s*\)"),
|
|
88
92
|
]
|
|
89
93
|
|
|
90
94
|
DB_HINTS = [
|
|
@@ -94,7 +98,7 @@ DB_HINTS = [
|
|
|
94
98
|
]
|
|
95
99
|
|
|
96
100
|
TEXT_SAMPLE_EXTS = {".sql", ".php", ".inc", ".java", ".jsp", ".xml", ".cs", ".vb", ".aspx", ".asax", ".config", ".asp",
|
|
97
|
-
".pl", ".pm", ".cgi", ".cfm", ".cfc", ".js", ".mjs", ".ts", ".py", ".rb", ".kt", ".kts",
|
|
101
|
+
".pl", ".pm", ".cgi", ".cfm", ".cfc", ".js", ".mjs", ".ts", ".tsx", ".jsx", ".py", ".rb", ".kt", ".kts",
|
|
98
102
|
".rs", ".go", ".toml", ".mod"}
|
|
99
103
|
|
|
100
104
|
|
|
@@ -28,7 +28,7 @@ LANG_BY_EXT = {
|
|
|
28
28
|
".asp": "vb", ".asa": "vb",
|
|
29
29
|
".pl": "perl", ".pm": "perl", ".cgi": "perl",
|
|
30
30
|
".cfm": "cfml", ".cfc": "cfml", ".cfml": "cfml",
|
|
31
|
-
".js": "js", ".mjs": "js", ".ts": "js", ".py": "python", ".rb": "ruby",
|
|
31
|
+
".js": "js", ".mjs": "js", ".cjs": "js", ".mts": "js", ".ts": "js", ".tsx": "js", ".jsx": "js", ".py": "python", ".rb": "ruby",
|
|
32
32
|
".rs": "rust", ".go": "go",
|
|
33
33
|
".xml": "xml", ".sql": "sql",
|
|
34
34
|
}
|
|
@@ -108,6 +108,28 @@ ASSIGN = {
|
|
|
108
108
|
"rust": re.compile(r"\b(\w+)\s*(\+=|=)(?!=)"), "go": re.compile(r"\b(\w+)\s*(\+=|=)(?!=)"),
|
|
109
109
|
}
|
|
110
110
|
APPEND_CALL = re.compile(r"\b(\w+)\s*\.\s*(append|Append|AppendLine|concat)\s*\(")
|
|
111
|
+
# JS/TS tagged templates that bind ${...} as parameters: Drizzle / postgres.js / Slonik `sql`, Prisma.sql, $queryRaw
|
|
112
|
+
SQL_TAG = re.compile(r"(?:(?<![\w$])(?:[\w$]+\.)*sql|\$queryRaw|\$executeRaw)\s*(?:<[^`]*>)?\s*$")
|
|
113
|
+
# escape hatches that put raw text into a tagged template
|
|
114
|
+
RAW_FRAGMENT = re.compile(r"\b(?:sql\.raw|sql\.unsafe|Prisma\.raw)\s*\(")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def untagged(line: str, lits: List[re.Match], lang: str) -> List[re.Match]:
|
|
118
|
+
"""Drop JS template literals whose tag binds interpolations as query parameters."""
|
|
119
|
+
if lang != "js":
|
|
120
|
+
return lits
|
|
121
|
+
return [m for m in lits if not (m.group(0).startswith("`") and SQL_TAG.search(line[:m.start()]))]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def raw_fragment_arg_is_constant(arg: str) -> bool:
|
|
125
|
+
"""True when sql.raw(...) gets a plain string literal (no ${...})."""
|
|
126
|
+
arg = arg.lstrip()
|
|
127
|
+
if arg[:1] in ("'", '"'):
|
|
128
|
+
return True
|
|
129
|
+
if arg[:1] == "`":
|
|
130
|
+
end = arg.find("`", 1)
|
|
131
|
+
return end != -1 and "${" not in arg[1:end]
|
|
132
|
+
return False
|
|
111
133
|
|
|
112
134
|
|
|
113
135
|
def strip_literals(line: str, lit: re.Pattern) -> str:
|
|
@@ -188,7 +210,8 @@ class Finder:
|
|
|
188
210
|
self.seen = set()
|
|
189
211
|
self.suppressed: List[Dict] = []
|
|
190
212
|
|
|
191
|
-
def add(self, lang, rel, lineno, lines, rule, subtype, category, sql_var=None, file_has_input=False
|
|
213
|
+
def add(self, lang, rel, lineno, lines, rule, subtype, category, sql_var=None, file_has_input=False,
|
|
214
|
+
low_without_input=False):
|
|
192
215
|
key = (rel, lineno)
|
|
193
216
|
if key in self.seen:
|
|
194
217
|
return
|
|
@@ -209,7 +232,8 @@ class Finder:
|
|
|
209
232
|
"file": rel, "line": lineno, "snippet": redact(snippet(lines, lineno)),
|
|
210
233
|
"sql_var": sql_var, "input_hint": hint,
|
|
211
234
|
"sanitizer_hints": sorted(set(m.group(0)[:30] for m in SANITIZER_RE.finditer(ctx)))[:5],
|
|
212
|
-
"priority": "high" if hint in ("request_input_on_line", "request_input_nearby")
|
|
235
|
+
"priority": "high" if hint in ("request_input_on_line", "request_input_nearby")
|
|
236
|
+
else "low" if low_without_input and hint == "none" else "normal",
|
|
213
237
|
})
|
|
214
238
|
|
|
215
239
|
# ----------------------------------------------------------------------------------
|
|
@@ -224,7 +248,7 @@ class Finder:
|
|
|
224
248
|
for i, line in enumerate(lines, 1):
|
|
225
249
|
if com and com.search(line):
|
|
226
250
|
continue
|
|
227
|
-
lits = literals(line, lit)
|
|
251
|
+
lits = untagged(line, literals(line, lit), lang)
|
|
228
252
|
if not any(STRONG_SQL.search(m.group(0)) for m in lits):
|
|
229
253
|
continue
|
|
230
254
|
if assign_rx:
|
|
@@ -258,7 +282,7 @@ class Finder:
|
|
|
258
282
|
if heredoc_sql and INTERP[lang].search(line):
|
|
259
283
|
self.add(lang, rel, i, lines, "heredoc-interpolation", "string_interpolation", "sql", None, file_has_input)
|
|
260
284
|
continue
|
|
261
|
-
lits = literals(line, lit)
|
|
285
|
+
lits = untagged(line, literals(line, lit), lang)
|
|
262
286
|
sql_lits = [m for m in lits if STRONG_SQL.search(m.group(0))]
|
|
263
287
|
target_var = None
|
|
264
288
|
if assign_rx:
|
|
@@ -300,6 +324,11 @@ class Finder:
|
|
|
300
324
|
for i, line in enumerate(lines, 1):
|
|
301
325
|
if com and com.search(line):
|
|
302
326
|
continue
|
|
327
|
+
if lang == "js":
|
|
328
|
+
fm = RAW_FRAGMENT.search(line)
|
|
329
|
+
if fm and not raw_fragment_arg_is_constant(line[fm.end():]):
|
|
330
|
+
self.add(lang, rel, i, lines, "sql-raw-fragment", "raw_fragment", "sql", None, file_has_input,
|
|
331
|
+
low_without_input=True)
|
|
303
332
|
rm = RAW_APIS.search(line)
|
|
304
333
|
if rm:
|
|
305
334
|
arg = line[rm.end():].strip()
|
|
@@ -15,7 +15,14 @@ DEFAULT_CONFIG: Dict = {
|
|
|
15
15
|
"include": [],
|
|
16
16
|
"exclude": ["vendor/", "node_modules/", "bower_components/", "packages/", "bin/", "obj/",
|
|
17
17
|
"target/", "build/", "dist/", ".git/", ".svn/", ".hg/", "test/", "tests/", "spec/", "docs/",
|
|
18
|
-
|
|
18
|
+
# framework build output and caches
|
|
19
|
+
".next/", ".nuxt/", ".output/", ".svelte-kit/", ".vercel/", ".netlify/", ".turbo/",
|
|
20
|
+
".parcel-cache/", ".cache/", "out/", "storybook-static/",
|
|
21
|
+
# test and coverage output
|
|
22
|
+
"coverage/", "test-results/", "playwright-report/", ".nyc_output/",
|
|
23
|
+
# Python environments and caches
|
|
24
|
+
"__pycache__/", ".venv/", "venv/", ".tox/", ".mypy_cache/", ".pytest_cache/",
|
|
25
|
+
"*.min.js", "*jquery*.js", "*.map"],
|
|
19
26
|
"web_roots": [],
|
|
20
27
|
"file_endpoints": "all",
|
|
21
28
|
"max_file_kb": 1024,
|
|
@@ -41,7 +48,7 @@ SOURCE_EXTS = {
|
|
|
41
48
|
".asp", ".asa",
|
|
42
49
|
".pl", ".pm", ".cgi",
|
|
43
50
|
".cfm", ".cfc", ".cfml",
|
|
44
|
-
".js", ".mjs", ".ts", ".py", ".rb", ".erb", ".sql",
|
|
51
|
+
".js", ".mjs", ".cjs", ".mts", ".ts", ".tsx", ".jsx", ".py", ".rb", ".erb", ".sql",
|
|
45
52
|
".rs", ".go", ".toml", ".mod",
|
|
46
53
|
}
|
|
47
54
|
|
|
@@ -22,7 +22,8 @@ sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
|
22
22
|
from lsa_common import load_config, read_json, write_json # noqa: E402
|
|
23
23
|
|
|
24
24
|
STACK_KNOWLEDGE = [("php", "php.md"), ("java", "java.md"), ("dotnet", "dotnet.md"), ("classic-asp", "classic-asp.md"),
|
|
25
|
-
("perl", "perl-cgi.md"), ("coldfusion", "coldfusion.md"), ("node", "
|
|
25
|
+
("perl", "perl-cgi.md"), ("coldfusion", "coldfusion.md"), ("node-nextjs", "nextjs.md"),
|
|
26
|
+
("node", "node-python-ruby.md"),
|
|
26
27
|
("python", "node-python-ruby.md"), ("ruby", "node-python-ruby.md")]
|
|
27
28
|
LANG_KNOWLEDGE = {"php": "php.md", "java": "java.md", "xml": "java.md", "cs": "dotnet.md", "vb": "dotnet.md",
|
|
28
29
|
"aspx": "dotnet.md", "perl": "perl-cgi.md", "cfml": "coldfusion.md", "js": "node-python-ruby.md",
|
|
@@ -21,6 +21,7 @@ from typing import Dict, List, Optional, Tuple
|
|
|
21
21
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
22
22
|
from lsa_common import (KIT_VERSION, compile_id_patterns, is_id_name, iter_source_files, # noqa: E402
|
|
23
23
|
line_of, load_config, read_text, write_json)
|
|
24
|
+
from nextjs_routes import middleware_note, nextjs_declared # noqa: E402
|
|
24
25
|
from privilege import annotate_privilege # noqa: E402
|
|
25
26
|
|
|
26
27
|
# --------------------------------------------------------------------------------------
|
|
@@ -138,7 +139,7 @@ LANG_BY_EXT = {
|
|
|
138
139
|
".asp": "asp", ".asa": "asp",
|
|
139
140
|
".pl": "perl", ".pm": "perl", ".cgi": "perl",
|
|
140
141
|
".cfm": "cfml", ".cfc": "cfml", ".cfml": "cfml",
|
|
141
|
-
".js": "js", ".mjs": "js", ".ts": "js", ".py": "python", ".rb": "ruby",
|
|
142
|
+
".js": "js", ".mjs": "js", ".cjs": "js", ".mts": "js", ".ts": "js", ".tsx": "js", ".jsx": "js", ".py": "python", ".rb": "ruby",
|
|
142
143
|
".rs": "rust", ".go": "go",
|
|
143
144
|
}
|
|
144
145
|
|
|
@@ -764,7 +765,9 @@ def _scan_route_file_batch(args: Tuple[List[Tuple[str, str]], Dict, set, str]) -
|
|
|
764
765
|
declared = dotnet_declared(c, rel, t)
|
|
765
766
|
elif lang == "cfml" and ext == ".cfc":
|
|
766
767
|
declared = cfml_declared(c, rel, t)
|
|
767
|
-
elif lang
|
|
768
|
+
elif lang == "js":
|
|
769
|
+
declared = nextjs_declared(c, rel, t) or js_py_rb_declared(c, rel, t, lang)
|
|
770
|
+
elif lang in ("python", "ruby"):
|
|
768
771
|
declared = js_py_rb_declared(c, rel, t, lang)
|
|
769
772
|
elif lang == "rust":
|
|
770
773
|
declared = rust_declared(c, rel, t)
|
|
@@ -881,6 +884,11 @@ def map_routes(target: Path, config: Dict, stack_ids: Optional[set] = None, work
|
|
|
881
884
|
for n, it in enumerate(keep, 1):
|
|
882
885
|
it["route_id"] = f"R-{n:04d}"
|
|
883
886
|
c.items = keep
|
|
887
|
+
mw_note = middleware_note(target, [rel for _, rel in files], enc) \
|
|
888
|
+
if any(it["stack"] == "node-nextjs" for it in c.items) else None
|
|
889
|
+
for it in c.items:
|
|
890
|
+
if mw_note and it["stack"] == "node-nextjs":
|
|
891
|
+
it["notes"] = f"{it['notes']}; {mw_note}" if it["notes"] else mw_note
|
|
884
892
|
for it in c.items:
|
|
885
893
|
it["needs_audit_hint"] = bool(any(i.get("is_id_like") for i in it["id_params"]) or it["kind"] == "declared_route")
|
|
886
894
|
c.items = annotate_privilege(c.items, target, config)
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Next.js route inventory for map_routes.py. Python 3.8+, standard library only.
|
|
2
|
+
|
|
3
|
+
Covers App Router route handlers (app/**/route.ts), dynamic server-component pages
|
|
4
|
+
(app/**/page.tsx), Pages Router API routes (pages/api/**), and server actions
|
|
5
|
+
(files that start with "use server", and inline "use server" functions).
|
|
6
|
+
The file path is the URL, so routes come from folder names, not from declarations.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Dict, List, Optional
|
|
13
|
+
|
|
14
|
+
from lsa_common import is_id_name, line_of, read_text
|
|
15
|
+
|
|
16
|
+
JS_EXT = r"(?:[cm]?[jt]s|[jt]sx)"
|
|
17
|
+
APP_FILE = re.compile(r"(?:^|/)app/(?:(.+)/)?(route|page)\." + JS_EXT + "$")
|
|
18
|
+
PAGES_API = re.compile(r"(?:^|/)pages/(api(?:/.+)?)\." + JS_EXT + "$")
|
|
19
|
+
METHODS = "GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS"
|
|
20
|
+
HANDLER_DECL = re.compile(r"^export\s+(?:async\s+)?function\s+(" + METHODS + r")\b|^export\s+const\s+(" + METHODS + r")\s*=", re.M)
|
|
21
|
+
HANDLER_REEXPORT = re.compile(r"\b\w+\s+as\s+(" + METHODS + r")\b")
|
|
22
|
+
TOP_LEVEL = re.compile(r"^(?:export|function|async|const|let|var|class)\b", re.M)
|
|
23
|
+
ACTION_EXPORT = re.compile(r"^export\s+(?:default\s+)?async\s+function\s+(\w+)\s*\(|^export\s+const\s+(\w+)\s*=\s*async\s*\(", re.M)
|
|
24
|
+
INLINE_ACTION = re.compile(r"\basync\s+function\s+(\w+)\s*\(|\b(?:const|let)\s+(\w+)\s*=\s*async\s*\(")
|
|
25
|
+
USE_SERVER_STMT = re.compile(r"\s*['\"]use server['\"]")
|
|
26
|
+
REQ_METHOD = re.compile(r"req\.method\s*===?\s*['\"](\w+)['\"]|case\s+['\"](" + METHODS + r")['\"]\s*:")
|
|
27
|
+
MIDDLEWARE_FILE = re.compile(r"^(?:src/)?(middleware|proxy)\." + JS_EXT + "$")
|
|
28
|
+
MATCHER = re.compile(r"matcher\s*:\s*(\[[^\]]*\]|['\"][^'\"]*['\"])")
|
|
29
|
+
|
|
30
|
+
QUERY_INPUT = re.compile(r"searchParams\??\.get(?:All)?\(\s*['\"]([\w-]+)['\"]")
|
|
31
|
+
FORM_INPUT = re.compile(r"\b\w*[fF]orm\w*\??\.get(?:All)?\(\s*['\"]([\w-]+)['\"]")
|
|
32
|
+
JSON_DESTRUCT = re.compile(r"\{([^{}]*)\}\s*=\s*(?:await\s+)?[\w.]+\.(?:json|formData)\(\)")
|
|
33
|
+
DESTRUCT_NAME = re.compile(r"(?:^|,)\s*(?:\.\.\.)?(\w+)")
|
|
34
|
+
|
|
35
|
+
GUARD_HINTS = [re.compile(p) for p in [
|
|
36
|
+
r"\bauth\s*\(\s*\)", r"\bgetServerSession\s*\(", r"\bget(?:Session|User|Auth|CurrentUser)\s*\(", r"\bcurrentUser\s*\(",
|
|
37
|
+
r"\b(?:with|require|ensure|assert)\w*(?:Auth|User|Session|Role|Permission|Admin)\w*\s*\(",
|
|
38
|
+
r"\bpermission\s*:\s*['\"][^'\"]+['\"]", r"\bhas(?:Role|Permission)\s*\(",
|
|
39
|
+
r"redirect\s*\(\s*['\"]/(?:login|signin|sign-in)",
|
|
40
|
+
]]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def route_path(folder: Optional[str], prefix: str = "") -> Optional[str]:
|
|
44
|
+
"""URL path for an app/ or pages/ folder; None for private (_folder) segments."""
|
|
45
|
+
parts = [prefix] if prefix else []
|
|
46
|
+
for seg in (folder or "").split("/"):
|
|
47
|
+
if not seg or seg.startswith("@") or (seg.startswith("(") and seg.endswith(")")):
|
|
48
|
+
continue # route group or parallel-route slot: not part of the URL
|
|
49
|
+
if seg.startswith("_"):
|
|
50
|
+
return None
|
|
51
|
+
seg = re.sub(r"^(?:\(\.+\))+", "", seg) # interception marker (.) (..) (...)
|
|
52
|
+
catch = re.fullmatch(r"\[\[?\.\.\.(\w+)\]\]?", seg)
|
|
53
|
+
dyn = re.fullmatch(r"\[(\w+)\]", seg)
|
|
54
|
+
parts.append("{" + catch.group(1) + "*}" if catch else "{" + dyn.group(1) + "}" if dyn else seg)
|
|
55
|
+
return "/" + "/".join(p for p in parts if p)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _close(text: str, idx: int, open_ch: str, close_ch: str) -> int:
|
|
59
|
+
"""Index of the bracket that closes text[idx] (or len(text))."""
|
|
60
|
+
depth = 0
|
|
61
|
+
for j in range(idx, len(text)):
|
|
62
|
+
if text[j] == open_ch:
|
|
63
|
+
depth += 1
|
|
64
|
+
elif text[j] == close_ch:
|
|
65
|
+
depth -= 1
|
|
66
|
+
if depth == 0:
|
|
67
|
+
return j
|
|
68
|
+
return len(text)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _starts_with_use_server(text: str) -> bool:
|
|
72
|
+
"""True when the first statement of the file is the "use server" directive (comments may come first)."""
|
|
73
|
+
i, n = 0, len(text)
|
|
74
|
+
while i < n:
|
|
75
|
+
if text[i].isspace():
|
|
76
|
+
i += 1
|
|
77
|
+
elif text.startswith("//", i):
|
|
78
|
+
nl = text.find("\n", i)
|
|
79
|
+
i = n if nl == -1 else nl + 1
|
|
80
|
+
elif text.startswith("/*", i):
|
|
81
|
+
end = text.find("*/", i + 2)
|
|
82
|
+
i = n if end == -1 else end + 2
|
|
83
|
+
else:
|
|
84
|
+
break
|
|
85
|
+
return bool(USE_SERVER_STMT.match(text, i))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _segment_end(text: str, start: int) -> int:
|
|
89
|
+
nxt = TOP_LEVEL.search(text, text.find("\n", start) + 1 if "\n" in text[start:] else len(text))
|
|
90
|
+
return nxt.start() if nxt else len(text)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _signature_params(sig: str) -> List[str]:
|
|
94
|
+
"""Parameter names of an action signature; FormData parameters are read through .get() instead."""
|
|
95
|
+
names: List[str] = []
|
|
96
|
+
depth, cur, parts = 0, "", []
|
|
97
|
+
for ch in sig:
|
|
98
|
+
depth += ch in "({[<"
|
|
99
|
+
depth -= ch in ")}]>"
|
|
100
|
+
if ch == "," and depth == 0:
|
|
101
|
+
parts.append(cur)
|
|
102
|
+
cur = ""
|
|
103
|
+
else:
|
|
104
|
+
cur += ch
|
|
105
|
+
parts.append(cur)
|
|
106
|
+
for part in (p.strip() for p in parts):
|
|
107
|
+
if not part or re.search(r":\s*FormData\b", part):
|
|
108
|
+
continue
|
|
109
|
+
if part.startswith("{"):
|
|
110
|
+
names += DESTRUCT_NAME.findall(part[1:_close(part, 0, "{", "}")])
|
|
111
|
+
else:
|
|
112
|
+
m = re.match(r"(?:\.\.\.)?(\w+)", part)
|
|
113
|
+
if m:
|
|
114
|
+
names.append(m.group(1))
|
|
115
|
+
return names
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class NextRoutes:
|
|
119
|
+
def __init__(self, c, rel: str, text: str):
|
|
120
|
+
self.c, self.rel, self.text = c, rel, text
|
|
121
|
+
self.found = False
|
|
122
|
+
|
|
123
|
+
def _param(self, name: str, source: str, line: Optional[int]) -> Dict:
|
|
124
|
+
return {"name": name, "source": source, "line": line, "is_id_like": is_id_name(name, self.c.idp)}
|
|
125
|
+
|
|
126
|
+
def inputs(self, path: str, body: str, base_line: int, args: Optional[List[str]] = None) -> List[Dict]:
|
|
127
|
+
out = list(self.c.path_params(path)) if path.startswith("/") else []
|
|
128
|
+
out += [self._param(n, "body", base_line) for n in args or []]
|
|
129
|
+
for rx, src in ((QUERY_INPUT, "query"), (FORM_INPUT, "form")):
|
|
130
|
+
out += [self._param(m.group(1), src, base_line + line_of(body, m.start()) - 1) for m in rx.finditer(body)]
|
|
131
|
+
for m in JSON_DESTRUCT.finditer(body):
|
|
132
|
+
line = base_line + line_of(body, m.start()) - 1
|
|
133
|
+
out += [self._param(n, "body", line) for n in DESTRUCT_NAME.findall(m.group(1))]
|
|
134
|
+
out += self.c.inputs("js", body, base_line)
|
|
135
|
+
seen, dedup = set(), []
|
|
136
|
+
for p in out:
|
|
137
|
+
if p["name"] not in seen:
|
|
138
|
+
seen.add(p["name"])
|
|
139
|
+
dedup.append(p)
|
|
140
|
+
return dedup
|
|
141
|
+
|
|
142
|
+
def guards(self, body: str, base_line: int) -> List[Dict]:
|
|
143
|
+
out = self.c.guards(body, base_line)
|
|
144
|
+
for rx in GUARD_HINTS:
|
|
145
|
+
m = rx.search(body)
|
|
146
|
+
if m:
|
|
147
|
+
out.append({"hint": m.group(0)[:80], "line": base_line + line_of(body, m.start()) - 1})
|
|
148
|
+
seen, dedup = set(), []
|
|
149
|
+
for g in sorted(out, key=lambda g: g["line"]):
|
|
150
|
+
if (g["hint"], g["line"]) not in seen:
|
|
151
|
+
seen.add((g["hint"], g["line"]))
|
|
152
|
+
dedup.append(g)
|
|
153
|
+
return dedup[:8]
|
|
154
|
+
|
|
155
|
+
def add(self, kind: str, method: List[str], path: str, start: int, end: int, notes: str,
|
|
156
|
+
symbol: Optional[str] = None, args: Optional[List[str]] = None) -> None:
|
|
157
|
+
body = self.text[start:end]
|
|
158
|
+
line = line_of(self.text, start)
|
|
159
|
+
self.c.add(stack="node-nextjs", kind=kind, method=method, path=path,
|
|
160
|
+
handler={"file": self.rel, "line": line, "symbol": symbol},
|
|
161
|
+
id_params=self.inputs(path, body, line, args), guard_hints=self.guards(body, line),
|
|
162
|
+
data_hints=self.c.data_ops(body, line), notes=notes)
|
|
163
|
+
self.found = True
|
|
164
|
+
|
|
165
|
+
def route_handlers(self, path: str) -> None:
|
|
166
|
+
decls = list(HANDLER_DECL.finditer(self.text))
|
|
167
|
+
for i, m in enumerate(decls):
|
|
168
|
+
end = decls[i + 1].start() if i + 1 < len(decls) else len(self.text)
|
|
169
|
+
self.add("declared_route", [m.group(1) or m.group(2)], path, m.start(), end, "next.js route handler",
|
|
170
|
+
symbol=m.group(1) or m.group(2))
|
|
171
|
+
if not decls:
|
|
172
|
+
for m in HANDLER_REEXPORT.finditer(self.text):
|
|
173
|
+
self.add("declared_route", [m.group(1)], path, 0, len(self.text), "next.js route handler (re-exported)",
|
|
174
|
+
symbol=m.group(1))
|
|
175
|
+
|
|
176
|
+
def page(self, path: str) -> None:
|
|
177
|
+
if "{" in path or QUERY_INPUT.search(self.text) or "searchParams" in self.text:
|
|
178
|
+
self.add("file_endpoint", ["GET"], path, 0, len(self.text),
|
|
179
|
+
"next.js server component page: check data loaded for params/searchParams")
|
|
180
|
+
|
|
181
|
+
def pages_api(self, folder: str) -> None:
|
|
182
|
+
folder = re.sub(r"(?:^|/)index$", "", folder) or "api"
|
|
183
|
+
path = route_path(folder)
|
|
184
|
+
if path is None:
|
|
185
|
+
return
|
|
186
|
+
methods = sorted({(m.group(1) or m.group(2)).upper() for m in REQ_METHOD.finditer(self.text)}) or ["ANY"]
|
|
187
|
+
self.add("declared_route", methods, path, 0, len(self.text), "next.js pages/api route")
|
|
188
|
+
|
|
189
|
+
def server_actions(self) -> None:
|
|
190
|
+
if _starts_with_use_server(self.text):
|
|
191
|
+
for m in ACTION_EXPORT.finditer(self.text):
|
|
192
|
+
name = m.group(1) or m.group(2)
|
|
193
|
+
paren = m.end() - 1
|
|
194
|
+
args = _signature_params(self.text[paren + 1:_close(self.text, paren, "(", ")")])
|
|
195
|
+
self.add("declared_route", ["POST"], f"action:{self.rel}#{name}", m.start(),
|
|
196
|
+
_segment_end(self.text, m.start()), "next.js server action (callable by any client with its id)",
|
|
197
|
+
symbol=name, args=args)
|
|
198
|
+
return
|
|
199
|
+
for m in INLINE_ACTION.finditer(self.text):
|
|
200
|
+
paren = m.end() - 1
|
|
201
|
+
close = _close(self.text, paren, "(", ")")
|
|
202
|
+
brace = self.text.find("{", close)
|
|
203
|
+
if brace == -1 or not USE_SERVER_STMT.match(self.text, brace + 1):
|
|
204
|
+
continue
|
|
205
|
+
name = m.group(1) or m.group(2)
|
|
206
|
+
self.add("declared_route", ["POST"], f"action:{self.rel}#{name}", m.start(),
|
|
207
|
+
_close(self.text, brace, "{", "}") + 1, "next.js inline server action (closure values are sent to the client encrypted; arguments are not trusted)",
|
|
208
|
+
symbol=name, args=_signature_params(self.text[paren + 1:close]))
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def nextjs_declared(c, rel: str, text: str) -> bool:
|
|
212
|
+
"""Add Next.js routes found in one JS/TS file. Returns True when the file is a Next.js entry point."""
|
|
213
|
+
r = NextRoutes(c, rel, text)
|
|
214
|
+
am = APP_FILE.search(rel)
|
|
215
|
+
if am:
|
|
216
|
+
path = route_path(am.group(1))
|
|
217
|
+
if path is not None:
|
|
218
|
+
if am.group(2) == "route":
|
|
219
|
+
r.route_handlers(path)
|
|
220
|
+
else:
|
|
221
|
+
r.page(path)
|
|
222
|
+
pm = PAGES_API.search(rel)
|
|
223
|
+
if pm:
|
|
224
|
+
r.pages_api(pm.group(1))
|
|
225
|
+
r.server_actions()
|
|
226
|
+
return r.found or bool(am or pm)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def middleware_note(target: Path, rels: List[str], enc) -> Optional[str]:
|
|
230
|
+
"""Describe middleware.ts / proxy.ts (Next.js 16) so the auditor checks it covers each route."""
|
|
231
|
+
for rel in rels:
|
|
232
|
+
if MIDDLEWARE_FILE.match(rel):
|
|
233
|
+
m = MATCHER.search(read_text(target / rel, enc))
|
|
234
|
+
scope = f"matcher: {m.group(1)[:120]}" if m else "no matcher: runs on every route"
|
|
235
|
+
return f"{rel} runs before this route ({scope}); confirm it enforces auth here"
|
|
236
|
+
return None
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
},
|
|
9
9
|
"scope": {
|
|
10
10
|
"include": [],
|
|
11
|
-
"exclude": ["vendor/", "node_modules/", "bower_components/", "packages/", "bin/", "obj/", "target/", "build/", "dist/", ".git/", "test/", "tests/", "spec/", "docs/", "*.min.js", "*jquery*.js"],
|
|
11
|
+
"exclude": ["vendor/", "node_modules/", "bower_components/", "packages/", "bin/", "obj/", "target/", "build/", "dist/", ".git/", ".svn/", ".hg/", "test/", "tests/", "spec/", "docs/", ".next/", ".nuxt/", ".output/", ".svelte-kit/", ".vercel/", ".netlify/", ".turbo/", ".parcel-cache/", ".cache/", "out/", "storybook-static/", "coverage/", "test-results/", "playwright-report/", ".nyc_output/", "__pycache__/", ".venv/", "venv/", ".tox/", ".mypy_cache/", ".pytest_cache/", "*.min.js", "*jquery*.js", "*.map"],
|
|
12
12
|
"web_roots": [],
|
|
13
13
|
"_file_endpoints": "all = every reachable page file is a candidate (recommended); with_inputs = only pages with request input or data access",
|
|
14
14
|
"file_endpoints": "all",
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Default scan scope skips framework build output, caches and source maps (Issue #325)."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
import unittest
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
REPO_ROOT = Path(__file__).resolve().parents[3]
|
|
13
|
+
LSA_SCRIPTS = REPO_ROOT / "assess" / "lsa" / "scripts"
|
|
14
|
+
sys.path.insert(0, str(LSA_SCRIPTS))
|
|
15
|
+
|
|
16
|
+
from find_injection import find_injection # noqa: E402
|
|
17
|
+
from lsa_common import DEFAULT_CONFIG, deep_merge, iter_source_files, load_config # noqa: E402
|
|
18
|
+
|
|
19
|
+
TEMPLATE = REPO_ROOT / "assess" / "lsa" / "templates" / "audit-config.json"
|
|
20
|
+
|
|
21
|
+
VULN_JS = (
|
|
22
|
+
"const id = req.query.id;\n"
|
|
23
|
+
"db.query(`SELECT * FROM orders WHERE id = ${id}`);\n"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
GENERATED = [
|
|
27
|
+
".next/server/chunks/x.js",
|
|
28
|
+
".next/dev/server/chunks/y.js",
|
|
29
|
+
".nuxt/dist/server/a.js",
|
|
30
|
+
".output/server/index.mjs",
|
|
31
|
+
".svelte-kit/output/server/b.js",
|
|
32
|
+
".vercel/output/functions/c.js",
|
|
33
|
+
".turbo/cache/d.js",
|
|
34
|
+
"out/_next/e.js",
|
|
35
|
+
"coverage/lcov-report/f.js",
|
|
36
|
+
"test-results/g.js",
|
|
37
|
+
"playwright-report/h.js",
|
|
38
|
+
"storybook-static/i.js",
|
|
39
|
+
".venv/lib/site.py",
|
|
40
|
+
"__pycache__/m.py",
|
|
41
|
+
"src/app/page.js.map",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _write(root: Path, rel: str, text: str) -> None:
|
|
46
|
+
p = root / rel
|
|
47
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
p.write_text(text, encoding="utf-8")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class TestDefaultScope(unittest.TestCase):
|
|
52
|
+
def _repo(self, td: str) -> Path:
|
|
53
|
+
root = Path(td)
|
|
54
|
+
for rel in GENERATED:
|
|
55
|
+
_write(root, rel, VULN_JS)
|
|
56
|
+
_write(root, "src/lib/orders.js", VULN_JS)
|
|
57
|
+
return root
|
|
58
|
+
|
|
59
|
+
def test_generated_output_not_scanned(self) -> None:
|
|
60
|
+
with tempfile.TemporaryDirectory() as td:
|
|
61
|
+
root = self._repo(td)
|
|
62
|
+
rels = {rel for _, rel in iter_source_files(root, load_config(None))}
|
|
63
|
+
self.assertEqual(rels, {"src/lib/orders.js"})
|
|
64
|
+
|
|
65
|
+
def test_injection_candidates_only_from_source(self) -> None:
|
|
66
|
+
with tempfile.TemporaryDirectory() as td:
|
|
67
|
+
root = self._repo(td)
|
|
68
|
+
res = find_injection(root, load_config(None))
|
|
69
|
+
files = {c["file"] for c in res["candidates"]}
|
|
70
|
+
self.assertEqual(files, {"src/lib/orders.js"})
|
|
71
|
+
|
|
72
|
+
def test_user_exclude_override_brings_folder_back(self) -> None:
|
|
73
|
+
with tempfile.TemporaryDirectory() as td:
|
|
74
|
+
root = self._repo(td)
|
|
75
|
+
excludes = [e for e in DEFAULT_CONFIG["scope"]["exclude"] if e != ".next/"]
|
|
76
|
+
cfg = deep_merge(DEFAULT_CONFIG, {"scope": {"exclude": excludes}})
|
|
77
|
+
rels = {rel for _, rel in iter_source_files(root, cfg)}
|
|
78
|
+
self.assertIn(".next/server/chunks/x.js", rels)
|
|
79
|
+
|
|
80
|
+
def test_template_matches_default(self) -> None:
|
|
81
|
+
template = json.loads(TEMPLATE.read_text(encoding="utf-8"))
|
|
82
|
+
self.assertEqual(template["scope"]["exclude"], DEFAULT_CONFIG["scope"]["exclude"])
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
if __name__ == "__main__":
|
|
86
|
+
unittest.main()
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Parameterizing JS/TS sql tagged templates are not injection candidates (Issue #326).
|
|
3
|
+
|
|
4
|
+
Drizzle, postgres.js, @vercel/postgres, Slonik and Prisma ($queryRaw, Prisma.sql) send ${...}
|
|
5
|
+
inside a tagged template as a bound parameter. Raw-text escape hatches (sql.raw, Prisma.raw,
|
|
6
|
+
sql.unsafe, $queryRawUnsafe) and untagged templates are still flagged.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import sys
|
|
12
|
+
import tempfile
|
|
13
|
+
import unittest
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Dict, List
|
|
16
|
+
|
|
17
|
+
SCRIPTS_DIR = Path(__file__).resolve().parents[1] / "scripts"
|
|
18
|
+
if str(SCRIPTS_DIR) not in sys.path:
|
|
19
|
+
sys.path.insert(0, str(SCRIPTS_DIR))
|
|
20
|
+
|
|
21
|
+
from detect_stack import detect # noqa: E402
|
|
22
|
+
from find_injection import find_injection # noqa: E402
|
|
23
|
+
from lsa_common import load_config # noqa: E402
|
|
24
|
+
|
|
25
|
+
SAFE_TAGGED = '''\
|
|
26
|
+
import { sql } from "drizzle-orm";
|
|
27
|
+
import { Prisma } from "@prisma/client";
|
|
28
|
+
|
|
29
|
+
export async function counts(db, prisma, orderId, req) {
|
|
30
|
+
const id = req.query.id;
|
|
31
|
+
const lineCount = sql<number>`(select count(*)::int from ${orderLine} where ${orderLine.orderId} = ${orderId})`;
|
|
32
|
+
const rows = await db.execute(sql`select * from orders where id = ${id}`);
|
|
33
|
+
const p = await prisma.$queryRaw`SELECT * FROM users WHERE id = ${id}`;
|
|
34
|
+
await prisma.$executeRaw`UPDATE users SET name = ${req.body.name} WHERE id = ${id}`;
|
|
35
|
+
const frag = Prisma.sql`SELECT * FROM users WHERE email = ${req.body.email}`;
|
|
36
|
+
const pg = await pool.sql`select * from items where owner_id = ${id}`;
|
|
37
|
+
return [lineCount, rows, p, frag, pg];
|
|
38
|
+
}
|
|
39
|
+
'''
|
|
40
|
+
|
|
41
|
+
UNSAFE = '''\
|
|
42
|
+
import { sql } from "drizzle-orm";
|
|
43
|
+
|
|
44
|
+
export async function unsafe(db, prisma, req) {
|
|
45
|
+
const id = req.query.id;
|
|
46
|
+
const a = await db.execute(`SELECT * FROM orders WHERE id = ${id}`);
|
|
47
|
+
const b = await prisma.$queryRawUnsafe(`SELECT * FROM users WHERE id = ${id}`);
|
|
48
|
+
const c = await db.execute(sql`select * from orders order by ${sql.raw(req.query.sort)}`);
|
|
49
|
+
const d = await db.execute(sql`select nextval(${sql.raw(`'${customerSeq.seqName}'`)}) as value`);
|
|
50
|
+
const e = await db.execute(sql`select * from t order by ${sql.raw("created_at")}`);
|
|
51
|
+
const f = await prisma.$queryRaw`SELECT * FROM t ORDER BY ${Prisma.raw(req.query.sort)}`;
|
|
52
|
+
return [a, b, c, d, e, f];
|
|
53
|
+
}
|
|
54
|
+
'''
|
|
55
|
+
|
|
56
|
+
NO_INPUT = '''\
|
|
57
|
+
import { sql } from "drizzle-orm";
|
|
58
|
+
|
|
59
|
+
export async function nextCode(tx, sequence) {
|
|
60
|
+
return tx.execute(sql`select nextval(${sql.raw(`'${sequence.seqName}'`)}) as value`);
|
|
61
|
+
}
|
|
62
|
+
'''
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _scan(files: Dict[str, str]) -> List[Dict]:
|
|
66
|
+
with tempfile.TemporaryDirectory() as td:
|
|
67
|
+
root = Path(td)
|
|
68
|
+
for rel, text in files.items():
|
|
69
|
+
p = root / rel
|
|
70
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
p.write_text(text, encoding="utf-8")
|
|
72
|
+
return find_injection(root, load_config(None))["candidates"]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _by_line(cands: List[Dict]) -> Dict[int, Dict]:
|
|
76
|
+
return {c["line"]: c for c in cands}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class TestJsTaggedSql(unittest.TestCase):
|
|
80
|
+
def test_parameterizing_tags_not_flagged(self) -> None:
|
|
81
|
+
self.assertEqual(_scan({"src/safe.ts": SAFE_TAGGED}), [])
|
|
82
|
+
|
|
83
|
+
def test_untagged_and_unsafe_calls_flagged(self) -> None:
|
|
84
|
+
got = _by_line(_scan({"src/unsafe.ts": UNSAFE}))
|
|
85
|
+
self.assertEqual(got[5]["rule"], "sql-interpolation") # untagged template to db.execute
|
|
86
|
+
self.assertEqual(got[6]["rule"], "sql-interpolation") # $queryRawUnsafe
|
|
87
|
+
|
|
88
|
+
def test_raw_fragments(self) -> None:
|
|
89
|
+
got = _by_line(_scan({"src/unsafe.ts": UNSAFE}))
|
|
90
|
+
self.assertEqual(got[7]["rule"], "sql-raw-fragment") # sql.raw(req.query.sort)
|
|
91
|
+
self.assertEqual(got[7]["priority"], "high")
|
|
92
|
+
self.assertEqual(got[8]["rule"], "sql-raw-fragment") # sql.raw of a schema property
|
|
93
|
+
self.assertNotIn(9, got) # sql.raw("literal") is a constant
|
|
94
|
+
self.assertEqual(got[10]["rule"], "sql-raw-fragment") # Prisma.raw(req.query.sort)
|
|
95
|
+
self.assertEqual(got[10]["priority"], "high")
|
|
96
|
+
|
|
97
|
+
def test_raw_fragment_without_request_input_is_low(self) -> None:
|
|
98
|
+
got = _scan({"src/seq.ts": NO_INPUT})
|
|
99
|
+
self.assertEqual([(c["rule"], c["priority"]) for c in got], [("sql-raw-fragment", "low")])
|
|
100
|
+
|
|
101
|
+
def test_data_access_names_library(self) -> None:
|
|
102
|
+
with tempfile.TemporaryDirectory() as td:
|
|
103
|
+
root = Path(td)
|
|
104
|
+
(root / "a.ts").write_text('import { sql } from "drizzle-orm";\n', encoding="utf-8")
|
|
105
|
+
(root / "b.ts").write_text('import postgres from "postgres";\n', encoding="utf-8")
|
|
106
|
+
da = detect(root, load_config(None))["data_access"]
|
|
107
|
+
self.assertIn("Drizzle ORM (Node/TS)", da)
|
|
108
|
+
self.assertIn("postgres.js (Node/TS)", da)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
if __name__ == "__main__":
|
|
112
|
+
unittest.main()
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Next.js stack detection and route inventory (Issue #324).
|
|
3
|
+
|
|
4
|
+
Covers App Router route handlers (route groups, dynamic and catch-all segments, `export const`
|
|
5
|
+
handlers), Pages Router API routes, server actions (file-level and inline "use server"), dynamic
|
|
6
|
+
server-component pages, and middleware / proxy notes.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import sys
|
|
12
|
+
import tempfile
|
|
13
|
+
import unittest
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Dict, List
|
|
16
|
+
|
|
17
|
+
SCRIPTS_DIR = Path(__file__).resolve().parents[1] / "scripts"
|
|
18
|
+
if str(SCRIPTS_DIR) not in sys.path:
|
|
19
|
+
sys.path.insert(0, str(SCRIPTS_DIR))
|
|
20
|
+
|
|
21
|
+
from detect_stack import detect # noqa: E402
|
|
22
|
+
from lsa_common import load_config # noqa: E402
|
|
23
|
+
from map_routes import map_routes # noqa: E402
|
|
24
|
+
|
|
25
|
+
FILES: Dict[str, str] = {
|
|
26
|
+
"next.config.ts": "const nextConfig = {};\nexport default nextConfig;\n",
|
|
27
|
+
"src/proxy.ts": (
|
|
28
|
+
'import { NextResponse } from "next/server";\n'
|
|
29
|
+
"export async function proxy(request) { return NextResponse.next(); }\n"
|
|
30
|
+
'export const config = { matcher: ["/((?!_next|login).*)"] };\n'
|
|
31
|
+
),
|
|
32
|
+
# unguarded BOLA: id from the path, no owner filter
|
|
33
|
+
"src/app/api/orders/[id]/route.ts": (
|
|
34
|
+
'import { db } from "@/db";\n'
|
|
35
|
+
"export async function GET(request, { params }) {\n"
|
|
36
|
+
" const { id } = await params;\n"
|
|
37
|
+
" return Response.json(await db.query.orders.findFirst({ where: eq(orders.id, id) }));\n"
|
|
38
|
+
"}\n"
|
|
39
|
+
"\n"
|
|
40
|
+
"export async function DELETE(request, { params }) {\n"
|
|
41
|
+
" const session = await auth();\n"
|
|
42
|
+
" await db.delete(orders).where(eq(orders.id, params.id));\n"
|
|
43
|
+
" return new Response(null, { status: 204 });\n"
|
|
44
|
+
"}\n"
|
|
45
|
+
),
|
|
46
|
+
# route group, export const with a guard wrapper, query + JSON body inputs
|
|
47
|
+
"src/app/(admin)/api/users/[userId]/route.ts": (
|
|
48
|
+
'import { withAuth } from "@/lib/auth/guard";\n'
|
|
49
|
+
"export const PATCH = withAuth(async (request, { actor, params }) => {\n"
|
|
50
|
+
" const { role, tenantId } = await request.json();\n"
|
|
51
|
+
' const dry = request.nextUrl.searchParams.get("dryRun");\n'
|
|
52
|
+
" return changeRole(actor, params.userId, role);\n"
|
|
53
|
+
'}, { permission: "users.manage" });\n'
|
|
54
|
+
),
|
|
55
|
+
"app/api/files/[...path]/route.js": "export async function GET(req, ctx) { return serve(ctx.params.path); }\n",
|
|
56
|
+
"app/api/health/route.ts": "export function GET() { return Response.json({ ok: true }); }\n",
|
|
57
|
+
# private folder: not routable
|
|
58
|
+
"app/_lib/route.ts": "export function GET() {}\n",
|
|
59
|
+
"pages/api/invoices/[invoiceId].ts": (
|
|
60
|
+
"export default async function handler(req, res) {\n"
|
|
61
|
+
' if (req.method === "DELETE") { await removeInvoice(req.query.invoiceId); }\n'
|
|
62
|
+
' if (req.method === "GET") { res.json(await getInvoice(req.query.invoiceId)); }\n'
|
|
63
|
+
"}\n"
|
|
64
|
+
),
|
|
65
|
+
"pages/api/index.ts": "export default function handler(req, res) { res.json({}); }\n",
|
|
66
|
+
"src/app/orders/actions.ts": (
|
|
67
|
+
'"use server";\n'
|
|
68
|
+
'import { auth } from "@/auth";\n'
|
|
69
|
+
"export async function cancelOrder(orderId: number, reason: string) {\n"
|
|
70
|
+
" const session = await auth();\n"
|
|
71
|
+
" await db.update(orders).set({ status: 'cancelled' }).where(eq(orders.id, orderId));\n"
|
|
72
|
+
"}\n"
|
|
73
|
+
"export const archiveOrder = async (formData: FormData) => {\n"
|
|
74
|
+
' const id = formData.get("orderId");\n'
|
|
75
|
+
" await db.delete(orders).where(eq(orders.id, id));\n"
|
|
76
|
+
"};\n"
|
|
77
|
+
"function helper() {}\n"
|
|
78
|
+
),
|
|
79
|
+
"src/app/orders/[code]/page.tsx": (
|
|
80
|
+
"export default async function OrderPage({ params }) {\n"
|
|
81
|
+
" const { code } = await params;\n"
|
|
82
|
+
" const order = await getOrderByCode(code);\n"
|
|
83
|
+
" async function approve() {\n"
|
|
84
|
+
' "use server";\n'
|
|
85
|
+
" await approveOrder(order.id);\n"
|
|
86
|
+
" }\n"
|
|
87
|
+
" return <form action={approve}><p>{order.code}</p></form>;\n"
|
|
88
|
+
"}\n"
|
|
89
|
+
),
|
|
90
|
+
"src/app/about/page.tsx": "export default function About() { return <p>about</p>; }\n",
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _write(root: Path) -> None:
|
|
95
|
+
for rel, text in FILES.items():
|
|
96
|
+
p = root / rel
|
|
97
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
98
|
+
p.write_text(text, encoding="utf-8")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _routes(root: Path) -> List[Dict]:
|
|
102
|
+
cfg = load_config(None)
|
|
103
|
+
stacks = {s["id"] for s in detect(root, cfg)["stacks"]}
|
|
104
|
+
return map_routes(root, cfg, stacks)["candidates"]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _find(routes: List[Dict], method: str, path: str) -> Dict:
|
|
108
|
+
hits = [r for r in routes if r["path"] == path and method in r["method"]]
|
|
109
|
+
if len(hits) != 1:
|
|
110
|
+
raise AssertionError(f"{method} {path}: {len(hits)} hits in {[(r['method'], r['path']) for r in routes]}")
|
|
111
|
+
return hits[0]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _names(route: Dict) -> Dict[str, str]:
|
|
115
|
+
return {p["name"]: p["source"] for p in route["id_params"]}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class TestNextjs(unittest.TestCase):
|
|
119
|
+
@classmethod
|
|
120
|
+
def setUpClass(cls) -> None:
|
|
121
|
+
cls._td = tempfile.TemporaryDirectory()
|
|
122
|
+
cls.root = Path(cls._td.name)
|
|
123
|
+
_write(cls.root)
|
|
124
|
+
cls.routes = _routes(cls.root)
|
|
125
|
+
|
|
126
|
+
@classmethod
|
|
127
|
+
def tearDownClass(cls) -> None:
|
|
128
|
+
cls._td.cleanup()
|
|
129
|
+
|
|
130
|
+
def test_stack_detected(self) -> None:
|
|
131
|
+
prof = detect(self.root, load_config(None))
|
|
132
|
+
self.assertIn("node-nextjs", {s["id"] for s in prof["stacks"]})
|
|
133
|
+
self.assertIn("knowledge/stacks/nextjs.md", prof["knowledge_files"])
|
|
134
|
+
|
|
135
|
+
def test_all_routes_are_nextjs(self) -> None:
|
|
136
|
+
self.assertEqual({r["stack"] for r in self.routes}, {"node-nextjs"})
|
|
137
|
+
|
|
138
|
+
def test_route_handlers_per_method(self) -> None:
|
|
139
|
+
get = _find(self.routes, "GET", "/api/orders/{id}")
|
|
140
|
+
self.assertEqual(get["kind"], "declared_route")
|
|
141
|
+
self.assertEqual(get["handler"]["file"], "src/app/api/orders/[id]/route.ts")
|
|
142
|
+
self.assertEqual(get["handler"]["line"], 2)
|
|
143
|
+
self.assertEqual(_names(get), {"id": "path"})
|
|
144
|
+
self.assertTrue(get["needs_audit_hint"])
|
|
145
|
+
self.assertEqual(get["guard_hints"], [])
|
|
146
|
+
delete = _find(self.routes, "DELETE", "/api/orders/{id}")
|
|
147
|
+
self.assertTrue(any("auth()" in g["hint"] for g in delete["guard_hints"]))
|
|
148
|
+
|
|
149
|
+
def test_route_group_and_export_const(self) -> None:
|
|
150
|
+
patch = _find(self.routes, "PATCH", "/api/users/{userId}")
|
|
151
|
+
self.assertEqual(_names(patch), {"userId": "path", "role": "body", "tenantId": "body", "dryRun": "query"})
|
|
152
|
+
hints = " ".join(g["hint"] for g in patch["guard_hints"])
|
|
153
|
+
self.assertIn("withAuth", hints)
|
|
154
|
+
self.assertIn("users.manage", hints)
|
|
155
|
+
|
|
156
|
+
def test_catch_all_and_static(self) -> None:
|
|
157
|
+
_find(self.routes, "GET", "/api/files/{path*}")
|
|
158
|
+
self.assertEqual(_find(self.routes, "GET", "/api/health")["id_params"], [])
|
|
159
|
+
|
|
160
|
+
def test_private_folder_skipped(self) -> None:
|
|
161
|
+
self.assertFalse(any(r["handler"]["file"] == "app/_lib/route.ts" for r in self.routes))
|
|
162
|
+
|
|
163
|
+
def test_pages_api(self) -> None:
|
|
164
|
+
inv = _find(self.routes, "DELETE", "/api/invoices/{invoiceId}")
|
|
165
|
+
self.assertEqual(sorted(inv["method"]), ["DELETE", "GET"])
|
|
166
|
+
self.assertEqual(_names(inv)["invoiceId"], "path")
|
|
167
|
+
self.assertEqual(_find(self.routes, "ANY", "/api")["handler"]["file"], "pages/api/index.ts")
|
|
168
|
+
|
|
169
|
+
def test_server_actions(self) -> None:
|
|
170
|
+
cancel = _find(self.routes, "POST", "action:src/app/orders/actions.ts#cancelOrder")
|
|
171
|
+
self.assertEqual(_names(cancel), {"orderId": "body", "reason": "body"})
|
|
172
|
+
self.assertTrue(any("auth" in g["hint"] for g in cancel["guard_hints"]))
|
|
173
|
+
archive = _find(self.routes, "POST", "action:src/app/orders/actions.ts#archiveOrder")
|
|
174
|
+
self.assertEqual(_names(archive), {"orderId": "form"})
|
|
175
|
+
self.assertFalse(any(r["path"].endswith("#helper") for r in self.routes))
|
|
176
|
+
|
|
177
|
+
def test_inline_server_action_and_page(self) -> None:
|
|
178
|
+
_find(self.routes, "POST", "action:src/app/orders/[code]/page.tsx#approve")
|
|
179
|
+
page = _find(self.routes, "GET", "/orders/{code}")
|
|
180
|
+
self.assertEqual(page["kind"], "file_endpoint")
|
|
181
|
+
self.assertEqual(_names(page), {"code": "path"})
|
|
182
|
+
self.assertFalse(any(r["path"] == "/about" for r in self.routes))
|
|
183
|
+
|
|
184
|
+
def test_use_server_after_comments(self) -> None:
|
|
185
|
+
from nextjs_routes import _starts_with_use_server
|
|
186
|
+
|
|
187
|
+
self.assertTrue(_starts_with_use_server('// x\n/* y */\n "use server";\nexport async function a() {}'))
|
|
188
|
+
self.assertFalse(_starts_with_use_server('import x from "y";\n"use server";'))
|
|
189
|
+
self.assertFalse(_starts_with_use_server("/*" + "*//*" * 5000))
|
|
190
|
+
|
|
191
|
+
def test_proxy_noted(self) -> None:
|
|
192
|
+
get = _find(self.routes, "GET", "/api/orders/{id}")
|
|
193
|
+
self.assertIn("src/proxy.ts", get["notes"])
|
|
194
|
+
self.assertIn("matcher", get["notes"])
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
if __name__ == "__main__":
|
|
198
|
+
unittest.main()
|
package/operate/cli/__init__.py
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hoanghainh/komagu",
|
|
3
|
-
"version": "2.0
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "Security-First Legacy Modernization Platform CLI (LSA Kit, IAM Perimeter, Cross-User Testing)",
|
|
5
5
|
"main": "remediate/authz/nodejs/src/index.js",
|
|
6
6
|
"types": "remediate/authz/nodejs/src/index.d.ts",
|