@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 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") else "normal",
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
- "*.min.js", "*jquery*.js"],
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", "node-python-ruby.md"),
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 in ("js", "python", "ruby"):
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()
@@ -2,4 +2,4 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- __version__ = "2.0.1" # x-release-please-version
5
+ __version__ = "2.1.0" # x-release-please-version
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hoanghainh/komagu",
3
- "version": "2.0.1",
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",