vibesec-backend / scanner /layer1_framework_auth.py
thefounder03's picture
Fix VibeSec pipeline logic bugs, routing, feedback store, and RLS test cases
35a0968
Raw
History Blame Contribute Delete
21.9 kB
from __future__ import annotations
import re
import os
import bisect
from pathlib import Path
from typing import List
from .models import Finding, Severity, Confidence, ScanDomain
from .ast_utils import ASTContext
class _LineCounter:
def __init__(self, content: str):
self.newlines = [i for i, c in enumerate(content) if c == '\n']
def line_of(self, pos: int) -> int:
return bisect.bisect_right(self.newlines, pos) + 1
_lc_cache: dict = {}
def _ln(content: str, pos: int) -> int:
c_hash = id(content)
if c_hash not in _lc_cache:
_lc_cache[c_hash] = _LineCounter(content)
return _lc_cache[c_hash].line_of(pos)
def _read(repo_path: str, rel_path: str) -> str | None:
try:
return Path(os.path.join(repo_path, rel_path)).read_text(encoding="utf-8", errors="replace")
except Exception:
return None
# Route-bearing directory segments — checked against Path.parts (platform-agnostic)
_ROUTE_DIR_PARTS = frozenset({
"api", "routes", "controllers", "handler", "handlers",
"pages", # Next.js pages/api
"app", # Next.js app router
"router",
})
# Filename stems that indicate a route/controller file
_ROUTE_FILE_STEMS = frozenset({
"routes", "router", "controller", "handler",
"index", # pages/api/index.ts etc.
})
def _is_api_or_route_file(rel_path: str) -> bool:
"""
Platform-agnostic route file detector.
Uses Path.parts instead of substring matching to avoid
leading-slash sensitivity on Windows vs Linux.
"""
try:
parts = Path(rel_path).parts
except Exception:
return False
parts_lower = [p.lower() for p in parts]
# Check: does any directory segment indicate a routing layer?
for i, part in enumerate(parts_lower[:-1]): # exclude the filename
if part in _ROUTE_DIR_PARTS:
if part == "pages":
if i + 1 < len(parts_lower) and parts_lower[i + 1] == "api":
return True
elif part == "app":
if i + 1 < len(parts_lower) and parts_lower[i + 1] == "api":
return True
else:
return True
# Check: filename stem indicates a route handler
if parts_lower:
stem = Path(parts_lower[-1]).stem
if stem == "index" or any(kw in stem for kw in ("route", "router", "controller", "handler", "api")):
return True
return False
# Public endpoint path segments — these are intentionally unauthenticated.
# We keep scanning and keep findings, but downgrade confidence and add context label.
_PUBLIC_ENDPOINT_SEGMENTS = frozenset({
"webhook", "webhooks", "signup", "sign-up", "login", "sign-in",
"signin", "callback", "register", "auth", "oauth", "verify",
"reset-password", "forgot-password", "health", "healthcheck",
})
def _is_public_endpoint(rel_path: str) -> bool:
"""Returns True if the file path suggests a public/unauthenticated endpoint."""
parts = Path(rel_path).parts
return any(p.lower() in _PUBLIC_ENDPOINT_SEGMENTS for p in parts)
# ---------------------------------------------------------------------------
# Check #14 — Clerk Auth: handler must call auth() and check userId
# ---------------------------------------------------------------------------
_CLERK_IMPORT_RE = re.compile(r"from\s+['\"]@clerk/nextjs['\"]|require\(['\"]@clerk/nextjs['\"]", re.IGNORECASE)
_CLERK_AUTH_CALL_RE = re.compile(r"\bauth\(\)|currentUser\(\)|getAuth\(", re.IGNORECASE)
_CLERK_USERID_CHECK_RE = re.compile(r"userId\s*[!=]=|if\s*\(\s*!?\s*userId|userId\s*\?\.|!userId", re.IGNORECASE)
def _check_clerk_auth(content: str, rel_path: str) -> List[Finding]:
findings: List[Finding] = []
if not _is_api_or_route_file(rel_path):
return findings
if not _CLERK_IMPORT_RE.search(content):
return findings
is_public = _is_public_endpoint(rel_path)
context_suffix = (
" [Context: Expected public endpoint — verify auth is intentionally absent]"
if is_public else ""
)
ext = Path(rel_path).suffix
ctx = ASTContext(content, ext)
if not ctx.is_valid():
return findings
funcs = ctx.find_all_functions()
for func in funcs:
func_text = ctx.get_node_text(func)
# We only care about exported functions or ones that look like handlers
if not re.search(r"req|res|NextRequest|NextResponse|GET|POST|PUT|DELETE|PATCH", func_text):
continue
if not _CLERK_AUTH_CALL_RE.search(func_text):
findings.append(Finding(
title="Clerk Auth Import Without auth() Call",
description=(
f"A handler in {rel_path} does not call auth() or currentUser(). "
f"The route is likely unprotected.{context_suffix}"
),
severity=Severity.HIGH,
confidence=Confidence.LOW if is_public else Confidence.HIGH,
domain=ScanDomain.SAST,
check_id="VS-FW-014a",
check_category="auth",
requires_llm_gate=True,
policy_reference="Clerk Auth — Handler Protection",
file_path=rel_path,
line_number=func.start_point[0] + 1,
suggested_fix="Add: const { userId } = auth(); if (!userId) return new NextResponse('Unauthorized', {status: 401});"
))
elif not _CLERK_USERID_CHECK_RE.search(func_text):
findings.append(Finding(
title="Clerk auth() Called But userId Not Validated",
description=(
f"A handler in {rel_path} calls auth() but never checks if userId is null. "
"auth() returns {{ userId: null }} for unauthenticated requests.{context_suffix}"
),
severity=Severity.HIGH,
confidence=Confidence.LOW if is_public else Confidence.MEDIUM,
domain=ScanDomain.SAST,
check_id="VS-FW-014b",
check_category="auth",
requires_llm_gate=True,
policy_reference="Clerk Auth — userId Null Check",
file_path=rel_path,
line_number=func.start_point[0] + 1,
suggested_fix="Add: if (!userId) return new NextResponse('Unauthorized', {status: 401});"
))
return findings
# ---------------------------------------------------------------------------
# Check #15 — NextAuth: unprotected API routes in /api/ (no getServerSession)
# ---------------------------------------------------------------------------
_NEXTAUTH_IMPORT_RE = re.compile(
r"from\s+['\"]next-auth['\"]|from\s+['\"]next-auth/react['\"]|"
r"getServerSession|getSession\(",
re.IGNORECASE,
)
_SESSION_CHECK_RE = re.compile(
r"getServerSession|getSession\(|useSession|session\?\.user|session\.user",
re.IGNORECASE,
)
_NEXTAUTH_CONFIG_RE = re.compile(r"\[\.\.\.nextauth\]|authOptions|NextAuth\(", re.IGNORECASE)
def _check_nextauth_session(content: str, rel_path: str) -> List[Finding]:
findings: List[Finding] = []
if not _is_api_or_route_file(rel_path):
return findings
if _NEXTAUTH_CONFIG_RE.search(content):
return findings
# Skip non-Next.js files (e.g. Supabase Edge Functions, Deno, Express, FastAPI)
# NextAuth checks only apply to Next.js API routes/pages
is_nextjs = bool(re.search(r"from\s+['\"]next['\"]|from\s+['\"]next-auth['\"]", content)) or "pages/api" in rel_path.replace("\\", "/").lower()
is_deno_edge = rel_path.replace("\\", "/").lower().startswith("supabase/functions/") or "deno" in content.lower()[:200]
if not is_nextjs or is_deno_edge:
return findings
has_db = re.compile(r"prisma\.|supabase\.|db\.\w+\.|mongoose\.", re.IGNORECASE)
has_session = re.compile(
r"getServerSession|getSession\(|useSession|session\?\.user|session\.user",
re.IGNORECASE,
)
# ✅ NEW: if session is checked anywhere in the file, don't flag anything
# This handles module-level middleware patterns and top-of-file guards
if has_session.search(content):
return findings
ext = Path(rel_path).suffix
ctx = ASTContext(content, ext)
if not ctx.is_valid():
return findings
is_public = _is_public_endpoint(rel_path)
context_suffix = " [Context: Expected public endpoint]" if is_public else ""
funcs = ctx.find_all_functions()
for func in funcs:
func_text = ctx.get_node_text(func)
if not re.search(r"req|res|NextRequest|NextResponse|GET|POST|PUT|DELETE|PATCH", func_text):
continue
if has_db.search(func_text):
findings.append(Finding(
title="NextAuth — API Route Accesses DB Without Session Verification",
description=(
f"A handler in {rel_path} performs database operations but does not call "
f"getServerSession() within its scope to verify the caller is authenticated.{context_suffix}"
),
severity=Severity.HIGH,
confidence=Confidence.LOW if is_public else Confidence.HIGH,
domain=ScanDomain.SAST,
check_id="VS-FW-015",
check_category="auth",
requires_llm_gate=True,
policy_reference="NextAuth — Session-Protected Routes",
file_path=rel_path,
line_number=func.start_point[0] + 1,
suggested_fix="Add session verification: const session = await getServerSession(req, res, authOptions); if (!session) return res.status(401);"
))
break
return findings
# def _check_nextauth_session(content: str, rel_path: str) -> List[Finding]:
# findings: List[Finding] = []
# if not _is_api_or_route_file(rel_path):
# return findings
# if _NEXTAUTH_CONFIG_RE.search(content):
# return findings
# has_db = re.compile(r"prisma\.|supabase\.|db\.\w+\.|mongoose\.", re.IGNORECASE)
# has_session = re.compile(r"getServerSession|getSession\(|useSession|session\?\.user|session\.user", re.IGNORECASE)
# ext = Path(rel_path).suffix
# ctx = ASTContext(content, ext)
# if not ctx.is_valid():
# return findings
# is_public = _is_public_endpoint(rel_path)
# context_suffix = " [Context: Expected public endpoint]" if is_public else ""
# funcs = ctx.find_all_functions()
# for func in funcs:
# func_text = ctx.get_node_text(func)
# # Only care about handlers (e.g. ones with req/res or exported as GET/POST)
# if not re.search(r"req|res|NextRequest|NextResponse|GET|POST|PUT|DELETE|PATCH", func_text):
# continue
# if has_db.search(func_text) and not has_session.search(func_text):
# findings.append(Finding(
# title="NextAuth — API Route Accesses DB Without Session Verification",
# description=(
# f"A handler in {rel_path} performs database operations but does not call "
# "getServerSession() within its scope to verify the caller is authenticated.{context_suffix}"
# ),
# severity=Severity.HIGH,
# confidence=Confidence.LOW if is_public else Confidence.HIGH,
# domain=ScanDomain.SAST,
# check_id="VS-FW-015",
# check_category="auth",
# requires_llm_gate=True,
# policy_reference="NextAuth — Session-Protected Routes",
# file_path=rel_path,
# line_number=func.start_point[0] + 1,
# suggested_fix="Add session verification: const session = await getServerSession(req, res, authOptions); if (!session) return res.status(401);"
# ))
# # Don't flood with findings for every helper function in the file
# break
# return findings
# ---------------------------------------------------------------------------
# Check #16 — Firebase: Overly Permissive Security Rules
# ---------------------------------------------------------------------------
_FIREBASE_WEAK_RULE_RE = re.compile(
r"allow\s+(?:read|write|read\s*,\s*write)\s*:\s*if\s+true\b",
re.IGNORECASE,
)
def _check_firebase_rules(all_files: List[str], repo_path: str) -> List[Finding]:
findings: List[Finding] = []
firebase_rule_files = [
f for f in all_files
if Path(f).name.lower() in ("firestore.rules", "database.rules.json", "storage.rules")
or f.replace("\\", "/").lower().endswith(".rules")
]
for rel_path in firebase_rule_files:
content = _read(repo_path, rel_path)
if not content:
continue
for m in _FIREBASE_WEAK_RULE_RE.finditer(content):
findings.append(Finding(
title="Firebase — Overly Permissive Security Rule (allow if true)",
description=(
f"Rule at line {_ln(content, m.start())} in {rel_path} permits "
"unconditional read/write access. Any unauthenticated user can "
"read or overwrite the entire database/storage path."
),
severity=Severity.CRITICAL,
confidence=Confidence.HIGH,
domain=ScanDomain.CONFIG,
check_id="VS-FW-016",
check_category="auth",
requires_llm_gate=True,
policy_reference="Firebase Security Rules — Principle of Least Privilege",
file_path=rel_path,
line_number=_ln(content, m.start()),
suggested_fix=(
"Replace 'if true' with an authentication check: "
"allow read, write: if request.auth != null && request.auth.uid == userId; "
"Never deploy with 'if true' outside of local development."
),
))
return findings
# ---------------------------------------------------------------------------
# Check #17 — Auth0: JWT Handler Without Scope Validation
# ---------------------------------------------------------------------------
_AUTH0_JWT_RE = re.compile(
r"from\s+['\"]express-oauth2-jwt-bearer['\"]|"
r"from\s+['\"]auth0['\"]|"
r"checkJwt\b|auth0\.verify\b|jwksRsa\b",
re.IGNORECASE,
)
_SCOPE_CHECK_RE = re.compile(
r"requiredScopes\(|checkScopes\(|req\.auth\.payload\.scope|"
r"hasScope\(|verifyScopes\(|scope.*includes",
re.IGNORECASE,
)
def _check_auth0_scopes(content: str, rel_path: str) -> List[Finding]:
findings: List[Finding] = []
if not _is_api_or_route_file(rel_path):
return findings
if not _AUTH0_JWT_RE.search(content):
return findings
if not _SCOPE_CHECK_RE.search(content):
findings.append(Finding(
title="Auth0 JWT — No OAuth Scope Validation",
description=(
f"{rel_path} uses Auth0 JWT verification but never validates OAuth scopes. "
"Any valid Auth0 token (even one for a different audience or low-privilege "
"scope) can access this endpoint."
),
severity=Severity.MEDIUM,
confidence=Confidence.MEDIUM,
domain=ScanDomain.SAST,
check_id="VS-FW-017",
check_category="auth",
requires_llm_gate=True,
policy_reference="OAuth2 — Scope Enforcement",
file_path=rel_path,
line_number=1,
suggested_fix=(
"Add scope validation after JWT verification: "
"const { requiredScopes } = require('express-oauth2-jwt-bearer'); "
"router.get('/admin', checkJwt, requiredScopes('admin'), handler);"
),
))
return findings
# ---------------------------------------------------------------------------
# Check #4.1 — Missing Auth in Server Actions
# ---------------------------------------------------------------------------
_SERVER_ACTION_RE = re.compile(
r"export\s+(?:async\s+)?(?:function|const)\s+(\w+)\s*(?:=\s*(?:async\s*)?(?:\([^)]*\)|[a-zA-Z_]\w*)\s*=>|\()",
re.IGNORECASE,
)
def _check_missing_auth_server_actions(content: str, rel_path: str) -> List[Finding]:
findings: List[Finding] = []
file_has_use_server = '"use server"' in content or "'use server'" in content
db_write_re = re.compile(
r"\.insert\(|\.update\(|\.delete\(|\.create\(|\.upsert\(|\.destroy\(|db\..*?\.(?:add|delete|save|update)",
re.IGNORECASE,
)
auth_check_re = re.compile(
r"getServerSession|auth\(\)|currentUser\(\)|getAuth\(|req\.user|session\?\.user|session\.user|requireAuth|ensureAuth|protect|guard",
re.IGNORECASE,
)
ext = Path(rel_path).suffix
ctx = ASTContext(content, ext)
if not ctx.is_valid():
return findings
funcs = ctx.find_all_functions()
for func in funcs:
func_text = ctx.get_node_text(func)
func_has_use_server = '"use server"' in func_text or "'use server'" in func_text
is_exported = bool(re.search(r"\bexport\b", func_text))
is_server_action = func_has_use_server or (file_has_use_server and is_exported)
if is_server_action:
if db_write_re.search(func_text) and not auth_check_re.search(func_text):
findings.append(Finding( title="Missing Authentication in Next.js Server Action",
description=(
f"Server Action in '{rel_path}' performs database write operations "
"but does not verify the caller's session within the function scope. "
"Any client can invoke Server Actions directly, bypassing frontend UI blocks."
),
severity=Severity.CRITICAL,
confidence=Confidence.HIGH,
domain=ScanDomain.SAST,
check_id="VS-FW-021",
check_category="auth",
policy_reference="Next.js Server Actions — Protection",
file_path=rel_path,
line_number=func.start_point[0] + 1,
suggested_fix="Add an authentication check inside the Server Action: const { userId } = auth(); if (!userId) throw new Error('Unauthorized');"))
return findings
# ---------------------------------------------------------------------------
# Check #4.2 — Middleware Auth Bypass
# ---------------------------------------------------------------------------
def _check_middleware_auth_bypass(content: str, rel_path: str) -> List[Finding]:
findings: List[Finding] = []
if Path(rel_path).name.lower() != "middleware.ts":
return findings
if "matcher" in content:
# Match either matcher: [...] or matcher: '...' / matcher: "..."
m_match = re.search(r"matcher\s*:\s*(?:\[([^\]]+)\]|['\"`]([^'\"`]+)['\"`])", content, re.DOTALL)
if m_match:
matcher_val = m_match.group(1) or m_match.group(2) or ""
is_excluded = False
if "/api/" not in matcher_val and "api" not in matcher_val:
is_excluded = True
elif "(?!api" in matcher_val or "(?!.*api" in matcher_val:
is_excluded = True
if is_excluded:
findings.append(Finding(
title="Next.js Middleware Auth Bypass for API Routes",
description=(
"Next.js middleware matcher does not include '/api/' paths. "
"This means all API routes bypass global middleware authentication checks."
),
severity=Severity.HIGH,
confidence=Confidence.HIGH,
domain=ScanDomain.CONFIG,
check_id="VS-FW-022",
check_category="auth",
policy_reference="Next.js Middleware — Matcher Config",
file_path=rel_path,
line_number=1,
suggested_fix=(
"Include api routes in the middleware matcher config: "
"matcher: ['/dashboard/:path*', '/api/:path*']"
),
))
return findings
# ---------------------------------------------------------------------------
# Public entry point
# ---------------------------------------------------------------------------
def run_framework_auth_checks(repo_path: str, production_files: List[str], all_files: List[str]) -> List[Finding]:
"""
Runs all framework-specific auth checks (Clerk, NextAuth, Firebase, Auth0).
Called from orchestrator.py after Layer 0 indexing.
"""
findings: List[Finding] = []
_lc_cache.clear()
# Firebase rules may be in config files (not just production_files)
findings.extend(_check_firebase_rules(all_files, repo_path))
for rel_path in all_files:
content = _read(repo_path, rel_path)
if not content:
continue
findings.extend(_check_middleware_auth_bypass(content, rel_path))
for rel_path in production_files:
content = _read(repo_path, rel_path)
if not content:
continue
findings.extend(_check_clerk_auth(content, rel_path))
findings.extend(_check_nextauth_session(content, rel_path))
findings.extend(_check_auth0_scopes(content, rel_path))
findings.extend(_check_missing_auth_server_actions(content, rel_path))
return findings