From 8d9253fd17e03ef1a2381b4d4516a52e43e720e3 Mon Sep 17 00:00:00 2001 From: Kailash Date: Mon, 8 Jun 2026 19:07:38 +0530 Subject: [PATCH 1/3] feat(pattern-scanner): fix ARCH-LOGIC-002 by truncating long lines instead of skipping --- src/zeroclaw/scanners/pattern_scanner.py | 152 +++++++++++++++++++++-- tests/test_pattern_scanner.py | 86 +++++++++++++ tests/test_secret_scanner.py | 15 +++ 3 files changed, 244 insertions(+), 9 deletions(-) diff --git a/src/zeroclaw/scanners/pattern_scanner.py b/src/zeroclaw/scanners/pattern_scanner.py index 6c00cc0..ec1801c 100644 --- a/src/zeroclaw/scanners/pattern_scanner.py +++ b/src/zeroclaw/scanners/pattern_scanner.py @@ -1,19 +1,153 @@ """Code pattern scanner: SQLi, XSS, unsafe patterns.""" +import logging +import os +import re +import stat +from collections import deque from pathlib import Path -from zeroclaw.models import Finding +from zeroclaw.models import Category, Finding, Severity + +logger = logging.getLogger(__name__) DANGEROUS_PATTERNS = [ - (r"execute\s*\(\s*f['\"]", "Possible SQL injection (f-string in execute)"), - (r"\.format\s*\(.*\).*execute", "Possible SQL injection (format in execute)"), - (r"dangerouslySetInnerHTML", "XSS risk: dangerouslySetInnerHTML"), - (r"innerHTML\s*=", "XSS risk: innerHTML assignment"), - (r"eval\s*\(", "Code injection: eval()"), - (r"document\.write\s*\(", "XSS risk: document.write"), - (r"subprocess\.call\s*\(.*shell\s*=\s*True", "Command injection: shell=True"), + ( + r"\.execute\s*\(\s*f['\"]", + "Possible SQL injection (f-string in execute)", + Severity.HIGH, + ), + ( + r"\.execute\s*\(\s*['\"][^'\"]*\+", + "Possible SQL injection (string concat in execute)", + Severity.HIGH, + ), + ( + r"dangerouslySetInnerHTML", + "XSS risk: dangerouslySetInnerHTML", + Severity.HIGH, + ), + ( + r"\.innerHTML\s*=(?!\s*\"\")", + "XSS risk: innerHTML assignment", + Severity.HIGH, + ), + ( + r"document\.write\s*\(", + "XSS risk: document.write", + Severity.MEDIUM, + ), + ( + r"subprocess\.(call|run|Popen)\s*\([\s\S]*?shell\s*=\s*True", + "Command injection: shell=True", + Severity.HIGH, + ), ] +EXTENSIONS = { + ".py", ".js", ".jsx", ".ts", ".tsx", + ".html", ".htm", ".vue", ".svelte", +} + +MAX_LINE_LENGTH = 2048 +WINDOW_SIZE = 3 + + +def _has_dangerous_pattern(text: str) -> tuple[str, Severity] | None: + """Check text against dangerous patterns.""" + for pattern, message, severity in DANGEROUS_PATTERNS: + if re.search(pattern, text): + return message, severity + return None + def scan_patterns(target_dir: Path) -> list[Finding]: """Scan for dangerous code patterns.""" - raise NotImplementedError("Phase 2 task: Varshit implements this") + findings: list[Finding] = [] + resolved_target = target_dir.resolve() + + for file_path in target_dir.rglob("*"): + # skip symlinks + if file_path.is_symlink(): + logger.warning("Skipping symbolic link: %s", file_path) + continue + + # only process regular files + if not file_path.is_file(): + continue + + if file_path.suffix not in EXTENSIONS: + continue + + fd = None + try: + # Boundary check using Path.resolve() before open + resolved_file = file_path.resolve() + if not resolved_file.is_relative_to(resolved_target): + logger.warning("Skipping file outside target directory: %s", file_path) + continue + + # Open file descriptor securely (O_NOFOLLOW prevents following trailing symlinks) + flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) + fd = os.open(resolved_file, flags) + + # Inspect metadata securely on the opened descriptor (TOCTOU fix) + fstat_info = os.fstat(fd) + if not stat.S_ISREG(fstat_info.st_mode): + logger.warning("Skipping non-regular file: %s", file_path) + os.close(fd) + fd = None + continue + + # Limit file size to 5MB + if fstat_info.st_size > 5 * 1024 * 1024: + os.close(fd) + fd = None + continue + + # Read securely using the file descriptor + with os.fdopen(fd, "r", encoding="utf-8", errors="ignore") as f: + fd = None # os.fdopen takes ownership of the descriptor + window: deque[str] = deque(maxlen=WINDOW_SIZE) + + for line_number, line in enumerate(f, start=1): + if len(line) > MAX_LINE_LENGTH: + line = line[:MAX_LINE_LENGTH] + + window.append(line) + context = "".join(window) + + res = _has_dangerous_pattern(context) + if res is None: + continue + + message, severity = res + + findings.append( + Finding( + id=f"PATTERN-{len(findings)+1:04d}", + severity=severity, + category=Category.CODE_PATTERN, + title=message, + description=f"{message} at line {line_number}: {line.strip()}", + file_path=str(file_path), + line_number=line_number, + remediation="Use parameterized queries or safe DOM APIs.", + ) + ) + + except (OSError, UnicodeDecodeError) as e: + if fd is not None: + try: + os.close(fd) + except OSError: + pass + logger.warning("Could not read file %s: %s", file_path, e) + except Exception as e: + if fd is not None: + try: + os.close(fd) + except OSError: + pass + logger.error("Unexpected error processing file %s: %s", file_path, e) + + return findings diff --git a/tests/test_pattern_scanner.py b/tests/test_pattern_scanner.py index 165193d..00b0909 100644 --- a/tests/test_pattern_scanner.py +++ b/tests/test_pattern_scanner.py @@ -34,3 +34,89 @@ def test_finding_has_line_number(self, vulnerable_repo): findings = scan_patterns(vulnerable_repo) for f in findings: assert f.line_number is not None, f"Finding {f.id} missing line number" + + def test_xss_in_frontend_templates(self, tmp_path): + """Should detect XSS patterns in HTML, Vue, and Svelte templates.""" + html_file = tmp_path / "index.html" + html_file.write_text("
element.innerHTML = 'bad'
") + + vue_file = tmp_path / "App.vue" + vue_file.write_text("
") + + svelte_file = tmp_path / "App.svelte" + svelte_file.write_text("") + + findings = scan_patterns(tmp_path) + assert len(findings) >= 3 + + def test_unreadable_file_logged(self, tmp_path, caplog): + """Should log a warning message when a file cannot be read due to OS error.""" + import logging + from unittest.mock import patch + + bad_file = tmp_path / "unreadable.py" + bad_file.write_text("execute(f'SELECT *')") + + with patch("os.open", side_effect=PermissionError("Mocked permission error")): + with caplog.at_level(logging.WARNING): + findings = scan_patterns(tmp_path) + assert len(findings) == 0 + + warnings = [rec.message for rec in caplog.records if rec.levelno == logging.WARNING] + assert any("Could not read file" in w and "Mocked permission error" in w for w in warnings) + + def test_large_file_skipped(self, tmp_path): + """Should skip scanning files that exceed the 5MB size limit.""" + large_file = tmp_path / "large.py" + large_file.write_text("execute(f'SELECT *')" + (" " * 5 * 1024 * 1024)) + findings = scan_patterns(tmp_path) + assert len(findings) == 0 + + def test_path_traversal_prevention(self, tmp_path): + """Should prevent scanning files resolving outside the target boundary.""" + from unittest.mock import MagicMock, patch + from pathlib import Path + + file_mock = MagicMock(spec=Path) + file_mock.is_symlink.return_value = False + file_mock.is_file.return_value = True + file_mock.suffix = ".py" + file_mock.resolve.return_value = Path("C:/Windows") if Path("C:/").exists() else Path("/etc") + file_mock.stat.return_value.st_size = 100 + + with patch.object(Path, "rglob", return_value=[file_mock]): + findings = scan_patterns(tmp_path) + assert len(findings) == 0 + + def test_multiline_command_injection(self, tmp_path): + """Should detect multi-line subprocess execution with shell=True.""" + cmd_file = tmp_path / "command.py" + cmd_file.write_text( + "subprocess.run(\n" + " ['ls', '-l'],\n" + " shell=True\n" + ")\n" + ) + findings = scan_patterns(tmp_path) + assert len(findings) == 1 + assert "Command injection" in findings[0].title + + def test_is_safe_bypass_prevented(self, tmp_path): + """Should detect dangerous patterns even if safe-looking comments are present on the same line.""" + bypass_file = tmp_path / "bypass.html" + bypass_file.write_text("el.innerHTML = bad; // .textContent = safe\n") + findings = scan_patterns(tmp_path) + assert len(findings) == 1 + assert "XSS risk" in findings[0].title + + def test_long_line_bypass_prevented(self, tmp_path): + """Should detect dangerous patterns on lines exceeding MAX_LINE_LENGTH by truncating them.""" + long_line_file = tmp_path / "long_line.js" + payload = "element.innerHTML = bad;" + (" " * 3000) + "\n" + long_line_file.write_text(payload) + findings = scan_patterns(tmp_path) + assert len(findings) == 1 + assert "XSS risk" in findings[0].title + + + diff --git a/tests/test_secret_scanner.py b/tests/test_secret_scanner.py index 0d34c09..97326c5 100644 --- a/tests/test_secret_scanner.py +++ b/tests/test_secret_scanner.py @@ -81,3 +81,18 @@ def test_default_fallback_token_detected(self, tmp_path): assert len(findings) == 1 assert "Anthropic API key" in findings[0].title + def test_unreadable_file_logged(self, tmp_path, capsys): + """Should log a warning message when a file cannot be read.""" + from unittest.mock import patch + unreadable_file = tmp_path / "unreadable.py" + unreadable_file.write_text("API_KEY = 'sk-ant-12345678901234567890'") + + with patch("builtins.open", side_effect=PermissionError("Mocked permission error")): + findings = scan_secrets(tmp_path) + assert len(findings) == 0 + + captured = capsys.readouterr() + assert "WARNING: Could not read file" in captured.out + assert "Mocked permission error" in captured.out + + From b7e77f03f590f4d38f9530a820f6aded3ec68c43 Mon Sep 17 00:00:00 2001 From: Kailash Date: Fri, 12 Jun 2026 23:22:32 +0530 Subject: [PATCH 2/3] feat: implement phase 3 dynamic API security tester and AST auth scanner refactoring --- src/zeroclaw/scanners/api_security_tester.py | 446 +++++++++++++++++++ src/zeroclaw/scanners/auth_scanner.py | 277 +++++++++++- tests/test_api_security_tester.py | 194 ++++++++ tests/test_auth_scanner.py | 179 ++++++++ 4 files changed, 1088 insertions(+), 8 deletions(-) create mode 100644 src/zeroclaw/scanners/api_security_tester.py create mode 100644 tests/test_api_security_tester.py create mode 100644 tests/test_auth_scanner.py diff --git a/src/zeroclaw/scanners/api_security_tester.py b/src/zeroclaw/scanners/api_security_tester.py new file mode 100644 index 0000000..4e209a3 --- /dev/null +++ b/src/zeroclaw/scanners/api_security_tester.py @@ -0,0 +1,446 @@ +"""API Security Tester for dynamic analysis of auth bypass, rate limiting, and session security.""" +from __future__ import annotations + +import asyncio +import concurrent.futures +import logging +from typing import Any +import httpx +from fastapi import FastAPI + +logger = logging.getLogger(__name__) + + +class SecurityTestError(AssertionError): + """Raised when a security test fails.""" + pass + + +def run_async(coro) -> Any: + """Run an async coroutine synchronously, handling any active event loops.""" + try: + return asyncio.run(coro) + except RuntimeError: + with concurrent.futures.ThreadPoolExecutor() as executor: + future = executor.submit(asyncio.run, coro) + return future.result() + + +class APISecurityTester: + """Utility class to dynamically test FastAPI applications for security vulnerabilities.""" + + @staticmethod + def test_auth_bypass(app: FastAPI, protected_routes: list[dict[str, Any]]) -> None: + """ + Verify that protected endpoints reject unauthorized requests. + + For each route in protected_routes: + - Hits the route without an auth token -> expects 401 or 403. + - Hits the route with an expired token -> expects 401 or 403. + - Hits the route with an incorrect user role -> expects 403. + + Each route dict can specify: + - 'path' (str): the target endpoint path (required) + - 'method' (str): HTTP method, e.g., 'GET', 'POST' (default 'GET') + - 'params' (dict): query parameters (optional) + - 'json' (dict): JSON body (optional) + - 'headers' (dict): normal/base headers (optional) + - 'cookies' (dict): normal/base cookies (optional) + - 'expired_headers' (dict): headers for expired token test (optional) + - 'expired_cookies' (dict): cookies for expired token test (optional) + - 'wrong_role_headers' (dict): headers for wrong role test (optional) + - 'wrong_role_cookies' (dict): cookies for wrong role test (optional) + """ + async def run(): + failures = [] + transport = httpx.ASGITransport(app=app) + + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client: + for idx, route in enumerate(protected_routes): + path = route.get("path") + if not path: + failures.append(f"Route at index {idx} is missing 'path'.") + continue + + method = route.get("method", "GET").upper() + params = route.get("params") + json_body = route.get("json") + base_headers = route.get("headers") or {} + base_cookies = route.get("cookies") or {} + + # 1. No Auth Check + # Remove any authorization headers or session cookies + no_auth_headers = {k: v for k, v in base_headers.items() if k.lower() != "authorization"} + no_auth_cookies = {k: v for k, v in base_cookies.items() if "session" not in k.lower()} + + client.cookies.clear() + client.cookies.update(no_auth_cookies) + + try: + response = await client.request( + method=method, + url=path, + params=params, + json=json_body, + headers=no_auth_headers, + ) + if response.status_code not in (401, 403): + failures.append( + f"Auth Bypass Check Failed: Endpoint {method} {path} with no auth headers " + f"returned {response.status_code} instead of 401/403." + ) + except Exception as e: + failures.append(f"Auth Bypass Check Error on {method} {path} (No Auth): {e}") + + # 2. Expired Token Check + expired_headers = route.get("expired_headers") + expired_cookies = route.get("expired_cookies") + + # Fallback to dummy values if not provided + if not expired_headers and not expired_cookies: + expired_headers = {"Authorization": "Bearer expired_token_xyz"} + + req_headers = {**base_headers, **(expired_headers or {})} + req_cookies = {**base_cookies, **(expired_cookies or {})} + + client.cookies.clear() + client.cookies.update(req_cookies) + + try: + response = await client.request( + method=method, + url=path, + params=params, + json=json_body, + headers=req_headers, + ) + if response.status_code not in (401, 403): + failures.append( + f"Auth Bypass Check Failed: Endpoint {method} {path} with expired credentials " + f"returned {response.status_code} instead of 401/403." + ) + except Exception as e: + failures.append(f"Auth Bypass Check Error on {method} {path} (Expired Auth): {e}") + + # 3. Wrong Role Check + wrong_role_headers = route.get("wrong_role_headers") + wrong_role_cookies = route.get("wrong_role_cookies") + + # Fallback to dummy values if not provided + if not wrong_role_headers and not wrong_role_cookies: + wrong_role_headers = {"Authorization": "Bearer wrong_role_token_xyz"} + + req_headers = {**base_headers, **(wrong_role_headers or {})} + req_cookies = {**base_cookies, **(wrong_role_cookies or {})} + + client.cookies.clear() + client.cookies.update(req_cookies) + + try: + response = await client.request( + method=method, + url=path, + params=params, + json=json_body, + headers=req_headers, + ) + # Wrong role should result in a 403 Forbidden + if response.status_code != 403: + failures.append( + f"Auth Bypass Check Failed: Endpoint {method} {path} with incorrect role " + f"returned {response.status_code} instead of 403." + ) + except Exception as e: + failures.append(f"Auth Bypass Check Error on {method} {path} (Wrong Role): {e}") + + if failures: + raise SecurityTestError("\n".join(failures)) + + run_async(run()) + + @staticmethod + def test_rate_limiting(app: FastAPI, rate_limited_routes: list[dict[str, Any]]) -> None: + """ + Verify that rate-limited routes enforce limits and return 429 Too Many Requests. + + For each route in rate_limited_routes: + - Sends requests sequentially exceeding the expected limit. + - Asserts that we eventually receive a 429 status code. + + Each route dict can specify: + - 'path' (str): the target endpoint path (required) + - 'method' (str): HTTP method, e.g., 'GET', 'POST' (default 'GET') + - 'limit' (int): the configured limit before hitting 429 (default 5) + - 'params' (dict): query parameters (optional) + - 'json' (dict): JSON body (optional) + - 'headers' (dict): request headers (optional) + - 'cookies' (dict): request cookies (optional) + """ + async def run(): + failures = [] + transport = httpx.ASGITransport(app=app) + + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client: + for idx, route in enumerate(rate_limited_routes): + path = route.get("path") + if not path: + failures.append(f"Route at index {idx} is missing 'path'.") + continue + + method = route.get("method", "GET").upper() + limit = route.get("limit", 5) + params = route.get("params") + json_body = route.get("json") + headers = route.get("headers") + cookies = route.get("cookies") or {} + + client.cookies.clear() + client.cookies.update(cookies) + + hit_429 = False + status_codes = [] + + # We attempt up to limit + 10 requests to trigger rate limiting + max_attempts = limit + 10 + for i in range(max_attempts): + try: + response = await client.request( + method=method, + url=path, + params=params, + json=json_body, + headers=headers, + ) + status_codes.append(response.status_code) + if response.status_code == 429: + hit_429 = True + break + except Exception as e: + failures.append(f"Rate Limiting Check Error on {method} {path} at request {i+1}: {e}") + break + + if not hit_429: + failures.append( + f"Rate Limiting Check Failed: Endpoint {method} {path} did not return 429 after {max_attempts} " + f"requests. Response status codes: {status_codes}" + ) + + if failures: + raise SecurityTestError("\n".join(failures)) + + run_async(run()) + + @staticmethod + def test_session_security(app: FastAPI, session_routes: list[dict[str, Any]]) -> None: + """ + Verify session fixation, session hijacking (signature tampering), and session timeout. + + Each item in session_routes can specify: + - 'login_path' (str): login endpoint to test session fixation + - 'login_method' (str): HTTP method for login (default 'POST') + - 'login_data' (dict): credentials payload for login (optional) + - 'session_cookie_name' (str): name of cookie used for sessions (optional) + - 'session_header_name' (str): name of header or JSON response key for session tokens (optional) + - 'protected_path' (str): a protected path to verify hijacking and timeouts (required for hijacking/timeout) + - 'protected_method' (str): method for protected path (default 'GET') + - 'valid_headers' (dict): valid session headers if login bypass is preferred (optional) + - 'valid_cookies' (dict): valid session cookies if login bypass is preferred (optional) + - 'expired_headers' (dict): expired session headers (optional) + - 'expired_cookies' (dict): expired session cookies (optional) + """ + async def run(): + failures = [] + transport = httpx.ASGITransport(app=app) + + for idx, route in enumerate(session_routes): + cookie_name = route.get("session_cookie_name") + header_name = route.get("session_header_name") + if not cookie_name and not header_name: + cookie_name = "session" + + # 1. Session Fixation Check + login_path = route.get("login_path") + if login_path: + login_method = route.get("login_method", "POST").upper() + login_data = route.get("login_data") + + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client: + # Set pre-existing session token/cookie + dummy_token = "pre_existing_session_id_123456" + if cookie_name: + client.cookies[cookie_name] = dummy_token + + req_headers = {} + if header_name: + req_headers[header_name] = f"Bearer {dummy_token}" + + try: + response = await client.request( + method=login_method, + url=login_path, + json=login_data, + headers=req_headers, + ) + + # Verify fixation protection without ValueError on duplicates + new_cookie_val = response.cookies.get(cookie_name) + if not new_cookie_val: + # Inspect client jar safely + matching_vals = [c.value for c in client.cookies.jar if c.name == cookie_name] + if len(matching_vals) == 1 and matching_vals[0] == dummy_token: + failures.append( + f"Session Fixation Vulnerability: Login at {login_path} did not set a new " + f"session cookie '{cookie_name}'. Value remained the pre-existing '{dummy_token}'." + ) + else: + if new_cookie_val == dummy_token: + failures.append( + f"Session Fixation Vulnerability: Login at {login_path} set session cookie " + f"'{cookie_name}' but kept the pre-existing value '{dummy_token}'." + ) + + if header_name: + new_header_val = response.headers.get(header_name) + try: + res_json = response.json() + new_json_val = res_json.get(header_name) or res_json.get("access_token") or res_json.get("token") + except Exception: + new_json_val = None + + new_val = new_header_val or new_json_val + if new_val == dummy_token: + failures.append( + f"Session Fixation Vulnerability: Login at {login_path} did not rotate the " + f"session header/token '{header_name}'. Value remained '{dummy_token}'." + ) + + except Exception as e: + failures.append(f"Session Fixation Check Error at {login_path}: {e}") + + # 2. Session Hijacking & Tampering / Removal + protected_path = route.get("protected_path") + if protected_path: + protected_method = route.get("protected_method", "GET").upper() + valid_headers = route.get("valid_headers") or {} + valid_cookies = route.get("valid_cookies") or {} + + # If login_path is specified and we don't have valid headers/cookies, log in dynamically + if not valid_headers and not valid_cookies and login_path: + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client: + try: + login_method = route.get("login_method", "POST").upper() + login_data = route.get("login_data") + response = await client.request(method=login_method, url=login_path, json=login_data) + if response.status_code in (200, 201): + valid_cookies = dict(client.cookies) + try: + res_json = response.json() + token = res_json.get("access_token") or res_json.get("token") + if token: + valid_headers = {"Authorization": f"Bearer {token}"} + else: + cookie_val = response.cookies.get(cookie_name or "session") + if cookie_val: + valid_cookies = {cookie_name or "session": cookie_val} + except Exception: + pass + except Exception as e: + failures.append(f"Login setup for hijacking check failed: {e}") + + # Establish baseline request works with valid credentials + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client: + client.cookies.clear() + client.cookies.update(valid_cookies) + try: + res_ok = await client.request( + method=protected_method, + url=protected_path, + headers=valid_headers, + ) + if res_ok.status_code not in (200, 201, 204): + logger.warning( + "Baseline request for hijacking test on %s %s returned %s instead of success. " + "Will proceed with tampering checks.", + protected_method, protected_path, res_ok.status_code + ) + except Exception as e: + logger.warning("Baseline request for hijacking test error: %s", e) + + # Tamper test 1: Removal of credentials + client.cookies.clear() + try: + res_removed = await client.request( + method=protected_method, + url=protected_path, + ) + if res_removed.status_code not in (401, 403): + failures.append( + f"Session Hijacking / Credentials Removal Vulnerability: Protected route " + f"{protected_method} {protected_path} accessed without credentials returned " + f"{res_removed.status_code} instead of 401/403." + ) + except Exception as e: + failures.append(f"Session Hijacking Removal Check Error: {e}") + + # Tamper test 2: Modified/Corrupted credentials + tampered_headers = {} + for k, v in valid_headers.items(): + if k.lower() == "authorization": + if "bearer " in v.lower(): + tampered_headers[k] = v + "invalidsignature" + else: + tampered_headers[k] = v + "_tampered" + else: + tampered_headers[k] = v + "_altered" + + tampered_cookies = {k: v + "_tampered" for k, v in valid_cookies.items()} + + client.cookies.clear() + client.cookies.update(tampered_cookies) + if tampered_headers or tampered_cookies: + try: + res_tampered = await client.request( + method=protected_method, + url=protected_path, + headers=tampered_headers, + ) + if res_tampered.status_code not in (401, 403): + failures.append( + f"Session Hijacking / Signature Tampering Vulnerability: Protected route " + f"{protected_method} {protected_path} accessed with tampered credentials " + f"returned {res_tampered.status_code} instead of 401/403." + ) + except Exception as e: + failures.append(f"Session Hijacking Tampering Check Error: {e}") + + # 3. Session Timeout Check + if protected_path: + expired_headers = route.get("expired_headers") + expired_cookies = route.get("expired_cookies") + + if not expired_headers and not expired_cookies: + expired_headers = {"Authorization": "Bearer expired_session_token_123"} + expired_cookies = {"session": "expired_session_cookie_123"} + + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client: + client.cookies.clear() + client.cookies.update(expired_cookies or {}) + try: + res_timeout = await client.request( + method=protected_method, + url=protected_path, + headers=expired_headers, + ) + if res_timeout.status_code not in (401, 403): + failures.append( + f"Session Timeout / Expiration Vulnerability: Protected route " + f"{protected_method} {protected_path} accessed with expired credentials " + f"returned {res_timeout.status_code} instead of 401/403." + ) + except Exception as e: + failures.append(f"Session Timeout Check Error: {e}") + + if failures: + raise SecurityTestError("\n".join(failures)) + + run_async(run()) diff --git a/src/zeroclaw/scanners/auth_scanner.py b/src/zeroclaw/scanners/auth_scanner.py index 95257e5..fc8af92 100644 --- a/src/zeroclaw/scanners/auth_scanner.py +++ b/src/zeroclaw/scanners/auth_scanner.py @@ -1,20 +1,281 @@ """Auth scanner: detect unprotected endpoints, missing RLS.""" +import ast +import logging +import os +import re +import stat from pathlib import Path -from zeroclaw.models import Finding +from zeroclaw.models import Category, Finding, Severity + +logger = logging.getLogger(__name__) + +EXTENSIONS_PY = {".py"} +EXTENSIONS_SQL = {".sql"} + +HTTP_METHODS = {"get", "post", "put", "delete", "patch", "options", "head", "api_route"} + +CREATE_TABLE_PAT = re.compile( + r"create\s+table\s+(?:if\s+(?:not\s+)?exists\s+)?([a-zA-Z0-9_\-\"\.]+)", re.IGNORECASE +) +RLS_ENABLE_PAT = re.compile( + r"alter\s+table\s+([a-zA-Z0-9_\-\"\.]+)\s+enable\s+row\s+level\s+security", re.IGNORECASE +) + + +def contains_depends_or_security(tree_node) -> bool: + """Recursively search an AST node for any call to Depends or Security.""" + if not tree_node: + return False + for child in ast.walk(tree_node): + if isinstance(child, ast.Call): + if isinstance(child.func, ast.Name): + if child.func.id in {"Depends", "Security"}: + return True + elif isinstance(child.func, ast.Attribute): + if child.func.attr in {"Depends", "Security"}: + return True + return False + + +def get_fastapi_route_decorator(node) -> ast.Call | None: + """Return the FastAPI route decorator node if present, otherwise None.""" + for dec in node.decorator_list: + if isinstance(dec, ast.Call): + if isinstance(dec.func, ast.Attribute): + if dec.func.attr in HTTP_METHODS: + return dec + elif isinstance(dec.func, ast.Name): + if dec.func.id in HTTP_METHODS: + return dec + return None def scan_fastapi_auth(target_dir: Path) -> list[Finding]: - """Check FastAPI routes for missing auth dependencies.""" + """Check FastAPI routes for missing auth dependencies using AST parsing.""" findings: list[Finding] = [] - # Structural scaffolding for Phase 3: - # 1. Walk target_dir for python files (*.py) - # 2. Check each file line-by-line for route decorators (e.g. @app.get, @router.post) - # 3. Assert if route definitions contain Depends(get_current_user) or equivalent security checks - # 4. Generate Category.AUTH findings for routes missing these parameters + resolved_target = target_dir.resolve() + + for file_path in target_dir.rglob("*"): + # skip symlinks + if file_path.is_symlink(): + logger.warning("Skipping symbolic link: %s", file_path) + continue + + # only process regular files + if not file_path.is_file(): + continue + + if file_path.suffix not in EXTENSIONS_PY: + continue + + fd = None + try: + # Boundary check using Path.resolve() before open + resolved_file = file_path.resolve() + if not resolved_file.is_relative_to(resolved_target): + logger.warning("Skipping file outside target directory: %s", file_path) + continue + + # Open file descriptor securely (O_NOFOLLOW prevents following trailing symlinks) + flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) + fd = os.open(resolved_file, flags) + + # Inspect metadata securely on the opened descriptor (TOCTOU fix) + fstat_info = os.fstat(fd) + if not stat.S_ISREG(fstat_info.st_mode): + logger.warning("Skipping non-regular file: %s", file_path) + os.close(fd) + fd = None + continue + + # Limit file size to 5MB + if fstat_info.st_size > 5 * 1024 * 1024: + os.close(fd) + fd = None + continue + + # Read securely using the file descriptor + with os.fdopen(fd, "r", encoding="utf-8", errors="ignore") as f: + fd = None # os.fdopen takes ownership of the descriptor + content = f.read() + + try: + tree = ast.parse(content) + except SyntaxError as e: + logger.warning("Syntax error parsing %s: %s", file_path, e) + continue + + for node in ast.walk(tree): + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + route_dec = get_fastapi_route_decorator(node) + if route_dec: + # Check protection status + protected = False + + # Check decorator keywords (e.g. dependencies=[Depends(...)]) + for kw in route_dec.keywords: + if kw.arg == "dependencies": + if contains_depends_or_security(kw.value): + protected = True + break + + # Check function default arguments + if not protected: + for default in node.args.defaults: + if contains_depends_or_security(default): + protected = True + break + + if not protected: + for default in node.args.kw_defaults: + if contains_depends_or_security(default): + protected = True + break + + if not protected: + route_start_line = route_dec.lineno + func_name = node.name + relative_path = str(resolved_file.relative_to(resolved_target)) + + findings.append( + Finding( + id=f"AUTH-{len(findings)+1:04d}", + severity=Severity.HIGH, + category=Category.AUTH, + title="Unprotected FastAPI Route", + description=( + f"FastAPI endpoint '{func_name}' at line {route_start_line} " + "does not enforce authentication/authorization checks." + ), + file_path=relative_path, + line_number=route_start_line, + remediation=( + "Add Depends(get_current_user) or equivalent security " + "dependency check to the route handler." + ), + ) + ) + + except (OSError, UnicodeDecodeError) as e: + if fd is not None: + try: + os.close(fd) + except OSError: + pass + logger.warning("Could not read file %s: %s", file_path, e) + except Exception as e: + if fd is not None: + try: + os.close(fd) + except OSError: + pass + logger.error("Unexpected error processing file %s: %s", file_path, e) + return findings +def normalize_table_name(name: str) -> str: + """Normalize a database table name by stripping quotes and lowercasing.""" + return name.strip().strip('"').strip("'").lower() + + def scan_supabase_rls(target_dir: Path) -> list[Finding]: """Check Supabase migrations for missing RLS policies.""" - raise NotImplementedError("Phase 3 task: Sania implements this") + findings: list[Finding] = [] + resolved_target = target_dir.resolve() + + created_tables: dict[str, tuple[Path, int, str]] = {} + enabled_tables: set[str] = set() + + for file_path in target_dir.rglob("*"): + if file_path.is_symlink(): + logger.warning("Skipping symbolic link: %s", file_path) + continue + + if not file_path.is_file(): + continue + + if file_path.suffix not in EXTENSIONS_SQL: + continue + + fd = None + try: + resolved_file = file_path.resolve() + if not resolved_file.is_relative_to(resolved_target): + logger.warning("Skipping file outside target directory: %s", file_path) + continue + + flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) + fd = os.open(resolved_file, flags) + + fstat_info = os.fstat(fd) + if not stat.S_ISREG(fstat_info.st_mode): + logger.warning("Skipping non-regular file: %s", file_path) + os.close(fd) + fd = None + continue + + if fstat_info.st_size > 5 * 1024 * 1024: + os.close(fd) + fd = None + continue + + with os.fdopen(fd, "r", encoding="utf-8", errors="ignore") as f: + fd = None + content = f.read() + + # Find all table creations + for match in CREATE_TABLE_PAT.finditer(content): + raw_table_name = match.group(1) + norm_name = normalize_table_name(raw_table_name) + char_idx = match.start() + line_num = content[:char_idx].count("\n") + 1 + if norm_name not in created_tables: + created_tables[norm_name] = (resolved_file, line_num, raw_table_name) + + # Find all RLS enablement + for match in RLS_ENABLE_PAT.finditer(content): + raw_table_name = match.group(1) + norm_name = normalize_table_name(raw_table_name) + enabled_tables.add(norm_name) + + except (OSError, UnicodeDecodeError) as e: + if fd is not None: + try: + os.close(fd) + except OSError: + pass + logger.warning("Could not read SQL file %s: %s", file_path, e) + except Exception as e: + if fd is not None: + try: + os.close(fd) + except OSError: + pass + logger.error("Unexpected error processing SQL file %s: %s", file_path, e) + + # Reconcile created tables against enabled tables + for norm_name, (resolved_file, line_num, original_name) in created_tables.items(): + if norm_name not in enabled_tables: + relative_path = str(resolved_file.relative_to(resolved_target)) + findings.append( + Finding( + id=f"RLS-{len(findings)+1:04d}", + severity=Severity.HIGH, + category=Category.AUTH, + title="Missing Row Level Security (RLS) Policy", + description=( + f"Database table '{original_name}' is created at line {line_num} " + "but Row Level Security (RLS) is not enabled." + ), + file_path=relative_path, + line_number=line_num, + remediation=( + f"Add 'ALTER TABLE {original_name} ENABLE ROW LEVEL SECURITY;' " + "to your migration file to enable RLS." + ), + ) + ) + + return findings diff --git a/tests/test_api_security_tester.py b/tests/test_api_security_tester.py new file mode 100644 index 0000000..262a87a --- /dev/null +++ b/tests/test_api_security_tester.py @@ -0,0 +1,194 @@ +import pytest +from fastapi import FastAPI, Depends, HTTPException, status, Response, Request +from fastapi.responses import JSONResponse +from zeroclaw.scanners.api_security_tester import APISecurityTester, SecurityTestError + +# ----------------- Helper Mock Apps ----------------- + +def create_secure_app() -> FastAPI: + app = FastAPI() + + # Simple auth dependency + def get_user(request: Request): + auth = request.headers.get("Authorization") + if not auth: + raise HTTPException(status_code=401, detail="Missing token") + if "expired" in auth: + raise HTTPException(status_code=401, detail="Token expired") + if "wrong_role" in auth: + raise HTTPException(status_code=403, detail="Forbidden: wrong role") + return {"user": "alice"} + + @app.get("/protected") + def protected_route(user: dict = Depends(get_user)): + return {"status": "success", "user": user} + + # Rate limiting mock (state stored in app state for simplicity in tests) + app.state.request_count = 0 + + @app.get("/rate-limited") + def rate_limited_route(): + app.state.request_count += 1 + if app.state.request_count > 5: + return JSONResponse(status_code=429, content={"detail": "Too many requests"}) + return {"status": "ok"} + + # Login and session management mocks + @app.post("/login") + def login(request: Request, response: Response): + # Session fixation defense: always rotate session ID + response.set_cookie(key="session", value="new_secure_session_id_789") + return {"status": "logged_in"} + + @app.get("/session-protected") + def session_protected(request: Request): + cookie = request.cookies.get("session") + if not cookie: + raise HTTPException(status_code=401, detail="No session") + if "tampered" in cookie or "expired" in cookie: + raise HTTPException(status_code=401, detail="Invalid session signature or expired") + return {"status": "ok"} + + return app + + +def create_vulnerable_app() -> FastAPI: + app = FastAPI() + + # Unprotected route (vulnerable to auth bypass) + @app.get("/protected") + def protected_route(): + return {"status": "success", "bypass": True} + + # Endpoint with no rate limiting (vulnerable) + @app.get("/rate-limited") + def rate_limited_route(): + return {"status": "ok"} + + # Login route vulnerable to session fixation + @app.post("/login") + def login(request: Request, response: Response): + # Does NOT rotate cookie if pre-existing + old_session = request.cookies.get("session") + if old_session: + response.set_cookie(key="session", value=old_session) + return {"status": "logged_in", "rotated": False} + response.set_cookie(key="session", value="new_session") + return {"status": "logged_in", "rotated": True} + + # Protected route vulnerable to hijacking (accepts tampered sessions) + @app.get("/session-protected") + def session_protected(request: Request): + cookie = request.cookies.get("session") + # Vuln 1: doesn't require session at all, or accepts tampered/expired ones + if cookie and "tampered" in cookie: + # Accepts tampered cookie! + return {"status": "ok", "tampered_accepted": True} + if not cookie: + # Accepts missing cookie as well! + return {"status": "ok", "no_session_accepted": True} + return {"status": "ok"} + + return app + + +# ----------------- Tests for APISecurityTester ----------------- + +class TestAPISecurityTesterAuthBypass: + def test_auth_bypass_passes_on_secure_app(self): + app = create_secure_app() + protected_routes = [ + { + "path": "/protected", + "method": "GET", + "headers": {"Authorization": "Bearer valid_token"}, + "expired_headers": {"Authorization": "Bearer expired_token"}, + "wrong_role_headers": {"Authorization": "Bearer wrong_role_token"}, + } + ] + # Should execute without raising SecurityTestError + APISecurityTester.test_auth_bypass(app, protected_routes) + + def test_auth_bypass_fails_on_vulnerable_app(self): + app = create_vulnerable_app() + protected_routes = [ + { + "path": "/protected", + "method": "GET", + "headers": {"Authorization": "Bearer valid_token"}, + "expired_headers": {"Authorization": "Bearer expired_token"}, + "wrong_role_headers": {"Authorization": "Bearer wrong_role_token"}, + } + ] + with pytest.raises(SecurityTestError) as exc_info: + APISecurityTester.test_auth_bypass(app, protected_routes) + + assert "Auth Bypass Check Failed" in str(exc_info.value) + assert "/protected" in str(exc_info.value) + + +class TestAPISecurityTesterRateLimiting: + def test_rate_limiting_passes_on_secure_app(self): + app = create_secure_app() + rate_limited_routes = [ + { + "path": "/rate-limited", + "method": "GET", + "limit": 5, + } + ] + # Should pass because secure app returns 429 on 6th request + APISecurityTester.test_rate_limiting(app, rate_limited_routes) + + def test_rate_limiting_fails_on_vulnerable_app(self): + app = create_vulnerable_app() + rate_limited_routes = [ + { + "path": "/rate-limited", + "method": "GET", + "limit": 5, + } + ] + with pytest.raises(SecurityTestError) as exc_info: + APISecurityTester.test_rate_limiting(app, rate_limited_routes) + + assert "Rate Limiting Check Failed" in str(exc_info.value) + assert "/rate-limited" in str(exc_info.value) + + +class TestAPISecurityTesterSessionSecurity: + def test_session_security_passes_on_secure_app(self): + app = create_secure_app() + session_routes = [ + { + "login_path": "/login", + "login_method": "POST", + "session_cookie_name": "session", + "protected_path": "/session-protected", + "protected_method": "GET", + "valid_cookies": {"session": "valid_session_123"}, + "expired_cookies": {"session": "expired_session_123"}, + } + ] + # Should pass cleanly + APISecurityTester.test_session_security(app, session_routes) + + def test_session_security_fails_on_vulnerable_app(self): + app = create_vulnerable_app() + session_routes = [ + { + "login_path": "/login", + "login_method": "POST", + "session_cookie_name": "session", + "protected_path": "/session-protected", + "protected_method": "GET", + "valid_cookies": {"session": "valid_session_123"}, + "expired_cookies": {"session": "expired_session_123"}, + } + ] + with pytest.raises(SecurityTestError) as exc_info: + APISecurityTester.test_session_security(app, session_routes) + + err_msg = str(exc_info.value) + # Verify fixation vulnerability, hijacking vulnerability, or timeout vulnerability is flagged + assert "Session Fixation" in err_msg or "Session Hijacking" in err_msg or "Session Timeout" in err_msg diff --git a/tests/test_auth_scanner.py b/tests/test_auth_scanner.py new file mode 100644 index 0000000..7dacee1 --- /dev/null +++ b/tests/test_auth_scanner.py @@ -0,0 +1,179 @@ +from pathlib import Path +import pytest +from zeroclaw.scanners.auth_scanner import scan_fastapi_auth, scan_supabase_rls +from zeroclaw.models import Category, Severity + + +class TestAuthScanner: + def test_detects_unprotected_endpoints(self, tmp_path): + """Should detect endpoints that do not have Depends or Security parameters.""" + file = tmp_path / "routes.py" + file.write_text(''' +@router.post("/auth/login") +async def auth_login(): + return {"status": "ok"} + +@app.get("/users/{user_id}") +def get_user(user_id: str): + return {"user_id": user_id} +''') + findings = scan_fastapi_auth(tmp_path) + assert len(findings) == 2 + + # Verify first finding + assert findings[0].category == Category.AUTH + assert findings[0].severity == Severity.HIGH + assert findings[0].line_number == 2 + assert "auth_login" in findings[0].description + + # Verify second finding + assert findings[1].line_number == 6 + assert "get_user" in findings[1].description + + def test_ignores_protected_endpoints(self, tmp_path): + """Should ignore routes that contain Depends or Security in their signature or decorator.""" + file = tmp_path / "routes.py" + file.write_text(''' +@router.post("/auth/logout") +async def auth_logout(admin: AdminContext = Depends(_admin_dep)): + return {"status": "ok"} + +@router.get("/users", dependencies=[Depends(_admin_dep)]) +async def users_list(): + return [] + +@app.get("/secure") +def secure_route(api_key: str = Security(api_key_header)): + return {"status": "secure"} +''') + findings = scan_fastapi_auth(tmp_path) + assert len(findings) == 0 + + def test_unreadable_file_logged(self, tmp_path, caplog): + """Should log warning when file cannot be read due to OS error.""" + import logging + from unittest.mock import patch + + bad_file = tmp_path / "unreadable.py" + bad_file.write_text("@app.get('/unprotected')\ndef bad_route(): pass") + + with patch("os.open", side_effect=PermissionError("Mocked permission error")): + with caplog.at_level(logging.WARNING): + findings = scan_fastapi_auth(tmp_path) + assert len(findings) == 0 + + warnings = [rec.message for rec in caplog.records if rec.levelno == logging.WARNING] + assert any("Could not read file" in w and "Mocked permission error" in w for w in warnings) + + def test_large_file_skipped(self, tmp_path): + """Should skip scanning files that exceed the 5MB size limit.""" + large_file = tmp_path / "large.py" + large_file.write_text("@app.get('/unprotected')\ndef bad(): pass" + (" " * 5 * 1024 * 1024)) + findings = scan_fastapi_auth(tmp_path) + assert len(findings) == 0 + + def test_path_traversal_prevention(self, tmp_path): + """Should prevent scanning files resolving outside the target boundary.""" + from unittest.mock import MagicMock, patch + from pathlib import Path + + file_mock = MagicMock(spec=Path) + file_mock.is_symlink.return_value = False + file_mock.is_file.return_value = True + file_mock.suffix = ".py" + file_mock.resolve.return_value = Path("C:/Windows") if Path("C:/").exists() else Path("/etc") + file_mock.stat.return_value.st_size = 100 + + with patch.object(Path, "rglob", return_value=[file_mock]): + findings = scan_fastapi_auth(tmp_path) + assert len(findings) == 0 + + def test_comment_bypass_prevented(self, tmp_path): + """Should detect unprotected endpoint even if comments or string literals contain 'Depends'.""" + file = tmp_path / "routes.py" + file.write_text(''' +@router.post("/auth/login") +async def auth_login(): + # This is a comment containing Depends() or Security() + dummy = "Depends(auth_provider)" + return {"status": "ok"} +''') + findings = scan_fastapi_auth(tmp_path) + assert len(findings) == 1 + assert findings[0].line_number == 2 + assert "auth_login" in findings[0].description + + def test_relative_target_dir_no_value_error(self, tmp_path): + """Should support relative Path objects without raising a ValueError on relative_to.""" + file = tmp_path / "routes.py" + file.write_text(''' +@router.get("/unprotected") +def unprotected(): + pass +''') + import os + # Change current working directory to parent of tmp_path to make a relative path + old_cwd = os.getcwd() + os.chdir(tmp_path.parent) + try: + relative_path = Path(tmp_path.name) + # This should not raise ValueError + findings = scan_fastapi_auth(relative_path) + assert len(findings) == 1 + assert findings[0].file_path == "routes.py" + finally: + os.chdir(old_cwd) + + +class TestSupabaseRLSScanner: + def test_detects_missing_rls(self, tmp_path): + """Should detect created tables that do not have RLS enabled.""" + file = tmp_path / "migration.sql" + file.write_text(''' +CREATE TABLE users ( + id UUID PRIMARY KEY, + email TEXT +); + +CREATE TABLE IF EXISTS posts ( + id SERIAL PRIMARY KEY, + title TEXT +); +''') + findings = scan_supabase_rls(tmp_path) + assert len(findings) == 2 + + assert findings[0].category == Category.AUTH + assert findings[0].severity == Severity.HIGH + assert "users" in findings[0].description + assert findings[0].line_number == 2 + + assert "posts" in findings[1].description + assert findings[1].line_number == 7 + + def test_ignores_enabled_rls(self, tmp_path): + """Should ignore tables that have RLS enabled in the SQL file.""" + file = tmp_path / "migration.sql" + file.write_text(''' +CREATE TABLE users ( + id UUID PRIMARY KEY +); + +ALTER TABLE users ENABLE ROW LEVEL SECURITY; +''') + findings = scan_supabase_rls(tmp_path) + assert len(findings) == 0 + + def test_supabase_rls_mixed_case_and_formatting(self, tmp_path): + """Should correctly handle formatting changes, newlines, and case insensitivity.""" + file = tmp_path / "migration.sql" + file.write_text(''' +Create Table "Users" ( + id UUID PRIMARY KEY +); + +Alter Table "users" + Enable Row Level Security; +''') + findings = scan_supabase_rls(tmp_path) + assert len(findings) == 0 From 80c60ac01c664a378e25867c5216e016ab3fba56 Mon Sep 17 00:00:00 2001 From: Kailash Date: Tue, 23 Jun 2026 22:20:42 +0530 Subject: [PATCH 3/3] chore: resolve merge conflicts in scanners, reporter, and cli --- .pre-commit-config.yaml | 9 + src/zeroclaw/cli.py | 74 ++-- src/zeroclaw/reporter.py | 393 +++++++++---------- src/zeroclaw/scanners/api_security_tester.py | 33 +- src/zeroclaw/scanners/dependency_scanner.py | 2 +- src/zeroclaw/scanners/pattern_scanner.py | Bin 20114 -> 9802 bytes 6 files changed, 265 insertions(+), 246 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f5bb04c..e526c6d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -28,3 +28,12 @@ repos: - id: detect-secrets args: [--baseline, .secrets.baseline] exclude: .env.example + + - repo: local + hooks: + - id: zeroclaw-scan + name: ZeroClaw Security Scan + entry: python -m zeroclaw.cli scan --target . --stream local-commit + language: system + pass_filenames: false + always_run: true diff --git a/src/zeroclaw/cli.py b/src/zeroclaw/cli.py index c29ff3c..55492c3 100644 --- a/src/zeroclaw/cli.py +++ b/src/zeroclaw/cli.py @@ -3,12 +3,13 @@ import argparse import json +import os import sys import time from datetime import datetime, timezone from pathlib import Path -from .models import ScanResult +from zeroclaw.models import ScanResult def main() -> None: @@ -32,7 +33,7 @@ def main() -> None: report_cmd = subparsers.add_parser("report", help="Generate a report from a saved scan") report_cmd.add_argument("--format", choices=["terminal", "json"], default="terminal") - report_cmd.add_argument("--input", default="zeroclaw_scan.json", help="Input JSON scan file") + report_cmd.add_argument("--input", default="reports/latest_scan.json", help="Input JSON scan file") args = parser.parse_args() @@ -49,17 +50,17 @@ def main() -> None: def _run_scan(args: argparse.Namespace) -> None: """Execute the 3-phase scan pipeline: Gather → Enrich → Report.""" - from .scanners.pattern_scanner import scan_patterns - from .scanners.dependency_scanner import scan_dependencies - from .scanners.secret_scanner import scan_secrets - from .scanners.auth_scanner import scan_fastapi_auth, scan_supabase_rls + from zeroclaw.scanners.pattern_scanner import scan_patterns + from zeroclaw.scanners.dependency_scanner import scan_dependencies + from zeroclaw.scanners.secret_scanner import scan_secrets + from zeroclaw.scanners.auth_scanner import scan_fastapi_auth, scan_supabase_rls target = Path(args.target).resolve() if not target.exists(): print(f"Error: target directory {target} does not exist") sys.exit(1) - stream = args.stream + stream = args.stream if args.stream else target.name # ── Phase 1: Gathering ────────────────────────────────────────────── print(f"[ZeroClaw] Phase 1/3 — Static analysis on {target}") @@ -91,9 +92,8 @@ def _run_scan(args: argparse.Namespace) -> None: raw_findings.extend(rls) # Tag findings with stream label - if stream: - for f in raw_findings: - f.stream = stream + for f in raw_findings: + f.stream = stream print(f"\n[ZeroClaw] Total raw findings: {len(raw_findings)}") @@ -113,7 +113,7 @@ def _run_scan(args: argparse.Namespace) -> None: ) if enrichable: - from .agent_client import ZeroClawClient + from zeroclaw.agent_client import ZeroClawClient client = ZeroClawClient() @@ -151,26 +151,43 @@ def _run_scan(args: argparse.Namespace) -> None: # Build the ScanResult stats = {} for f in enriched_findings: - sev = f.severity.value - stats[sev] = stats.get(sev, 0) + 1 + if f.false_positive: + continue + stats[f.severity.value] = stats.get(f.severity.value, 0) + 1 + stats[f.category.value] = stats.get(f.category.value, 0) + 1 result = ScanResult( stream=stream, - repo_url=str(target), + repo_url=f"https://github.com/lifeatlas/{stream}", scanned_at=datetime.now(timezone.utc), findings=enriched_findings, stats=stats, ) - from .reporter import generate_terminal_report, generate_json_report + from zeroclaw.reporter import generate_terminal_report, generate_json_report + + # Ensure reports directory exists + reports_dir = Path("reports") + reports_dir.mkdir(parents=True, exist_ok=True) + + # Save to reports/latest_scan.json and reports/{stream}_scan.json + json_data = generate_json_report(result) + latest_file = reports_dir / "latest_scan.json" + stream_file = reports_dir / f"{stream}_scan.json" + + # Datetime serializer for JSON + def dt_serializer(obj): + if isinstance(obj, datetime): + return obj.isoformat() + raise TypeError("Type not serializable") + + with open(latest_file, "w", encoding="utf-8") as fh: + json.dump(json_data, fh, indent=2, default=dt_serializer) + with open(stream_file, "w", encoding="utf-8") as fh: + json.dump(json_data, fh, indent=2, default=dt_serializer) if args.format == "json": - report = generate_json_report(result) - # Save to file - out_path = Path("zeroclaw_scan.json") - with open(out_path, "w", encoding="utf-8") as fh: - json.dump(report, fh, indent=2, default=str) - print(f"\n[ZeroClaw] JSON report saved to {out_path}") + print(json.dumps(json_data, indent=2, default=dt_serializer)) else: output = generate_terminal_report(result) print(output) @@ -185,12 +202,17 @@ def _run_report(args: argparse.Namespace) -> None: print(f"Error: input file {input_path} does not exist") sys.exit(1) - with open(input_path, encoding="utf-8") as fh: - data = json.load(fh) - - result = ScanResult(**data) + try: + with open(input_path, encoding="utf-8") as fh: + data = json.load(fh) + + # Parse back to ScanResult model (Pydantic resolves datetime strings) + result = ScanResult(**data) + except Exception as e: + print(f"Error reading or parsing scan results: {e}") + sys.exit(1) - from .reporter import generate_terminal_report, generate_json_report + from zeroclaw.reporter import generate_terminal_report, generate_json_report if args.format == "json": report = generate_json_report(result) diff --git a/src/zeroclaw/reporter.py b/src/zeroclaw/reporter.py index 31369b2..772b111 100644 --- a/src/zeroclaw/reporter.py +++ b/src/zeroclaw/reporter.py @@ -1,229 +1,216 @@ """Report generation: terminal, JSON, PDF.""" from __future__ import annotations +from datetime import datetime +import json +from collections import defaultdict -from zeroclaw.models import ScanResult, StreamScore +from zeroclaw.models import ScanResult, StreamScore, Finding, Severity, Category -def generate_terminal_report(result: ScanResult) -> str: - """Rich terminal output of scan results with ZeroClaw enrichment data.""" - lines: list[str] = [] +def calculate_stream_score(result: ScanResult) -> StreamScore: + """Calculate 0-10 security score for a stream. + + Formula: + Base score is 10.0. + For each finding: + - Critical: -2.5 + - High: -1.5 + - Medium: -0.75 + - Low: -0.25 + - Info: -0.0 + The score is bounded to [0.0, 10.0] and rounded to 1 decimal place. + """ + severity_deductions = { + Severity.CRITICAL: 2.5, + Severity.HIGH: 1.5, + Severity.MEDIUM: 0.75, + Severity.LOW: 0.25, + Severity.INFO: 0.0, + } - # ── Header ────────────────────────────────────────────────────────── - lines.append("") - lines.append("=" * 72) - lines.append(" ZEROCLAW SECURITY SCAN REPORT") - lines.append("=" * 72) - lines.append("") - lines.append(f" Repository: {result.repo_url}") - if result.stream: - lines.append(f" Stream: {result.stream}") - lines.append(f" Scanned at: {result.scanned_at.isoformat()}") - lines.append("") + counts = { + "critical": 0, + "high": 0, + "medium": 0, + "low": 0, + "info": 0, + } - # ── Executive Summary ─────────────────────────────────────────────── - total = len(result.findings) - lines.append("─" * 72) - lines.append(" EXECUTIVE SUMMARY") - lines.append("─" * 72) - lines.append("") + # Count findings by severity, ignoring false positives + for finding in result.findings: + if finding.false_positive: + continue + counts[finding.severity.value] = counts.get(finding.severity.value, 0) + 1 + + # Calculate score + deductions = sum(counts[sev] * severity_deductions[Severity(sev)] for sev in counts) + score_val = max(0.0, 10.0 - deductions) + score_val = round(score_val, 1) + + # Prioritize and identify top issues + severity_order = { + Severity.CRITICAL: 0, + Severity.HIGH: 1, + Severity.MEDIUM: 2, + Severity.LOW: 3, + Severity.INFO: 4, + } - if total == 0: - lines.append(" ✅ No vulnerabilities detected.") - lines.append("") - lines.append("=" * 72) - return "\n".join(lines) + real_findings = [f for f in result.findings if not f.false_positive] + real_findings.sort(key=lambda f: (severity_order.get(f.severity, 99), f.title)) - lines.append(f" Total Findings: {total}") - lines.append("") + top_issues = [] + seen_titles = set() + for f in real_findings: + if f.title not in seen_titles: + seen_titles.add(f.title) + top_issues.append(f"{f.severity.value.upper()}: {f.title}") + if len(top_issues) == 5: + break - # Severity breakdown - severity_order = ["critical", "high", "medium", "low", "info"] - severity_icons = { - "critical": "🔴", - "high": "🟠", - "medium": "🟡", - "low": "🔵", - "info": "⚪", - } + return StreamScore( + stream=result.stream, + score=score_val, + findings_by_severity=counts, + top_issues=top_issues, + ) - for sev in severity_order: - count = result.stats.get(sev, 0) - if count > 0: - icon = severity_icons.get(sev, " ") - lines.append(f" {icon} {sev.upper():10s} {count}") - lines.append("") +def generate_json_report(result: ScanResult) -> dict: + """JSON report for dashboard consumption.""" + scorecard = calculate_stream_score(result) + + # Calculate stats + stats = {} + for f in result.findings: + if f.false_positive: + continue + stats[f.category.value] = stats.get(f.category.value, 0) + 1 + stats[f.severity.value] = stats.get(f.severity.value, 0) + 1 + + result.stats = stats - # Security posture - critical = result.stats.get("critical", 0) - high = result.stats.get("high", 0) - if critical > 0: - lines.append(" ⛔ Security Posture: CRITICAL — Immediate remediation required.") - elif high > 0: - lines.append(" ⚠️ Security Posture: AT RISK — High-severity issues must be addressed.") + if hasattr(result, "model_dump_json"): + serialized = json.loads(result.model_dump_json()) else: - lines.append(" 📋 Security Posture: MODERATE — Review findings before production.") + serialized = json.loads(result.json()) + + scorecard_dict = scorecard.model_dump() if hasattr(scorecard, "model_dump") else scorecard.dict() + serialized["scorecard"] = scorecard_dict + return serialized + + +REMEDIATIONS_GUIDES = { + Category.SECRET: { + "guideline": "API keys, passwords, and sensitive tokens should never be hardcoded in source files or committed to Git. Instead, load them from environment variables or a secret manager.", + "bad": 'API_KEY = "sk-ant-api03-exampleKeyValHere1234567890"', + "good": 'import os\nAPI_KEY = os.environ.get("API_KEY")' + }, + Category.DEPENDENCY: { + "guideline": "Vulnerable dependencies expose applications to known exploits. Floating/unpinned dependencies risk supply chain compromise. Pin versions and use cryptographic hashes.", + "bad": 'requests>=2.25.0', + "good": 'requests==2.31.0 --hash=sha256:7486c32d... # or use lockfiles' + }, + Category.CODE_PATTERN: { + "guideline": "Avoid dynamic SQL query building and direct innerHTML assignments. Use parameterized queries or secure DOM APIs.", + "bad": 'cursor.execute(f"SELECT * FROM users WHERE id = {user_id}")\n# Or in frontend:\nelement.innerHTML = user_input', + "good": 'cursor.execute("SELECT * FROM users WHERE id = %s", (user_id,))\n# Or in frontend:\nelement.textContent = user_input' + }, + Category.AUTH: { + "guideline": "Endpoints that handle user data must enforce access control via authentication dependencies or Supabase row-level security (RLS) policies.", + "bad": '@app.get("/data")\ndef get_data(): ...', + "good": '@app.get("/data")\ndef get_data(user: User = Depends(get_current_user)): ...\n# SQL RLS:\nALTER TABLE profiles ENABLE ROW LEVEL SECURITY;' + } +} + +def generate_terminal_report(result: ScanResult) -> str: + """Rich terminal output of scan results.""" + scorecard = calculate_stream_score(result) + + # Calculate stats + stats = { + "critical": 0, + "high": 0, + "medium": 0, + "low": 0, + "info": 0, + } + for f in result.findings: + if f.false_positive: + continue + stats[f.severity.value] = stats.get(f.severity.value, 0) + 1 + + lines = [] + lines.append("=" * 80) + lines.append(f" ZEROCLAW SECURITY REPORT — {result.stream.upper()}") + lines.append(f" Scanned At: {result.scanned_at.strftime('%Y-%m-%d %H:%M:%S')}") + lines.append("=" * 80) + lines.append("") + + # Score details + lines.append(f" GLASS Security Score: {scorecard.score}/10.0") + lines.append(f" Findings by Severity: CRITICAL: {stats['critical']} | HIGH: {stats['high']} | MEDIUM: {stats['medium']} | LOW: {stats['low']} | INFO: {stats['info']}") lines.append("") - # ── Detailed Findings ─────────────────────────────────────────────── - lines.append("─" * 72) - lines.append(" DETAILED FINDINGS") - lines.append("─" * 72) + if not result.findings: + lines.append(" ✅ No security findings detected! The repository is clean.") + lines.append("-" * 80) + return "\n".join(lines) - for i, finding in enumerate(result.findings, 1): - sev = finding.severity.value.upper() - icon = severity_icons.get(finding.severity.value, " ") + # Group findings by category + grouped: dict[Category, list[Finding]] = defaultdict(list) + for f in result.findings: + grouped[f.category].append(f) - lines.append("") - lines.append(f" {icon} [{i}/{total}] {finding.id}") - lines.append(f" {'─' * 60}") - lines.append(f" Severity: {sev}") - lines.append(f" Category: {finding.category.value}") - lines.append(f" Title: {finding.title}") - lines.append(f" File: {finding.file_path}") - if finding.line_number is not None: - lines.append(f" Line: {finding.line_number}") - lines.append("") - lines.append(f" Description:") - for desc_line in finding.description.split("\n"): - lines.append(f" {desc_line}") - lines.append("") - lines.append(f" Remediation:") - for rem_line in finding.remediation.split("\n"): - lines.append(f" {rem_line}") + # Print Findings grouped by Category + lines.append("DETAILED FINDINGS") + lines.append("-" * 80) - # ── ZeroClaw Enrichment (if available) ────────────────────────── - if finding.reasoning_chain: + for cat in Category: + cat_findings = grouped.get(cat, []) + if not cat_findings: + continue + + lines.append(f"## {cat.value.upper()} SCANNER — {len(cat_findings)} findings") + lines.append("") + + for idx, f in enumerate(cat_findings, start=1): + fp_flag = " [FALSE POSITIVE]" if f.false_positive else "" + lines.append(f" {idx}. [{f.severity.value.upper()}]{fp_flag} {f.title}") + lines.append(f" File: {f.file_path}:{f.line_number or 'N/A'}") + lines.append(f" ID: {f.id}") + lines.append(f" Description: {f.description}") + lines.append(f" Remediation: {f.remediation}") + + # Print ZeroClaw Reasoning and Fixed Code if present + if getattr(f, "reasoning_chain", None): + lines.append("") + lines.append(" 🧠 ZeroClaw Reasoning:") + for chain_line in f.reasoning_chain.splitlines(): + lines.append(f" {chain_line}") + if getattr(f, "fixed_code", None): + lines.append("") + lines.append(" 🔧 ZeroClaw Fixed Code:") + lines.append(" ┌" + "─" * 56 + "┐") + for code_line in f.fixed_code.splitlines(): + lines.append(f" │ {code_line}") + lines.append(" └" + "─" * 56 + "┘") lines.append("") - lines.append(f" 🧠 ZeroClaw Reasoning:") - for chain_line in finding.reasoning_chain.split("\n"): - lines.append(f" {chain_line}") - if finding.fixed_code: + # Add remediation guidance + code examples + guide = REMEDIATIONS_GUIDES.get(cat) + if guide: + lines.append(" Remediation Guidance:") + lines.append(f" {guide['guideline']}") + lines.append(" [BAD EXAMPLES]") + for bad_line in guide["bad"].splitlines(): + lines.append(f" - {bad_line}") + lines.append(" [GOOD EXAMPLES]") + for good_line in guide["good"].splitlines(): + lines.append(f" + {good_line}") lines.append("") - lines.append(f" 🔧 ZeroClaw Fixed Code:") - lines.append(f" ┌{'─' * 56}┐") - for code_line in finding.fixed_code.split("\n"): - lines.append(f" │ {code_line}") - lines.append(f" └{'─' * 56}┘") - - lines.append("") - - # ── Footer ────────────────────────────────────────────────────────── - enriched_count = sum(1 for f in result.findings if f.reasoning_chain is not None) - lines.append("=" * 72) - lines.append(f" {total} findings | {enriched_count} AI-enriched | powered by ZeroClaw") - lines.append("=" * 72) - lines.append("") + lines.append("-" * 80) return "\n".join(lines) - -def generate_json_report(result: ScanResult) -> dict: - """JSON report formatted precisely to the LifeAtlasEcosystemSecurityFindingSchema.""" - - findings_array = [] - for f in result.findings: - # Default stride based on category - stride = "Tampering" - if f.category.value == "auth": - stride = "Elevation of Privilege" - elif f.category.value == "secret": - stride = "Information Disclosure" - elif f.category.value == "dependency": - stride = "Tampering" - elif f.category.value == "injection": - stride = "Tampering" - - steps = f.remediation - if f.fixed_code: - steps += f"\n\nFixed Code:\n```\n{f.fixed_code}\n```" - - findings_array.append({ - "id": f.id, - "reasoning_chain": f.reasoning_chain or "Static analysis identified the vulnerability; AI enrichment skipped or unavailable.", - "severity": f.severity.value.upper(), - "stride_classification": stride, - "owasp_alignment": "LA-01", - "affected_component": f.file_path, - "description": f.description, - "remediation": { - "steps": steps - } - }) - - stream_id = 1 - try: - import re - if result.stream: - match = re.search(r'\d+', result.stream) - if match: - stream_id = int(match.group(0)) - except Exception: - pass - - # Extract base name from repo_url or target path - import os - repo_name = os.path.basename(os.path.normpath(result.repo_url)) or "unknown-repo" - - return { - "scan_metadata": { - "timestamp": result.scanned_at.isoformat(), - "scanner_tool": "custom-regex", - "execution_environment": "local-dev-env" - }, - "target_scope": { - "stream_id": stream_id, - "repository_name": repo_name, - "commit_sha": "0000000000000000000000000000000000000000" - }, - "summary": { - "total_findings": len(result.findings), - "critical_count": result.stats.get("critical", 0), - "high_count": result.stats.get("high", 0), - "medium_count": result.stats.get("medium", 0), - "low_count": result.stats.get("low", 0) - }, - "findings": findings_array - } - - -def calculate_stream_score(result: ScanResult) -> StreamScore: - """Calculate 0-10 security score for a stream.""" - # Severity weights for scoring - weights = { - "critical": 10.0, - "high": 5.0, - "medium": 2.0, - "low": 0.5, - "info": 0.0, - } - - total_penalty = 0.0 - findings_by_severity: dict[str, int] = {} - - for finding in result.findings: - sev = finding.severity.value - findings_by_severity[sev] = findings_by_severity.get(sev, 0) + 1 - total_penalty += weights.get(sev, 0) - - # Score: 10.0 (perfect) minus penalties, floored at 0.0 - raw_score = max(0.0, 10.0 - total_penalty) - score = round(raw_score, 1) - - # Top issues: up to 5 highest-severity finding titles - sorted_findings = sorted( - result.findings, - key=lambda f: list(weights.keys()).index(f.severity.value) - if f.severity.value in weights - else 999, - ) - top_issues = [f.title for f in sorted_findings[:5]] - - return StreamScore( - stream=result.stream, - score=score, - findings_by_severity=findings_by_severity, - top_issues=top_issues, - ) diff --git a/src/zeroclaw/scanners/api_security_tester.py b/src/zeroclaw/scanners/api_security_tester.py index 4e209a3..aef78d0 100644 --- a/src/zeroclaw/scanners/api_security_tester.py +++ b/src/zeroclaw/scanners/api_security_tester.py @@ -267,7 +267,7 @@ async def run(): async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client: # Set pre-existing session token/cookie dummy_token = "pre_existing_session_id_123456" - if cookie_name: + if isinstance(cookie_name, str): client.cookies[cookie_name] = dummy_token req_headers = {} @@ -283,21 +283,22 @@ async def run(): ) # Verify fixation protection without ValueError on duplicates - new_cookie_val = response.cookies.get(cookie_name) - if not new_cookie_val: - # Inspect client jar safely - matching_vals = [c.value for c in client.cookies.jar if c.name == cookie_name] - if len(matching_vals) == 1 and matching_vals[0] == dummy_token: - failures.append( - f"Session Fixation Vulnerability: Login at {login_path} did not set a new " - f"session cookie '{cookie_name}'. Value remained the pre-existing '{dummy_token}'." - ) - else: - if new_cookie_val == dummy_token: - failures.append( - f"Session Fixation Vulnerability: Login at {login_path} set session cookie " - f"'{cookie_name}' but kept the pre-existing value '{dummy_token}'." - ) + if isinstance(cookie_name, str): + new_cookie_val = response.cookies.get(cookie_name) + if not new_cookie_val: + # Inspect client jar safely + matching_vals = [c.value for c in client.cookies.jar if c.name == cookie_name] + if len(matching_vals) == 1 and matching_vals[0] == dummy_token: + failures.append( + f"Session Fixation Vulnerability: Login at {login_path} did not set a new " + f"session cookie '{cookie_name}'. Value remained the pre-existing '{dummy_token}'." + ) + else: + if new_cookie_val == dummy_token: + failures.append( + f"Session Fixation Vulnerability: Login at {login_path} set session cookie " + f"'{cookie_name}' but kept the pre-existing value '{dummy_token}'." + ) if header_name: new_header_val = response.headers.get(header_name) diff --git a/src/zeroclaw/scanners/dependency_scanner.py b/src/zeroclaw/scanners/dependency_scanner.py index cf64792..362b573 100644 --- a/src/zeroclaw/scanners/dependency_scanner.py +++ b/src/zeroclaw/scanners/dependency_scanner.py @@ -1,4 +1,4 @@ -"""Dependency vulnerability scanner: npm audit + pip-audit + CVE lookup.""" +import os import json import re import subprocess diff --git a/src/zeroclaw/scanners/pattern_scanner.py b/src/zeroclaw/scanners/pattern_scanner.py index 4f2b637b250bc820f074b4b324b554cfca2d4846..8d7f25f01d7d68c70c97dff2cac57af7475266c6 100644 GIT binary patch literal 9802 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 Y6qO=*9vaonT`l$rMiJ)7)nZude^~p}H~;_u literal 20114 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