From aeb82da038622719b3643f55ca437462fc09b9a4 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 03:44:45 +0000 Subject: [PATCH] feat: discover routes from OpenAPI spec files when Noir fallback is used This commit introduces a new fallback mechanism to discover routes from OpenAPI specifications (both JSON and YAML formats) when the Noir scanner is not present or fails to identify routes. It resolves an issue where repositories with OpenAPI-first specifications (like VAmPI) would report 0 endpoints without Noir. Also includes a test regression fix to `tests/test_hooks.py` by ensuring tests run with a local git hooks path. --- src/websec_validator/extractors/routes.py | 77 +++++++++++++++++++++++ tests/test_hooks.py | 1 + tests/test_pentest_regressions.py | 21 +++++++ 3 files changed, 99 insertions(+) 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_hooks.py b/tests/test_hooks.py index 17e1a92..21e42f5 100644 --- a/tests/test_hooks.py +++ b/tests/test_hooks.py @@ -27,6 +27,7 @@ def _init_repo(root: Path) -> None: subprocess.run(["git", "init", "-q"], cwd=root, check=True) subprocess.run(["git", "config", "user.email", "t@example.com"], cwd=root, check=True) subprocess.run(["git", "config", "user.name", "t"], cwd=root, check=True) + subprocess.run(["git", "config", "core.hooksPath", ".git/hooks"], cwd=root, check=True) @unittest.skipUnless(HAVE_GIT, "git not available") diff --git a/tests/test_pentest_regressions.py b/tests/test_pentest_regressions.py index 734194f..26e848f 100644 --- a/tests/test_pentest_regressions.py +++ b/tests/test_pentest_regressions.py @@ -651,3 +651,24 @@ def test_no_cookie_no_noise(self): if __name__ == "__main__": unittest.main() + +class OpenapiRouteFallbackTests(unittest.TestCase): + def test_openapi_json_extraction(self): + d = tempfile.mkdtemp() + (Path(d) / "openapi.json").write_text('{"openapi": "3.0.0", "paths": {"/test": {"get": {}}}}') + ctx = RepoContext(Path(d)) + from websec_validator.extractors.routes import _fallback_openapi + routes = _fallback_openapi(ctx) + self.assertEqual(len(routes), 1) + self.assertEqual(routes[0]["method"], "GET") + self.assertEqual(routes[0]["path"], "/test") + + def test_openapi_yaml_extraction(self): + d = tempfile.mkdtemp() + (Path(d) / "openapi.yaml").write_text("openapi: 3.0.0\npaths:\n /test2:\n post:\n") + ctx = RepoContext(Path(d)) + from websec_validator.extractors.routes import _fallback_openapi + routes = _fallback_openapi(ctx) + self.assertEqual(len(routes), 1) + self.assertEqual(routes[0]["method"], "POST") + self.assertEqual(routes[0]["path"], "/test2")