diff --git a/CHANGELOG.md b/CHANGELOG.md index db53af9..038926d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` / diff --git a/src/websec_validator/extractors/routes.py b/src/websec_validator/extractors/routes.py index dc3627d..7bcc491 100644 --- a/src/websec_validator/extractors/routes.py +++ b/src/websec_validator/extractors/routes.py @@ -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(), [] diff --git a/tests/test_pentest_regressions.py b/tests/test_pentest_regressions.py index 734194f..b45068e 100644 --- a/tests/test_pentest_regressions.py +++ b/tests/test_pentest_regressions.py @@ -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)