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
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
1 change: 1 addition & 0 deletions tests/test_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
21 changes: 21 additions & 0 deletions tests/test_pentest_regressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")