From def75c1a8af754cb7b339397f3cd8c2dc4a85199 Mon Sep 17 00:00:00 2001 From: Roberto Salgado Date: Tue, 28 Jul 2026 20:37:01 -0700 Subject: [PATCH 1/7] Security and robustness hardening pass Core hardening: - Symlink/TOCTOU-safe file writes (open_secure_write), owner-only perms - Versioned, fingerprinted checkpoints; fail-safe against new config fields - Trusted-remote verification for self-update; explicit refs/heads/main pull - Rich-markup escaping of attacker-influenced output; CSV formula-injection guard - CRLF/NUL header validation; structured FUZZ/param substitution (no regex) - TOML config type validation and unknown-key warnings Review fixes: - Fix pre-commit hooks: drop unsupported `run --staged`; correct pip-audit --desc - Dedup composite-OS CSV rows (no duplicate requests; 958 default cases) - Partial transient request failures no longer fail an otherwise good scan - Text output files no longer hard-wrap long paths - Non-interactive EOF no longer auto-approves OS narrowing - CLI headers extend config headers with CLI precedence - Consistent header deprecation warnings and FUZZ detection Detection change (intentional): full response comparison replaces Content-Length-only shortcut; quick_ratio() upper-bound preserves perf. Pending end-to-end validation against a live LFI target. CI: SHA-pinned GitHub Actions, least-privilege permissions, Dependabot, minimum-version job. 268 tests pass. --- .gitattributes | 2 + .githooks/pre-commit | 122 ++++++++++ .github/constraints-min.txt | 7 + .github/dependabot.yml | 18 ++ .github/workflows/ci.yml | 60 +++++ .gitignore | 7 +- .pre-commit-config.yaml | 9 +- README.md | 156 ++++++++++++- panoptic/__main__.py | 2 +- panoptic/cases.py | 39 +++- panoptic/cli.py | 312 ++++++++++++++++--------- panoptic/config.py | 166 +++++++++++-- panoptic/core.py | 449 ++++++++++++++++++++++++----------- panoptic/data/cases.csv | 68 +++--- panoptic/heuristic.py | 14 +- panoptic/models.py | 4 + panoptic/network.py | 71 +++--- panoptic/output.py | 78 +++++-- panoptic/parsers.py | 9 +- panoptic/update.py | 76 ++++-- panoptic/utils.py | 190 +++++++++++++-- pyproject.toml | 21 +- tests/test_cases.py | 80 ++++++- tests/test_cli.py | 124 +++++++++- tests/test_config.py | 53 ++++- tests/test_core.py | 450 +++++++++++++++++++++++++++++++++++- tests/test_heuristic.py | 6 + tests/test_integration.py | 8 +- tests/test_models.py | 8 + tests/test_network.py | 35 +++ tests/test_output.py | 155 ++++++++++++- tests/test_parsers.py | 9 + tests/test_update.py | 51 +++- tests/test_utils.py | 113 +++++++++ 34 files changed, 2526 insertions(+), 446 deletions(-) create mode 100755 .githooks/pre-commit create mode 100644 .github/constraints-min.txt create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yml diff --git a/.gitattributes b/.gitattributes index d9bd16b..0354f3c 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,3 @@ *.py text eol=lf + +panoptic/data/*.csv whitespace=cr-at-eol diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..4182437 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,122 @@ +#!/bin/bash +# Pre-commit hook for Panoptic +# +# This hook delegates to the pre-commit framework for consistent tool configuration. +# The canonical check definitions are in .pre-commit-config.yaml. +# +# Setup options: +# Option A (recommended): Use pre-commit framework directly +# pip install pre-commit && pre-commit install +# +# Option B: Use this script via git config +# git config core.hooksPath .githooks +# +# Option C: Copy to .git/hooks +# cp .githooks/pre-commit .git/hooks/pre-commit && chmod +x .git/hooks/pre-commit + +# Do not use set -e; we want to capture all failures and report them + +echo "Running pre-commit checks..." + +EXIT_CODE=0 +CHECKS_RAN=0 + +# Prefer pre-commit framework if available (uses .pre-commit-config.yaml) +if command -v pre-commit &> /dev/null; then + echo "Using pre-commit framework..." + if ! pre-commit run; then + EXIT_CODE=1 + fi + CHECKS_RAN=1 +else + echo "Note: pre-commit framework not installed." + echo "Install with: pip install pre-commit && pre-commit install" + echo "Falling back to direct tool invocation..." + echo "" + + # Get list of staged Python files + STAGED_PY=() + while IFS= read -r file; do + [ -n "$file" ] && STAGED_PY+=("$file") + done < <(git diff --cached --name-only --diff-filter=ACM | grep '\.py$' || true) + + # Get list of staged Markdown files + STAGED_MD=() + while IFS= read -r file; do + [ -n "$file" ] && STAGED_MD+=("$file") + done < <(git diff --cached --name-only --diff-filter=ACM | grep '\.md$' || true) + + # Python checks + if [ "${#STAGED_PY[@]}" -gt 0 ]; then + echo "Checking Python files: ${STAGED_PY[*]}" + + # Run ruff check (replaces flake8 - no need for both) + echo "Running ruff check..." + if command -v ruff &> /dev/null; then + if ! ruff check "${STAGED_PY[@]}"; then + EXIT_CODE=1 + fi + CHECKS_RAN=1 + else + echo "Warning: ruff not installed, skipping ruff check" + fi + + # Run ruff format check (don't auto-fix in hook, just check) + echo "Running ruff format check..." + if command -v ruff &> /dev/null; then + if ! ruff format --check "${STAGED_PY[@]}"; then + EXIT_CODE=1 + fi + CHECKS_RAN=1 + else + echo "Warning: ruff not installed, skipping format check" + fi + + # Run pyright + echo "Running pyright..." + if command -v pyright &> /dev/null; then + if ! pyright "${STAGED_PY[@]}"; then + EXIT_CODE=1 + fi + CHECKS_RAN=1 + else + echo "Warning: pyright not installed, skipping type checking" + fi + else + echo "No Python files staged." + fi + + # Markdown checks + if [ "${#STAGED_MD[@]}" -gt 0 ]; then + echo "Checking Markdown files: ${STAGED_MD[*]}" + + echo "Running markdownlint..." + if command -v markdownlint &> /dev/null; then + if ! markdownlint "${STAGED_MD[@]}"; then + EXIT_CODE=1 + fi + CHECKS_RAN=1 + elif command -v npx >/dev/null 2>&1; then + if ! npx markdownlint "${STAGED_MD[@]}"; then + EXIT_CODE=1 + fi + CHECKS_RAN=1 + else + echo "Warning: markdownlint not installed, skipping markdown lint" + fi + else + echo "No Markdown files staged." + fi +fi + +# Summary +echo "" +if [ "$CHECKS_RAN" -eq 0 ]; then + echo "No checks were run (no tools installed or no files staged)." +elif [ "$EXIT_CODE" -eq 0 ]; then + echo "All checks passed!" +else + echo "Some checks failed. Please fix the issues above before committing." +fi + +exit $EXIT_CODE diff --git a/.github/constraints-min.txt b/.github/constraints-min.txt new file mode 100644 index 0000000..09e1513 --- /dev/null +++ b/.github/constraints-min.txt @@ -0,0 +1,7 @@ +# Lower bounds of the declared runtime dependencies (see pyproject.toml). +# Used by the "minimum-versions" CI job to prove Panoptic works against the +# oldest dependency releases it claims to support, not just the newest. +httpx==0.28.1 +rich==15.0.0 +rich-argparse==1.4 +tomli==2.0; python_version < "3.11" diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..c577cb2 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,18 @@ +version: 2 +updates: + - package-ecosystem: pip + directory: / + schedule: + interval: weekly + groups: + python-dependencies: + patterns: + - "*" + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + groups: + github-actions: + patterns: + - "*" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..fbb0739 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,60 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + tests: + name: Python ${{ matrix.python-version }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: ${{ matrix.python-version }} + cache: pip + - run: python -m pip install --upgrade pip + - run: python -m pip install -e ".[dev]" + - run: pytest -q + + minimum-versions: + name: Minimum dependency versions (Python 3.10) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.10" + cache: pip + - run: python -m pip install --upgrade pip + # Install the package pinned to the lowest declared dependency versions to + # catch accidental use of newer dependency APIs than pyproject.toml permits. + - run: python -m pip install -e ".[dev]" -c .github/constraints-min.txt + - run: python -m pip check + - run: pytest -q + + quality: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.13" + cache: pip + - run: python -m pip install --upgrade pip + - run: python -m pip install -e ".[dev]" + - run: ruff check . + - run: ruff format --check . + - run: mypy panoptic + - run: python -m build + - run: python -m twine check dist/* + - run: pip-audit . diff --git a/.gitignore b/.gitignore index 3ebfcd2..69ccf55 100644 --- a/.gitignore +++ b/.gitignore @@ -4,10 +4,15 @@ output/ .worktrees/ .venv/ panoptic.egg-info/ +build/ +dist/ +.coverage +htmlcov/ .mcp.json CLAUDE.md .claude/ .claire/ .codex-review/ .coderabbit-results/ -docs/ \ No newline at end of file +.fathom/ +docs/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 99a6e4a..90cf582 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,13 +1,13 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.15.6 + rev: v0.16.0 hooks: - id: ruff args: [--fix] - id: ruff-format - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.16.0 + rev: v2.3.0 hooks: - id: mypy exclude: ^(panoptic\.py|tests/|\.claire/) @@ -25,7 +25,8 @@ repos: exclude: ^(docs/superpowers/|\.claire/) - repo: https://github.com/pypa/pip-audit - rev: v2.10.0 + rev: v2.10.1 hooks: - id: pip-audit - args: [--desc] + args: [--desc=on, .] + pass_filenames: false diff --git a/README.md b/README.md index 9a0fd7b..9dc5359 100644 --- a/README.md +++ b/README.md @@ -38,9 +38,12 @@ path traversal vulnerabilities. * TOML config files for persistent settings (`--config`) * Multiple traversal bypass techniques: prefixes, postfixes, multiplier, slash replacement, double encoding -* HTTP/HTTPS and SOCKS4/SOCKS5 proxy support with validation +* HTTP/HTTPS and SOCKS5 proxy support with validation * Random or custom User-Agent, cookie, and header support -* Credential redaction in banner and log output +* Credential redaction in banner, log, and machine-readable output + (including numeric and boolean JSON body values) +* Sensitive artifacts hardened with owner-only `0600` permissions on + POSIX and final-component symlink protection where the OS supports it * Self-update with remote URL verification (`--update`) ## Requirements @@ -197,22 +200,167 @@ panoptic --url "http://target/include.php?file=test.txt" \ --config ~/.config/panoptic/config.toml ``` -Default config location: `~/.config/panoptic/config.toml` +Default config location: `~/.config/panoptic/config.toml` (loaded +automatically when present, even without `--config`). ```toml [defaults] +# Any long option name (with dashes as underscores) is accepted here, +# including the target and output destinations. +url = "http://target/include.php?file=test.txt" concurrency = 8 verbose = true automatic = true +all_versions = true +output_format = "json" +output_file = "results.json" +log_file = "scan.log" +resume_file = "scan.checkpoint" [proxy] url = "socks5://127.0.0.1:9050" [headers] user_agent = "Mozilla/5.0" +cookie = "sid=foobar; auth=1" +values = ["X-Forwarded-For: 127.0.0.1"] ``` -Priority: CLI args > config file > built-in defaults. +Priority: **CLI args > config file > built-in defaults.** + +### Config-supported target and output options + +The `[defaults]` table accepts any scan option, not just performance +tuning. In particular you can persist: + +* `url` — the default target (override per-run with `--url`) +* `output_format`, `output_file` — where machine-readable results go +* `log_file` — mirror console output to a file +* `resume_file` — checkpoint location for resumable scans + +Sensitive artifacts written by Panoptic — the log file, the results/list +output file, and any files saved with `--write-files` — are forced to +owner-only `0600` permissions on POSIX. Platforms exposing `O_NOFOLLOW` +also atomically refuse a pre-existing symlink at the final path component. +On platforms without `O_NOFOLLOW`, Panoptic performs a best-effort +pre-open symlink/junction check, but the check cannot eliminate a race. +Windows mode bits do not configure NTFS ACLs, so use an appropriately +restricted directory when artifacts may contain sensitive data. + +### Overriding config booleans on the command line + +Every boolean flag has a `--no-` counterpart, so a value set to `true` +in the config file can be turned off for a single run without editing +the file: + +```bash +# config.toml sets verbose = true and automatic = true +panoptic --url "http://target/x.php?file=test.txt" --no-verbose --no-auto +``` + +Because an omitted boolean flag is left unset (rather than defaulting to +`false`), the config value is used unless you pass the flag or its +`--no-` form explicitly. + +## Proxying + +Route traffic through an HTTP(S) or SOCKS proxy: + +```bash +panoptic --url "https://target/x.php?file=test.txt" \ + --proxy "socks5://127.0.0.1:9050" +``` + +* Accepted schemes: `http://`, `https://`, `socks5://`, `socks5h://` + (`socks5h` resolves DNS through the proxy — useful for Tor and to + avoid local DNS leaks). SOCKS4 is not supported by the underlying + HTTP client. The scheme and host are validated before the scan starts. +* SOCKS support comes from the `httpx[socks]` extra, which is installed + by default. +* By default Panoptic honours the standard `HTTP_PROXY` / `HTTPS_PROXY` / + `NO_PROXY` environment variables. Pass `--ignore-proxy` to bypass them + and connect directly (this also disables any env-configured proxy). +* Combine with `--invalid-ssl` when intercepting HTTPS through a proxy + with its own CA. This disables certificate verification and is unsafe + on untrusted networks. + +## Version Expansion (`--all-versions`) + +Some bundled paths are version templates (for example JBoss release +directories written as `[JBOSS]`). By default these template rows are +skipped, because a literal `[JBOSS]` path can never match a real file. +Passing `--all-versions` expands each template against the bundled +version list, adding one concrete path per known version — a much larger +but far more thorough scan: + +```bash +panoptic --url "http://target/x.php?file=test.txt" --auto --all-versions +``` + +## Response Comparison + +Panoptic does not classify a path from the target-controlled +`Content-Length` header alone. It compares the complete normalized body +against the invalid-path baseline. A cheap similarity upper bound avoids +the full comparison only when it can already prove the bodies differ; +ambiguous responses use the exact ratio to avoid reordered-body false +negatives. + +## Resume / Checkpoints + +`--resume-file PATH` records completed cases so an interrupted scan can +continue where it left off: + +```bash +panoptic --url "http://target/x.php?file=test.txt" \ + --resume-file scan.checkpoint +# ... interrupt with Ctrl-C, then re-run the same command to resume +``` + +The checkpoint stores only completed case IDs plus a SHA-256 +**fingerprint** of the scan definition (URL, injection parameters, +detection options, headers, cookie, User-Agent, and the exact case set). +It never stores credentials, headers, or URLs in plaintext. + +On resume, the fingerprint is recomputed and compared. If anything that +would change the meaning of the scan differs — a different target, +parameter, header, cookie, filter set, or `--all-versions` toggle — the +checkpoint is rejected with a warning and the scan starts fresh, so a +stale checkpoint can never silently skip cases from a different scan. +Legacy bare-list checkpoints are accepted with a warning and restricted +to case IDs present in the current scan; because that old format has no +fingerprint, users should replace it with the newly written format. +Failed (network-error) requests are deliberately **not** checkpointed so +they are retried on resume. + +## Version and Update + +```bash +panoptic --version # print the installed version and exit +panoptic --update # fast-forward update from the official GitHub repo +``` + +`--update` only works from a git checkout whose `origin` remote uses +HTTPS or SSH and matches the official upstream (verified before pulling, +to resist insecure or tampered remote configuration). It fast-forwards +the checked-out `main` branch from the explicit upstream `main` ref; for +pip installs it prints the correct +`pip install -U` command instead. When run from a checkout, the banner +also shows the current short git revision. + +## Exit Codes + +Panoptic uses conventional process exit codes so it can be scripted: + +* `0` — Success: the scan (or `--list` / `--update`) completed. Individual + requests that exhaust their retries are warned about and remain eligible + for a resumed scan, but do not fail an otherwise successful run. +* `1` — Invalid usage: missing or invalid arguments, or no injectable + parameter could be found. +* `2` — Operational failure: cannot connect, no matching test cases, all + queued requests failed, or a worker, checkpoint, output write, or + configuration failed. +* `130` — Interrupted by the user (Ctrl-C / SIGINT). ## Contributing diff --git a/panoptic/__main__.py b/panoptic/__main__.py index d12fb49..f41f06a 100644 --- a/panoptic/__main__.py +++ b/panoptic/__main__.py @@ -9,7 +9,7 @@ def main() -> None: from panoptic.cli import run try: - asyncio.run(run()) + sys.exit(asyncio.run(run())) except KeyboardInterrupt: sys.exit(130) diff --git a/panoptic/cases.py b/panoptic/cases.py index 25ba0e6..b701399 100644 --- a/panoptic/cases.py +++ b/panoptic/cases.py @@ -8,12 +8,17 @@ from urllib.parse import urlsplit from panoptic.models import Case, FileType, ScanConfig -from panoptic.utils import load_data_file +from panoptic.utils import load_data_file, normalize_os_name, normalize_os_names, os_matches_restriction REQUIRED_COLUMNS = frozenset({"path", "os", "software", "category", "type"}) VALID_TYPES = frozenset(t.value for t in FileType) +def _expand_os_values(raw: str) -> tuple[str, ...]: + """Expand comma-separated OS metadata and normalize known aliases.""" + return normalize_os_names(raw) + + def _validate_csv(rows: list[dict[str, str]]) -> None: """Validate CSV data on first load. Raises ValueError on bad data.""" for i, row in enumerate(rows, start=2): # line 1 is header @@ -65,7 +70,9 @@ def parse_cases(config: ScanConfig) -> list[Case]: # Build replacements dict for placeholder expansion replacements: dict[str, str] = {} if config.url: - replacements["HOST"] = urlsplit(config.url).netloc + hostname = urlsplit(config.url).hostname or "" + replacements["HOST"] = hostname + replacements["DOMAIN"] = hostname # Load versions for expansion versions = load_versions() if config.all_versions else {} @@ -73,14 +80,19 @@ def parse_cases(config: ScanConfig) -> list[Case]: cases: list[Case] = [] for row in rows: - os_val = row["os"] + os_values = _expand_os_values(row["os"]) software_val = row["software"] category_val = row["category"] file_type = FileType(row["type"]) location = row["path"] - # Apply filters - if config.os_filter and os_val.lower() != config.os_filter.lower(): + # Apply filters. The OS prefilter uses the same Unix-family hierarchy as + # the runtime restriction (os_matches_restriction) so a "*NIX" filter + # keeps FreeBSD/OS X/... cases and a specific Unix filter still keeps the + # generic "*NIX" cases — otherwise the list would silently drop cases the + # scan would actually test. + normalized_filter = normalize_os_name(config.os_filter) + if normalized_filter and not any(os_matches_restriction(os_val, normalized_filter) for os_val in os_values): continue if config.software_filter and software_val.lower() != config.software_filter.lower(): continue @@ -97,13 +109,22 @@ def parse_cases(config: ScanConfig) -> list[Case]: # Version expansion ([SECTION] patterns) match = re.search(r"\[([^\]]+)\]", location) - if match and config.all_versions and match.group(1) in versions: + if match and config.all_versions: + if match.group(1) not in versions: + raise ValueError(f"cases.csv references unknown version section '{match.group(1)}'") locations = [location.replace(match.group(0), v) for v in versions[match.group(1)]] + elif match: + # Version templates are meaningful only when explicitly expanded. + # Sending the raw "[SECTION]" path can never find a real file. + continue else: locations = [location] + # A comma-separated OS field describes one case compatible with any of + # those systems; it must not fan out into duplicate requests. + case_os = ", ".join(os_values) for loc in locations: cases.append( - Case(location=loc, os=os_val, category=category_val, software=software_val, file_type=file_type) + Case(location=loc, os=case_os, category=category_val, software=software_val, file_type=file_type) ) return cases @@ -133,9 +154,13 @@ def list_values(group: str, config: ScanConfig | None = None) -> set[str]: if config is not None: cases = parse_cases(config) + if group == "os": + return {os_value for case in cases for os_value in normalize_os_names(case.os)} return {getattr(c, group) for c in cases if getattr(c, group)} rows = _load_csv() + if group == "os": + return {os_val for row in rows for os_val in _expand_os_values(row[group])} return {row[group] for row in rows if row.get(group)} diff --git a/panoptic/cli.py b/panoptic/cli.py index b71b338..c17cfb9 100644 --- a/panoptic/cli.py +++ b/panoptic/cli.py @@ -6,36 +6,23 @@ from __future__ import annotations import argparse +import csv import json -import re import sys +from pathlib import Path from typing import Any -from urllib.parse import urlsplit +from urllib.parse import parse_qsl, urlsplit from rich_argparse import RawDescriptionRichHelpFormatter -from panoptic.utils import normalize_url, parse_status_codes, validate_header, validate_url_scheme - -# Boolean/value defaults from argparse that should be stripped before config merge, -# so they don't override config file values. ScanConfig defaults apply when absent -# from both CLI and file config. -_ARGPARSE_DEFAULTS: dict[str, object] = { - "path_based": False, - "write_files": False, - "skip_parsing": False, - "invalid_ssl": False, - "automatic": False, - "all_versions": False, - "random_agent": False, - "ignore_proxy": False, - "verbose": False, - "follow_redirects": False, - "quiet": False, - "base64_encode": False, - "prefix": "", - "postfix": "", - "multiplier": 1, -} +from panoptic.utils import ( + has_parameter, + normalize_url, + open_secure_write, + parse_status_codes, + validate_header, + validate_url_scheme, +) EXAMPLES = """ Examples: @@ -61,8 +48,19 @@ def parse_args(argv: list[str] | None = None) -> dict[str, Any]: conn = parser.add_argument_group("Connection / Proxy") conn.add_argument("-u", "--url", help="Target URL vulnerable to path traversal") conn.add_argument("--proxy", help="Route requests through proxy (e.g. 'socks5://127.0.0.1:9050')") - conn.add_argument("--ignore-proxy", action="store_true", help="Bypass system proxy settings") - conn.add_argument("--random-agent", action="store_true", dest="random_agent", help="Choose random User-Agent") + conn.add_argument( + "--ignore-proxy", + action=argparse.BooleanOptionalAction, + default=None, + help="Bypass system proxy settings", + ) + conn.add_argument( + "--random-agent", + action=argparse.BooleanOptionalAction, + default=None, + dest="random_agent", + help="Choose random User-Agent", + ) conn.add_argument( "--header", dest="headers", @@ -76,7 +74,8 @@ def parse_args(argv: list[str] | None = None) -> dict[str, Any]: conn.add_argument( "--follow-redirects", dest="follow_redirects", - action="store_true", + action=argparse.BooleanOptionalAction, + default=None, help="Follow HTTP redirects (default: don't follow)", ) @@ -100,14 +99,15 @@ def parse_args(argv: list[str] | None = None) -> dict[str, Any]: "-P", "--path-based", dest="path_based", - action="store_true", + action=argparse.BooleanOptionalAction, + default=None, help="Target file paths directly instead of query parameters", ) scan.add_argument("-d", "--data", help="Send parameters via POST instead of GET") scan.add_argument("-t", "--type", dest="type_filter", help="Filter by type ('conf', 'log', 'other')") - scan.add_argument("--prefix", default="", help="Add prefix to file paths (e.g. '../')") - scan.add_argument("--postfix", default="", help="Add suffix to file paths (e.g. '%%00')") - scan.add_argument("--multiplier", type=int, default=1, help="Repeat prefix N times (default: 1)") + scan.add_argument("--prefix", default=None, help="Add prefix to file paths (e.g. '../')") + scan.add_argument("--postfix", default=None, help="Add suffix to file paths (e.g. '%%00')") + scan.add_argument("--multiplier", type=int, default=None, help="Repeat prefix N times (default: 1)") scan.add_argument("--bad-string", dest="bad_string", help="Skip paths if this string appears in response") scan.add_argument( "--match-string", dest="match_string", help="Only report findings containing this string in response" @@ -126,11 +126,18 @@ def parse_args(argv: list[str] | None = None) -> dict[str, Any]: scan.add_argument( "--base64", dest="base64_encode", - action="store_true", + action=argparse.BooleanOptionalAction, + default=None, help="Base64-encode file paths before injection (for endpoints that decode)", ) scan.add_argument("--ext-param", dest="ext_param", help="Name of parameter containing file extension") - scan.add_argument("--all-versions", dest="all_versions", action="store_true", help="Test all versioned file paths") + scan.add_argument( + "--all-versions", + dest="all_versions", + action=argparse.BooleanOptionalAction, + default=None, + help="Test all versioned file paths", + ) # Performance perf = parser.add_argument_group("Performance") @@ -147,28 +154,51 @@ def parse_args(argv: list[str] | None = None) -> dict[str, Any]: # Output out = parser.add_argument_group("Output") - out.add_argument("-v", "--verbose", action="store_true", help="Show detailed information") + out.add_argument( + "-v", + "--verbose", + action=argparse.BooleanOptionalAction, + default=None, + help="Show detailed information", + ) out.add_argument( "-q", "--quiet", - action="store_true", + action=argparse.BooleanOptionalAction, + default=None, help="Suppress banner, info, and progress — only show findings and warnings", ) out.add_argument( "-w", "--write-files", dest="write_files", - action="store_true", + action=argparse.BooleanOptionalAction, + default=None, help="Save discovered files to local output directory", ) out.add_argument( - "-x", "--skip-parsing", dest="skip_parsing", action="store_true", help="Don't extract users from passwd files" + "-x", + "--skip-parsing", + dest="skip_parsing", + action=argparse.BooleanOptionalAction, + default=None, + help="Don't extract users from passwd files", ) out.add_argument( - "-i", "--invalid-ssl", dest="invalid_ssl", action="store_true", help="Ignore SSL certificate validation errors" + "-i", + "--invalid-ssl", + dest="invalid_ssl", + action=argparse.BooleanOptionalAction, + default=None, + help="Ignore SSL certificate validation errors", ) out.add_argument( - "-a", "--auto", dest="automatic", action="store_true", help="Avoid user interaction by using default options" + "-a", + "--auto", + dest="automatic", + action=argparse.BooleanOptionalAction, + default=None, + help="Avoid user interaction by using default options", ) out.add_argument( "--output-format", @@ -185,6 +215,9 @@ def parse_args(argv: list[str] | None = None) -> dict[str, Any]: parser.add_argument("--load", dest="list_file", help="Test custom file list from FILE") parser.add_argument("--config", dest="config_file", help="Path to TOML config file") parser.add_argument("--update", action="store_true", help="Update from GitHub repository") + from panoptic import __version__ + + parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}") parser.add_argument( "--list-all-files", dest="list_all_files", @@ -195,18 +228,10 @@ def parse_args(argv: list[str] | None = None) -> dict[str, Any]: parsed = parser.parse_args(argv) result = vars(parsed) - for key, default in _ARGPARSE_DEFAULTS.items(): - if result.get(key) == default: - result.pop(key, None) - # Normalize URL if result.get("url") and not result["url"].lower().startswith(("http://", "https://")): result["url"] = normalize_url(result["url"]) - # Apply prefix multiplier - if result.get("prefix"): - result["prefix"] = result["prefix"] * result.get("multiplier", 1) - # Parse --random-delay "MIN-MAX" into tuple if result.get("random_delay") and isinstance(result["random_delay"], str): try: @@ -259,6 +284,12 @@ def validate_args(args: dict[str, Any]) -> None: if args.get("concurrency") is not None and args["concurrency"] < 1: print("[!] --concurrency must be at least 1", file=sys.stderr) sys.exit(1) + if args.get("retries") is not None and args["retries"] < 0: + print("[!] --retries must be non-negative", file=sys.stderr) + sys.exit(1) + if args.get("multiplier") is not None and args["multiplier"] < 1: + print("[!] --multiplier must be at least 1", file=sys.stderr) + sys.exit(1) if args.get("timeout") is not None and args["timeout"] <= 0: print("[!] --timeout must be greater than 0", file=sys.stderr) @@ -269,112 +300,187 @@ def validate_args(args: dict[str, Any]) -> None: # Proxy scheme validation if args.get("proxy"): - allowed_proxy_schemes = ("http://", "https://", "socks4://", "socks5://") + # SOCKS4 is intentionally excluded: httpx (via socksio) only supports + # SOCKS5, so accepting socks4:// here would fail later at connection time. + allowed_proxy_schemes = ("http://", "https://", "socks5://", "socks5h://") if not args["proxy"].lower().startswith(allowed_proxy_schemes): - print("[!] Invalid proxy scheme. Use http://, https://, socks4://, or socks5://", file=sys.stderr) + print( + "[!] Invalid proxy scheme. Use http://, https://, socks5://, or socks5h://", + file=sys.stderr, + ) sys.exit(1) + parsed_proxy = urlsplit(args["proxy"]) + if not parsed_proxy.hostname: + print("[!] Proxy URL must include a hostname", file=sys.stderr) + sys.exit(1) + try: + proxy_port = parsed_proxy.port + except ValueError as exc: + print(f"[!] Invalid proxy port: {exc}", file=sys.stderr) + sys.exit(1) + del proxy_port # Header CRLF validation for hdr in args.get("headers") or []: try: - validate_header(hdr) + validate_header(hdr, warn_deprecated=False) except ValueError as e: print(f"[!] Invalid header: {e}", file=sys.stderr) sys.exit(1) # Cookie CRLF validation - if args.get("cookie") and any(c in args["cookie"] for c in "\r\n"): - print("[!] Cookie contains CRLF characters (possible header injection)", file=sys.stderr) + if args.get("cookie") and any(c in args["cookie"] for c in "\r\n\x00"): + print("[!] Cookie contains CRLF/NUL characters (possible header injection)", file=sys.stderr) sys.exit(1) # User-Agent CRLF validation - if args.get("user_agent") and any(c in args["user_agent"] for c in "\r\n"): - print("[!] User-Agent contains CRLF characters (possible header injection)", file=sys.stderr) + if args.get("user_agent") and any(c in args["user_agent"] for c in "\r\n\x00"): + print("[!] User-Agent contains CRLF/NUL characters (possible header injection)", file=sys.stderr) + sys.exit(1) + + if args.get("quiet") and args.get("verbose"): + print("[!] --quiet and --verbose cannot be used together", file=sys.stderr) sys.exit(1) + if args.get("base64_encode") and args.get("ext_param"): + print("[!] --base64 and --ext-param cannot be combined", file=sys.stderr) + sys.exit(1) -async def run(argv: list[str] | None = None) -> None: + match_codes = set(args.get("match_codes") or []) + filter_codes = set(args.get("filter_codes") or []) + overlap = sorted(match_codes & filter_codes) + if overlap: + print(f"[!] Status codes cannot be both matched and filtered: {overlap}", file=sys.stderr) + sys.exit(1) + + output_paths = [args.get(key) for key in ("output_file", "log_file", "resume_file")] + normalized_paths = [str(Path(path).expanduser().resolve()) for path in output_paths if path] + if len(normalized_paths) != len(set(normalized_paths)): + print("[!] --output-file, --log-file, and --resume-file must use different paths", file=sys.stderr) + sys.exit(1) + + +def _write_list_output(values: list[str], header: str, fmt: str, output_file: str | None) -> int: + """Write list command output using format-aware serialization.""" + stream = sys.stdout + file_stream = None + try: + if output_file: + file_stream = open_secure_write(output_file, newline="") + stream = file_stream + if fmt == "json": + json.dump(values, stream, indent=2) + stream.write("\n") + elif fmt == "csv": + writer = csv.writer(stream, lineterminator="\n") + writer.writerow([header]) + writer.writerows([value] for value in values) + else: + for value in values: + stream.write(f"{value}\n") + except OSError as exc: + print(f"[!] Cannot write output: {exc}", file=sys.stderr) + return 2 + finally: + if file_stream: + file_stream.close() + return 0 + + +async def run(argv: list[str] | None = None) -> int: """Main async entry point — parse args, load config, dispatch.""" args = parse_args(argv) - validate_args(args) # Handle non-scan commands if args.get("update"): from panoptic.update import do_update - do_update() - return + validate_args(args) + return do_update() - if args.get("list_all_files"): - from panoptic.cases import list_all_files + # Lazy import — only needed for scan and --list modes + from panoptic.config import load_config, merge_config - paths = list_all_files() - fmt = args.get("output_format") or "text" + config_file = args.pop("config_file", None) + file_config = load_config(config_file) - if fmt == "json": - print(json.dumps(paths, indent=2)) - elif fmt == "csv": - print("path") - for path in paths: - print(path) - else: - for path in paths: - print(path) - return + if args.get("list_all_files"): + from panoptic.cases import list_all_files - # Lazy import — only needed for scan and --list modes - from panoptic.config import load_config, merge_config + validate_args(args) + try: + list_config = merge_config(args, file_config) + except (TypeError, ValueError) as exc: + print(f"[!] Invalid configuration: {exc}", file=sys.stderr) + return 2 + return _write_list_output( + list_all_files(), + "path", + list_config.output_format.value, + list_config.output_file, + ) if args.get("list"): from panoptic.cases import list_values - _file_config = load_config(args.get("config_file")) - _config = merge_config(args, _file_config) + validate_args(args) + try: + _config = merge_config(args, file_config) + except (TypeError, ValueError) as exc: + print(f"[!] Invalid configuration: {exc}", file=sys.stderr) + return 2 values = list_values(args["list"], config=_config) - fmt = args.get("output_format") or "text" - - if fmt == "json": - print(json.dumps(sorted(values), indent=2)) - elif fmt == "csv": - print(args["list"]) - for val in sorted(values): - print(val) - else: + fmt = _config.output_format.value + sorted_values = sorted(values) + if fmt == "text" and not _config.output_file: print(f"Available {args['list']} values ({len(values)}):") - for val in sorted(values): - print(f" {val}") - return + sorted_values = [f" {value}" for value in sorted_values] + return _write_list_output(sorted_values, args["list"], fmt, _config.output_file) - file_config = load_config(args.pop("config_file", None)) - config = merge_config(args, file_config) + try: + config = merge_config(args, file_config) + except (TypeError, ValueError) as exc: + print(f"[!] Invalid configuration: {exc}", file=sys.stderr) + return 2 + + validate_args(vars(config)) # Shared URL parsing for param detection and ext-param validation parsed = urlsplit(config.url) params = config.data if config.data else parsed.query - # Check if FUZZ marker is used in any injectable position - has_fuzz = "FUZZ" in (config.data or "") or any("FUZZ" in h for h in (config.headers or [])) + # Check if FUZZ is used in an injectable body or header value. A header name + # containing the literal text "FUZZ" is not an injection point. + has_fuzz = "FUZZ" in (config.data or "") or any( + "FUZZ" in validate_header(header, warn_deprecated=False)[1] for header in (config.headers or []) + ) # Auto-detect vulnerable parameter if not specified (ported from original) if not config.path_based and not config.param and not has_fuzz: - match = re.match(r"(?P[^=&]+)=(?P[^&]+)", params) - if match: - config = config.replace(param=match.group("param")) + detected_param = next( + (name for name, value in parse_qsl(params, keep_blank_values=True) if name and value), + None, + ) + if detected_param: + config = config.replace(param=detected_param) else: print("[!] No usable GET/POST parameters found.", file=sys.stderr) print("[!] If this is a path-based URL, use --path-based", file=sys.stderr) - sys.exit(1) + return 1 - # Validate --ext-param exists in query/data (anchored to prevent substring matches) - if config.ext_param and not re.search( - rf"(?:^|(?<=&)){re.escape(config.ext_param)}=(?P[^&]*)", - params, - ): + if not config.path_based and config.param and not has_fuzz and not has_parameter(params, config.param): + print(f"[!] Parameter '{config.param}' not found in query/POST form data.", file=sys.stderr) + if config.data: + print("[!] For JSON or opaque POST bodies, place FUZZ at the injection point.", file=sys.stderr) + return 1 + + # Validate --ext-param against decoded or raw query/form parameter names. + if config.ext_param and not has_parameter(params, config.ext_param): print(f"[!] Extension parameter '{config.ext_param}' not found.", file=sys.stderr) - sys.exit(1) + return 1 # Dispatch to scanner from panoptic.core import Scanner scanner = Scanner(config) - await scanner.run() + return await scanner.run() diff --git a/panoptic/config.py b/panoptic/config.py index b051ea6..9f07d9f 100644 --- a/panoptic/config.py +++ b/panoptic/config.py @@ -11,7 +11,7 @@ from typing import Any from panoptic.models import OutputFormat, ScanConfig -from panoptic.utils import parse_status_codes +from panoptic.utils import normalize_url, parse_status_codes if sys.version_info >= (3, 11): import tomllib @@ -24,6 +24,65 @@ DEFAULT_CONFIG_PATH = Path.home() / ".config" / "panoptic" / "config.toml" +_STRING_FIELDS = { + "url", + "param", + "data", + "prefix", + "postfix", + "ext_param", + "bad_string", + "match_string", + "replace_slash", + "output_file", + "log_file", + "proxy", + "user_agent", + "cookie", + "os_filter", + "software_filter", + "category_filter", + "type_filter", + "list_file", + "resume_file", +} +_BOOLEAN_FIELDS = { + "path_based", + "base64_encode", + "write_files", + "skip_parsing", + "automatic", + "invalid_ssl", + "all_versions", + "follow_redirects", + "verbose", + "quiet", + "ignore_proxy", + "random_agent", +} +_INTEGER_FIELDS = {"concurrency", "retries", "multiplier"} +_NUMBER_FIELDS = {"timeout", "delay", "heuristic_ratio"} + + +def _validate_merged_types(merged: dict[str, Any]) -> None: + """Reject TOML values that would otherwise fail later with raw type errors.""" + for field in sorted(_STRING_FIELDS): + value = merged.get(field) + if value is not None and not isinstance(value, str): + raise ValueError(f"{field} must be a string") + for field in sorted(_BOOLEAN_FIELDS): + value = merged.get(field) + if value is not None and not isinstance(value, bool): + raise ValueError(f"{field} must be a boolean") + for field in sorted(_INTEGER_FIELDS): + value = merged.get(field) + if value is not None and (not isinstance(value, int) or isinstance(value, bool)): + raise ValueError(f"{field} must be an integer") + for field in sorted(_NUMBER_FIELDS): + value = merged.get(field) + if value is not None and (not isinstance(value, int | float) or isinstance(value, bool)): + raise ValueError(f"{field} must be a number") + def load_config(path: str | None = None) -> dict[str, Any]: """Load a TOML config file. @@ -33,6 +92,8 @@ def load_config(path: str | None = None) -> dict[str, Any]: config_path = Path(path) if path else DEFAULT_CONFIG_PATH if not config_path.exists(): + if path is not None: + print(f"[!] Warning: config file '{config_path}' does not exist", file=sys.stderr) return {} if tomllib is None: @@ -51,32 +112,74 @@ def merge_config(cli_args: dict[str, Any], file_config: dict[str, Any]) -> ScanC Priority: CLI args > file config > ScanConfig defaults. """ + valid_fields = set(ScanConfig.__dataclass_fields__) + known_sections = {"defaults", "proxy", "headers"} + for section in sorted(set(file_config) - known_sections): + print(f"[!] Warning: unknown config section '{section}'", file=sys.stderr) + + defaults = file_config.get("defaults", {}) + if not isinstance(defaults, dict): + raise ValueError("[defaults] must be a TOML table") + for key in sorted(set(defaults) - valid_fields - {"header"}): + print(f"[!] Warning: unknown config option 'defaults.{key}'", file=sys.stderr) + # Start with file config defaults - merged: dict[str, Any] = dict(file_config.get("defaults", {})) + merged: dict[str, Any] = {key: value for key, value in defaults.items() if key in valid_fields or key == "header"} + + # Backward compatibility: normalize singular "header" before merging the + # dedicated [headers] table and CLI repetitions. + if "header" in merged: + legacy_headers = merged.pop("header") + if isinstance(legacy_headers, str): + merged["headers"] = [legacy_headers] + elif isinstance(legacy_headers, list): + merged["headers"] = legacy_headers # Map proxy and header sections to flat ScanConfig fields - proxy_url = file_config.get("proxy", {}).get("url") + proxy_section = file_config.get("proxy", {}) + if not isinstance(proxy_section, dict): + raise ValueError("[proxy] must be a TOML table") + for key in sorted(set(proxy_section) - {"url"}): + print(f"[!] Warning: unknown config option 'proxy.{key}'", file=sys.stderr) + proxy_url = proxy_section.get("url") if proxy_url: merged["proxy"] = proxy_url headers_section = file_config.get("headers", {}) - if isinstance(headers_section, dict): - header_ua = headers_section.get("user_agent") - if header_ua: - merged["user_agent"] = header_ua - - # Apply CLI args (override file config) + if not isinstance(headers_section, dict): + raise ValueError("[headers] must be a TOML table") + for key in sorted(set(headers_section) - {"user_agent", "cookie", "values"}): + print(f"[!] Warning: unknown config option 'headers.{key}'", file=sys.stderr) + header_ua = headers_section.get("user_agent") + if header_ua: + merged["user_agent"] = header_ua + header_cookie = headers_section.get("cookie") + if header_cookie: + merged["cookie"] = header_cookie + header_values = headers_section.get("values") + if header_values: + if not isinstance(header_values, list) or not all(isinstance(header, str) for header in header_values): + raise ValueError("headers.values must be a list of strings") + merged["headers"] = [*(merged.get("headers") or []), *header_values] + + # Scalar CLI arguments override file values. Repeatable headers are + # additive, with CLI values last so duplicate header names retain CLI + # precedence when the request header mapping is built. for key, value in cli_args.items(): if value is not None: - merged[key] = value + if key == "headers": + if not isinstance(value, list): + raise ValueError("headers must be a list of strings") + merged[key] = [*(merged.get(key) or []), *value] + else: + merged[key] = value # Handle output_format enum conversion if "output_format" in merged and isinstance(merged["output_format"], str): try: merged["output_format"] = OutputFormat(merged["output_format"]) except ValueError as e: - print(f"[!] Invalid output_format in config: {e}", file=sys.stderr) - merged.pop("output_format") + raise ValueError(f"invalid output_format: {e}") from e # Normalize match_codes / filter_codes from TOML (may be string or list) for code_key in ("match_codes", "filter_codes"): @@ -85,20 +188,33 @@ def merge_config(cli_args: dict[str, Any], file_config: dict[str, Any]) -> ScanC try: merged[code_key] = parse_status_codes(val) except (ValueError, TypeError) as e: - print(f"[!] Invalid {code_key} in config: {e}", file=sys.stderr) - merged.pop(code_key) - - # Backward compatibility: normalize singular "header" to plural "headers" - if "header" in merged and "headers" not in merged: - val = merged.pop("header") - if isinstance(val, str): - merged["headers"] = [val] - elif isinstance(val, list): - merged["headers"] = val - elif "header" in merged: - merged.pop("header") + raise ValueError(f"invalid {code_key}: {e}") from e + + random_delay = merged.get("random_delay") + if isinstance(random_delay, str): + parts = random_delay.split("-") + if len(parts) != 2: + raise ValueError("random_delay must use MIN-MAX format") + try: + merged["random_delay"] = (float(parts[0]), float(parts[1])) + except ValueError as exc: + raise ValueError("random_delay must contain numeric MIN-MAX values") from exc + elif isinstance(random_delay, list): + if len(random_delay) != 2: + raise ValueError("random_delay must contain exactly two values") + merged["random_delay"] = (float(random_delay[0]), float(random_delay[1])) + + headers = merged.get("headers") + if headers is not None and ( + not isinstance(headers, list) or not all(isinstance(header, str) for header in headers) + ): + raise ValueError("headers must be a list of strings") + + _validate_merged_types(merged) # Ensure url is present url = merged.pop("url", "") + if url and not url.lower().startswith(("http://", "https://")): + url = normalize_url(url) - return ScanConfig(url=url, **{k: v for k, v in merged.items() if k in ScanConfig.__dataclass_fields__}) + return ScanConfig(url=url, **{k: v for k, v in merged.items() if k in valid_fields}) diff --git a/panoptic/core.py b/panoptic/core.py index 045cc35..79fb655 100644 --- a/panoptic/core.py +++ b/panoptic/core.py @@ -9,13 +9,14 @@ import asyncio import base64 import contextlib +import hashlib import json import os import random -import re import sys import tempfile import time +from dataclasses import fields as dataclass_fields from pathlib import Path from typing import TextIO from urllib.parse import quote as url_quote @@ -33,12 +34,7 @@ ) from panoptic.cases import load_custom_list, parse_cases -from panoptic.heuristic import ( - SKIP_RETRIEVE_THRESHOLD, - clean_response, - filter_content, - is_match, -) +from panoptic.heuristic import clean_response, filter_content, is_match from panoptic.models import Case, OutputFormat, ScanConfig, ScanResult from panoptic.network import NetworkClient from panoptic.output import CsvFormatter, JsonFormatter, TeeWriter, TextFormatter @@ -47,13 +43,36 @@ from panoptic.utils import ( generate_invalid_filename, get_random_agent, + normalize_os_name, + open_secure_write, + os_matches_restriction, redact_url, + replace_parameter_value, sanitize_filename, validate_header, ) PASSWD_FILES = frozenset({"/etc/passwd", "/etc/security/passwd"}) FUZZ_MARKER = "FUZZ" +CHECKPOINT_VERSION = 1 +_CHECKPOINT_CONFIG_EXCLUSIONS = frozenset( + { + # These options change execution mechanics or presentation, not which + # cases and responses constitute the scan. + "concurrency", + "timeout", + "retries", + "delay", + "random_delay", + "write_files", + "output_format", + "output_file", + "log_file", + "verbose", + "quiet", + "resume_file", + } +) def process_path(config: ScanConfig, location: str) -> str: @@ -61,7 +80,7 @@ def process_path(config: ScanConfig, location: str) -> str: if config.replace_slash: location = location.replace("/", config.replace_slash) - prefix = config.prefix + prefix = config.prefix * config.multiplier if prefix.endswith("/") and location.startswith("/"): location = location.lstrip("/") full_path = f"{prefix}{location}{config.postfix}" @@ -78,12 +97,44 @@ def _encode_param_value(value: str, *, is_post: bool = False) -> str: Safe chars (not encoded): / : LFI path traversal separators must stay literal % : Preserve pre-encoded sequences (e.g., --replace-slash "%2F") - GET-specific safe: = and + (base64 compat, PHP $_GET handles natively) - POST-specific: + MUST be encoded as %2B (decoded as space by form parsers) + The plus sign is always encoded because standard query and form parsers + decode a literal '+' as a space, corrupting some Base64 payloads. """ - if is_post: - return url_quote(value, safe="=/%") - return url_quote(value, safe="=+/%") + return url_quote(value, safe="/%") + + +def _replace_fuzz_in_json(obj: object, replacement: str) -> object: + """Recursively replace the FUZZ marker in every JSON string key and value.""" + if isinstance(obj, dict): + return { + (k.replace(FUZZ_MARKER, replacement) if isinstance(k, str) else k): _replace_fuzz_in_json(v, replacement) + for k, v in obj.items() + } + if isinstance(obj, list): + return [_replace_fuzz_in_json(v, replacement) for v in obj] + if isinstance(obj, str): + return obj.replace(FUZZ_MARKER, replacement) + return obj + + +def _substitute_fuzz(template: str, replacement: str, *, is_post: bool) -> str: + """Replace the FUZZ marker while keeping the surrounding body well-formed. + + A JSON body is parsed and re-serialized so injected Windows paths, backslashes, + and quotes stay valid JSON. A form-encoded body/query has the replacement + percent-encoded so plus signs and delimiters (``&``, ``=``) cannot corrupt the + structure. Any other (opaque) body keeps the raw, user-controlled substitution. + """ + if template.lstrip().startswith(("{", "[")): + try: + obj = json.loads(template) + except (json.JSONDecodeError, ValueError): + pass + else: + return json.dumps(_replace_fuzz_in_json(obj, replacement), separators=(",", ":")) + if "=" in template: + return template.replace(FUZZ_MARKER, _encode_param_value(replacement, is_post=is_post)) + return template.replace(FUZZ_MARKER, replacement) def build_payload(config: ScanConfig, location: str, request_params: str) -> str: @@ -98,48 +149,62 @@ def build_payload(config: ScanConfig, location: str, request_params: str) -> str last_slash = path.rfind("/") if last_slash >= 0: base_path = path[:last_slash] - return f"{parsed.scheme}://{parsed.netloc}{base_path}/{full_path}{query_suffix}" - return f"{parsed.scheme}://{parsed.netloc}/{full_path}{query_suffix}" + return f"{parsed.scheme}://{parsed.netloc}{base_path}/{full_path.lstrip('/')}{query_suffix}" + return f"{parsed.scheme}://{parsed.netloc}/{full_path.lstrip('/')}{query_suffix}" is_post = bool(config.data) result = request_params if FUZZ_MARKER in result: - result = result.replace(FUZZ_MARKER, full_path) + result = _substitute_fuzz(result, full_path, is_post=is_post) elif config.ext_param and config.param and "." in full_path: # When ext_param is set, split path into base and extension path_without_ext, ext = full_path.rsplit(".", 1) encoded_path = _encode_param_value(path_without_ext, is_post=is_post) encoded_ext = _encode_param_value(ext, is_post=is_post) - result = re.sub( - rf"(?:^|(?<=&)){re.escape(config.param)}=(?P[^&]*)", - rf"{config.param}={encoded_path}", - result, - ) - result = re.sub( - rf"(?:^|(?<=&)){re.escape(config.ext_param)}=(?P[^&]*)", - rf"{config.ext_param}={encoded_ext}", - result, - ) + result = replace_parameter_value(result, config.param, encoded_path) + result = replace_parameter_value(result, config.ext_param, encoded_ext) elif config.param: encoded_full_path = _encode_param_value(full_path, is_post=is_post) - result = re.sub( - rf"(?:^|(?<=&)){re.escape(config.param)}=(?P[^&]*)", - rf"{config.param}={encoded_full_path}", - result, - ) + result = replace_parameter_value(result, config.param, encoded_full_path) if config.data: return result return f"{parsed.scheme}://{parsed.netloc}{parsed.path}?{result}" -def save_checkpoint(filepath: str, completed_ids: set[str]) -> None: +def checkpoint_fingerprint(config: ScanConfig, cases: list[Case]) -> str: + """Hash all scan-defining inputs without storing credentials in the checkpoint.""" + # Include new ScanConfig fields by default. A future option cannot silently + # become resume-unsafe merely because this function was not updated. + scan_definition = { + field.name: getattr(config, field.name) + for field in dataclass_fields(config) + if field.name not in _CHECKPOINT_CONFIG_EXCLUSIONS + } + # Status code order does not affect scan semantics. + for code_field in ("match_codes", "filter_codes"): + if scan_definition[code_field] is not None: + scan_definition[code_field] = sorted(set(scan_definition[code_field])) + scan_definition["os_filter"] = normalize_os_name(scan_definition["os_filter"]) + scan_definition["case_ids"] = sorted(case.case_id for case in cases) + serialized = json.dumps(scan_definition, sort_keys=True, separators=(",", ":")) + return hashlib.sha256(serialized.encode()).hexdigest() + + +def save_checkpoint(filepath: str, completed_ids: set[str], fingerprint: str = "") -> None: """Save completed case IDs to a checkpoint file atomically.""" dir_name = os.path.dirname(filepath) or "." fd, tmp_path = tempfile.mkstemp(dir=dir_name, suffix=".tmp") try: with os.fdopen(fd, "w", encoding="utf-8") as f: - json.dump(sorted(completed_ids), f) + json.dump( + { + "version": CHECKPOINT_VERSION, + "fingerprint": fingerprint, + "completed_ids": sorted(completed_ids), + }, + f, + ) os.replace(tmp_path, filepath) except BaseException: with contextlib.suppress(OSError): @@ -147,15 +212,43 @@ def save_checkpoint(filepath: str, completed_ids: set[str]) -> None: raise -def load_checkpoint(filepath: str) -> set[str]: - """Load completed case IDs from a checkpoint file.""" +def _load_checkpoint_data( + filepath: str, + expected_fingerprint: str | None = None, +) -> tuple[set[str], bool]: + """Load checkpoint IDs and report whether the file uses the legacy format.""" if not os.path.exists(filepath): - return set() - try: - with open(filepath, encoding="utf-8") as f: - return set(json.load(f)) - except (json.JSONDecodeError, OSError): - return set() + return set(), False + with open(filepath, encoding="utf-8") as f: + try: + data = json.load(f) + except json.JSONDecodeError as exc: + raise ValueError("checkpoint is not valid JSON") from exc + + if isinstance(data, list): + if not all(isinstance(case_id, str) for case_id in data): + raise ValueError("legacy checkpoint must be a list of strings") + return set(data), True + if not isinstance(data, dict): + raise ValueError("checkpoint must be an object or legacy list") + if data.get("version") != CHECKPOINT_VERSION: + raise ValueError(f"unsupported checkpoint version: {data.get('version')!r}") + + fingerprint = data.get("fingerprint") + if not isinstance(fingerprint, str): + raise ValueError("checkpoint fingerprint is missing or invalid") + if expected_fingerprint is not None and fingerprint != expected_fingerprint: + raise ValueError("checkpoint belongs to a different scan configuration") + + completed_ids = data.get("completed_ids") + if not isinstance(completed_ids, list) or not all(isinstance(case_id, str) for case_id in completed_ids): + raise ValueError("checkpoint completed_ids must be a list of strings") + return set(completed_ids), False + + +def load_checkpoint(filepath: str, expected_fingerprint: str | None = None) -> set[str]: + """Load completed case IDs from a current or legacy checkpoint file.""" + return _load_checkpoint_data(filepath, expected_fingerprint)[0] class Scanner: @@ -170,19 +263,26 @@ def __init__(self, config: ScanConfig) -> None: self.invalid_response: str = "" self.invalid_status_code: int = 0 self.invalid_filename: str = "" - self.restrict_os: str | None = config.os_filter + # Canonicalize OS aliases (e.g. "OSX" -> "OS X") so the runtime restriction + # compares against the same canonical OS labels parse_cases assigns to cases; + # otherwise an aliased --os would load cases and then skip every one of them. + self.restrict_os = normalize_os_name(config.os_filter) self.first_found = False self._first_found_lock = asyncio.Lock() self.completed_ids: set[str] = set() self.enqueued_ids: set[str] = set() self.total_queued = 0 self.total_processed = 0 + self.total_failed = 0 + self.operational_errors: list[str] = [] self._checkpoint_dirty = False self._last_checkpoint_time = 0.0 self._checkpoint_lock = asyncio.Lock() + self._checkpoint_fingerprint = "" + self._checkpoint_disabled = False self._pause_lock = asyncio.Lock() - async def run(self) -> None: + async def run(self) -> int: """Execute the full scan workflow.""" from panoptic import __version__ @@ -190,16 +290,27 @@ async def run(self) -> None: log_fp = None stderr_stream: TextIO = sys.stderr if self.config.log_file: - log_fp = open(self.config.log_file, "w", encoding="utf-8") # noqa: SIM115 + try: + log_fp = open_secure_write(self.config.log_file) + except OSError as exc: + print(f"[!] Cannot open log file '{self.config.log_file}': {exc}", file=sys.stderr) + self._write_output([], TextFormatter(sys.stderr, quiet=self.config.quiet)) + return 2 stderr_stream = TeeWriter(sys.stderr, log_fp) # type: ignore[assignment] try: - await self._run_scan(stderr_stream, __version__) + try: + return await self._run_scan(stderr_stream, __version__) + except (OSError, ValueError, httpx.HTTPError) as exc: + text_out = TextFormatter(stderr_stream, quiet=self.config.quiet) + text_out.write_warning(f"Scan failed: {exc}") + self._write_output([], text_out) + return 2 finally: if log_fp: log_fp.close() - async def _run_scan(self, stderr_stream: TextIO, version: str) -> None: + async def _run_scan(self, stderr_stream: TextIO, version: str) -> int: """Execute the scan with the given output stream.""" text_out = TextFormatter(stderr_stream, quiet=self.config.quiet) @@ -208,10 +319,6 @@ async def _run_scan(self, stderr_stream: TextIO, version: str) -> None: banner_version = f"{version}-{rev}" if rev else version text_out.write_banner(banner_version, redact_url(self.config.url), self.config) - if self.config.random_agent and not self.config.user_agent: - self.config = self.config.replace(user_agent=get_random_agent()) - text_out.write_info(f"Using random User-Agent: {self.config.user_agent}") - if self.config.invalid_ssl: text_out.write_warning("SSL certificate verification is disabled. Traffic is vulnerable to MITM.") @@ -219,10 +326,32 @@ async def _run_scan(self, stderr_stream: TextIO, version: str) -> None: if not cases: text_out.write_warning("No available test cases with the specified attributes.") - return + self._write_output([], text_out) + return 2 + + if self.config.random_agent and not self.config.user_agent: + self.config = self.config.replace(user_agent=get_random_agent()) + text_out.write_info(f"Using random User-Agent: {self.config.user_agent}") + + self._checkpoint_fingerprint = checkpoint_fingerprint(self.config, cases) if self.config.resume_file: - self.completed_ids = load_checkpoint(self.config.resume_file) + try: + self.completed_ids, legacy_checkpoint = _load_checkpoint_data( + self.config.resume_file, + self._checkpoint_fingerprint, + ) + except (OSError, ValueError) as exc: + text_out.write_warning(f"Ignoring resume checkpoint: {exc}") + self.completed_ids = set() + else: + valid_case_ids = {case.case_id for case in cases} + self.completed_ids.intersection_update(valid_case_ids) + if legacy_checkpoint: + text_out.write_warning( + "Legacy checkpoint has no scan fingerprint; " + "only IDs matching the current case set were accepted" + ) if self.completed_ids: text_out.write_info(f"Resuming: {len(self.completed_ids)} cases already completed") @@ -235,7 +364,8 @@ async def _run_scan(self, stderr_stream: TextIO, version: str) -> None: orig_resp = await self._fetch(client, self._base_url, self.config.data or self.config.url) if orig_resp is None: text_out.write_warning("Cannot connect to target. Check connection settings.") - return + self._write_output([], text_out) + return 2 self.original_response = orig_resp.text self.invalid_filename = generate_invalid_filename() @@ -245,7 +375,8 @@ async def _run_scan(self, stderr_stream: TextIO, version: str) -> None: if inv_resp is None: text_out.write_warning("Cannot retrieve invalid response baseline.") - return + self._write_output([], text_out) + return 2 self.invalid_response = inv_resp.text self.invalid_status_code = inv_resp.status_code @@ -296,15 +427,33 @@ async def worker() -> None: try: async with self._pause_lock: pass # Block while interactive prompt is active - await self._process_case(case, client, request_params, queue, scan_out, progress_ctx) - self.total_processed += 1 - # Update progress bar total when dynamic cases are added + processed = await self._process_case( + case, + client, + request_params, + queue, + scan_out, + progress_ctx, + ) + if processed: + self.total_processed += 1 + else: + self.total_failed += 1 + except Exception as exc: + self.total_failed += 1 + message = f"Case '{case.location}' failed: {exc}" + self.operational_errors.append(message) + scan_out.write_warning(message) + finally: + queue.task_done() + # Update progress even for failed cases so the queue's + # completion state remains visible and accurate. if progress_ctx is not None and progress_task_id is not None: progress_ctx.update( - progress_task_id, total=self.total_queued, completed=self.total_processed + progress_task_id, + total=self.total_queued, + completed=self.total_processed + self.total_failed, ) - finally: - queue.task_done() worker_tasks = [asyncio.create_task(worker()) for _ in range(self.config.concurrency)] @@ -314,7 +463,10 @@ async def worker() -> None: finally: await self._flush_checkpoint() stop_event.set() - await asyncio.gather(*worker_tasks, return_exceptions=True) + worker_results = await asyncio.gather(*worker_tasks, return_exceptions=True) + for result in worker_results: + if isinstance(result, BaseException): + self.operational_errors.append(f"Worker terminated unexpectedly: {result}") finally: if progress_ctx is not None: progress_ctx.stop() @@ -331,22 +483,40 @@ async def worker() -> None: text_out.write_info(f"Completed in {elapsed:.1f}s ({rps:.0f} req/s)") text_out.write_info(f"Finishing scan at: {time.strftime('%X')}") - if self.config.output_format != OutputFormat.TEXT or self.config.output_file: - - def _write_output(stream: TextIO) -> None: - match self.config.output_format: - case OutputFormat.JSON: - JsonFormatter(stream).write_results(found_results) - case OutputFormat.CSV: - CsvFormatter(stream).write_results(found_results) - case OutputFormat.TEXT: - TextFormatter(stream).write_summary(found_results, self.total_processed) + if self.total_failed: + text_out.write_warning(f"{self.total_failed} requests failed and were not checkpointed") + for error in self.operational_errors: + if not error.startswith("Case '"): + text_out.write_warning(error) + + output_ok = self._write_output(found_results, text_out) + all_requests_failed = self.total_failed > 0 and self.total_processed == 0 + return 0 if output_ok and not all_requests_failed and not self.operational_errors else 2 + + def _write_output(self, found_results: list[ScanResult], text_out: TextFormatter) -> bool: + """Write configured machine/file output, including valid empty results on failure.""" + if self.config.output_format == OutputFormat.TEXT and not self.config.output_file: + return True + + def write_to(stream: TextIO) -> None: + match self.config.output_format: + case OutputFormat.JSON: + JsonFormatter(stream).write_results(found_results) + case OutputFormat.CSV: + CsvFormatter(stream).write_results(found_results) + case OutputFormat.TEXT: + TextFormatter(stream).write_results(found_results, self.total_processed) + try: if self.config.output_file: - with open(self.config.output_file, "w", encoding="utf-8") as stream: - _write_output(stream) + with open_secure_write(self.config.output_file, newline="") as stream: + write_to(stream) else: - _write_output(sys.stdout) + write_to(sys.stdout) + except OSError as exc: + text_out.write_warning(f"Cannot write output: {exc}") + return False + return True async def _process_case( self, @@ -356,7 +526,7 @@ async def _process_case( queue: asyncio.Queue[Case], text_out: TextFormatter, progress: Progress | None = None, - ) -> None: + ) -> bool: """Process a single case: fetch, compare, record result.""" if self.config.random_delay: delay = random.uniform(*self.config.random_delay) @@ -364,10 +534,10 @@ async def _process_case( elif self.config.delay > 0: await asyncio.sleep(self.config.delay) - if self.restrict_os and case.os and case.os != self.restrict_os: + if not os_matches_restriction(case.os, self.restrict_os): # OS-filtered cases are marked complete so they are not retried on resume await self._mark_completed(case) - return + return True payload_str = build_payload(self.config, case.location, request_params) @@ -379,57 +549,35 @@ async def _process_case( if response is None: # Network failure: do NOT checkpoint so the case is retried on resume - return - - # Skip responses where the server returned a different error class than - # the baseline. E.g. baseline returns 200 (PHP warning) but this path - # gets a 403/404 from the web server before the app even runs — that's - # not a found file, it's a different error page. - if response.status_code // 100 != self.invalid_status_code // 100 and response.status_code >= 400: - await self._mark_completed(case) - return + return False # User-specified status code filtering if self.config.filter_codes and response.status_code in self.config.filter_codes: await self._mark_completed(case) - return + return True if self.config.match_codes and response.status_code not in self.config.match_codes: await self._mark_completed(case) - return + return True - # Content-Length skip optimization: if response is much larger than baseline - # and we're not writing files, classify as found by status alone - raw_length = response.headers.get("content-length", "0") - content_length = int(raw_length) if raw_length.isdigit() else 0 - baseline_length = max(len(self.original_response), len(self.invalid_response)) + # Unless the user explicitly selected status codes, skip responses where + # the server returned a different error class than the invalid baseline. if ( - not self.config.write_files - and not self.config.match_string - and not self.config.bad_string - and content_length > 0 - and content_length - baseline_length > SKIP_RETRIEVE_THRESHOLD + not self.config.match_codes + and response.status_code // 100 != self.invalid_status_code // 100 + and response.status_code >= 400 ): - result = ScanResult( - case=case, - found=True, - url=payload_str, - status_code=response.status_code, - content_length=content_length, - ) - self.results.append(result) - text_out.write_found(result) await self._mark_completed(case) - return + return True html = response.text if self.config.bad_string and self.config.bad_string in html: await self._mark_completed(case) - return + return True if self.config.match_string and self.config.match_string not in html: await self._mark_completed(case) - return + return True cleaned_html = clean_response(html, case.location) cleaned_invalid = clean_response(self.invalid_response, self.invalid_filename) @@ -459,10 +607,15 @@ async def _process_case( if progress is not None: progress.stop() try: - answer = await asyncio.to_thread( - input, - f"[?] Restrict further scans to '{case.os}'? [Y/n] ", - ) + try: + answer = await asyncio.to_thread( + input, + f"[?] Restrict further scans to '{case.os}'? [Y/n] ", + ) + except EOFError: + # EOF means there is no interactive user + # available to approve narrowing the scan. + answer = "n" if answer.strip().lower() in ("", "y", "yes"): self.restrict_os = case.os finally: @@ -479,6 +632,7 @@ async def _process_case( await self._enqueue_new_cases(extract_binlog_cases(html, case), queue) await self._mark_completed(case) + return True def _fuzz_headers(self, location: str) -> dict[str, str] | None: """Build per-request headers with FUZZ replaced, or None if no FUZZ in headers.""" @@ -488,14 +642,15 @@ def _fuzz_headers(self, location: str) -> dict[str, str] | None: fuzz_hdrs: dict[str, str] = {} processed = process_path(self.config, location) for hdr in self.config.headers: - if FUZZ_MARKER in hdr: - name, value = validate_header(hdr) - substituted = value.replace(FUZZ_MARKER, processed) - # Re-validate after substitution: case locations from custom - # lists could inject control characters via the FUZZ marker. - if any(c in substituted for c in "\r\n\x00"): - continue - fuzz_hdrs[name] = substituted + name, value = validate_header(hdr, warn_deprecated=False) + if FUZZ_MARKER not in value: + continue + substituted = value.replace(FUZZ_MARKER, processed) + # Re-validate after substitution: case locations from custom + # lists could inject control characters via the FUZZ marker. + if any(c in substituted for c in "\r\n\x00"): + continue + fuzz_hdrs[name] = substituted return fuzz_hdrs or None async def _fetch( @@ -513,7 +668,7 @@ async def _fetch( async def _mark_completed(self, case: Case) -> None: """Record a case as completed for resume/checkpoint support.""" self.completed_ids.add(case.case_id) - if self.config.resume_file: + if self.config.resume_file and not self._checkpoint_disabled: self._checkpoint_dirty = True now = time.monotonic() if now - self._last_checkpoint_time >= 5.0: @@ -523,14 +678,29 @@ async def _mark_completed(self, case: Case) -> None: async def _flush_checkpoint(self) -> None: """Flush checkpoint to disk if dirty.""" - if self._checkpoint_dirty and self.config.resume_file: - await asyncio.to_thread( - save_checkpoint, - self.config.resume_file, - self.completed_ids.copy(), - ) - self._checkpoint_dirty = False - self._last_checkpoint_time = time.monotonic() + if self._checkpoint_dirty and self.config.resume_file and not self._checkpoint_disabled: + # Snapshot exactly what is being persisted. completed_ids grows while + # the blocking save runs in a worker thread; if a case completes during + # the save the snapshot will not contain it, so the dirty flag must stay + # set to guarantee the newer ID is flushed on the next write. + snapshot = self.completed_ids.copy() + try: + await asyncio.to_thread( + save_checkpoint, + self.config.resume_file, + snapshot, + self._checkpoint_fingerprint, + ) + except OSError as exc: + self._checkpoint_disabled = True + self._checkpoint_dirty = False + self.operational_errors.append(f"Checkpoint disabled after write failure: {exc}") + else: + self._last_checkpoint_time = time.monotonic() + # completed_ids only ever grows, so a size change means new IDs + # landed during the save and still need to be written. + if len(self.completed_ids) == len(snapshot): + self._checkpoint_dirty = False async def _enqueue_new_cases(self, cases: list[Case], queue: asyncio.Queue[Case]) -> None: """Add newly discovered cases to the queue, skipping duplicates.""" @@ -545,10 +715,14 @@ def _write_file(self, case: Case, html: str) -> None: """Write discovered file content to local output directory.""" try: base = (Path.cwd() / "output").resolve() - output_dir = (base / self._parsed_url.netloc.replace(":", "_")).resolve() - if not str(output_dir).startswith(str(base)): + host = self._parsed_url.hostname or "unknown-host" + if self._parsed_url.port: + host = f"{host}_{self._parsed_url.port}" + output_dir = (base / sanitize_filename(host)).resolve() + if not output_dir.is_relative_to(base): raise ValueError(f"Unsafe output directory: {output_dir}") - output_dir.mkdir(parents=True, exist_ok=True) + output_dir.mkdir(parents=True, exist_ok=True, mode=0o700) + output_dir.chmod(0o700) sanitized = sanitize_filename(case.location) # Always include case_id suffix to prevent collisions from paths that @@ -563,6 +737,9 @@ def _write_file(self, case: Case, html: str) -> None: content = filter_content(html, self.original_response) if self.original_response else html - filepath.write_text(content, encoding="utf-8") - except OSError as exc: + # open_secure_write uses the strongest final-component symlink and + # permission hardening exposed by the current platform. + with open_secure_write(str(filepath)) as stream: + stream.write(content) + except (OSError, ValueError) as exc: print(f"[!] Warning: could not write file for '{case.location}': {exc}", file=sys.stderr) diff --git a/panoptic/data/cases.csv b/panoptic/data/cases.csv index d88388e..4e19dd2 100644 --- a/panoptic/data/cases.csv +++ b/panoptic/data/cases.csv @@ -94,13 +94,13 @@ path,os,software,category,type /var/log/mysqlderror.log,*NIX,MySQL,Databases,log /var/mysql-bin.index,*NIX,MySQL,Databases,log /var/mysql.log,*NIX,MySQL,Databases,log -/usr/local/mysql/data/mysql-bin.index,OSX,MySQL,Databases,log -/usr/local/mysql/data/mysql-bin.log,OSX,MySQL,Databases,log -/usr/local/mysql/data/mysql-slow.log,OSX,MySQL,Databases,log -/usr/local/mysql/data/mysql.err,OSX,MySQL,Databases,log -/usr/local/mysql/data/mysql.log,OSX,MySQL,Databases,log -/usr/local/mysql/data/mysqlderror.log,OSX,MySQL,Databases,log -/usr/local/mysql/data/{HOST}.err,OSX,MySQL,Databases,log +/usr/local/mysql/data/mysql-bin.index,OS X,MySQL,Databases,log +/usr/local/mysql/data/mysql-bin.log,OS X,MySQL,Databases,log +/usr/local/mysql/data/mysql-slow.log,OS X,MySQL,Databases,log +/usr/local/mysql/data/mysql.err,OS X,MySQL,Databases,log +/usr/local/mysql/data/mysql.log,OS X,MySQL,Databases,log +/usr/local/mysql/data/mysqlderror.log,OS X,MySQL,Databases,log +/usr/local/mysql/data/{HOST}.err,OS X,MySQL,Databases,log /MySQL/data/mysql-bin.index,Windows,MySQL,Databases,log /MySQL/data/mysql-bin.log,Windows,MySQL,Databases,log /MySQL/data/mysql.err,Windows,MySQL,Databases,log @@ -427,7 +427,7 @@ path,os,software,category,type /usr/local/php5/lib/php.ini,*NIX,PHP,Programming,conf /var/local/www/conf/php.ini,*NIX,PHP,Programming,conf /web/conf/php.ini,*NIX,PHP,Programming,conf -/Volumes/Macintosh_HD1/usr/local/php/lib/php.ini,OSX,PHP,Programming,conf +/Volumes/Macintosh_HD1/usr/local/php/lib/php.ini,OS X,PHP,Programming,conf /NetServer/bin/stable/apache/php.ini,Windows,PHP,Programming,conf /PHP/php.ini,Windows,PHP,Programming,conf /WINDOWS/php.ini,Windows,PHP,Programming,conf @@ -970,32 +970,32 @@ path,os,software,category,type /var/log/webmin/miniserv.log,*NIX,Webmin,Web Servers,log /usr/local/zeus/web/global.cfg,*NIX,Zeus Web Server (ZWS),Web Servers,conf /usr/local/zeus/web/log/errors,*NIX,Zeus Web Server (ZWS),Web Servers,log -/etc/lighttpd/lighthttpd.conf,*NIX,lighthttpd,Web Servers,conf -/home/user/lighttpd/lighttpd.conf,*NIX,lighthttpd,Web Servers,conf -/usr/home/user/lighttpd/lighttpd.conf,*NIX,lighthttpd,Web Servers,conf -/usr/local/etc/lighttpd.conf,*NIX,lighthttpd,Web Servers,conf -/usr/local/etc/lighttpd.conf.new,*NIX,lighthttpd,Web Servers,conf -/usr/local/lighttpd/conf/lighttpd.conf,*NIX,lighthttpd,Web Servers,conf -/var/www/.lighttpdpassword,*NIX,lighthttpd,Web Servers,conf -/usr/home/user/var/log/apache.log,*NIX,lighthttpd,Web Servers,log -/usr/home/user/var/log/lighttpd.error.log,*NIX,lighthttpd,Web Servers,log -/usr/local/apache/logs/lighttpd.error.log,*NIX,lighthttpd,Web Servers,log -/usr/local/apache/logs/lighttpd.log,*NIX,lighthttpd,Web Servers,log -/usr/local/apache2/logs/lighttpd.error.log,*NIX,lighthttpd,Web Servers,log -/usr/local/apache2/logs/lighttpd.log,*NIX,lighthttpd,Web Servers,log -/usr/local/lighttpd/log/access.log,*NIX,lighthttpd,Web Servers,log -/usr/local/lighttpd/log/lighttpd.error.log,*NIX,lighthttpd,Web Servers,log -/var/lighttpd.log,*NIX,lighthttpd,Web Servers,log -/var/log/lighttpd.access.log,*NIX,lighthttpd,Web Servers,log -/var/log/lighttpd.error.log,*NIX,lighthttpd,Web Servers,log -/var/log/lighttpd/,*NIX,lighthttpd,Web Servers,log -/var/log/lighttpd/access.log,*NIX,lighthttpd,Web Servers,log -/var/log/lighttpd/access.www.log,*NIX,lighthttpd,Web Servers,log -/var/log/lighttpd/error.log,*NIX,lighthttpd,Web Servers,log -/var/log/lighttpd/error.www.log,*NIX,lighthttpd,Web Servers,log -/var/log/lighttpd/{DOMAIN}/access.log,*NIX,lighthttpd,Web Servers,log -/var/log/lighttpd/{DOMAIN}/error.log,*NIX,lighthttpd,Web Servers,log -/var/logs/access.log,*NIX,lighthttpd,Web Servers,log +/etc/lighttpd/lighthttpd.conf,*NIX,lighttpd,Web Servers,conf +/home/user/lighttpd/lighttpd.conf,*NIX,lighttpd,Web Servers,conf +/usr/home/user/lighttpd/lighttpd.conf,*NIX,lighttpd,Web Servers,conf +/usr/local/etc/lighttpd.conf,*NIX,lighttpd,Web Servers,conf +/usr/local/etc/lighttpd.conf.new,*NIX,lighttpd,Web Servers,conf +/usr/local/lighttpd/conf/lighttpd.conf,*NIX,lighttpd,Web Servers,conf +/var/www/.lighttpdpassword,*NIX,lighttpd,Web Servers,conf +/usr/home/user/var/log/apache.log,*NIX,lighttpd,Web Servers,log +/usr/home/user/var/log/lighttpd.error.log,*NIX,lighttpd,Web Servers,log +/usr/local/apache/logs/lighttpd.error.log,*NIX,lighttpd,Web Servers,log +/usr/local/apache/logs/lighttpd.log,*NIX,lighttpd,Web Servers,log +/usr/local/apache2/logs/lighttpd.error.log,*NIX,lighttpd,Web Servers,log +/usr/local/apache2/logs/lighttpd.log,*NIX,lighttpd,Web Servers,log +/usr/local/lighttpd/log/access.log,*NIX,lighttpd,Web Servers,log +/usr/local/lighttpd/log/lighttpd.error.log,*NIX,lighttpd,Web Servers,log +/var/lighttpd.log,*NIX,lighttpd,Web Servers,log +/var/log/lighttpd.access.log,*NIX,lighttpd,Web Servers,log +/var/log/lighttpd.error.log,*NIX,lighttpd,Web Servers,log +/var/log/lighttpd/,*NIX,lighttpd,Web Servers,log +/var/log/lighttpd/access.log,*NIX,lighttpd,Web Servers,log +/var/log/lighttpd/access.www.log,*NIX,lighttpd,Web Servers,log +/var/log/lighttpd/error.log,*NIX,lighttpd,Web Servers,log +/var/log/lighttpd/error.www.log,*NIX,lighttpd,Web Servers,log +/var/log/lighttpd/{DOMAIN}/access.log,*NIX,lighttpd,Web Servers,log +/var/log/lighttpd/{DOMAIN}/error.log,*NIX,lighttpd,Web Servers,log +/var/logs/access.log,*NIX,lighttpd,Web Servers,log /etc/nginx/nginx.conf,*NIX,nginx,Web Servers,conf /usr/local/etc/nginx/nginx.conf,*NIX,nginx,Web Servers,conf /usr/local/nginx/conf/nginx.conf,*NIX,nginx,Web Servers,conf diff --git a/panoptic/heuristic.py b/panoptic/heuristic.py index 86085ba..48eae58 100644 --- a/panoptic/heuristic.py +++ b/panoptic/heuristic.py @@ -11,9 +11,6 @@ # Default similarity ratio above which responses are considered "the same" DEFAULT_HEURISTIC_RATIO = 0.9 -# Content-Length threshold for skipping full body retrieval -SKIP_RETRIEVE_THRESHOLD = 1000 - def clean_response(response: str, filepath: str) -> str: """Remove occurrences of a filepath from a response string. @@ -58,8 +55,17 @@ def is_match( if html is None or invalid_response is None: return False + if html == invalid_response: + return False + + # quick_ratio() is an upper bound: when it is already below the threshold, + # the full ratio must also be below it and can safely be skipped. A high + # quick ratio is not conclusive (reordered bodies can score 1.0), so those + # responses still require the full comparison. matcher = difflib.SequenceMatcher(None, html, invalid_response) - return matcher.quick_ratio() < ratio + if matcher.quick_ratio() < ratio: + return True + return matcher.ratio() < ratio def filter_content(html: str, original_response: str) -> str: diff --git a/panoptic/models.py b/panoptic/models.py index a9a5101..a487866 100644 --- a/panoptic/models.py +++ b/panoptic/models.py @@ -67,6 +67,10 @@ class ScanConfig: def __post_init__(self) -> None: if self.concurrency < 1: raise ValueError(f"concurrency must be >= 1, got {self.concurrency}") + if self.retries < 0: + raise ValueError(f"retries must be >= 0, got {self.retries}") + if self.multiplier < 1: + raise ValueError(f"multiplier must be >= 1, got {self.multiplier}") if not (0.0 < self.heuristic_ratio < 1.0): raise ValueError(f"heuristic_ratio must be between 0 and 1 (exclusive), got {self.heuristic_ratio}") if self.timeout <= 0: diff --git a/panoptic/network.py b/panoptic/network.py index 14bfb31..55a2c65 100644 --- a/panoptic/network.py +++ b/panoptic/network.py @@ -5,7 +5,6 @@ from __future__ import annotations -import ssl import sys from types import TracebackType @@ -28,29 +27,19 @@ def __init__(self, config: ScanConfig) -> None: self._client: httpx.AsyncClient | None = None async def __aenter__(self) -> NetworkClient: - timeout = httpx.Timeout(self.config.timeout, connect=5.0) - - proxy = self.config.proxy - - # SSL verification - ssl_verify: bool | ssl.SSLContext = True - if self.config.invalid_ssl: - ctx = ssl.create_default_context() - ctx.check_hostname = False - ctx.verify_mode = ssl.CERT_NONE - ssl_verify = ctx - - # Transport with retry - transport = httpx.AsyncHTTPTransport(retries=self.config.retries) + timeout = httpx.Timeout(self.config.timeout) # Build default headers headers = self._build_headers() + # Do not pass an explicit transport here. In HTTPX, doing so makes + # client-level verify/trust_env settings inapplicable to direct requests + # and prevents environment proxy mounts from being created. Retries are + # handled uniformly in fetch() so they also work with proxy transports. self._client = httpx.AsyncClient( timeout=timeout, - proxy=proxy, - verify=ssl_verify, - transport=transport, + proxy=self.config.proxy, + verify=not self.config.invalid_ssl, headers=headers, follow_redirects=self.config.follow_redirects, trust_env=not self.config.ignore_proxy, @@ -81,27 +70,31 @@ async def fetch( if self._client is None: raise RuntimeError("NetworkClient must be used as async context manager") - try: - if data is not None: - # Infer content type from payload: JSON bodies get application/json, - # everything else defaults to form-encoded. - content_type = "application/x-www-form-urlencoded" - stripped = data.lstrip() - if stripped.startswith(("{", "[")): - content_type = "application/json" - post_headers = {"Content-Type": content_type} - if headers: - post_headers.update(headers) - response = await self._client.post( - url, - content=data.encode("utf-8"), - headers=post_headers, - ) - else: - response = await self._client.get(url, headers=headers) - return response - except httpx.HTTPError: - return None + for attempt in range(self.config.retries + 1): + try: + if data is not None: + # Infer content type from payload: JSON bodies get application/json, + # everything else defaults to form-encoded. + content_type = "application/x-www-form-urlencoded" + stripped = data.lstrip() + if stripped.startswith(("{", "[")): + content_type = "application/json" + post_headers = {"Content-Type": content_type} + if headers: + post_headers.update(headers) + return await self._client.post( + url, + content=data.encode("utf-8"), + headers=post_headers, + ) + return await self._client.get(url, headers=headers) + except (httpx.ConnectError, httpx.ConnectTimeout): + if attempt >= self.config.retries: + return None + except httpx.HTTPError: + return None + + return None def _build_headers(self) -> dict[str, str]: """Build default headers from config, with validation.""" diff --git a/panoptic/output.py b/panoptic/output.py index 1dc25a9..2ff6e05 100644 --- a/panoptic/output.py +++ b/panoptic/output.py @@ -14,7 +14,7 @@ from rich.markup import escape as rich_escape from panoptic.models import ScanConfig, ScanResult -from panoptic.utils import _PARAM_VALUE_RE, redact_url +from panoptic.utils import redact_parameter_values, redact_url, validate_header class TeeWriter: @@ -37,29 +37,42 @@ def fileno(self) -> int: def _redact_json_values(obj: object) -> object: - """Recursively replace all string values in a JSON structure with '***'.""" + """Recursively redact every scalar leaf in a JSON structure. + + Strings, numbers, and booleans are all replaced with '***' — a credential + can be a numeric PIN or a bare token just as easily as a string, so redacting + only strings would leak scalar secrets. ``null`` is preserved because it + carries no value and keeps the structure faithful. + """ if isinstance(obj, dict): return {k: _redact_json_values(v) for k, v in obj.items()} if isinstance(obj, list): return [_redact_json_values(v) for v in obj] - if isinstance(obj, str): - return "***" - return obj + if obj is None: + return None + return "***" def _redact_field(url: str) -> str: """Redact a URL or POST body for safe serialization.""" - if url.startswith(("http://", "https://")): + if url.lower().startswith(("http://", "https://")): return redact_url(url) - # JSON body: redact all string values by parsing then recursively replacing - if url.lstrip().startswith("{"): - try: - obj = json.loads(url) + # JSON body: redact every leaf value, preserving container structure and keys. + try: + obj = json.loads(url) + except (json.JSONDecodeError, ValueError): + pass + else: + if isinstance(obj, (dict, list)): return json.dumps(_redact_json_values(obj)) - except (json.JSONDecodeError, ValueError): - pass - # POST body: redact form-encoded values (key=VALUE → key=***) - return _PARAM_VALUE_RE.sub("***", url) + # Bare JSON scalar (number, quoted string, bool, null): nothing structural + # to keep, so redact the whole field. + return "***" + # Form-encoded body: redact values while keeping keys for context (key=VALUE → key=***). + if "=" in url: + return redact_parameter_values(url) + # Opaque body with no recognizable structure (e.g. a raw token): redact entirely. + return "***" if url else url def _result_to_dict(r: ScanResult) -> dict[str, object]: @@ -86,7 +99,13 @@ def _has_fuzz(config: ScanConfig) -> bool: if config.data and _FUZZ_MARKER in config.data: return True if config.headers: - return any(_FUZZ_MARKER in h for h in config.headers) + for header in config.headers: + try: + _name, value = validate_header(header, warn_deprecated=False) + except ValueError: + continue + if _FUZZ_MARKER in value: + return True return False @@ -143,19 +162,21 @@ def write_banner( pupil = _scan_mode_pupil(config) if config else "()" mode = _scan_mode_label(config) if config else "" mode_str = f" [dim]·[/dim] [cyan]{mode}[/cyan]" if mode else "" + # The URL is attacker-influenced (target, redacted userinfo, IPv6 brackets) + # and must be escaped so it cannot inject or break Rich markup. self._console.print( - f"[bold cyan] .-',--.`-.[/] [bold]Panoptic[/] {version}\n" - f"[bold cyan]<_ | {pupil} | _>[/] [dim]{url}[/dim]\n" + f"[bold cyan] .-',--.`-.[/] [bold]Panoptic[/] {rich_escape(version)}\n" + f"[bold cyan]<_ | {pupil} | _>[/] [dim]{rich_escape(url)}[/dim]\n" f"[bold cyan] `-`=='-'[/] {mode_str}\n" ) def write_info(self, message: str) -> None: if self._quiet: return - self._console.print(f"[blue][i][/blue] {message}") + self._console.print(f"[blue][i][/blue] {rich_escape(message)}") def write_warning(self, message: str) -> None: - self._console.print(f"[red][!][/red] {message}") + self._console.print(f"[red][!][/red] {rich_escape(message)}") def write_found(self, result: ScanResult) -> None: case = result.case @@ -167,7 +188,7 @@ def write_found(self, result: ScanResult) -> None: def write_verbose(self, message: str) -> None: if self._quiet: return - self._console.print(f"[dim][*] {message}[/dim]") + self._console.print(f"[dim][*] {rich_escape(message)}[/dim]") def write_summary(self, found: list[ScanResult], total_cases: int) -> None: if self._quiet: @@ -176,6 +197,23 @@ def write_summary(self, found: list[ScanResult], total_cases: int) -> None: self._console.print(f" Cases tested: {total_cases}") self._console.print(f" Files found: [green]{len(found)}[/green]") + def write_results(self, found: list[ScanResult], total_cases: int) -> None: + """Write complete plain-text results for files and redirected output.""" + if self._quiet: + return + if found: + self._console.print("[bold]Findings[/bold]") + for result in found: + status = result.status_code if result.status_code is not None else "-" + length = result.content_length if result.content_length is not None else "-" + self._console.print( + f" {rich_escape(result.case.location)} [dim](status={status}, length={length})[/dim]", + soft_wrap=True, + ) + else: + self._console.print("No files found.") + self.write_summary(found, total_cases) + class JsonFormatter: """JSON output for pipeline integration.""" diff --git a/panoptic/parsers.py b/panoptic/parsers.py index 5a12877..c256b91 100644 --- a/panoptic/parsers.py +++ b/panoptic/parsers.py @@ -74,8 +74,13 @@ def extract_binlog_cases( if not index_content: return [] - # Match entries like '.\mysql-bin.000001' (dot then backslash prefix). - binlogs = re.findall(r"\.\\(?Pmysql-bin\.\d{1,6})", index_content) + # Support Windows and Unix relative entries plus the modern MySQL 8 + # default "binlog" basename. + binlogs = re.findall( + r"(?:^|[\r\n])(?:\.[\\/])?(?P(?:mysql-bin|binlog)\.\d{1,9})(?:\r?$)", + index_content, + flags=re.MULTILINE, + ) # Extract directory from parent case location last_slash = parent_case.location.rfind("/") diff --git a/panoptic/update.py b/panoptic/update.py index 30cdfef..5bdae18 100644 --- a/panoptic/update.py +++ b/panoptic/update.py @@ -9,29 +9,50 @@ import os import re import subprocess +from urllib.parse import urlsplit GIT_REPOSITORY_URL = "https://github.com/lightos/Panoptic.git" +GIT_UPSTREAM_BRANCH = "main" +GIT_UPSTREAM_REF = f"refs/heads/{GIT_UPSTREAM_BRANCH}" _PACKAGE_DIR = os.path.dirname(os.path.abspath(__file__)) _PROJECT_ROOT = os.path.dirname(_PACKAGE_DIR) def _normalise_git_url(url: str) -> str: - """Normalise a git remote URL for comparison (strip trailing .git and slash).""" - url = url.rstrip("/") - if url.endswith(".git"): - url = url[:-4] - return url.lower() + """Normalize HTTPS, SSH URL, and scp-style git remotes for comparison.""" + url = url.strip().rstrip("/") + if "://" not in url: + scp_match = re.fullmatch(r"(?:[^@]+@)?(?P[^:]+):(?P.+)", url) + if scp_match: + host = scp_match.group("host") + path = scp_match.group("path") + else: + return url.removesuffix(".git").lower() + else: + parsed = urlsplit(url) + host = parsed.hostname or "" + path = parsed.path.lstrip("/") + return f"{host}/{path.removesuffix('.git')}".lower() + +def _uses_secure_git_transport(url: str) -> bool: + """Return whether a remote uses authenticated HTTPS or SSH transport.""" + stripped = url.strip() + if "://" not in stripped: + # Git's scp-like syntax (git@github.com:owner/repo.git) uses SSH. + return re.fullmatch(r"(?:[^@]+@)?[^:]+:.+", stripped) is not None + return urlsplit(stripped).scheme.lower() in {"https", "ssh"} -def do_update() -> None: + +def do_update() -> int: """Perform self-update from git or print pip guidance.""" git_dir = os.path.join(_PROJECT_ROOT, ".git") if not os.path.exists(git_dir): print("[i] Panoptic appears to be installed via pip.") print("[i] To update, run: pip install -U panoptic") - return + return 0 print("[i] Checking for updates...") @@ -42,32 +63,54 @@ def do_update() -> None: ["git", "remote", "get-url", "origin"], capture_output=True, cwd=_PROJECT_ROOT, + timeout=30, ) except FileNotFoundError: print("[!] 'git' is not installed or not in PATH.") print("[i] Please install git or update via: pip install -U panoptic") - return + return 2 + except subprocess.TimeoutExpired: + print("[!] Timed out while checking the git remote.") + return 2 if remote_check.returncode != 0: print("[!] Cannot determine remote URL. Please verify your git configuration.") - return + return 2 remote_url = remote_check.stdout.decode("utf-8", errors="replace").strip() - if _normalise_git_url(remote_url) != _normalise_git_url(GIT_REPOSITORY_URL): - print(f"[!] Remote 'origin' ({remote_url}) does not match expected upstream.") + if not _uses_secure_git_transport(remote_url) or _normalise_git_url(remote_url) != _normalise_git_url( + GIT_REPOSITORY_URL + ): + print(f"[!] Remote 'origin' ({remote_url}) is not a trusted upstream URL.") print(f"[i] Expected: {GIT_REPOSITORY_URL}") + print("[i] HTTPS and SSH remotes for the official repository are accepted.") print("[i] Aborting update for safety. Please verify your git remotes.") - return + return 2 try: + branch_check = subprocess.run( + ["git", "symbolic-ref", "--quiet", "--short", "HEAD"], + capture_output=True, + cwd=_PROJECT_ROOT, + timeout=30, + ) + current_branch = branch_check.stdout.decode("utf-8", errors="replace").strip() + if branch_check.returncode != 0 or current_branch != GIT_UPSTREAM_BRANCH: + print(f"[!] Self-update requires the '{GIT_UPSTREAM_BRANCH}' branch to be checked out.") + return 2 + result = subprocess.run( - ["git", "pull", "--ff-only", "origin"], + ["git", "pull", "--ff-only", "origin", GIT_UPSTREAM_REF], capture_output=True, cwd=_PROJECT_ROOT, + timeout=300, ) except FileNotFoundError: print("[!] 'git' is not installed or not in PATH.") - return + return 2 + except subprocess.TimeoutExpired: + print("[!] Update timed out.") + return 2 if result.returncode == 0: stdout = result.stdout.decode("utf-8", errors="replace") @@ -77,10 +120,12 @@ def do_update() -> None: print(f"[i] Updated to revision '{revision}'.") else: print(f"[i] Already at the latest revision '{revision}'.") + return 0 else: stderr = result.stderr.decode("utf-8", errors="replace").strip() print(f"[!] Update failed: {stderr}") print("[i] Please make sure 'git' is installed and accessible.") + return 2 def get_revision() -> str | None: @@ -90,11 +135,12 @@ def get_revision() -> str | None: ["git", "rev-parse", "--verify", "HEAD"], capture_output=True, cwd=_PROJECT_ROOT, + timeout=30, ) if result.returncode == 0: stdout = result.stdout.decode("utf-8", errors="replace").strip() if re.match(r"[0-9a-f]{40}", stdout): return stdout[:7] - except FileNotFoundError: + except (FileNotFoundError, subprocess.TimeoutExpired): pass return None diff --git a/panoptic/utils.py b/panoptic/utils.py index 2fc446a..750206e 100644 --- a/panoptic/utils.py +++ b/panoptic/utils.py @@ -2,16 +2,75 @@ from __future__ import annotations +import errno +import os import random import re import secrets +import stat import sys from importlib.resources import files -from typing import Any -from urllib.parse import urlsplit - -# Regex to redact values in key=value&... encoded strings (query params, form bodies) -_PARAM_VALUE_RE = re.compile(r"(?<==)[^&]*") +from typing import Any, TextIO +from urllib.parse import unquote_plus, urlsplit + +_HEADER_NAME_RE = re.compile(r"^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$") +_OS_ALIASES = { + "osx": "OS X", + "os x": "OS X", + "dragonflybsd": "DragonFly BSD", + "dragonfly bsd": "DragonFly BSD", +} + + +def normalize_os_name(value: str | None) -> str | None: + """Normalize known OS aliases case-insensitively, preserving unknown names.""" + if value is None: + return None + stripped = value.strip() + return _OS_ALIASES.get(stripped.casefold(), stripped) + + +def normalize_os_names(value: str | None) -> tuple[str, ...]: + """Split and normalize comma-separated OS metadata.""" + if not value: + return () + return tuple(normalized for part in value.split(",") if (normalized := normalize_os_name(part))) + + +def _single_os_matches(case_os: str, restriction: str) -> bool: + """Compare two already-normalized, non-empty OS labels.""" + case_folded = case_os.casefold() + restriction_folded = restriction.casefold() + if restriction_folded == "*nix": + return case_folded != "windows" + if case_folded == "*nix": + return restriction_folded != "windows" + return case_folded == restriction_folded + + +def os_matches_restriction(case_os: str | None, restriction: str | None) -> bool: + """Return True if a case's OS is compatible with an OS restriction/filter. + + Applies a hierarchical Unix-family rule so the case-list prefilter + (``parse_cases``) and the runtime scan restriction stay in lockstep: + + * an empty case OS or empty restriction always matches; + * a ``*NIX`` restriction includes every non-Windows OS (FreeBSD, OS X, ...); + * a specific Unix restriction (e.g. FreeBSD) still includes the generic + ``*NIX`` cases; + * otherwise the comparison is exact (case-insensitive). + """ + if not case_os or not restriction: + return True + case_values = normalize_os_names(case_os) + restriction_values = normalize_os_names(restriction) + if not case_values or not restriction_values: + return True + return any( + _single_os_matches(case_value, restriction_value) + for case_value in case_values + for restriction_value in restriction_values + ) def validate_url_scheme(url: str) -> None: @@ -20,11 +79,18 @@ def validate_url_scheme(url: str) -> None: Raises ValueError if the scheme is invalid (prevents SSRF via file://, ftp://, etc.). """ parsed = urlsplit(url) - if parsed.scheme not in ("http", "https"): + if parsed.scheme.lower() not in ("http", "https"): raise ValueError(f"Only http:// and https:// URLs are supported, got '{parsed.scheme}://'") + if not parsed.hostname: + raise ValueError("URL must include a hostname") + try: + port = parsed.port + except ValueError as exc: + raise ValueError(f"Invalid URL port: {exc}") from exc + del port -def validate_header(header: str) -> tuple[str, str]: +def validate_header(header: str, *, warn_deprecated: bool = True) -> tuple[str, str]: """Parse and validate a custom HTTP header string. Expected format: 'Name: Value' (standard HTTP header format). @@ -35,10 +101,11 @@ def validate_header(header: str) -> tuple[str, str]: if ":" not in header: # Backward compatibility: original used Name=Value format if "=" in header: - print( - "[!] Warning: header format 'Name=Value' is deprecated, use 'Name: Value'", - file=sys.stderr, - ) + if warn_deprecated: + print( + "[!] Warning: header format 'Name=Value' is deprecated, use 'Name: Value'", + file=sys.stderr, + ) name, _, value = header.partition("=") else: raise ValueError("Header must contain a colon separator (format: 'Name: Value')") @@ -46,18 +113,41 @@ def validate_header(header: str) -> tuple[str, str]: name, _, value = header.partition(":") # Check for CRLF BEFORE stripping — strip() would silently remove injection chars - if any(c in name + value for c in "\r\n"): - raise ValueError("Header contains CRLF characters (possible header injection)") + if any(c in name + value for c in "\r\n\x00"): + raise ValueError("Header contains CRLF/NUL characters (possible header injection)") name = name.strip() value = value.strip() if not name: raise ValueError("Header name cannot be empty") + if not _HEADER_NAME_RE.fullmatch(name): + raise ValueError(f"Invalid HTTP header name: {name!r}") return name, value +def has_parameter(parameters: str, expected_name: str) -> bool: + """Return whether form/query syntax contains a decoded or raw parameter name.""" + for segment in parameters.split("&"): + raw_name, separator, _value = segment.partition("=") + if separator and (raw_name == expected_name or unquote_plus(raw_name) == expected_name): + return True + return False + + +def replace_parameter_value(parameters: str, expected_name: str, value: str) -> str: + """Replace matching form/query values while preserving each raw parameter name.""" + replaced: list[str] = [] + for segment in parameters.split("&"): + raw_name, separator, _old_value = segment.partition("=") + if separator and (raw_name == expected_name or unquote_plus(raw_name) == expected_name): + replaced.append(f"{raw_name}={value}") + else: + replaced.append(segment) + return "&".join(replaced) + + def sanitize_filename(path: str) -> str: """Sanitize a file path for use as a local output filename. @@ -74,6 +164,54 @@ def sanitize_filename(path: str) -> str: return sanitized or "unnamed" +def open_secure_write(path: str, *, newline: str | None = None) -> TextIO: + """Open a file for writing with platform-appropriate hardening. + + Sensitive scan artifacts (log files, result/list output, discovered file + content) may echo target paths, redacted URLs, or retrieved file bodies. + On POSIX, the file is forced to owner-only mode 0o600 before truncation. + Symlink protection depends on the primitives exposed by the platform: + + * With ``O_NOFOLLOW``, ``os.open`` atomically refuses a symlink at the final + path component. + * Without ``O_NOFOLLOW``, an ``lstat`` check rejects an already-present + symlink (and Windows junction where detectable), but cannot eliminate a + check/open race. + + The file is opened *without* ``O_TRUNC`` and only truncated after + permissions are confirmed, so a permission-hardening failure fails closed + on POSIX instead of destroying a pre-existing file's contents. Windows mode + bits do not configure NTFS ACLs, so owner-only access is not guaranteed there. + Raises OSError on failure. + """ + nofollow = getattr(os, "O_NOFOLLOW", 0) + if not nofollow: + try: + target_stat = os.lstat(path) + except FileNotFoundError: + pass + else: + is_junction = getattr(os.path, "isjunction", lambda _path: False) + if stat.S_ISLNK(target_stat.st_mode) or is_junction(path): + raise OSError(errno.ELOOP, "refusing to follow symlink or junction", path) + + flags = os.O_WRONLY | os.O_CREAT | nofollow | getattr(os, "O_BINARY", 0) + fd = os.open(path, flags, 0o600) + try: + # Tighten permissions BEFORE truncating. If we cannot guarantee 0600 we + # must fail closed rather than proceed — and because we have not yet + # truncated, a pre-existing file's contents are left intact. + if hasattr(os, "fchmod"): + os.fchmod(fd, 0o600) + elif os.name == "posix": + raise OSError(errno.ENOTSUP, "fchmod is unavailable; cannot enforce mode 0o600") + os.ftruncate(fd, 0) + return os.fdopen(fd, "w", encoding="utf-8", newline=newline) + except BaseException: + os.close(fd) + raise + + def load_data_file(filename: str) -> str: """Load a data file from the panoptic.data package. @@ -100,6 +238,20 @@ def generate_invalid_filename() -> str: return secrets.token_hex(8) +def redact_parameter_values(value: str) -> str: + """Redact values in query/form syntax, including bare non-key segments.""" + redacted: list[str] = [] + for segment in value.split("&"): + if "=" in segment: + key, _, _parameter_value = segment.partition("=") + redacted.append(f"{key}=***") + elif segment: + redacted.append("***") + else: + redacted.append("") + return "&".join(redacted) + + def redact_url(url: str) -> str: """Redact sensitive parts of a URL for safe display. @@ -109,11 +261,17 @@ def redact_url(url: str) -> str: parsed = urlsplit(url) # Rebuild netloc without userinfo host = parsed.hostname or "" - if parsed.port: - host = f"{host}:{parsed.port}" + if ":" in host: + host = f"[{host}]" + try: + port = parsed.port + except ValueError: + port = None + if port: + host = f"{host}:{port}" # Redact query parameter values but keep keys for context if parsed.query: - redacted_query = _PARAM_VALUE_RE.sub("***", parsed.query) + redacted_query = redact_parameter_values(parsed.query) return f"{parsed.scheme}://{host}{parsed.path}?{redacted_query}" return f"{parsed.scheme}://{host}{parsed.path}" diff --git a/pyproject.toml b/pyproject.toml index a364b90..d00ed21 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools>=68.0"] +requires = ["setuptools>=77.0"] build-backend = "setuptools.build_meta" [project] @@ -7,28 +7,35 @@ name = "panoptic" version = "2.0.0" description = "Search and retrieve content of common log and config files via path traversal vulnerability" readme = "README.md" -license = {text = "MIT"} +license = "MIT" requires-python = ">=3.10" authors = [ {name = "Roberto Christopher Salgado Bjerre"}, {name = "Miroslav Stampar"}, ] dependencies = [ - "httpx[socks]>=0.27", - "rich>=13.0", + "httpx[socks]>=0.28.1", + "rich>=15.0.0", "rich-argparse>=1.4", "tomli>=2.0; python_version < '3.11'", ] +[project.urls] +Homepage = "https://github.com/lightos/Panoptic" +Issues = "https://github.com/lightos/Panoptic/issues" +Source = "https://github.com/lightos/Panoptic" + [project.optional-dependencies] dev = [ "pytest>=8.0", - "pytest-asyncio>=0.23", + "pytest-asyncio>=1.4.0", "pytest-httpx>=0.30", "ruff>=0.4", "mypy>=1.10", - "pre-commit>=4.0", - "pip-audit>=2.7", + "pre-commit>=4.6.0", + "pip-audit>=2.10.1", + "build>=1.2", + "twine>=5.0", ] [tool.setuptools.packages.find] diff --git a/tests/test_cases.py b/tests/test_cases.py index e299796..c4125df 100644 --- a/tests/test_cases.py +++ b/tests/test_cases.py @@ -22,10 +22,32 @@ def test_cases_are_frozen(self) -> None: with pytest.raises(AttributeError): cases[0].location = "modified" # type: ignore[misc] - def test_filter_by_os(self) -> None: + def test_filter_by_os_nix_includes_unix_family(self) -> None: + """A *NIX filter excludes Windows but keeps the whole Unix family.""" config = ScanConfig(url="http://example.com", os_filter="*NIX") cases = parse_cases(config) - assert all(c.os == "*NIX" for c in cases if c.os is not None) + os_values = {c.os for c in cases if c.os is not None} + assert "Windows" not in os_values + assert "*NIX" in os_values + assert "FreeBSD" in os_values # specific Unix OS retained under *NIX + + def test_filter_by_specific_unix_includes_generic_nix(self) -> None: + """A specific Unix filter still includes the generic *NIX cases (matches runtime).""" + config = ScanConfig(url="http://example.com", os_filter="FreeBSD") + cases = parse_cases(config) + os_values = {c.os for c in cases if c.os is not None} + assert "FreeBSD" in os_values + assert "*NIX" in os_values + assert "Windows" not in os_values + + def test_filter_by_lowercase_os_alias_keeps_os_x_cases(self) -> None: + config = ScanConfig(url="http://example.com", os_filter="osx") + cases = parse_cases(config) + os_values = {case.os for case in cases} + assert "OS X" in os_values + assert "*NIX" in os_values + assert "Windows" not in os_values + assert any(case.location == "/usr/local/mysql/data/mysql.log" for case in cases) def test_filter_by_software(self) -> None: config = ScanConfig(url="http://example.com", software_filter="PHP") @@ -44,6 +66,37 @@ def test_host_placeholder_expansion(self) -> None: for case in cases: assert "{HOST}" not in case.location + def test_domain_placeholder_expansion(self) -> None: + config = ScanConfig(url="http://target.example.com:8080/test.php?f=x") + cases = parse_cases(config) + domain_cases = [case for case in cases if "/var/log/lighttpd/target.example.com/" in case.location] + assert len(domain_cases) == 2 + assert all("{" not in case.location for case in cases) + + def test_version_templates_are_skipped_unless_requested(self) -> None: + default_cases = parse_cases(ScanConfig(url="http://example.com")) + versioned_cases = parse_cases(ScanConfig(url="http://example.com", all_versions=True)) + assert all("[" not in case.location for case in default_cases) + assert len(versioned_cases) > len(default_cases) + assert any("JBoss-6.0.0.Final" in case.location for case in versioned_cases) + + def test_unknown_version_template_fails_fast(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + "panoptic.cases._load_csv", + lambda: [ + { + "path": "/opt/[UNKNOWN]/config", + "os": "*NIX", + "software": "Example", + "category": "Other", + "type": "conf", + } + ], + ) + monkeypatch.setattr("panoptic.cases.load_versions", lambda: {"KNOWN": ["1.0"]}) + with pytest.raises(ValueError, match="unknown version section 'UNKNOWN'"): + parse_cases(ScanConfig(url="http://example.com", all_versions=True)) + class TestLoadVersions: def test_returns_dict(self) -> None: @@ -82,7 +135,19 @@ def test_parse_cases_returns_expected_count(self) -> None: """CSV parser returns expected number of cases.""" config = ScanConfig(url="http://example.com") cases = parse_cases(config) - assert len(cases) == 1024 + assert len(cases) == 958 + + def test_composite_os_rows_emit_one_case_per_location(self) -> None: + cases = parse_cases(ScanConfig(url="http://example.com")) + locations = { + "/usr/ports/ftp/pure-ftpd/pure-ftpd.conf", + "/usr/ports/ftp/pure-ftpd/pureftpd.passwd", + "/usr/ports/ftp/pure-ftpd/pureftpd.pdb", + } + composite_cases = [case for case in cases if case.location in locations] + assert len(composite_cases) == len(locations) + assert {case.location for case in composite_cases} == locations + assert all(case.os == "DragonFly BSD, FreeBSD" for case in composite_cases) def test_all_cases_have_metadata(self) -> None: """Every case from CSV has all metadata fields populated.""" @@ -109,6 +174,15 @@ def test_list_values_os(self) -> None: values = list_values("os") assert "*NIX" in values assert "Windows" in values + assert "OS X" in values + assert "OSX" not in values + assert "DragonFly BSD" in values + assert "DragonflyBSD, FreeBSD" not in values + + filtered_values = list_values("os", ScanConfig(url="http://example.com")) + assert "DragonFly BSD" in filtered_values + assert "FreeBSD" in filtered_values + assert "DragonFly BSD, FreeBSD" not in filtered_values def test_list_values_software(self) -> None: from panoptic.cases import list_values diff --git a/tests/test_cli.py b/tests/test_cli.py index a23bc60..4e7a18a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,5 +1,7 @@ """Tests for panoptic.cli — argument parsing and validation.""" +import os +from pathlib import Path from unittest.mock import AsyncMock, patch import pytest @@ -48,7 +50,12 @@ def test_new_flags(self) -> None: def test_prefix_multiplier(self) -> None: args = parse_args(["-u", "http://example.com", "--prefix", "../", "--multiplier", "3"]) - assert args["prefix"] == "../../../" + assert args["prefix"] == "../" + assert args["multiplier"] == 3 + + def test_boolean_flag_can_override_config_with_false(self) -> None: + args = parse_args(["-u", "http://example.com", "--no-auto"]) + assert args["automatic"] is False def test_list_command(self) -> None: args = parse_args(["--list", "software"]) @@ -141,6 +148,46 @@ def test_rejects_negative_delay(self) -> None: } ) + def test_rejects_url_without_hostname(self) -> None: + with pytest.raises(SystemExit): + validate_args( + { + "url": "http:///test.php?file=x", + "list": None, + "update": False, + "list_all_files": False, + "headers": None, + } + ) + + def test_rejects_socks4_proxy(self) -> None: + """httpx (socksio) only supports SOCKS5, so socks4:// must be rejected up front.""" + with pytest.raises(SystemExit): + validate_args( + { + "url": "http://example.com/test.php?file=x", + "list": None, + "update": False, + "list_all_files": False, + "headers": None, + "proxy": "socks4://127.0.0.1:9050", + } + ) + + def test_accepts_socks5_proxy(self) -> None: + # Should not raise for supported SOCKS5 variants. + for scheme in ("socks5://127.0.0.1:9050", "socks5h://127.0.0.1:9050"): + validate_args( + { + "url": "http://example.com/test.php?file=x", + "list": None, + "update": False, + "list_all_files": False, + "headers": None, + "proxy": scheme, + } + ) + class TestListFormatting: def test_list_json_output(self) -> None: @@ -176,6 +223,24 @@ def test_list_all_files_csv_output(self) -> None: assert lines[0] == "path" assert len(lines) > 1 + async def test_list_csv_quotes_values_with_commas(self, capsys: pytest.CaptureFixture[str]) -> None: + import csv + import io + + exit_code = await run(["--list", "os", "--output-format", "csv"]) + rows = list(csv.reader(io.StringIO(capsys.readouterr().out))) + assert exit_code == 0 + assert all(len(row) == 1 for row in rows) + assert ["DragonFly BSD"] in rows + + @pytest.mark.skipif(os.name != "posix", reason="requires POSIX file permissions") + async def test_list_output_file_is_owner_only(self, tmp_path: Path) -> None: + out_path = tmp_path / "os-list.txt" + exit_code = await run(["--list", "os", "--output-file", str(out_path)]) + assert exit_code == 0 + assert out_path.exists() + assert os.stat(out_path).st_mode & 0o777 == 0o600 + class TestParamAutodetection: async def test_base64_param_autodetect(self) -> None: @@ -186,3 +251,60 @@ async def test_base64_param_autodetect(self) -> None: await run(["--url", "http://example.com/test.php?file=dGVzdC50eHQ=&id=1", "--auto"]) config = mock_scanner_cls.call_args[0][0] assert config.param == "file" + + async def test_skips_empty_parameter_during_autodetect(self) -> None: + with patch("panoptic.core.Scanner") as mock_scanner_cls: + mock_scanner = AsyncMock() + mock_scanner.run.return_value = 0 + mock_scanner_cls.return_value = mock_scanner + exit_code = await run(["--url", "http://example.com/test.php?empty=&file=test", "--auto"]) + config = mock_scanner_cls.call_args[0][0] + assert exit_code == 0 + assert config.param == "file" + + async def test_url_can_come_from_config(self, tmp_path: Path) -> None: + config_file = tmp_path / "config.toml" + config_file.write_text('[defaults]\nurl = "example.com/test.php?file=x"\nautomatic = true\n') + with patch("panoptic.core.Scanner") as mock_scanner_cls: + mock_scanner = AsyncMock() + mock_scanner.run.return_value = 0 + mock_scanner_cls.return_value = mock_scanner + exit_code = await run(["--config", str(config_file)]) + config = mock_scanner_cls.call_args[0][0] + assert exit_code == 0 + assert config.url == "http://example.com/test.php?file=x" + + async def test_invalid_config_type_returns_operational_error( + self, tmp_path: Path, capsys: pytest.CaptureFixture[str] + ) -> None: + config_file = tmp_path / "config.toml" + config_file.write_text('[defaults]\nurl = "example.com/?file=x"\nos_filter = 123\n') + exit_code = await run(["--config", str(config_file), "--auto"]) + assert exit_code == 2 + assert "os_filter must be a string" in capsys.readouterr().err + + async def test_explicit_missing_parameter_is_rejected(self, capsys: pytest.CaptureFixture[str]) -> None: + with patch("panoptic.core.Scanner") as mock_scanner_cls: + exit_code = await run(["--url", "http://example.com/?file=x", "--param", "typo"]) + assert exit_code == 1 + assert "Parameter 'typo' not found" in capsys.readouterr().err + mock_scanner_cls.assert_not_called() + + async def test_json_post_requires_fuzz_injection_marker(self, capsys: pytest.CaptureFixture[str]) -> None: + body = '{"file":"old"}' + with patch("panoptic.core.Scanner") as mock_scanner_cls: + exit_code = await run(["--url", "http://example.com/api", "--data", body, "--param", "file"]) + assert exit_code == 1 + assert "place FUZZ at the injection point" in capsys.readouterr().err + mock_scanner_cls.assert_not_called() + + async def test_json_post_with_fuzz_is_accepted(self) -> None: + body = '{"file":"FUZZ"}' + with patch("panoptic.core.Scanner") as mock_scanner_cls: + mock_scanner = AsyncMock() + mock_scanner.run.return_value = 0 + mock_scanner_cls.return_value = mock_scanner + exit_code = await run(["--url", "http://example.com/api", "--data", body]) + assert exit_code == 0 + config = mock_scanner_cls.call_args.args[0] + assert config.data == body diff --git a/tests/test_config.py b/tests/test_config.py index 6d1b7f2..50fe370 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -2,7 +2,10 @@ from pathlib import Path +import pytest + from panoptic.config import load_config, merge_config +from panoptic.core import build_payload from panoptic.models import OutputFormat @@ -13,9 +16,10 @@ def test_returns_dict(self, tmp_path: Path) -> None: result = load_config(str(config_file)) assert result["defaults"]["concurrency"] == 16 - def test_missing_file_returns_empty(self) -> None: + def test_missing_file_returns_empty(self, capsys: pytest.CaptureFixture[str]) -> None: result = load_config("/nonexistent/config.toml") assert result == {} + assert "does not exist" in capsys.readouterr().err def test_invalid_toml_returns_empty(self, tmp_path: Path) -> None: config_file = tmp_path / "bad.toml" @@ -55,7 +59,54 @@ def test_headers_from_file(self) -> None: config = merge_config(cli_args, file_config) assert config.user_agent == "CustomBot/1.0" + def test_cli_headers_extend_config_headers(self) -> None: + config = merge_config( + {"url": "http://example.com", "headers": ["X-CLI: cli"]}, + {"headers": {"values": ["X-Config: config"]}}, + ) + assert config.headers == ["X-Config: config", "X-CLI: cli"] + def test_output_format_enum(self) -> None: cli_args = {"url": "http://example.com", "output_format": "json"} config = merge_config(cli_args, {}) assert config.output_format == OutputFormat.JSON + + def test_cli_false_overrides_true_boolean(self) -> None: + config = merge_config( + {"url": "http://example.com", "automatic": False}, + {"defaults": {"automatic": True}}, + ) + assert config.automatic is False + + def test_config_multiplier_applies_to_payload(self) -> None: + config = merge_config( + {"url": "http://example.com/?file=x"}, + {"defaults": {"param": "file", "prefix": "../", "multiplier": 3}}, + ) + assert "../../../etc/passwd" in build_payload(config, "/etc/passwd", "file=x") + + def test_config_url_is_normalized(self) -> None: + config = merge_config({}, {"defaults": {"url": "example.com/?file=x"}}) + assert config.url == "http://example.com/?file=x" + + def test_invalid_output_format_raises(self) -> None: + with pytest.raises(ValueError, match="output_format"): + merge_config({"url": "http://example.com"}, {"defaults": {"output_format": "xml"}}) + + def test_unknown_option_warns(self, capsys: pytest.CaptureFixture[str]) -> None: + merge_config({"url": "http://example.com"}, {"defaults": {"concurreny": 8}}) + assert "concurreny" in capsys.readouterr().err + + @pytest.mark.parametrize( + ("file_config", "message"), + [ + ({"proxy": {"url": 123}}, "proxy must be a string"), + ({"headers": {"cookie": 123}}, "cookie must be a string"), + ({"defaults": {"os_filter": 123}}, "os_filter must be a string"), + ({"defaults": {"automatic": "yes"}}, "automatic must be a boolean"), + ({"defaults": {"concurrency": 4.5}}, "concurrency must be an integer"), + ], + ) + def test_invalid_config_types_raise(self, file_config: dict[str, object], message: str) -> None: + with pytest.raises(ValueError, match=message): + merge_config({"url": "http://example.com"}, file_config) diff --git a/tests/test_core.py b/tests/test_core.py index 53b0930..671ad6c 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1,13 +1,26 @@ """Tests for panoptic.core — async scanner orchestrator.""" import asyncio +import io import json +import os +import threading import time from pathlib import Path from unittest.mock import AsyncMock, patch -from panoptic.core import Scanner, build_payload, load_checkpoint, save_checkpoint -from panoptic.models import Case, ScanConfig +import pytest + +from panoptic.core import ( + Scanner, + build_payload, + checkpoint_fingerprint, + load_checkpoint, + save_checkpoint, +) +from panoptic.models import Case, OutputFormat, ScanConfig +from panoptic.output import TextFormatter +from panoptic.utils import os_matches_restriction class TestBuildPayload: @@ -28,7 +41,7 @@ def test_prefix_postfix(self) -> None: def test_path_based(self) -> None: config = ScanConfig(url="http://example.com/files/view/test.txt", path_based=True) url = build_payload(config, "/etc/passwd", "") - assert "/etc/passwd" in url + assert url == "http://example.com/files/view/etc/passwd" def test_replace_slash(self) -> None: config = ScanConfig(url="http://example.com/test.php?file=test.txt", param="file", replace_slash="/./") @@ -68,11 +81,21 @@ def test_post_payload_encodes_plus_as_percent2b(self) -> None: payload = build_payload(config, "/etc/foo+bar", "file=test.txt") assert "%2B" in payload or "foo%2Bbar" in payload - def test_get_payload_preserves_plus(self) -> None: - """GET payloads should keep + unencoded for base64 compat with PHP $_GET.""" + def test_get_payload_encodes_plus(self) -> None: + """GET payloads must encode + because query parsers decode it as a space.""" config = ScanConfig(url="http://example.com/test.php?file=test.txt", param="file") payload = build_payload(config, "/etc/foo+bar", "file=test.txt") - assert "foo+bar" in payload + assert "foo%2Bbar" in payload + + def test_multiplier_applies_during_payload_construction(self) -> None: + config = ScanConfig( + url="http://example.com/test.php?file=test.txt", + param="file", + prefix="../", + multiplier=3, + ) + payload = build_payload(config, "/etc/passwd", "file=test.txt") + assert "../../../etc/passwd" in payload def test_replace_slash_percent_not_double_encoded(self) -> None: """--replace-slash with pre-encoded value like %2F must not double-encode.""" @@ -91,6 +114,62 @@ def test_fuzz_mode_no_encoding(self) -> None: payload = build_payload(config, "/etc/passwd", '{"file":"FUZZ"}') assert "/etc/passwd" in payload + def test_fuzz_json_body_preserves_valid_json_for_windows_path(self) -> None: + """A Windows path with backslashes/quotes must stay valid JSON after FUZZ substitution.""" + body = '{"file":"FUZZ"}' + config = ScanConfig(url="http://example.com/api", data=body) + payload = build_payload(config, r"C:\Windows\win.ini", body) + # Must round-trip as valid JSON with the raw path recovered intact. + assert json.loads(payload) == {"file": r"C:\Windows\win.ini"} + + def test_fuzz_json_body_escapes_embedded_quote(self) -> None: + body = '{"path":"FUZZ"}' + config = ScanConfig(url="http://example.com/api", data=body) + payload = build_payload(config, 'a"b', body) + assert json.loads(payload) == {"path": 'a"b'} + + def test_fuzz_json_marker_in_key_is_replaced(self) -> None: + body = '{"FUZZ":"x"}' + config = ScanConfig(url="http://example.com/api", data=body) + payload = build_payload(config, "/etc/passwd", body) + assert json.loads(payload) == {"/etc/passwd": "x"} + + def test_fuzz_form_body_percent_encodes_delimiters(self) -> None: + """Form-encoded FUZZ bodies must encode +, &, = so the structure survives.""" + body = "file=FUZZ&id=1" + config = ScanConfig(url="http://example.com/api", data=body) + payload = build_payload(config, "/etc/foo+bar&baz=qux", body) + assert "foo%2Bbar%26baz%3Dqux" in payload + assert payload.endswith("&id=1") # trailing structure preserved + + def test_fuzz_opaque_body_keeps_raw_substitution(self) -> None: + """A non-JSON, non-form body leaves the substitution untouched.""" + body = "prefix-FUZZ-suffix" + config = ScanConfig(url="http://example.com/api", data=body) + payload = build_payload(config, "/etc/passwd", body) + assert payload == "prefix-/etc/passwd-suffix" + + def test_encoded_parameter_name_is_replaced(self) -> None: + params = "file%5B0%5D=old&id=1" + config = ScanConfig(url=f"http://example.com/?{params}", param="file[0]") + payload = build_payload(config, "/etc/passwd", params) + assert payload == "http://example.com/?file%5B0%5D=/etc/passwd&id=1" + + def test_parameter_name_backslash_is_not_a_regex_replacement(self) -> None: + params = r"x\1=old&id=1" + config = ScanConfig(url=f"http://example.com/?{params}", param=r"x\1") + payload = build_payload(config, "/etc/passwd", params) + assert payload.endswith(r"?x\1=/etc/passwd&id=1") + + def test_base64_padding_is_percent_encoded(self) -> None: + config = ScanConfig( + url="http://example.com/?file=old", + param="file", + base64_encode=True, + ) + payload = build_payload(config, "/etc/passwd", "file=old") + assert payload.endswith("file=L2V0Yy9wYXNzd2Q%3D") + class TestScanner: async def test_scanner_initializes(self) -> None: @@ -126,6 +205,21 @@ async def test_checkpoint_state(self, tmp_path: Path) -> None: async def test_load_empty_checkpoint(self, tmp_path: Path) -> None: assert load_checkpoint(str(tmp_path / "nonexistent.json")) == set() + def test_generic_unix_restriction_keeps_specific_unix_cases(self) -> None: + assert os_matches_restriction("FreeBSD", "*NIX") is True + assert os_matches_restriction("OS X", "*NIX") is True + assert os_matches_restriction("Windows", "*NIX") is False + assert os_matches_restriction("*NIX", "FreeBSD") is True + assert os_matches_restriction("DragonFly BSD, FreeBSD", "FreeBSD") is True + + def test_runtime_restriction_canonicalizes_os_alias(self) -> None: + """An aliased --os (e.g. OSX) must match the canonical case OS at runtime.""" + for alias in ("OSX", "osx", "OsX"): + scanner = Scanner(ScanConfig(url="http://example.com", os_filter=alias)) + assert scanner.restrict_os == "OS X" + # A canonicalized OS X case must now pass the runtime restriction. + assert os_matches_restriction("OS X", scanner.restrict_os) is True + class TestWriteFile: def test_no_filename_collision(self, tmp_path: Path) -> None: @@ -149,6 +243,79 @@ def test_no_filename_collision(self, tmp_path: Path) -> None: output_dir = tmp_path / "output" / "example.com" files = list(output_dir.iterdir()) assert len(files) == 2, f"Expected 2 files, got {len(files)}: {files}" + assert all(os.stat(path).st_mode & 0o777 == 0o600 for path in files) + + @pytest.mark.skipif(os.name != "posix", reason="requires POSIX symlinks") + def test_discovered_file_write_refuses_symlink(self, tmp_path: Path) -> None: + """A symlink pre-planted at the discovered-file path must not be clobbered.""" + from panoptic.utils import sanitize_filename + + config = ScanConfig(url="http://example.com/test.php?file=x", param="file", write_files=True) + scanner = Scanner(config) + scanner.original_response = "original" + case = Case(location="/etc/passwd", os="*NIX") + + secret = tmp_path / "secret.txt" + secret.write_text("do-not-clobber", encoding="utf-8") + + with patch("panoptic.core.Path.cwd", return_value=tmp_path): + output_dir = tmp_path / "output" / "example.com" + output_dir.mkdir(parents=True) + filename = f"{sanitize_filename(case.location)}_{case.case_id[:8]}.txt" + (output_dir / filename).symlink_to(secret) + # _write_file swallows OSError (logs a warning) rather than raising. + scanner._write_file(case, "malicious content") + + assert secret.read_text(encoding="utf-8") == "do-not-clobber" + + +class TestScanOutputFilePermissions: + @pytest.mark.skipif(os.name != "posix", reason="requires POSIX file permissions") + async def test_output_file_is_owner_only(self, tmp_path: Path) -> None: + out_path = tmp_path / "results.json" + config = ScanConfig( + url="http://example.com/test.php?file=x", + param="file", + quiet=True, + output_format=OutputFormat.JSON, + output_file=str(out_path), + ) + scanner = Scanner(config) + with ( + patch("panoptic.core.parse_cases", return_value=[Case(location="/etc/passwd")]), + patch("panoptic.core.NetworkClient") as client_cls, + patch("panoptic.core.get_revision", return_value=None), + patch.object(scanner, "_fetch", new=AsyncMock(return_value=None)), + ): + client_cls.return_value.__aenter__ = AsyncMock(return_value=AsyncMock()) + client_cls.return_value.__aexit__ = AsyncMock(return_value=None) + await scanner._run_scan(io.StringIO(), "test") + + assert out_path.exists() + assert os.stat(out_path).st_mode & 0o777 == 0o600 + + @pytest.mark.skipif(os.name != "posix", reason="requires POSIX file permissions") + async def test_log_file_is_owner_only(self, tmp_path: Path) -> None: + log_path = tmp_path / "scan.log" + config = ScanConfig( + url="http://example.com/test.php?file=x", + param="file", + quiet=True, + log_file=str(log_path), + ) + scanner = Scanner(config) + with ( + patch("panoptic.core.parse_cases", return_value=[Case(location="/etc/passwd")]), + patch("panoptic.core.NetworkClient") as client_cls, + patch("panoptic.core.get_revision", return_value=None), + patch.object(scanner, "_fetch", new=AsyncMock(return_value=None)), + ): + client_cls.return_value.__aenter__ = AsyncMock(return_value=AsyncMock()) + client_cls.return_value.__aexit__ = AsyncMock(return_value=None) + await scanner.run() + + assert log_path.exists() + assert os.stat(log_path).st_mode & 0o777 == 0o600 class TestFirstFoundRace: @@ -196,10 +363,12 @@ class TestAtomicCheckpoint: def test_save_checkpoint_atomic(self, tmp_path: Path) -> None: """Checkpoint writes must be atomic (temp file + rename).""" filepath = str(tmp_path / "checkpoint.json") - save_checkpoint(filepath, {"id1", "id2"}) + save_checkpoint(filepath, {"id1", "id2"}, "fingerprint") with open(filepath) as f: data = json.load(f) - assert set(data) == {"id1", "id2"} + assert data["version"] == 1 + assert data["fingerprint"] == "fingerprint" + assert set(data["completed_ids"]) == {"id1", "id2"} def test_save_checkpoint_no_partial_writes(self, tmp_path: Path) -> None: """If process dies mid-write, old checkpoint must survive.""" @@ -208,7 +377,45 @@ def test_save_checkpoint_no_partial_writes(self, tmp_path: Path) -> None: save_checkpoint(filepath, {"id1", "id2", "id3"}) with open(filepath) as f: data = json.load(f) - assert len(data) == 3 + assert len(data["completed_ids"]) == 3 + + def test_checkpoint_rejects_different_scan(self, tmp_path: Path) -> None: + filepath = str(tmp_path / "checkpoint.json") + save_checkpoint(filepath, {"id1"}, "scan-a") + with pytest.raises(ValueError, match="different scan"): + load_checkpoint(filepath, "scan-b") + + def test_checkpoint_fingerprint_changes_with_target(self) -> None: + cases = [Case(location="/etc/passwd", os="*NIX")] + first = checkpoint_fingerprint(ScanConfig(url="http://one.example/?f=x"), cases) + second = checkpoint_fingerprint(ScanConfig(url="http://two.example/?f=x"), cases) + assert first != second + + def test_checkpoint_fingerprint_normalizes_unordered_status_codes(self) -> None: + cases = [Case(location="/etc/passwd", os="*NIX")] + first = checkpoint_fingerprint( + ScanConfig(url="http://example.com/?f=x", match_codes=[200, 404]), + cases, + ) + second = checkpoint_fingerprint( + ScanConfig(url="http://example.com/?f=x", match_codes=[404, 200]), + cases, + ) + assert first == second + + def test_checkpoint_fingerprint_ignores_output_destination(self) -> None: + cases = [Case(location="/etc/passwd", os="*NIX")] + first = checkpoint_fingerprint(ScanConfig(url="http://example.com/?f=x"), cases) + second = checkpoint_fingerprint( + ScanConfig(url="http://example.com/?f=x", output_file="results.json"), + cases, + ) + assert first == second + + def test_legacy_checkpoint_is_loaded(self, tmp_path: Path) -> None: + filepath = tmp_path / "checkpoint.json" + filepath.write_text(json.dumps(["id1", "id2"]), encoding="utf-8") + assert load_checkpoint(str(filepath), "fingerprint-not-present-in-legacy") == {"id1", "id2"} class TestMatchString: @@ -304,6 +511,65 @@ async def test_match_string_disables_content_length_fast_path(self) -> None: assert len(scanner.results) == 0 + async def test_large_identical_response_is_not_found(self) -> None: + """A large Content-Length must not bypass body comparison.""" + config = ScanConfig( + url="http://example.com/test.php?file=test.txt", + param="file", + automatic=True, + ) + scanner = Scanner(config) + scanner.invalid_response = "not found" * 1000 + scanner.original_response = scanner.invalid_response + scanner.invalid_status_code = 200 + scanner.invalid_filename = "nonexistent" + + mock_response = AsyncMock() + mock_response.status_code = 200 + mock_response.text = scanner.invalid_response + mock_response.headers = {"content-length": "50000"} + + queue: asyncio.Queue[Case] = asyncio.Queue() + case = Case(location="/etc/passwd", os="*NIX") + text_out = AsyncMock() + with patch.object(scanner, "_fetch", return_value=mock_response): + processed = await scanner._process_case(case, AsyncMock(), "file=test.txt", queue, text_out) + + assert processed is True + assert scanner.results == [] + + +class TestInteractiveRestriction: + async def test_eof_does_not_approve_os_restriction(self) -> None: + scanner = Scanner( + ScanConfig( + url="http://example.com/test.php?file=x", + param="file", + skip_parsing=True, + ) + ) + scanner.invalid_response = "not found" + scanner.invalid_status_code = 200 + scanner.invalid_filename = "missing" + response = AsyncMock() + response.status_code = 200 + response.text = "found contents" + + with ( + patch.object(scanner, "_fetch", return_value=response), + patch("panoptic.core.is_match", return_value=True), + patch("builtins.input", side_effect=EOFError), + ): + await scanner._process_case( + Case(location="/a/file", os="FreeBSD"), + AsyncMock(), + "file=x", + asyncio.Queue(), + TextFormatter(io.StringIO()), + ) + + assert scanner.restrict_os is None + class TestCheckpointThrottling: async def test_rapid_marks_throttle_writes(self, tmp_path: Path) -> None: @@ -316,10 +582,10 @@ async def test_rapid_marks_throttle_writes(self, tmp_path: Path) -> None: write_count = 0 original_save = save_checkpoint - def counting_save(filepath: str, completed_ids: set[str]) -> None: + def counting_save(filepath: str, completed_ids: set[str], fingerprint: str = "") -> None: nonlocal write_count write_count += 1 - original_save(filepath, completed_ids) + original_save(filepath, completed_ids, fingerprint) with patch("panoptic.core.save_checkpoint", side_effect=counting_save): for i in range(20): @@ -328,6 +594,50 @@ def counting_save(filepath: str, completed_ids: set[str]) -> None: assert write_count <= 1, f"Expected <=1 writes during throttle window, got {write_count}" + async def test_flush_retains_dirty_during_real_event_loop_concurrency(self, tmp_path: Path) -> None: + """An event-loop task completing a case mid-save must remain dirty.""" + checkpoint_file = str(tmp_path / "checkpoint.json") + config = ScanConfig(url="http://example.com", resume_file=checkpoint_file) + scanner = Scanner(config) + scanner._checkpoint_fingerprint = "fp" + first = Case(location="/first") + second = Case(location="/second") + scanner.completed_ids = {first.case_id} + scanner._checkpoint_dirty = True + # Keep _mark_completed from starting its own throttled flush; this test + # exercises a second event-loop task mutating state while the first flush + # is blocked inside asyncio.to_thread. + scanner._last_checkpoint_time = time.monotonic() + + original_save = save_checkpoint + save_started = threading.Event() + allow_save = threading.Event() + + def blocking_save(filepath: str, completed_ids: set[str], fingerprint: str = "") -> None: + save_started.set() + if not allow_save.wait(timeout=2): + raise TimeoutError("test did not release checkpoint save") + original_save(filepath, completed_ids, fingerprint) + + with patch("panoptic.core.save_checkpoint", side_effect=blocking_save): + flush_task = asyncio.create_task(scanner._flush_checkpoint()) + while not save_started.is_set(): + await asyncio.sleep(0) + await scanner._mark_completed(second) + allow_save.set() + await asyncio.wait_for(flush_task, timeout=2) + + # The first snapshot contains only the case completed before the save. + with open(checkpoint_file) as f: + data = json.load(f) + assert data["completed_ids"] == [first.case_id] + # The second case arrived on the event loop while the worker-thread save + # was blocked, so dirty must remain set and a subsequent flush must persist it. + assert scanner._checkpoint_dirty is True + await scanner._flush_checkpoint() + assert load_checkpoint(checkpoint_file, "fp") == {first.case_id, second.case_id} + assert scanner._checkpoint_dirty is False + async def test_flush_on_shutdown(self, tmp_path: Path) -> None: """_flush_checkpoint must write dirty state even without time threshold.""" checkpoint_file = str(tmp_path / "checkpoint.json") @@ -340,5 +650,121 @@ async def test_flush_on_shutdown(self, tmp_path: Path) -> None: with open(checkpoint_file) as f: data = json.load(f) - assert set(data) == {"id1", "id2", "id3"} + assert set(data["completed_ids"]) == {"id1", "id2", "id3"} assert scanner._checkpoint_dirty is False + + +class TestWorkerSupervision: + async def test_case_exception_does_not_deadlock_queue(self) -> None: + config = ScanConfig( + url="http://example.com/test.php?file=x", + param="file", + concurrency=1, + automatic=True, + quiet=True, + ) + scanner = Scanner(config) + response = AsyncMock() + response.text = "baseline" + response.status_code = 200 + + cases = [Case(location="/first"), Case(location="/second")] + with ( + patch("panoptic.core.parse_cases", return_value=cases), + patch("panoptic.core.NetworkClient") as client_cls, + patch.object(scanner, "_fetch", new=AsyncMock(side_effect=[response, response])), + patch.object(scanner, "_process_case", new=AsyncMock(side_effect=[RuntimeError("boom"), True])), + ): + client_cls.return_value.__aenter__ = AsyncMock(return_value=AsyncMock()) + client_cls.return_value.__aexit__ = AsyncMock(return_value=None) + exit_code = await asyncio.wait_for(scanner._run_scan(io.StringIO(), "test"), timeout=1) + + assert exit_code == 2 + assert scanner.total_failed == 1 + assert scanner.total_processed == 1 + + async def test_partial_request_failure_does_not_fail_completed_scan(self) -> None: + config = ScanConfig( + url="http://example.com/test.php?file=x", + param="file", + concurrency=1, + automatic=True, + quiet=True, + ) + scanner = Scanner(config) + response = AsyncMock() + response.text = "baseline" + response.status_code = 200 + + with ( + patch("panoptic.core.parse_cases", return_value=[Case(location="/first"), Case(location="/second")]), + patch("panoptic.core.NetworkClient") as client_cls, + patch.object(scanner, "_fetch", new=AsyncMock(side_effect=[response, response])), + patch.object(scanner, "_process_case", new=AsyncMock(side_effect=[False, True])), + ): + client_cls.return_value.__aenter__ = AsyncMock(return_value=AsyncMock()) + client_cls.return_value.__aexit__ = AsyncMock(return_value=None) + exit_code = await scanner._run_scan(io.StringIO(), "test") + + assert exit_code == 0 + assert scanner.total_failed == 1 + assert scanner.total_processed == 1 + + async def test_all_request_failures_are_operational_failure(self) -> None: + config = ScanConfig( + url="http://example.com/test.php?file=x", + param="file", + concurrency=1, + automatic=True, + quiet=True, + ) + scanner = Scanner(config) + response = AsyncMock() + response.text = "baseline" + response.status_code = 200 + + with ( + patch("panoptic.core.parse_cases", return_value=[Case(location="/only")]), + patch("panoptic.core.NetworkClient") as client_cls, + patch.object(scanner, "_fetch", new=AsyncMock(side_effect=[response, response])), + patch.object(scanner, "_process_case", new=AsyncMock(return_value=False)), + ): + client_cls.return_value.__aenter__ = AsyncMock(return_value=AsyncMock()) + client_cls.return_value.__aexit__ = AsyncMock(return_value=None) + exit_code = await scanner._run_scan(io.StringIO(), "test") + + assert exit_code == 2 + assert scanner.total_failed == 1 + assert scanner.total_processed == 0 + + async def test_connection_failure_emits_valid_empty_json(self) -> None: + config = ScanConfig( + url="http://example.com/test.php?file=x", + param="file", + quiet=True, + output_format=OutputFormat.JSON, + ) + scanner = Scanner(config) + output = io.StringIO() + with ( + patch("panoptic.core.parse_cases", return_value=[Case(location="/etc/passwd")]), + patch("panoptic.core.NetworkClient") as client_cls, + patch("panoptic.core.get_revision", return_value=None), + patch.object(scanner, "_fetch", new=AsyncMock(return_value=None)), + patch("panoptic.core.sys.stdout", output), + ): + client_cls.return_value.__aenter__ = AsyncMock(return_value=AsyncMock()) + client_cls.return_value.__aexit__ = AsyncMock(return_value=None) + exit_code = await scanner._run_scan(io.StringIO(), "test") + + assert exit_code == 2 + assert json.loads(output.getvalue()) == [] + + async def test_checkpoint_write_failure_is_operational_error(self, tmp_path: Path) -> None: + checkpoint = tmp_path / "missing" / "checkpoint.json" + scanner = Scanner(ScanConfig(url="http://example.com", resume_file=str(checkpoint))) + scanner.completed_ids = {"id1"} + scanner._checkpoint_dirty = True + await scanner._flush_checkpoint() + assert scanner._checkpoint_disabled is True + assert any("Checkpoint disabled" in error for error in scanner.operational_errors) diff --git a/tests/test_heuristic.py b/tests/test_heuristic.py index aca809c..0093b3b 100644 --- a/tests/test_heuristic.py +++ b/tests/test_heuristic.py @@ -63,6 +63,12 @@ def test_none_invalid_returns_false(self) -> None: """Guard: if invalid_response is None, return False.""" assert is_match("some html", None) is False + def test_reordered_body_uses_full_similarity_ratio(self) -> None: + """Bodies with identical character counts but different structure must differ.""" + found = "A" * 5000 + "B" * 5000 + invalid = "AB" * 5000 + assert is_match(found, invalid) is True + class TestFilterContent: def test_strips_common_prefix_suffix(self) -> None: diff --git a/tests/test_integration.py b/tests/test_integration.py index b0122e7..891f9ba 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -2,6 +2,7 @@ from panoptic.cli import parse_args, validate_args from panoptic.config import merge_config +from panoptic.core import build_payload from panoptic.models import ScanConfig @@ -32,7 +33,9 @@ def test_cli_to_config_pipeline(self) -> None: assert config.url == "http://target.test/vuln.php?file=test.txt" assert config.param == "file" - assert config.prefix == "../../../" + assert config.prefix == "../" + assert config.multiplier == 3 + assert "../../../etc/passwd" in build_payload(config, "/etc/passwd", "file=test.txt") assert config.timeout == 5.0 assert config.concurrency == 2 assert config.automatic is True @@ -66,6 +69,7 @@ def test_cases_load_with_filters(self) -> None: assert len(cases) > 0 for case in cases: if case.os: - assert case.os == "*NIX" + # *NIX filter keeps the Unix family (FreeBSD, OS X, ...) but excludes Windows. + assert case.os != "Windows" if case.file_type: assert case.file_type.value == "conf" diff --git a/tests/test_models.py b/tests/test_models.py index b8f89b0..e329cee 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -98,6 +98,14 @@ def test_valid_random_delay_accepted(self) -> None: config = ScanConfig(url="http://example.com", random_delay=(0.5, 2.0)) assert config.random_delay == (0.5, 2.0) + def test_negative_retries_rejected(self) -> None: + with pytest.raises(ValueError, match="retries must be >= 0"): + ScanConfig(url="http://example.com", retries=-1) + + def test_zero_multiplier_rejected(self) -> None: + with pytest.raises(ValueError, match="multiplier must be >= 1"): + ScanConfig(url="http://example.com", multiplier=0) + class TestEnums: def test_file_type_values(self) -> None: diff --git a/tests/test_network.py b/tests/test_network.py index 78425ee..2704e68 100644 --- a/tests/test_network.py +++ b/tests/test_network.py @@ -1,5 +1,7 @@ """Tests for panoptic.network — async HTTP client.""" +from unittest.mock import AsyncMock, patch + import httpx import pytest from pytest_httpx import HTTPXMock @@ -44,10 +46,43 @@ async def test_fetch_timeout_returns_none(self, config: ScanConfig, httpx_mock: async def test_fetch_connection_error_returns_none(self, config: ScanConfig, httpx_mock: HTTPXMock) -> None: httpx_mock.add_exception(httpx.ConnectError("refused")) + httpx_mock.add_exception(httpx.ConnectError("refused again")) async with NetworkClient(config) as client: response = await client.fetch("http://example.com/down") assert response is None + async def test_connect_error_is_retried(self, config: ScanConfig, httpx_mock: HTTPXMock) -> None: + httpx_mock.add_exception(httpx.ConnectError("temporary")) + httpx_mock.add_response(url="http://example.com/retry", text="recovered") + async with NetworkClient(config) as client: + response = await client.fetch("http://example.com/retry") + assert response is not None + assert response.text == "recovered" + + async def test_ssl_and_environment_proxy_options_reach_httpx(self) -> None: + config = ScanConfig( + url="https://example.com", + invalid_ssl=True, + ignore_proxy=False, + ) + mock_client = AsyncMock() + with patch("panoptic.network.httpx.AsyncClient", return_value=mock_client) as client_cls: + async with NetworkClient(config): + pass + + kwargs = client_cls.call_args.kwargs + assert kwargs["verify"] is False + assert kwargs["trust_env"] is True + assert "transport" not in kwargs + + async def test_ignore_proxy_disables_environment_proxies(self) -> None: + config = ScanConfig(url="https://example.com", ignore_proxy=True) + mock_client = AsyncMock() + with patch("panoptic.network.httpx.AsyncClient", return_value=mock_client) as client_cls: + async with NetworkClient(config): + pass + assert client_cls.call_args.kwargs["trust_env"] is False + async def test_custom_user_agent(self, httpx_mock: HTTPXMock) -> None: config = ScanConfig(url="http://example.com", user_agent="CustomBot/1.0") httpx_mock.add_response() diff --git a/tests/test_output.py b/tests/test_output.py index 0c412e6..c2b99f1 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -6,8 +6,8 @@ import pytest -from panoptic.models import Case, FileType, ScanResult -from panoptic.output import CsvFormatter, JsonFormatter, TextFormatter +from panoptic.models import Case, FileType, ScanConfig, ScanResult +from panoptic.output import CsvFormatter, JsonFormatter, TextFormatter, _scan_mode_label @pytest.fixture @@ -120,6 +120,96 @@ def test_csv_output_redacts_url(self) -> None: content = buf.read() assert "secret123" not in content + def test_json_array_body_is_redacted(self) -> None: + result = ScanResult( + case=Case(location="/etc/passwd"), + found=True, + url='[{"file":"/etc/passwd","token":"supersecret"}]', + status_code=200, + ) + buf = io.StringIO() + JsonFormatter(buf).write_results([result]) + data = json.loads(buf.getvalue()) + assert "supersecret" not in data[0]["url"] + assert "***" in data[0]["url"] + + def test_json_numeric_and_bool_scalars_are_redacted(self) -> None: + """Non-string JSON leaves (a numeric PIN, a boolean flag) must not leak.""" + result = ScanResult( + case=Case(location="/etc/passwd"), + found=True, + url='{"file":"/etc/passwd","pin":90210,"admin":true}', + status_code=200, + ) + buf = io.StringIO() + JsonFormatter(buf).write_results([result]) + redacted = json.loads(buf.getvalue())[0]["url"] + assert "90210" not in redacted + assert "true" not in redacted + # Keys are preserved for context, values are not. + assert '"pin"' in redacted and '"admin"' in redacted + + def test_top_level_scalar_json_body_is_redacted(self) -> None: + """A bare scalar body (e.g. --data '31337') must not be echoed verbatim.""" + result = ScanResult( + case=Case(location="/etc/passwd"), + found=True, + url="31337", + status_code=200, + ) + buf = io.StringIO() + JsonFormatter(buf).write_results([result]) + assert json.loads(buf.getvalue())[0]["url"] == "***" + + def test_opaque_body_without_structure_is_fully_redacted(self) -> None: + """A non-URL, non-JSON, non-form body (a raw token) is redacted entirely.""" + result = ScanResult( + case=Case(location="/etc/passwd"), + found=True, + url="raw-session-token-abc123", + status_code=200, + ) + buf = io.StringIO() + JsonFormatter(buf).write_results([result]) + assert json.loads(buf.getvalue())[0]["url"] == "***" + + def test_form_body_keeps_keys_redacts_values(self) -> None: + result = ScanResult( + case=Case(location="/etc/passwd"), + found=True, + url="file=/etc/passwd&token=secret123", + status_code=200, + ) + buf = io.StringIO() + JsonFormatter(buf).write_results([result]) + redacted = json.loads(buf.getvalue())[0]["url"] + assert "secret123" not in redacted + assert redacted == "file=***&token=***" + + def test_uppercase_url_scheme_is_still_redacted(self) -> None: + result = ScanResult( + case=Case(location="/etc/passwd"), + found=True, + url="HTTPS://user:secret@example.com/path?token=secret123", + status_code=200, + ) + buf = io.StringIO() + JsonFormatter(buf).write_results([result]) + redacted = json.loads(buf.getvalue())[0]["url"] + assert "secret" not in redacted + assert redacted == "https://example.com/path?token=***" or redacted == "HTTPS://example.com/path?token=***" + + def test_bare_form_segment_is_redacted(self) -> None: + result = ScanResult( + case=Case(location="/etc/passwd"), + found=True, + url="file=/etc/passwd&bare-secret-token", + status_code=200, + ) + buf = io.StringIO() + JsonFormatter(buf).write_results([result]) + assert json.loads(buf.getvalue())[0]["url"] == "file=***&***" + class TestTextFormatter: def test_found_message(self, sample_results: list[ScanResult]) -> None: @@ -138,6 +228,41 @@ def test_summary(self, sample_results: list[ScanResult]) -> None: output = buf.read() assert "2" in output # 2 found + def test_file_results_include_discovered_paths(self, sample_results: list[ScanResult]) -> None: + buf = io.StringIO() + formatter = TextFormatter(buf) + formatter.write_results(sample_results, total_cases=100) + output = buf.getvalue() + assert "/etc/passwd" in output + assert "/var/log/syslog" in output + + def test_file_results_do_not_hard_wrap_long_paths(self) -> None: + location = f"/var/lib/{'very-long-directory/' * 8}configuration.conf" + result = ScanResult( + case=Case(location=location), + found=True, + url=f"http://example.com/?file={location}", + status_code=200, + content_length=123, + ) + buf = io.StringIO() + TextFormatter(buf).write_results([result], total_cases=1) + assert location in buf.getvalue() + + def test_fuzz_in_header_name_is_not_reported_as_injection_mode(self) -> None: + config = ScanConfig( + url="http://example.com/?file=x", + headers=["X-FUZZ-Label: fixed"], + ) + assert _scan_mode_label(config) == "GET" + + def test_fuzz_in_header_value_is_reported_as_injection_mode(self) -> None: + config = ScanConfig( + url="http://example.com/", + headers=["X-File: FUZZ"], + ) + assert _scan_mode_label(config) == "FUZZ" + class TestQuietMode: def test_quiet_suppresses_banner(self) -> None: @@ -180,3 +305,29 @@ def test_quiet_shows_warning(self) -> None: formatter.write_warning("SSL disabled") buf.seek(0) assert "SSL disabled" in buf.read() + + +class TestMarkupEscaping: + """Untrusted content (URLs, case locations, config values) must not inject Rich markup.""" + + def test_warning_escapes_markup(self) -> None: + buf = io.StringIO() + TextFormatter(buf).write_warning("Case '/etc/[red]passwd[/red]' failed") + assert "[red]passwd[/red]" in buf.getvalue() + + def test_info_escapes_markup(self) -> None: + buf = io.StringIO() + TextFormatter(buf).write_info("Using random User-Agent: [bold]evil[/bold]") + assert "[bold]evil[/bold]" in buf.getvalue() + + def test_verbose_escapes_markup(self) -> None: + buf = io.StringIO() + TextFormatter(buf).write_verbose("Trying '[green]/etc/passwd[/green]'") + assert "[green]/etc/passwd[/green]" in buf.getvalue() + + def test_banner_escapes_markup_in_url(self) -> None: + buf = io.StringIO() + # An injected style tag in the (redacted) URL must render literally, not + # be interpreted — an unescaped invalid tag would also raise at print time. + TextFormatter(buf).write_banner("1.0", "http://host/[red]x[/red]") + assert "[red]x[/red]" in buf.getvalue() diff --git a/tests/test_parsers.py b/tests/test_parsers.py index d635b2b..167a2f3 100644 --- a/tests/test_parsers.py +++ b/tests/test_parsers.py @@ -60,6 +60,15 @@ def test_preserves_directory(self) -> None: cases = extract_binlog_cases(content, parent_case) assert cases[0].location.startswith("/var/lib/mysql/") + def test_extracts_unix_and_modern_binlog_entries(self) -> None: + content = "./mysql-bin.000001\n./binlog.000002\n/absolute/binlog.000003\n" + parent_case = Case(location="/var/lib/mysql/mysql-bin.index", os="*NIX") + cases = extract_binlog_cases(content, parent_case) + assert [case.location for case in cases] == [ + "/var/lib/mysql/mysql-bin.000001", + "/var/lib/mysql/binlog.000002", + ] + def test_empty_content_returns_empty(self) -> None: parent_case = Case(location="/var/lib/mysql/mysql-bin.index") assert extract_binlog_cases("", parent_case) == [] diff --git a/tests/test_update.py b/tests/test_update.py index 5bd497b..4e64663 100644 --- a/tests/test_update.py +++ b/tests/test_update.py @@ -5,26 +5,63 @@ import pytest -from panoptic.update import do_update, get_revision +from panoptic.update import ( + GIT_UPSTREAM_REF, + _normalise_git_url, + _uses_secure_git_transport, + do_update, + get_revision, +) class TestDoUpdate: @patch("panoptic.update.subprocess.run") @patch("panoptic.update.os.path.exists", return_value=True) def test_git_checkout_runs_git_pull(self, mock_exists: Any, mock_run: Any) -> None: - mock_run.return_value = MagicMock(returncode=0, stdout=b"Already up to date.\n") - do_update() - mock_run.assert_called() + mock_run.side_effect = [ + MagicMock(returncode=0, stdout=b"git@github.com:lightos/Panoptic.git\n"), + MagicMock(returncode=0, stdout=b"main\n"), + MagicMock(returncode=0, stdout=b"Already up to date.\n"), + MagicMock(returncode=0, stdout=b"abc1234567890abcdef1234567890abcdef123456\n"), + ] + assert do_update() == 0 + assert mock_run.call_count == 4 # Should use list args, not shell=True - args = mock_run.call_args - assert isinstance(args[0][0], list) + pull_args = mock_run.call_args_list[2] + assert pull_args[0][0] == ["git", "pull", "--ff-only", "origin", GIT_UPSTREAM_REF] + + @patch("panoptic.update.subprocess.run") + @patch("panoptic.update.os.path.exists", return_value=True) + def test_git_checkout_rejects_non_main_branch(self, mock_exists: Any, mock_run: Any) -> None: + mock_run.side_effect = [ + MagicMock(returncode=0, stdout=b"git@github.com:lightos/Panoptic.git\n"), + MagicMock(returncode=0, stdout=b"feature\n"), + ] + assert do_update() == 2 + assert mock_run.call_count == 2 @patch("panoptic.update.os.path.exists", return_value=False) def test_pip_installed_prints_guidance(self, mock_exists: Any, capsys: pytest.CaptureFixture[str]) -> None: - do_update() + assert do_update() == 0 captured = capsys.readouterr() assert "pip" in captured.out.lower() + def test_ssh_and_https_remotes_are_equivalent(self) -> None: + assert _normalise_git_url("git@github.com:lightos/Panoptic.git") == _normalise_git_url( + "https://github.com/lightos/Panoptic.git" + ) + + @pytest.mark.parametrize( + "url", + [ + "http://github.com/lightos/Panoptic.git", + "git://github.com/lightos/Panoptic.git", + "file://github.com/lightos/Panoptic.git", + ], + ) + def test_insecure_remote_transports_are_rejected(self, url: str) -> None: + assert _uses_secure_git_transport(url) is False + class TestGetRevision: @patch("panoptic.update.subprocess.run") diff --git a/tests/test_utils.py b/tests/test_utils.py index 0b952f4..2983200 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,15 +1,23 @@ """Tests for panoptic.utils.""" +import os +from pathlib import Path + import pytest from panoptic.utils import ( get_random_agent, load_data_file, + normalize_os_name, + open_secure_write, + redact_url, sanitize_filename, validate_header, validate_url_scheme, ) +posix_only = pytest.mark.skipif(os.name != "posix", reason="requires POSIX file permissions/symlinks") + class TestValidateUrlScheme: def test_http_valid(self) -> None: @@ -30,6 +38,19 @@ def test_no_scheme_rejected(self) -> None: with pytest.raises(ValueError, match="Only http:// and https://"): validate_url_scheme("example.com/test") + def test_missing_hostname_rejected(self) -> None: + with pytest.raises(ValueError, match="hostname"): + validate_url_scheme("http:///path") + + +class TestNormalizeOsName: + @pytest.mark.parametrize("value", ["OSX", "osx", "OsX", "OS X", "os x"]) + def test_os_x_aliases_are_case_insensitive(self, value: str) -> None: + assert normalize_os_name(value) == "OS X" + + def test_unknown_os_preserves_display_name(self) -> None: + assert normalize_os_name(" FreeBSD ") == "FreeBSD" + class TestValidateHeader: def test_valid_header(self) -> None: @@ -55,6 +76,17 @@ def test_value_with_equals(self) -> None: assert name == "Authorization" assert value == "Bearer abc=def" + def test_invalid_header_name_rejected(self) -> None: + with pytest.raises(ValueError, match="header name"): + validate_header("Bad Header: value") + + def test_nul_rejected(self) -> None: + with pytest.raises(ValueError, match="NUL"): + validate_header("X-Test: value\x00injected") + + def test_ipv6_redaction_preserves_brackets(self) -> None: + assert redact_url("http://user:pass@[::1]:8080/path?token=x") == "http://[::1]:8080/path?token=***" + class TestSanitizeFilename: def test_slashes_replaced(self) -> None: @@ -92,3 +124,84 @@ def test_returns_different_values(self) -> None: random.seed(42) agents = {get_random_agent() for _ in range(20)} assert len(agents) > 1 + + +class TestOpenSecureWrite: + def test_creates_file_with_content(self, tmp_path: Path) -> None: + target = tmp_path / "out.txt" + with open_secure_write(str(target)) as fh: + fh.write("hello") + assert target.read_text(encoding="utf-8") == "hello" + + @posix_only + def test_new_file_is_owner_only(self, tmp_path: Path) -> None: + target = tmp_path / "out.txt" + with open_secure_write(str(target)) as fh: + fh.write("x") + assert os.stat(target).st_mode & 0o777 == 0o600 + + @posix_only + def test_tightens_permissions_on_existing_file(self, tmp_path: Path) -> None: + target = tmp_path / "out.txt" + target.write_text("old", encoding="utf-8") + os.chmod(target, 0o644) + with open_secure_write(str(target)) as fh: + fh.write("new") + assert os.stat(target).st_mode & 0o777 == 0o600 + assert target.read_text(encoding="utf-8") == "new" + + def test_truncates_existing_content(self, tmp_path: Path) -> None: + target = tmp_path / "out.txt" + target.write_text("a very long previous value", encoding="utf-8") + with open_secure_write(str(target)) as fh: + fh.write("short") + assert target.read_text(encoding="utf-8") == "short" + + def test_newline_is_not_translated(self, tmp_path: Path) -> None: + target = tmp_path / "out.csv" + with open_secure_write(str(target), newline="") as fh: + fh.write("a,b\r\n") + assert target.read_bytes() == b"a,b\r\n" + + @posix_only + def test_refuses_to_follow_symlink(self, tmp_path: Path) -> None: + """A pre-planted symlink at the target must not be written through.""" + secret = tmp_path / "secret.txt" + secret.write_text("do-not-clobber", encoding="utf-8") + link = tmp_path / "link.txt" + link.symlink_to(secret) + with pytest.raises(OSError): + open_secure_write(str(link)) + # The symlink target must be untouched. + assert secret.read_text(encoding="utf-8") == "do-not-clobber" + + @posix_only + def test_fallback_rejects_existing_symlink_without_nofollow( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Platforms lacking O_NOFOLLOW still reject an already-present symlink.""" + secret = tmp_path / "secret.txt" + secret.write_text("do-not-clobber", encoding="utf-8") + link = tmp_path / "link.txt" + link.symlink_to(secret) + monkeypatch.delattr(os, "O_NOFOLLOW", raising=False) + with pytest.raises(OSError): + open_secure_write(str(link)) + assert secret.read_text(encoding="utf-8") == "do-not-clobber" + + @posix_only + def test_fchmod_failure_fails_closed_without_truncating( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """If permissions can't be enforced, fail closed and preserve prior content.""" + target = tmp_path / "out.txt" + target.write_text("original content", encoding="utf-8") + + def boom(fd: int, mode: int) -> None: + raise PermissionError("cannot chmod") + + monkeypatch.setattr(os, "fchmod", boom) + with pytest.raises(OSError): + open_secure_write(str(target)) + # The pre-existing content must not have been truncated. + assert target.read_text(encoding="utf-8") == "original content" From 36609def088d9138f68b7adfddacb28c33641e01 Mon Sep 17 00:00:00 2001 From: Roberto Salgado Date: Tue, 28 Jul 2026 21:42:15 -0700 Subject: [PATCH 2/7] Document the Panoptic LFI testbed --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index 9dc5359..9758f4e 100644 --- a/README.md +++ b/README.md @@ -367,6 +367,13 @@ Panoptic uses conventional process exit codes so it can be scripted: Contributions are welcome! Please open issues or pull requests on [GitHub](https://github.com/lightos/Panoptic). +## Testbed + +For safe, reproducible end-to-end testing, see the +[Panoptic LFI Testbed](https://github.com/lightos/panoptic-lfi-testbed). +It provides deliberately vulnerable endpoints covering Panoptic's supported +injection methods and traversal transformations. + ## License This project is licensed under the MIT License - see the From 8dd19777d0a297e4c1b90e066f9209f52f9a1c3b Mon Sep 17 00:00:00 2001 From: Roberto Salgado Date: Tue, 28 Jul 2026 21:45:13 -0700 Subject: [PATCH 3/7] Update GitHub Actions runtimes --- .github/workflows/ci.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fbb0739..e70c523 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,8 +17,8 @@ jobs: matrix: python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: ${{ matrix.python-version }} cache: pip @@ -30,8 +30,8 @@ jobs: name: Minimum dependency versions (Python 3.10) runs-on: ubuntu-latest steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.10" cache: pip @@ -45,8 +45,8 @@ jobs: quality: runs-on: ubuntu-latest steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.13" cache: pip From c3c9b19709aaeb8c1044b687816f6b57ba69cc3d Mon Sep 17 00:00:00 2001 From: Roberto Salgado Date: Tue, 28 Jul 2026 21:46:16 -0700 Subject: [PATCH 4/7] Fix minimum-version CI isolation --- .github/workflows/ci.yml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e70c523..3babce1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,9 +36,11 @@ jobs: python-version: "3.10" cache: pip - run: python -m pip install --upgrade pip - # Install the package pinned to the lowest declared dependency versions to - # catch accidental use of newer dependency APIs than pyproject.toml permits. - - run: python -m pip install -e ".[dev]" -c .github/constraints-min.txt + # Install the package pinned to the lowest declared runtime dependency + # versions. Install only the test dependencies separately so unrelated + # development tools cannot raise those runtime lower bounds. + - run: python -m pip install -e . -c .github/constraints-min.txt + - run: python -m pip install pytest pytest-asyncio pytest-httpx -c .github/constraints-min.txt - run: python -m pip check - run: pytest -q From eef07b7c36a55df393ac2156196905bb06a098ca Mon Sep 17 00:00:00 2001 From: Roberto Salgado Date: Tue, 28 Jul 2026 21:56:07 -0700 Subject: [PATCH 5/7] Address hardening review findings --- .githooks/pre-commit | 2 +- .github/workflows/ci.yml | 6 ++++++ panoptic/config.py | 6 +++++- tests/test_config.py | 5 +++++ tests/test_core.py | 7 ++++--- 5 files changed, 21 insertions(+), 5 deletions(-) diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 4182437..79fd6bc 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -97,7 +97,7 @@ else fi CHECKS_RAN=1 elif command -v npx >/dev/null 2>&1; then - if ! npx markdownlint "${STAGED_MD[@]}"; then + if ! npx markdownlint-cli "${STAGED_MD[@]}"; then EXIT_CODE=1 fi CHECKS_RAN=1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3babce1..8f65149 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,8 @@ jobs: python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: ${{ matrix.python-version }} @@ -31,6 +33,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.10" @@ -48,6 +52,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.13" diff --git a/panoptic/config.py b/panoptic/config.py index 9f07d9f..0e3b0e1 100644 --- a/panoptic/config.py +++ b/panoptic/config.py @@ -134,6 +134,8 @@ def merge_config(cli_args: dict[str, Any], file_config: dict[str, Any]) -> ScanC merged["headers"] = [legacy_headers] elif isinstance(legacy_headers, list): merged["headers"] = legacy_headers + else: + raise ValueError("defaults.header must be a string or a list of strings") # Map proxy and header sections to flat ScanConfig fields proxy_section = file_config.get("proxy", {}) @@ -175,7 +177,9 @@ def merge_config(cli_args: dict[str, Any], file_config: dict[str, Any]) -> ScanC merged[key] = value # Handle output_format enum conversion - if "output_format" in merged and isinstance(merged["output_format"], str): + if "output_format" in merged and not isinstance(merged["output_format"], OutputFormat): + if not isinstance(merged["output_format"], str): + raise ValueError("output_format must be a string") try: merged["output_format"] = OutputFormat(merged["output_format"]) except ValueError as e: diff --git a/tests/test_config.py b/tests/test_config.py index 50fe370..c01e037 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -71,6 +71,9 @@ def test_output_format_enum(self) -> None: config = merge_config(cli_args, {}) assert config.output_format == OutputFormat.JSON + enum_config = merge_config({"url": "http://example.com", "output_format": OutputFormat.CSV}, {}) + assert enum_config.output_format == OutputFormat.CSV + def test_cli_false_overrides_true_boolean(self) -> None: config = merge_config( {"url": "http://example.com", "automatic": False}, @@ -105,6 +108,8 @@ def test_unknown_option_warns(self, capsys: pytest.CaptureFixture[str]) -> None: ({"defaults": {"os_filter": 123}}, "os_filter must be a string"), ({"defaults": {"automatic": "yes"}}, "automatic must be a boolean"), ({"defaults": {"concurrency": 4.5}}, "concurrency must be an integer"), + ({"defaults": {"header": 123}}, "defaults.header must be a string or a list of strings"), + ({"defaults": {"output_format": 123}}, "output_format must be a string"), ], ) def test_invalid_config_types_raise(self, file_config: dict[str, object], message: str) -> None: diff --git a/tests/test_core.py b/tests/test_core.py index 671ad6c..3c8136f 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -243,7 +243,8 @@ def test_no_filename_collision(self, tmp_path: Path) -> None: output_dir = tmp_path / "output" / "example.com" files = list(output_dir.iterdir()) assert len(files) == 2, f"Expected 2 files, got {len(files)}: {files}" - assert all(os.stat(path).st_mode & 0o777 == 0o600 for path in files) + if os.name == "posix": + assert all(os.stat(path).st_mode & 0o777 == 0o600 for path in files) @pytest.mark.skipif(os.name != "posix", reason="requires POSIX symlinks") def test_discovered_file_write_refuses_symlink(self, tmp_path: Path) -> None: @@ -621,8 +622,8 @@ def blocking_save(filepath: str, completed_ids: set[str], fingerprint: str = "") with patch("panoptic.core.save_checkpoint", side_effect=blocking_save): flush_task = asyncio.create_task(scanner._flush_checkpoint()) - while not save_started.is_set(): - await asyncio.sleep(0) + save_observed = await asyncio.wait_for(asyncio.to_thread(save_started.wait, 1), timeout=2) + assert save_observed, "checkpoint save did not start" await scanner._mark_completed(second) allow_save.set() await asyncio.wait_for(flush_task, timeout=2) From 30a2c6fa721d95b0745cde135df973b8d21df0e5 Mon Sep 17 00:00:00 2001 From: Roberto Salgado Date: Tue, 28 Jul 2026 22:02:49 -0700 Subject: [PATCH 6/7] Prevent hook dependency downloads --- .githooks/pre-commit | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 79fd6bc..f77f39c 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -97,7 +97,9 @@ else fi CHECKS_RAN=1 elif command -v npx >/dev/null 2>&1; then - if ! npx markdownlint-cli "${STAGED_MD[@]}"; then + if ! npx --no-install markdownlint-cli "${STAGED_MD[@]}"; then + echo "Error: markdownlint-cli is not installed locally." + echo "Install markdownlint-cli before committing Markdown changes." EXIT_CODE=1 fi CHECKS_RAN=1 From 8e5dd9545f58b9bc3dce4697e455355e153af433 Mon Sep 17 00:00:00 2001 From: Roberto Salgado Date: Tue, 28 Jul 2026 22:09:21 -0700 Subject: [PATCH 7/7] Use canonical pre-commit checks --- .githooks/pre-commit | 115 ++++--------------------------------------- 1 file changed, 9 insertions(+), 106 deletions(-) diff --git a/.githooks/pre-commit b/.githooks/pre-commit index f77f39c..87440a0 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -14,111 +14,14 @@ # Option C: Copy to .git/hooks # cp .githooks/pre-commit .git/hooks/pre-commit && chmod +x .git/hooks/pre-commit -# Do not use set -e; we want to capture all failures and report them - -echo "Running pre-commit checks..." - -EXIT_CODE=0 -CHECKS_RAN=0 - -# Prefer pre-commit framework if available (uses .pre-commit-config.yaml) -if command -v pre-commit &> /dev/null; then - echo "Using pre-commit framework..." - if ! pre-commit run; then - EXIT_CODE=1 - fi - CHECKS_RAN=1 -else - echo "Note: pre-commit framework not installed." - echo "Install with: pip install pre-commit && pre-commit install" - echo "Falling back to direct tool invocation..." - echo "" - - # Get list of staged Python files - STAGED_PY=() - while IFS= read -r file; do - [ -n "$file" ] && STAGED_PY+=("$file") - done < <(git diff --cached --name-only --diff-filter=ACM | grep '\.py$' || true) - - # Get list of staged Markdown files - STAGED_MD=() - while IFS= read -r file; do - [ -n "$file" ] && STAGED_MD+=("$file") - done < <(git diff --cached --name-only --diff-filter=ACM | grep '\.md$' || true) - - # Python checks - if [ "${#STAGED_PY[@]}" -gt 0 ]; then - echo "Checking Python files: ${STAGED_PY[*]}" - - # Run ruff check (replaces flake8 - no need for both) - echo "Running ruff check..." - if command -v ruff &> /dev/null; then - if ! ruff check "${STAGED_PY[@]}"; then - EXIT_CODE=1 - fi - CHECKS_RAN=1 - else - echo "Warning: ruff not installed, skipping ruff check" - fi - - # Run ruff format check (don't auto-fix in hook, just check) - echo "Running ruff format check..." - if command -v ruff &> /dev/null; then - if ! ruff format --check "${STAGED_PY[@]}"; then - EXIT_CODE=1 - fi - CHECKS_RAN=1 - else - echo "Warning: ruff not installed, skipping format check" - fi - - # Run pyright - echo "Running pyright..." - if command -v pyright &> /dev/null; then - if ! pyright "${STAGED_PY[@]}"; then - EXIT_CODE=1 - fi - CHECKS_RAN=1 - else - echo "Warning: pyright not installed, skipping type checking" - fi - else - echo "No Python files staged." - fi - - # Markdown checks - if [ "${#STAGED_MD[@]}" -gt 0 ]; then - echo "Checking Markdown files: ${STAGED_MD[*]}" - - echo "Running markdownlint..." - if command -v markdownlint &> /dev/null; then - if ! markdownlint "${STAGED_MD[@]}"; then - EXIT_CODE=1 - fi - CHECKS_RAN=1 - elif command -v npx >/dev/null 2>&1; then - if ! npx --no-install markdownlint-cli "${STAGED_MD[@]}"; then - echo "Error: markdownlint-cli is not installed locally." - echo "Install markdownlint-cli before committing Markdown changes." - EXIT_CODE=1 - fi - CHECKS_RAN=1 - else - echo "Warning: markdownlint not installed, skipping markdown lint" - fi - else - echo "No Markdown files staged." - fi -fi - -# Summary -echo "" -if [ "$CHECKS_RAN" -eq 0 ]; then - echo "No checks were run (no tools installed or no files staged)." -elif [ "$EXIT_CODE" -eq 0 ]; then - echo "All checks passed!" -else - echo "Some checks failed. Please fix the issues above before committing." +# All setup options require the pre-commit package. Keeping one execution path +# ensures the pinned hooks and arguments in .pre-commit-config.yaml are always +# used, including Ruff fixes, formatting, strict mypy, Markdown linting, and the +# project dependency audit. +if ! command -v pre-commit >/dev/null 2>&1; then + echo "Error: pre-commit is required to run the Panoptic checks." >&2 + echo "Install it with: python -m pip install pre-commit" >&2 + exit 1 fi -exit $EXIT_CODE +exec pre-commit run