Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
## [Unreleased]

### Added
- Improved route extraction with `_fallback_openapi` to discover routes from spec files (e.g. `openapi.yml`, `swagger.json`) directly when scanner falls back. Added parsing for `.yaml`, `.yml`, and `.json` files in the code fallback paths.

- **No-Row-Level-Security detection** (`missing-rls` class, in `schemas.py` + the ledger) — committed
Postgres/Supabase DDL declares owner/tenant-scoped tables but ships **zero** `CREATE POLICY` /
Expand Down
77 changes: 77 additions & 0 deletions src/websec_validator/extractors/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -422,10 +422,87 @@ def _handler_signal_count(ctx: RepoContext) -> int:

# ---- regex fallback (Noir absent — decorator/file frameworks the heuristic doesn't cover) ----

def _fallback_openapi(ctx: RepoContext) -> list:
rows = []
import json
from .base import SKIP_DIRS
for p in ctx.root.rglob("*"):
if p.is_file() and p.suffix.lower() in {".json", ".yaml", ".yml"}:
try:
if any(part in SKIP_DIRS for part in p.relative_to(ctx.root).parts):
continue
except ValueError:
continue
if ctx._excluded(str(p)):
continue
try:
text = p.read_text(errors="ignore")
if not re.search(r"^(?:openapi|swagger)[\s'\":]", text, re.I | re.M) and '"openapi"' not in text and '"swagger"' not in text:
continue
rel = ctx.rel(p)
# Naive openapi parsing for fallback using json/yaml if available or naive regex fallback
if p.suffix == ".json":
try:
data = json.loads(text)
for path, methods in data.get("paths", {}).items():
for method in methods.keys():
if method.upper() in {"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"}:
rows.append({
"method": method.upper(),
"path": path,
"params": [],
"technology": "openapi",
"code_path": rel,
"source": "fallback-openapi"
})
continue
except:
pass

in_paths = False
current_path = None
path_indent = -1
for line in text.splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
if re.match(r"^['\"]?paths['\"]?\s*:", line.lstrip()):
in_paths = True
path_indent = len(line) - len(line.lstrip())
continue

if in_paths:
indent = len(line) - len(line.lstrip())
if indent <= path_indent and not stripped.startswith(("}", "]")) and stripped != "":
in_paths = False
continue

path_match = re.match(r"^['\"]?(/[^'\"]*)['\"]?\s*:", stripped)
if path_match:
current_path = path_match.group(1)
continue

if current_path:
method_match = re.match(r"^['\"]?(get|post|put|patch|delete|options|head)['\"]?\s*:", stripped, re.I)
if method_match:
rows.append({
"method": method_match.group(1).upper(),
"path": current_path,
"params": [],
"technology": "openapi",
"code_path": rel,
"source": "fallback-openapi"
})
except Exception:
pass
return rows


def _fallback(ctx: RepoContext) -> list:
rows = []
rows += _fallback_next_app_router(ctx)
rows += _fallback_regex(ctx)
rows += _fallback_openapi(ctx)
rows += _router_calls(ctx) # generic router calls (Express/itty/Hono/Workers)
# clean + filter noise + de-dup on (method, path)
seen, out = set(), []
Expand Down
36 changes: 36 additions & 0 deletions tests/test_pentest_regressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -651,3 +651,39 @@ def test_no_cookie_no_noise(self):

if __name__ == "__main__":
unittest.main()

class TestFallbackOpenAPIRegression(unittest.TestCase):
def test_fallback_openapi_spec_first_fallback(self):
# A test to simulate VAmPI's openapi spec-first app fallback discovery.
# Ensure when Noir doesn't find routes, the fallback properly extracts endpoints from openapi3.yml.
from websec_validator.extractors.routes import _fallback_openapi
from websec_validator.extractors.base import RepoContext
from pathlib import Path
import tempfile
import shutil

d = Path(tempfile.mkdtemp())
try:
openapi_path = d / "openapi3.yml"
openapi_path.write_text("""openapi: 3.0.1
info:
title: VAmPI
paths:
/users/v1:
get:
summary: Displays all users with basic information
post:
summary: Register new user
/users/v1/login:
post:
summary: Login to VAmPI
""")
ctx = RepoContext(d)
routes = _fallback_openapi(ctx)
finally:
shutil.rmtree(d)

methods_paths = set((r["method"], r["path"]) for r in routes)
self.assertIn(("GET", "/users/v1"), methods_paths)
self.assertIn(("POST", "/users/v1"), methods_paths)
self.assertIn(("POST", "/users/v1/login"), methods_paths)