From 1cd9989c812e7977c79c61e2d1b658acba96b292 Mon Sep 17 00:00:00 2001 From: devUmut35 Date: Wed, 22 Jul 2026 19:20:54 +0300 Subject: [PATCH] feat: launch PreviewShield web UI --- .github/ISSUE_TEMPLATE/bug_report.yml | 110 ++ .github/ISSUE_TEMPLATE/config.yml | 8 + .github/ISSUE_TEMPLATE/feature_request.yml | 93 ++ .github/dependabot.yml | 31 + .github/pull_request_template.md | 31 + .github/workflows/action-test.yml | 70 + .github/workflows/ci.yml | 77 + .github/workflows/release.yml | 75 + .gitignore | 43 + CHANGELOG.md | 37 + CITATION.cff | 18 + CODE_OF_CONDUCT.md | 62 + CONTRIBUTING.md | 146 ++ Dockerfile | 21 + LICENSE | 202 +++ README.md | 373 +++-- SECURITY.md | 88 ++ SUPPORT.md | 35 + THIRD_PARTY_NOTICES.md | 14 + action.yml | 69 + docs/README.md | 18 + docs/getting-started.md | 165 ++ docs/github-action.md | 209 +++ docs/migration.md | 99 ++ docs/outputs.md | 126 ++ docs/policy-reference.md | 221 +++ docs/python-api.md | 125 ++ docs/rules.md | 76 + docs/security-model.md | 179 +++ docs/web-ui.md | 98 ++ examples/README.md | 23 + examples/custom-headers.previewshield.yml | 26 + examples/github-action.yml | 48 + examples/previewshield.yml | 29 + examples/strict.previewshield.yml | 34 + pyproject.toml | 136 ++ requirements.txt | 2 +- security_header_auditor.py | 478 +----- src/previewshield/__init__.py | 15 + src/previewshield/__main__.py | 6 + src/previewshield/_version.py | 3 + src/previewshield/action.py | 435 ++++++ src/previewshield/api.py | 57 + src/previewshield/checks.py | 974 ++++++++++++ src/previewshield/cli.py | 383 +++++ src/previewshield/diffing.py | 139 ++ src/previewshield/exceptions.py | 39 + src/previewshield/models.py | 263 ++++ src/previewshield/network.py | 822 ++++++++++ src/previewshield/policy.py | 491 ++++++ src/previewshield/py.typed | 1 + src/previewshield/reporters/__init__.py | 271 ++++ src/previewshield/reporters/console.py | 106 ++ src/previewshield/reporters/html.py | 185 +++ src/previewshield/reporters/json_report.py | 20 + src/previewshield/reporters/junit.py | 155 ++ src/previewshield/reporters/markdown.py | 174 +++ src/previewshield/reporters/sarif.py | 203 +++ src/previewshield/scanner.py | 186 +++ src/previewshield/utils.py | 64 + src/previewshield/webui/__init__.py | 5 + src/previewshield/webui/assets/__init__.py | 1 + src/previewshield/webui/assets/app.js | 468 ++++++ .../webui/assets/fonts/archivo-black-OFL.txt | 93 ++ .../webui/assets/fonts/archivo-black.ttf | Bin 0 -> 90988 bytes .../webui/assets/fonts/ibm-plex-mono-OFL.txt | 93 ++ .../webui/assets/fonts/ibm-plex-mono.ttf | Bin 0 -> 135580 bytes src/previewshield/webui/assets/index.html | 369 +++++ src/previewshield/webui/assets/logic.mjs | 47 + src/previewshield/webui/assets/styles.css | 1337 +++++++++++++++++ src/previewshield/webui/server.py | 491 ++++++ src/previewshield/webui/service.py | 159 ++ tests/test_action.py | 269 ++++ tests/test_api.py | 77 + tests/test_checks.py | 364 +++++ tests/test_cli.py | 230 +++ tests/test_diffing.py | 189 +++ tests/test_models_utils.py | 64 + tests/test_network.py | 748 +++++++++ tests/test_policy.py | 119 ++ tests/test_reporters.py | 244 +++ tests/test_scanner.py | 183 +++ tests/test_webui.py | 417 +++++ tests/test_webui_logic.mjs | 38 + 84 files changed, 14099 insertions(+), 593 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/dependabot.yml create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/action-test.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 CITATION.cff create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 SECURITY.md create mode 100644 SUPPORT.md create mode 100644 THIRD_PARTY_NOTICES.md create mode 100644 action.yml create mode 100644 docs/README.md create mode 100644 docs/getting-started.md create mode 100644 docs/github-action.md create mode 100644 docs/migration.md create mode 100644 docs/outputs.md create mode 100644 docs/policy-reference.md create mode 100644 docs/python-api.md create mode 100644 docs/rules.md create mode 100644 docs/security-model.md create mode 100644 docs/web-ui.md create mode 100644 examples/README.md create mode 100644 examples/custom-headers.previewshield.yml create mode 100644 examples/github-action.yml create mode 100644 examples/previewshield.yml create mode 100644 examples/strict.previewshield.yml create mode 100644 pyproject.toml create mode 100644 src/previewshield/__init__.py create mode 100644 src/previewshield/__main__.py create mode 100644 src/previewshield/_version.py create mode 100644 src/previewshield/action.py create mode 100644 src/previewshield/api.py create mode 100644 src/previewshield/checks.py create mode 100644 src/previewshield/cli.py create mode 100644 src/previewshield/diffing.py create mode 100644 src/previewshield/exceptions.py create mode 100644 src/previewshield/models.py create mode 100644 src/previewshield/network.py create mode 100644 src/previewshield/policy.py create mode 100644 src/previewshield/py.typed create mode 100644 src/previewshield/reporters/__init__.py create mode 100644 src/previewshield/reporters/console.py create mode 100644 src/previewshield/reporters/html.py create mode 100644 src/previewshield/reporters/json_report.py create mode 100644 src/previewshield/reporters/junit.py create mode 100644 src/previewshield/reporters/markdown.py create mode 100644 src/previewshield/reporters/sarif.py create mode 100644 src/previewshield/scanner.py create mode 100644 src/previewshield/utils.py create mode 100644 src/previewshield/webui/__init__.py create mode 100644 src/previewshield/webui/assets/__init__.py create mode 100644 src/previewshield/webui/assets/app.js create mode 100644 src/previewshield/webui/assets/fonts/archivo-black-OFL.txt create mode 100644 src/previewshield/webui/assets/fonts/archivo-black.ttf create mode 100644 src/previewshield/webui/assets/fonts/ibm-plex-mono-OFL.txt create mode 100644 src/previewshield/webui/assets/fonts/ibm-plex-mono.ttf create mode 100644 src/previewshield/webui/assets/index.html create mode 100644 src/previewshield/webui/assets/logic.mjs create mode 100644 src/previewshield/webui/assets/styles.css create mode 100644 src/previewshield/webui/server.py create mode 100644 src/previewshield/webui/service.py create mode 100644 tests/test_action.py create mode 100644 tests/test_api.py create mode 100644 tests/test_checks.py create mode 100644 tests/test_cli.py create mode 100644 tests/test_diffing.py create mode 100644 tests/test_models_utils.py create mode 100644 tests/test_network.py create mode 100644 tests/test_policy.py create mode 100644 tests/test_reporters.py create mode 100644 tests/test_scanner.py create mode 100644 tests/test_webui.py create mode 100644 tests/test_webui_logic.mjs diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..759d859 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,110 @@ +name: Bug report +description: Report reproducible incorrect behavior in PreviewShield. +title: "[Bug]: " +labels: + - bug +body: + - type: markdown + attributes: + value: | + Thank you for helping improve PreviewShield. Search existing issues first. + Do not include secrets, internal URLs, private IP addresses, or unredacted response data. + Security vulnerabilities belong in the private reporting channel linked below. + + - type: input + id: version + attributes: + label: PreviewShield version + description: Paste the output of `previewshield --version`. + placeholder: PreviewShield 1.0.0 + validations: + required: true + + - type: dropdown + id: interface + attributes: + label: Interface + options: + - CLI scan + - CLI diff + - Policy validation + - GitHub Action + - Python API + - Reporter + - Packaging or installation + - Other + validations: + required: true + + - type: input + id: environment + attributes: + label: Environment + description: Operating system, Python version, and CI provider/runner when applicable. + placeholder: Ubuntu 24.04, Python 3.12, GitHub-hosted runner + validations: + required: true + + - type: textarea + id: command + attributes: + label: Redacted command or integration + description: Show the smallest invocation. Replace hostnames and all secrets. + render: shell + validations: + required: true + + - type: textarea + id: policy + attributes: + label: Minimal redacted policy + description: Include only policy fields needed to reproduce the problem. + render: yaml + + - type: textarea + id: expected + attributes: + label: Expected behavior + description: What result, finding, diff classification, output, or exit code did you expect? + validations: + required: true + + - type: textarea + id: actual + attributes: + label: Actual behavior + description: What happened instead? Include the exact redacted error and exit code. + validations: + required: true + + - type: textarea + id: reproduction + attributes: + label: Reproduction steps + description: Provide a deterministic sequence using a target you own or a local fixture. + placeholder: | + 1. Create ... + 2. Run ... + 3. Observe ... + validations: + required: true + + - type: textarea + id: context + attributes: + label: Additional context + description: Add sanitized logs, screenshots, or related issue links if they help. + + - type: checkboxes + id: checks + attributes: + label: Checklist + options: + - label: I searched existing issues for this problem. + required: true + - label: I removed credentials, customer data, and internal network details. + required: true + - label: I am authorized to scan every target used in this report. + required: true + - label: This is not a security vulnerability in PreviewShield itself. + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..19c57af --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Security vulnerability + url: https://github.com/devUmut35/PreviewShield/security/advisories/new + about: Report vulnerabilities in PreviewShield privately. Do not open a public issue. + - name: Documentation + url: https://github.com/devUmut35/PreviewShield/tree/main/docs + about: Read setup, policy, Action, output, rule, and security-model guides. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..e50f879 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,93 @@ +name: Feature request +description: Propose a focused improvement, rule, integration, or report format. +title: "[Feature]: " +labels: + - enhancement +body: + - type: markdown + attributes: + value: | + Describe the user problem before prescribing an implementation. For a new security rule, + include an authoritative reference, default severity rationale, and false-positive risks. + + - type: dropdown + id: area + attributes: + label: Area + options: + - Security rule + - Policy schema + - Baseline comparison + - Network safety + - GitHub Action or CI + - Report format + - Python API + - Documentation + - Other + validations: + required: true + + - type: textarea + id: problem + attributes: + label: Problem + description: What cannot be done today, and who is affected? + validations: + required: true + + - type: textarea + id: proposal + attributes: + label: Proposed outcome + description: Describe observable behavior, configuration, and output rather than only code. + validations: + required: true + + - type: textarea + id: example + attributes: + label: Example + description: Show a safe command, policy snippet, API call, or expected finding if useful. + render: shell + + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: How do users handle this today, and why is that insufficient? + + - type: textarea + id: compatibility + attributes: + label: Compatibility and security considerations + description: Note schema, rule-ID, exit-code, network-boundary, or secret-handling impact. + + - type: textarea + id: references + attributes: + label: References + description: For rules, link to OWASP, MDN, a standard, or another authoritative source. + + - type: checkboxes + id: checks + attributes: + label: Checklist + options: + - label: I searched existing issues and the rule catalog. + required: true + - label: This request is focused on one user problem. + required: true + - label: I have not included secrets or unauthorized target data. + required: true + + - type: dropdown + id: contribution + attributes: + label: Contribution + description: Would you like to help implement this after design agreement? + options: + - Yes + - Maybe, with guidance + - No + validations: + required: true diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..a3de723 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,31 @@ +version: 2 +updates: + - package-ecosystem: pip + directory: "/" + schedule: + interval: weekly + day: monday + time: "06:00" + timezone: Europe/Istanbul + open-pull-requests-limit: 5 + labels: [dependencies, python] + + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly + day: monday + time: "06:15" + timezone: Europe/Istanbul + open-pull-requests-limit: 5 + labels: [dependencies, github-actions] + + - package-ecosystem: docker + directory: "/" + schedule: + interval: weekly + day: monday + time: "06:30" + timezone: Europe/Istanbul + open-pull-requests-limit: 3 + labels: [dependencies, docker] diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..d58c273 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,31 @@ +# Pull request + +## Summary + + + +## Behavior and security impact + + + +## Validation + + + +- [ ] `python -m ruff format --check .` +- [ ] `python -m ruff check .` +- [ ] `python -m mypy` +- [ ] `python -m bandit -c pyproject.toml -r src` +- [ ] `python -m pytest` + +## Checklist + +- [ ] The change is focused and linked to an issue when design discussion was needed. +- [ ] Tests cover new behavior, edge cases, and failure paths. +- [ ] Documentation and `CHANGELOG.md` reflect user-visible changes. +- [ ] Stable rule IDs, JSON schema, SARIF fingerprints, and exit codes remain compatible, or the + breaking impact is explicitly justified. +- [ ] Examples, fixtures, logs, and reports contain no credentials or unauthorized target data. +- [ ] Network or renderer changes include adversarial tests for the affected trust boundary. +- [ ] I have read and agree to follow the [Code of Conduct](../CODE_OF_CONDUCT.md). diff --git a/.github/workflows/action-test.yml b/.github/workflows/action-test.yml new file mode 100644 index 0000000..4ba6b7e --- /dev/null +++ b/.github/workflows/action-test.yml @@ -0,0 +1,70 @@ +name: Docker Action Test + +on: + push: + branches: [main] + paths: + - action.yml + - Dockerfile + - pyproject.toml + - "src/**" + - ".github/workflows/action-test.yml" + pull_request: + branches: [main] + paths: + - action.yml + - Dockerfile + - pyproject.toml + - "src/**" + - ".github/workflows/action-test.yml" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: action-test-${{ github.ref }} + cancel-in-progress: true + +jobs: + docker-action: + name: Build and exercise Docker action + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Check out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + # Local development does not require Docker; this job is the canonical + # Dockerfile validation on the same runner type used by consumers. + - name: Build container image + run: docker build --tag previewshield-action:test . + - name: Scan a public fixture + id: scan + uses: ./ + with: + target: https://example.com + paths: / + fail-on: critical + format: json + output: previewshield-action-report.json + - name: Verify reports and outputs + env: + REPORT_PATH: ${{ steps.scan.outputs.report }} + SCORE: ${{ steps.scan.outputs.score }} + GRADE: ${{ steps.scan.outputs.grade }} + PASSED: ${{ steps.scan.outputs.passed }} + run: | + python - <<'PY' + import os + from pathlib import Path + + report = Path(os.environ["REPORT_PATH"]) + if not report.is_file(): + raise SystemExit(f"Missing action report: {report}") + if not os.environ["SCORE"] or not os.environ["GRADE"]: + raise SystemExit("Action did not publish score and grade outputs") + if os.environ["PASSED"] not in {"true", "false"}: + raise SystemExit("Action published an invalid passed output") + PY diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7ddebe4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,77 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + quality: + name: Quality and package checks + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Check out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: Set up Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: pyproject.toml + - name: Install project and quality tools + run: python -m pip install -e ".[dev]" + - name: Lint + run: python -m ruff check . + - name: Check formatting + run: python -m ruff format --check . + - name: Type check + run: python -m mypy + - name: Check browser scripts + run: | + node --check src/previewshield/webui/assets/app.js + node --check src/previewshield/webui/assets/logic.mjs + node --test tests/test_webui_logic.mjs + - name: Static security analysis + run: python -m bandit -c pyproject.toml -r src + - name: Audit Python dependencies + run: python -m pip_audit . --strict --progress-spinner off + - name: Build distributions + run: python -m build + - name: Validate distributions + run: python -m twine check dist/* + + test: + name: Python ${{ matrix.python-version }} + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13"] + steps: + - name: Check out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: Set up Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: ${{ matrix.python-version }} + cache: pip + cache-dependency-path: pyproject.toml + - name: Install project and test tools + run: python -m pip install -e ".[dev]" + - name: Run test suite + run: python -m pytest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..00d0898 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,75 @@ +name: Release to PyPI + +on: + release: + types: [published] + +concurrency: + group: pypi-release-${{ github.event.release.tag_name }} + cancel-in-progress: false + +jobs: + build: + name: Build release distributions + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + steps: + - name: Check out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: Set up Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: pyproject.toml + - name: Install isolated build tools + run: python -m pip install "build>=1.2,<2" "twine>=6,<7" + - name: Verify release tag matches package version + env: + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: | + python - <<'PY' + import os + import tomllib + from pathlib import Path + + metadata = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8")) + version = metadata["project"]["version"] + tag = os.environ["RELEASE_TAG"].removeprefix("v") + if tag != version: + raise SystemExit(f"Release tag {tag!r} does not match package version {version!r}") + PY + - name: Build distributions + run: python -m build + - name: Validate distributions + run: python -m twine check dist/* + - name: Store immutable distributions + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: python-distributions + path: dist/ + if-no-files-found: error + retention-days: 7 + + publish: + name: Publish with PyPI trusted publishing + needs: build + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: + name: pypi + url: https://pypi.org/project/previewshield/ + permissions: + id-token: write + steps: + - name: Download distributions + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: python-distributions + path: dist/ + - name: Publish distributions to PyPI + uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # release/v1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f6b9772 --- /dev/null +++ b/.gitignore @@ -0,0 +1,43 @@ +# Python bytecode and caches +__pycache__/ +*.py[cod] +*$py.class +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.hypothesis/ + +# Coverage +.coverage +.coverage.* +coverage.xml +htmlcov/ + +# Build and packaging +build/ +dist/ +*.egg-info/ +*.egg + +# Virtual environments +.venv/ +venv/ +env/ + +# Local tools and editors +.tmp/ +.tox/ +.nox/ +.idea/ +.vscode/ +*.swp +*.swo + +# Operating-system files +.DS_Store +Thumbs.db + +# Locally generated reports +/previewshield-report.* +/artifacts/ +/reports/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..2fcf144 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,37 @@ +# Changelog + +All notable changes to PreviewShield are documented in this file. + +The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses +[Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- Local-only `previewshield ui` release control room with guided scan and diff workflows, finding + filters, in-memory multi-format report downloads, and GitHub Action YAML generation. +- Loopback UI protections including exact Host and Origin validation, HttpOnly SameSite sessions, + CSRF tokens, strict CSP, bounded requests and report memory, concurrency limits, and opt-in + private targets. +- Browser behavior tests for result-mode snapshots and generated GitHub Action workflows. + +## [1.0.0] - 2026-07-22 + +### Added + +- Production-to-preview security regression comparison with stable finding fingerprints. +- Policy schema version 1 with balanced and strict profiles, multi-route scans, severity + overrides, disabled rules, custom required headers, and regression or absolute diff modes. +- Thirty built-in checks covering HTTP transport, TLS, CSP, browser hardening headers, CORS, + cookie attributes, information disclosure, and response health. +- DNS-pinned network fetcher with public-address validation, redirect checks, TLS hostname + verification, sensitive-header stripping, bounded requests, and no proxy discovery. +- Console, JSON, Markdown, SARIF 2.1.0, JUnit XML, and standalone HTML reporters with output + sanitization and secret redaction. +- Docker-based GitHub Action with job summaries and report, score, grade, and passed outputs. +- Typed Python API, Python 3.10 through 3.13 support, test suite, security checks, packaging, and + PyPI trusted-publishing workflow. + +[Unreleased]: https://github.com/devUmut35/PreviewShield/compare/v1.0.0...HEAD +[1.0.0]: https://github.com/devUmut35/PreviewShield/releases/tag/v1.0.0 diff --git a/CITATION.cff b/CITATION.cff new file mode 100644 index 0000000..1d118b1 --- /dev/null +++ b/CITATION.cff @@ -0,0 +1,18 @@ +cff-version: 1.2.0 +message: If PreviewShield supports your work, please cite it using this metadata. +title: PreviewShield +type: software +version: 1.0.0 +date-released: 2026-07-22 +authors: + - family-names: Altan + given-names: Umutcan +repository-code: https://github.com/devUmut35/PreviewShield +url: https://github.com/devUmut35/PreviewShield +license: Apache-2.0 +keywords: + - web security + - policy as code + - security headers + - DevSecOps + - regression testing diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..7cfd4c1 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,62 @@ +# Code of Conduct + +## Our commitment + +We are committed to making participation in PreviewShield a harassment-free experience for +everyone, regardless of age, body size, disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socioeconomic status, nationality, +personal appearance, race, religion, or sexual identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, +and healthy community. + +## Expected behavior + +Examples of behavior that contributes to a positive community include: + +- showing empathy and respect for different viewpoints and experiences; +- giving and accepting constructive, specific feedback; +- focusing criticism on ideas and code rather than people; +- taking responsibility, apologizing, and learning when a mistake affects others; +- respecting privacy and avoiding disclosure of another person's information; and +- prioritizing the health of the project and community over individual advantage. + +Examples of unacceptable behavior include: + +- sexualized language, imagery, or attention; +- trolling, insulting or derogatory comments, and personal or political attacks; +- public or private harassment; +- publishing private information without explicit permission; +- deliberate intimidation, threats, or encouragement of harm; +- abusing security-reporting channels or scan results to target third parties; and +- other conduct that would reasonably be considered inappropriate in a professional setting. + +## Scope + +This Code applies in repository issues, pull requests, reviews, discussions, and other official +project spaces. It also applies when someone publicly represents PreviewShield or its community. + +## Reporting + +Report abusive or unacceptable behavior privately through the repository's +[private report form](https://github.com/devUmut35/PreviewShield/security/advisories/new). Begin the +report with `Code of Conduct report` and include links, dates, context, and any relevant evidence. +Do not publish sensitive details in an issue. + +Maintainers will protect the reporter's privacy as far as reasonably possible and will avoid +conflicts of interest. If a report concerns the person who would normally receive it, state that +clearly so another repository administrator can handle it when available. + +## Enforcement + +Project maintainers are responsible for clarifying and enforcing standards of acceptable +behavior. They may edit or remove contributions that violate this Code and may issue a private +warning, a temporary participation restriction, or a permanent ban depending on impact, +frequency, intent, and response to correction. + +Enforcement decisions will be explained to the affected participant when practical. Retaliation +against a reporter or participant in an investigation is itself a violation. + +## Attribution + +This Code is based on the [Contributor Covenant, version 2.1](https://www.contributor-covenant.org/version/2/1/code_of_conduct.html). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..605a820 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,146 @@ +# Contributing to PreviewShield + +Thank you for helping make web security regressions easier to catch. Contributions of code, +tests, documentation, threat-model review, new rules, and reproducible bug reports are welcome. + +Participation is governed by the [Code of Conduct](CODE_OF_CONDUCT.md). Security vulnerabilities +must be reported privately according to [SECURITY.md](SECURITY.md). + +## Start with an issue + +For a substantial rule, public API, policy schema, network behavior, or report-format change, open +an issue before investing in implementation. A short design discussion helps protect stable rule +IDs and output compatibility. Small documentation corrections and focused bug fixes can go +directly to a pull request. + +- [Report a bug](https://github.com/devUmut35/PreviewShield/issues/new?template=bug_report.yml) +- [Request a feature](https://github.com/devUmut35/PreviewShield/issues/new?template=feature_request.yml) + +## Development setup + +Fork and clone the repository, then create a virtual environment: + +```bash +python -m venv .venv +``` + +Activate it on Linux or macOS: + +```bash +source .venv/bin/activate +``` + +Or on PowerShell: + +```powershell +.venv\Scripts\Activate.ps1 +``` + +Install the package and development tools: + +```bash +python -m pip install --upgrade pip +python -m pip install -e ".[dev]" +``` + +PreviewShield supports Python 3.10 and newer. CI exercises Python 3.10 through 3.13; use one of +those versions locally. + +## Make a focused change + +- Branch from the latest `main`. +- Keep a pull request focused on one problem. +- Preserve public API, rule IDs, JSON schema, and exit-code compatibility unless the proposal + explicitly addresses a breaking change. +- Add or update tests for behavior changes. +- Update documentation and `CHANGELOG.md` when users will notice the change. +- Never include real tokens, internal URLs, customer headers, or sensitive scan output in tests. + +Use clear commit messages. Conventional Commit prefixes such as `feat:`, `fix:`, `docs:`, and +`test:` are encouraged but not required for external contributions. + +## Validate the change + +Run the complete local gate before opening a pull request: + +```bash +python -m ruff format --check . +python -m ruff check . +python -m mypy +python -m bandit -c pyproject.toml -r src +python -m pip_audit --strict +node --check src/previewshield/webui/assets/app.js +node --check src/previewshield/webui/assets/logic.mjs +node --test tests/test_webui_logic.mjs +python -m pytest +python -m build +python -m twine check dist/* +``` + +To apply Python formatting before the check: + +```bash +python -m ruff format . +``` + +Tests must not depend on arbitrary public services. Mock DNS and connections or use a loopback +fixture with the private-target boundary enabled explicitly. Network safety tests should cover +both the intended path and bypass attempts. + +## Adding or changing a rule + +A useful rule is deterministic, evidence-based, actionable, and tied to a defensible reference. +A rule pull request should include: + +- the threat or hardening failure it detects; +- an OWASP, MDN, standards, or similarly authoritative reference; +- a default severity rationale; +- precise pass, fail, edge-case, and disabled-rule tests; +- remediation that a developer can apply; and +- behavior in production-to-preview comparison. + +Built-in IDs use `PS` plus four digits. Do not reuse or renumber a released ID. Coordinate a new +ID in the issue or pull request. Changes that make a rule materially broader may need a new ID to +avoid surprising existing policies. + +## Reporter changes + +All renderers process attacker-controlled response metadata. Use the shared sanitizer and the +output format's escaping primitives. Add tests for control characters, markup injection, URL +credentials and query strings, duplicate headers, long values, and recognizable token patterns. + +JSON shape and SARIF fingerprints are public integration contracts. Call out any compatibility +impact explicitly. + +## Network and Action changes + +The outbound-request code and GitHub Action are high-risk surfaces. Contributions must preserve: + +- validation of every DNS answer and redirect destination; +- direct connection to a validated, pinned address; +- normal TLS hostname verification and SNI; +- removal of all caller-supplied headers on cross-origin redirects; +- bounded timeouts, redirects, URLs, and headers; +- no implicit proxy discovery; and +- shell-free handling of user-controlled Action inputs. + +Explain the threat model in the pull request and include adversarial tests for any changed trust +boundary. + +Local browser UI changes must preserve the exact Host, Origin, session, CSRF, CSP, request-size, +concurrency, and private-target controls. Never render scan data through `innerHTML`, persist scan +credentials in browser storage, or add external scripts, fonts, analytics, or CDN dependencies. + +## Documentation style + +- Write concise, inclusive English. +- Use examples that can be copied safely after replacing `example.com` values. +- Do not promise a security guarantee or describe a hardening finding as proof of exploitation. +- Use repository-relative links for project files and HTTPS for external references. +- Wrap commands in fenced blocks and name the language when useful. + +## Pull-request review + +Maintainers may request changes for correctness, security, compatibility, scope, test coverage, +or documentation. Approval does not guarantee an immediate release. By contributing, you agree +that your contribution is licensed under the repository's Apache License 2.0. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e846525 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +FROM python:3.12-slim + +ENV HOME=/tmp \ + PIP_DISABLE_PIP_VERSION_CHECK=1 \ + PIP_NO_CACHE_DIR=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /opt/previewshield + +COPY pyproject.toml README.md LICENSE ./ +COPY src ./src + +RUN python -m pip install . \ + && python -m pip check + +# GitHub requires the image's default user for its workspace and file-command +# mounts. The action itself uses an exec-form entrypoint and invokes no shell. +WORKDIR /github/workspace + +ENTRYPOINT ["python", "-m", "previewshield.action"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b635edc --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 Umutcan Altan + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + diff --git a/README.md b/README.md index de7b88a..4acaa4a 100644 --- a/README.md +++ b/README.md @@ -1,177 +1,292 @@ -# Security Header Auditor +# PreviewShield -

- - - -

+[![CI](https://github.com/devUmut35/PreviewShield/actions/workflows/ci.yml/badge.svg)](https://github.com/devUmut35/PreviewShield/actions/workflows/ci.yml) +[![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-3776AB?logo=python&logoColor=white)](https://www.python.org/) +[![License: Apache-2.0](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE) +[![GitHub stars](https://img.shields.io/github/stars/devUmut35/PreviewShield?logo=github)](https://github.com/devUmut35/PreviewShield/stargazers) -

- A simple HTTP security header and cookie configuration audit tool. -

+**Stop a pull request from quietly weakening your web security.** ---- +PreviewShield is a policy-as-code scanner and CI regression gate for web deployments. It +checks security headers, cookies, CORS, redirects, TLS, and response health, then compares a +pull-request preview with production. Existing production debt stays visible, while the gate +can fail only on findings that are new or more severe. -## About +```text +PreviewShield diff: FAIL +Baseline: https://example.com (A, 91/100) +Preview: https://preview.example.dev (B, 83/100) +Failure threshold: high +Changes: 1 regressions, 0 resolved, 0 changed, 18 unchanged -**Security Header Auditor** is a Python tool that checks the basic security configuration of a website. - -The tool does not attack the target. -It sends a normal HTTP request and audits the response headers and cookie flags. - -It checks: +REGRESSION (1): + [HIGH] PS1101 - Enforced Content-Security-Policy is missing +``` -- HTTPS usage -- Missing HTTP security headers -- Cross-origin security headers -- Cookie security flags -- Basic security score -- Audit findings +## Why PreviewShield? + +- **Purpose-built preview diffs.** Match findings by rule, route, and subject across different + hostnames, so production and ephemeral deployments compare cleanly. +- **Policy that lives with the code.** Choose a profile, scan multiple routes, override + severities, disable accepted rules, and require organization-specific headers in YAML. +- **CI-native outputs.** Render console, JSON, Markdown, SARIF 2.1.0, JUnit XML, or a standalone + HTML report from the same scan. +- **A local release control room.** Open a no-account browser interface for guided scans, + deployment comparisons, finding filters, and report downloads. +- **Safe network defaults.** Block non-public addresses, validate every redirect, pin connections + to validated DNS answers, preserve TLS hostname verification, and avoid environment proxies. +- **Actionable checks.** Every finding has a stable rule ID, severity, evidence, remediation, and + reference link. +- **Small and portable.** Python 3.10+ with one runtime dependency, PyYAML. Response bodies are + not downloaded. + +```mermaid +flowchart LR + P["Production"] --> S1["Scan selected routes"] + V["PR preview"] --> S2["Scan selected routes"] + Y[".previewshield.yml"] --> S1 + Y --> S2 + S1 --> D["Match rule + route + subject"] + S2 --> D + D --> G{"New or severity increased at threshold?"} + Y --> G + G --> R["Console / JSON / Markdown / SARIF / JUnit / HTML"] +``` ---- +## Quick start -## What Is It Useful For? +Install a released version from PyPI: -This tool does not prove that a website is exploitable. +```bash +python -m pip install previewshield +``` -It helps identify weak security hardening such as: +Until the first PyPI release, install directly from the repository: -- Missing `Strict-Transport-Security` -- Missing `X-Frame-Options` -- Missing `X-Content-Type-Options` -- Missing `Referrer-Policy` -- Missing `Permissions-Policy` -- Missing `Secure`, `HttpOnly`, or `SameSite` cookie flags +```bash +python -m pip install "git+https://github.com/devUmut35/PreviewShield.git" +``` -These findings are generally evaluated as **security misconfiguration** or **web hardening issues**. +Prefer a browser? Launch the local-only interface: ---- +```bash +previewshield ui +``` -## Features +PreviewShield opens a guided release control room on `127.0.0.1`. It supports single-site scans, +production-to-preview comparisons, result filtering, and HTML, JSON, Markdown, SARIF, or JUnit +downloads without an account or hosted scanning service. See the [web UI guide](docs/web-ui.md). -- Windows CMD title support -- HTTP security header audit -- Cookie flag audit -- HTTPS detection -- Score and grade output -- JSON output support -- Report export +Scan one deployment: ---- +```bash +previewshield scan https://example.com +``` -## Installation +Compare production with a pull-request preview and fail on high or critical regressions: ```bash -git clone https://github.com/devUmut35/security-header-auditor.git -cd security-header-auditor -pip install -r requirements.txt +previewshield diff \ + --baseline https://example.com \ + --preview https://pr-142.example.dev \ + --fail-on high +``` + +PreviewShield returns `0` when the policy passes and `1` when the configured threshold is +crossed, making the command a drop-in CI gate. + +## Add it to a pull request + +Run this job after your preview deployment. Replace `vars.PREVIEW_URL` with the URL produced by +your deployment provider. + +```yaml +name: Preview security + +on: + pull_request: + +permissions: + contents: read + +jobs: + previewshield: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Compare preview with production + id: previewshield + uses: devUmut35/PreviewShield@v1 + with: + baseline: https://example.com + preview: ${{ vars.PREVIEW_URL }} + config: .previewshield.yml + paths: | + / + /login + /api/health + fail-on: high + format: sarif + output: previewshield.sarif ``` ---- +The Action writes a job summary, generates JSON, Markdown, and SARIF sidecars, and exposes +`report`, `score`, `grade`, and `passed` outputs. See the +[GitHub Action guide](docs/github-action.md) for artifact and code-scanning examples. -## Usage +## Put the policy in your repository -Direct scan: +Generate and validate a starter policy: ```bash -py security_header_auditor.py https://example.com +previewshield init +previewshield policy validate .previewshield.yml +``` + +```yaml +version: 1 +name: public-web +profile: balanced +fail_on: high + +paths: + - / + - /login + - /api/health + +network: + timeout_seconds: 10 + max_redirects: 5 + allow_private: false + allowed_hosts: + - example.com + - "*.example.dev" + +checks: + min_hsts_max_age: 15552000 + certificate_warning_days: 30 + disabled: [] + severity_overrides: + PS1204: medium + required_headers: + X-Robots-Tag: + severity: medium + contains: noindex + +diff: + mode: regressions ``` -Interactive mode: +Unknown keys and invalid values are rejected instead of being silently ignored. Read the +[policy reference](docs/policy-reference.md) for every option and the difference between +`regressions` and `absolute` modes. + +## Useful commands ```bash -py security_header_auditor.py -``` +# Scan several routes +previewshield scan example.com --path / --path /login --path /api/health -Then: +# Produce a human report and CI sidecars in one request +previewshield scan example.com \ + --format html --output report.html \ + --also-format sarif=report.sarif \ + --also-format junit=report.xml -```txt -scan https://example.com -``` +# Use a request header for an authenticated preview; values are not written to reports +previewshield scan preview.example.dev \ + --header "Authorization: Bearer $PREVIEW_TOKEN" -JSON output: +# Inspect stable rule metadata +previewshield rules +previewshield rules --json -```bash -py security_header_auditor.py https://example.com --json +# Open the local browser interface without launching a new browser tab +previewshield ui --no-open ``` -Save report: +The command surface also includes `diff`, `init`, and `policy validate`. Run +`previewshield COMMAND --help` for all options. -```bash -py security_header_auditor.py https://example.com -o report.json -``` +## What it checks -Set timeout: +PreviewShield currently ships 30 stable rules across: -```bash -py security_header_auditor.py https://example.com --timeout 10 -``` +| Area | Examples | +| --- | --- | +| Transport and TLS | HTTPS, redirect downgrade, negotiated TLS, cipher, certificate expiry | +| Browser hardening | HSTS, CSP, clickjacking, MIME sniffing, referrer and permissions policies | +| Cross-origin policy | Wildcard or opaque origins, credentialed CORS, missing `Vary: Origin` | +| Cookies | `Secure`, `HttpOnly`, `SameSite`, `__Host-` and `__Secure-` prefix contracts | +| Response health | Client/server errors and exposed technology headers | +| Project policy | Required response headers and project-specific severity decisions | ---- - -## Checked Headers - -| Header | Purpose | -|---|---| -| `Content-Security-Policy` | Helps reduce XSS and content injection risks | -| `Strict-Transport-Security` | Forces browsers to use HTTPS | -| `X-Frame-Options` | Helps protect against clickjacking | -| `X-Content-Type-Options` | Helps reduce MIME sniffing risks | -| `Referrer-Policy` | Controls referrer information | -| `Permissions-Policy` | Limits browser feature permissions | - ---- - -## Cookie Checks - -| Flag | Purpose | -|---|---| -| `Secure` | Sends cookies only over HTTPS | -| `HttpOnly` | Blocks JavaScript access to cookies | -| `SameSite` | Helps reduce CSRF risk | - ---- - -## Example Output - -```txt -Target : https://example.com -Final URL : https://example.com/ -Status : 200 -HTTPS : YES -Score : 40/100 -Grade : D --------------------------------------------------------------------------------- -Required Headers -[OK] Content-Security-Policy: upgrade-insecure-requests -[MISSING] Strict-Transport-Security - Forces browsers to use HTTPS for future requests. -[MISSING] X-Frame-Options - Helps protect against clickjacking. --------------------------------------------------------------------------------- -Findings -- Strict-Transport-Security header is missing. -- X-Frame-Options header is missing. -``` +See the [rule catalog](docs/rules.md) for IDs, default severities, and remediation intent. ---- +## Reports and automation -## Project Structure +| Format | Best for | +| --- | --- | +| `console` | Local terminal feedback | +| `json` | Automation and long-term storage | +| `markdown` | Job summaries and pull-request comments | +| `sarif` | GitHub code scanning and SARIF-compatible platforms | +| `junit` | CI test-report viewers | +| `html` | Shareable, standalone human reports | -```txt -security-header-auditor/ -├── security_header_auditor.py -├── requirements.txt -├── README.md -└── .gitignore -``` +Output is consistently structured, sorted, and sanitized: credentials, query strings, fragments, +cookie-like fields, and common token patterns are redacted. Details are in the +[output guide](docs/outputs.md). + +## Security model + +Scanning URLs from CI creates an SSRF boundary. PreviewShield treats it as one: + +1. Only HTTP(S) targets without URL credentials are accepted. +2. Every hostname, including every redirect destination, is checked against the optional host + allowlist, then resolved and validated. +3. Non-public, loopback, link-local, reserved, multicast, and unspecified addresses are blocked + by default. +4. The socket connects to the exact validated address while HTTPS still uses normal hostname + verification and SNI. +5. All caller-supplied headers are removed on cross-origin redirects, environment proxies are + ignored, and response bodies are never read. + +The optional browser UI binds only to `127.0.0.1` and requires an exact Host, same-origin POST, +HttpOnly session cookie, and CSRF token. Private targets remain locked unless the user starts that +session with `previewshield ui --allow-private-targets` and confirms authorization in the UI. + +`--allow-private` deliberately relaxes the network boundary and should be used only for trusted +local test targets. PreviewShield is a hardening auditor, not a vulnerability scanner or proof +that a site is secure. Read the complete [security model](docs/security-model.md) and only scan +systems you own or are authorized to test. + +## Documentation + +- [Getting started](docs/getting-started.md) +- [Local web interface](docs/web-ui.md) +- [Policy reference](docs/policy-reference.md) +- [GitHub Action](docs/github-action.md) +- [Output formats](docs/outputs.md) +- [Rule catalog](docs/rules.md) +- [Security model](docs/security-model.md) +- [Python API](docs/python-api.md) +- [Migrating from Security Header Auditor](docs/migration.md) ---- +## Contributing -## Legal Notice +PreviewShield is Apache-2.0 licensed and built in the open. Bug reports, rule proposals, +reporter integrations, tests, documentation, and security review are welcome. Start with +[CONTRIBUTING.md](CONTRIBUTING.md), browse +[good first issues](https://github.com/devUmut35/PreviewShield/labels/good%20first%20issue), or +open a focused feature request. -Use only on systems you own, CTF/lab environments, or targets where you have explicit permission. +If PreviewShield protects one of your releases, consider starring the repository. It helps +other teams discover a practical security regression gate. ---- +Security vulnerabilities should be reported privately according to [SECURITY.md](SECURITY.md). +General support expectations are documented in [SUPPORT.md](SUPPORT.md). -## Author +## License -**devUmut35** +Copyright 2026 Umutcan Altan. Licensed under the [Apache License 2.0](LICENSE). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..e2b6b48 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,88 @@ +# Security policy + +PreviewShield's network fetcher, policy parser, report renderers, CLI, local browser interface, +and GitHub Action process untrusted input. Responsible vulnerability reports are appreciated. + +## Supported versions + +| Version | Security fixes | +| --- | --- | +| `1.x` | Supported | +| Pre-PreviewShield `security-header-auditor` versions | Not supported | + +Only the latest release in a supported major line is guaranteed to receive a fix. Upgrade before +reporting when practical. + +## Report a vulnerability privately + +Use [GitHub private vulnerability reporting](https://github.com/devUmut35/PreviewShield/security/advisories/new). +Do not open a public issue, pull request, discussion, or social-media thread before coordinated +disclosure. + +Include: + +- affected PreviewShield version or commit; +- component and vulnerability class; +- prerequisites and realistic impact; +- minimal, safe reproduction steps or proof of concept; +- whether private addresses, credentials, or a malicious redirect/DNS server are involved; +- suggested mitigation if known; and +- a safe way to contact you for follow-up. + +Never include real credentials, customer data, or an internal service that you do not own. Build +a local or disposable reproduction where possible. + +## Security-relevant examples + +Reports are especially useful for potential: + +- SSRF or non-public-address validation bypasses; +- DNS pinning or redirect validation gaps; +- leakage of any caller-supplied header across origins; +- TLS hostname-verification bypasses; +- report injection, stored script execution, or secret-redaction bypasses; +- local UI Host, Origin, session, CSRF, CSP, or report-isolation bypasses; +- policy parser denial of service or unsafe object construction; +- command, argument, output-file, or GitHub workflow injection; and +- vulnerabilities in shipped dependencies or release artifacts. + +A security header finding on a scanned third-party website is not a vulnerability in +PreviewShield. Report that issue to the website owner through their own disclosure process. + +## Response process + +The maintainers aim to: + +1. acknowledge a complete report within three business days; +2. confirm scope and severity within seven business days when reproduction is available; +3. develop and test a fix privately; +4. agree on a coordinated disclosure date with the reporter; and +5. publish a security advisory and patched release when users need action. + +These are best-effort targets, not a service-level agreement. Complex reports or maintainer +availability may require more time. Please allow a reasonable remediation window before public +disclosure. + +## Research safe harbor + +We will not pursue legal action against good-faith research that: + +- targets PreviewShield itself or infrastructure the researcher owns; +- avoids privacy violations, service disruption, data destruction, and access beyond what is + needed to demonstrate the issue; +- does not use the scanner against third parties without authorization; +- reports the issue promptly and keeps it confidential during remediation; and +- complies with applicable law. + +This statement cannot authorize testing of third-party services, GitHub, PyPI, or infrastructure +outside the project's control. + +## Operational guidance + +Read the [security model](docs/security-model.md) before scanning internal targets. Keep +`allow_private` disabled for untrusted pull requests, use narrow egress on self-hosted runners, +and treat policy and workflow changes as security-sensitive review. + +The browser interface must remain bound to `127.0.0.1`; do not expose it through a reverse proxy, +container port mapping, tunnel, or shared multi-user host. Start it with `--allow-private-targets` +only for a trusted session, and stop it after the private scan is complete. diff --git a/SUPPORT.md b/SUPPORT.md new file mode 100644 index 0000000..51a2a38 --- /dev/null +++ b/SUPPORT.md @@ -0,0 +1,35 @@ +# Support + +PreviewShield is a community-maintained open-source project. Support is best-effort and does not +include an uptime or response-time guarantee. + +## Before asking for help + +1. Read the [getting-started guide](docs/getting-started.md) and + [policy reference](docs/policy-reference.md). +2. Run `previewshield --version` and reproduce with the latest supported release. +3. Run `previewshield policy validate PATH` when configuration is involved. +4. Search [existing issues](https://github.com/devUmut35/PreviewShield/issues) for the exact error + or rule ID. +5. Remove credentials, internal hostnames, private addresses, and sensitive response data from + anything you plan to post publicly. + +## Where to ask + +- Reproducible defects belong in a + [bug report](https://github.com/devUmut35/PreviewShield/issues/new?template=bug_report.yml). +- Product proposals belong in a + [feature request](https://github.com/devUmut35/PreviewShield/issues/new?template=feature_request.yml). +- Security vulnerabilities must follow [SECURITY.md](SECURITY.md) and must not be posted publicly. + +Include the version, operating system, Python version, redacted command, policy, expected result, +actual result, and smallest safe reproduction. Maintainers may close requests that cannot be +reproduced or that concern an unsupported version. + +## Scope + +The issue tracker is not a security consulting service and cannot determine whether a third-party +website is vulnerable. PreviewShield findings are hardening signals; remediation must be reviewed +in the application's own architecture and threat model. + +Commercial support and private remediation guidance are not currently offered. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..a39487f --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,14 @@ +# Third-party notices + +PreviewShield's local browser interface bundles the following fonts so the UI works without a +CDN or external request: + +- **Archivo Black** — Copyright 2011 The Archivo Black Project Authors. Licensed under the SIL + Open Font License 1.1. The license text is distributed at + `src/previewshield/webui/assets/fonts/archivo-black-OFL.txt`. +- **IBM Plex Mono** — Copyright IBM Corp. 2017. Licensed under the SIL Open Font License 1.1. + The license text is distributed at + `src/previewshield/webui/assets/fonts/ibm-plex-mono-OFL.txt`. + +These notices apply to the font files only. PreviewShield itself remains licensed under the +Apache License 2.0. diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..fc4f1cb --- /dev/null +++ b/action.yml @@ -0,0 +1,69 @@ +name: PreviewShield +description: Block security regressions in preview deployments with policy-as-code. +author: devUmut35 + +branding: + icon: shield + color: blue + +inputs: + target: + description: URL to scan. Mutually exclusive with baseline and preview. + required: false + default: "" + baseline: + description: Production or known-good URL used by diff mode. + required: false + default: "" + preview: + description: Preview URL compared with baseline in diff mode. + required: false + default: "" + config: + description: Path to a PreviewShield YAML policy file. + required: false + default: "" + paths: + description: Comma-separated or multiline URL paths to inspect. + required: false + default: "/" + fail-on: + description: Minimum severity that fails the action. + required: false + default: high + format: + description: Primary report format (json, markdown, or sarif). + required: false + default: json + output: + description: Primary report path. A format-specific filename is used when omitted. + required: false + default: "" + allow-private: + description: Allow explicitly requested private or loopback targets. Disabled by default. + required: false + default: "false" + +outputs: + report: + description: Path to the primary report selected by format. + score: + description: PreviewShield score for the target or preview. + grade: + description: PreviewShield grade for the target or preview. + passed: + description: Whether the configured policy threshold passed. + +runs: + using: docker + image: Dockerfile + args: + - --target=${{ inputs.target }} + - --baseline=${{ inputs.baseline }} + - --preview=${{ inputs.preview }} + - --config=${{ inputs.config }} + - --paths=${{ inputs.paths }} + - --fail-on=${{ inputs.fail-on }} + - --format=${{ inputs.format }} + - --output=${{ inputs.output }} + - --allow-private=${{ inputs.allow-private }} diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..e090139 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,18 @@ +# PreviewShield documentation + +Use this index to move from a first local scan to a production CI gate. + +| Guide | Use it when | +| --- | --- | +| [Getting started](getting-started.md) | Installing PreviewShield and running the first scan or diff | +| [Local web interface](web-ui.md) | Running guided scans and downloading reports from a browser | +| [Policy reference](policy-reference.md) | Defining routes, network limits, thresholds, and custom checks | +| [GitHub Action](github-action.md) | Gating a preview deployment in a workflow | +| [Output formats](outputs.md) | Sending results to people, automation, or security platforms | +| [Rule catalog](rules.md) | Understanding stable rule IDs and default severities | +| [Security model](security-model.md) | Evaluating the scanner's network boundary and limitations | +| [Python API](python-api.md) | Embedding the engine in another Python application | +| [Migration guide](migration.md) | Moving from Security Header Auditor to PreviewShield | + +For project-level information, see the main [README](../README.md), +[contribution guide](../CONTRIBUTING.md), and [security policy](../SECURITY.md). diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..3fbe5f3 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,165 @@ +# Getting started + +PreviewShield can audit one deployment or compare a known-good production deployment with a +preview. The comparison is usually the most useful CI mode because it separates existing +security debt from changes introduced by the current release. + +## Requirements + +- Python 3.10 or newer for the CLI +- A network path from the runner to every target +- Explicit authorization to send HTTP GET requests to those targets +- Docker only when running the GitHub Action locally; CLI use does not require it + +## Install + +From PyPI after the first release is published: + +```bash +python -m pip install previewshield +previewshield --version +``` + +From the repository: + +```bash +git clone https://github.com/devUmut35/PreviewShield.git +cd PreviewShield +python -m pip install -e . +``` + +For an isolated command-line install, `pipx install previewshield` also works once the package +is available on PyPI. + +## Open the local web interface + +For a guided first run, start PreviewShield's local release control room: + +```bash +previewshield ui +``` + +The command binds only to `127.0.0.1:8765` and opens a browser automatically. The interface can +scan one deployment, compare production with a preview, filter findings, and download reports. +No account, database, hosted scanner, or separate frontend installation is required. + +Use `previewshield ui --no-open` when you want to open the printed URL yourself. Private and +loopback targets are deliberately locked in normal UI sessions; read the [web UI guide](web-ui.md) +before enabling them. + +## Run a scan + +A hostname without a scheme defaults to HTTPS: + +```bash +previewshield scan example.com +``` + +To scan several origin-relative routes, repeat `--path`: + +```bash +previewshield scan https://example.com \ + --path / \ + --path /login \ + --path '/api/health?verbose=false' +``` + +When no explicit paths are supplied, the policy's `paths` list is used. If the default policy +contains only `/` and the target itself contains a path, that target path is preserved. + +## Compare production and preview + +```bash +previewshield diff \ + --baseline https://example.com \ + --preview https://pr-142.example.dev \ + --path / \ + --path /login \ + --fail-on high +``` + +Both targets receive the same paths, request headers, policy, timeout, and threshold. Findings +are matched using a stable identity made from the rule, route, and subject rather than the +hostname. + +The default `regressions` mode fails on: + +- a new finding at or above the threshold; or +- an existing finding whose severity increased to or above the threshold. + +Resolved and unchanged findings remain in the report. Use `diff.mode: absolute` if every preview +finding at the threshold should fail, even when it already exists in production. + +## Create a policy + +```bash +previewshield init +previewshield policy validate .previewshield.yml +previewshield scan example.com --config .previewshield.yml +``` + +`init` refuses to replace an existing file unless `--force` is provided. Policy files are +strictly validated; misspelled keys produce a configuration error. + +Start with [the balanced example](../examples/previewshield.yml) or read the complete +[policy reference](policy-reference.md). + +## Save reports + +The default console report goes to standard output. Choose a format and output file for CI: + +```bash +previewshield scan example.com --format json --output previewshield.json +``` + +Create several representations without repeating the scan: + +```bash +previewshield diff \ + --baseline https://example.com \ + --preview https://preview.example.dev \ + --format markdown --output previewshield.md \ + --also-format sarif=previewshield.sarif \ + --also-format junit=previewshield.xml +``` + +Available formats are `console`, `html`, `json`, `junit`, `markdown`, and `sarif`. + +## Authenticated previews + +Repeat `--header` to add request headers: + +```bash +previewshield scan https://preview.example.dev \ + --header "Authorization: Bearer $PREVIEW_TOKEN" \ + --header "X-Preview-Access: $PREVIEW_ACCESS" +``` + +PreviewShield does not write request header values to reports and drops every caller-supplied +header on cross-origin redirects. The redirected request receives only fresh tool-owned headers. +Avoid placing secrets directly on a shared command line; prefer environment expansion in a +protected CI job. + +Transport framing headers, `Host`, and proxy authorization cannot be overridden. + +## Exit codes + +| Code | Meaning | +| --- | --- | +| `0` | Scan or diff completed and passed, or an informational command succeeded | +| `1` | A finding or regression crossed the configured failure threshold | +| `2` | CLI arguments or policy configuration were invalid | +| `3` | The network safety boundary rejected the target, or the scan could not connect | +| `4` | A report or unexpected internal operation failed | +| `130` | The process was interrupted from the keyboard | + +Treat `1` as a security policy decision. Codes `2` through `4` indicate that no reliable gate +decision was produced and should also fail a CI job. + +## Next steps + +- Put the policy under version control. +- Add every security-relevant public route, not just `/`. +- Use an explicit `network.allowed_hosts` list in stable CI environments. +- Add the [GitHub Action](github-action.md) after preview deployment. +- Review the [security model](security-model.md) before enabling private targets. diff --git a/docs/github-action.md b/docs/github-action.md new file mode 100644 index 0000000..7503f13 --- /dev/null +++ b/docs/github-action.md @@ -0,0 +1,209 @@ +# GitHub Action + +The PreviewShield Action runs the same engine as the CLI in a Docker container. It supports a +single-target scan or a production-to-preview diff, writes a GitHub job summary, and generates +JSON, Markdown, and SARIF reports in one invocation. + +Pin a stable major release in normal use: + +```yaml +- uses: devUmut35/PreviewShield@v1 +``` + +Security-sensitive organizations may instead pin a full commit SHA and update it deliberately. + +## Preview regression gate + +Run PreviewShield after the job that publishes the preview. In this provider-neutral example, +`vars.PREVIEW_URL` stands for the deployment URL. Replace it with a trusted output from your own +deployment job or integration. + +```yaml +name: Preview security + +on: + pull_request: + +permissions: + contents: read + +jobs: + previewshield: + runs-on: ubuntu-latest + steps: + - name: Check out policy + uses: actions/checkout@v6 + + - name: Gate web security regressions + id: previewshield + uses: devUmut35/PreviewShield@v1 + with: + baseline: https://example.com + preview: ${{ vars.PREVIEW_URL }} + config: .previewshield.yml + paths: | + / + /login + /api/health + fail-on: high + format: json + output: previewshield-report.json + + - name: Preserve report + if: always() + uses: actions/upload-artifact@v7 + with: + name: previewshield-report + path: | + previewshield-report.json + previewshield-report.md + previewshield-report.sarif + if-no-files-found: warn +``` + +The Action exits with the CLI status. A policy violation therefore fails the job while the +`if: always()` artifact step can still preserve the generated report. + +## Single-target scan + +Use `target` without `baseline` or `preview`: + +```yaml +- name: Audit production + uses: devUmut35/PreviewShield@v1 + with: + target: https://example.com + paths: /,/login,/api/health + fail-on: high + format: markdown + output: previewshield.md +``` + +`target` and `baseline`/`preview` are mutually exclusive. Diff mode requires both baseline and +preview. + +## Inputs + +| Input | Required | Default | Description | +| --- | --- | --- | --- | +| `target` | conditional | empty | URL for scan mode | +| `baseline` | conditional | empty | Production or known-good URL for diff mode | +| `preview` | conditional | empty | Preview URL for diff mode | +| `config` | no | empty | Repository-relative PreviewShield YAML policy path | +| `paths` | no | `/` | Comma-separated or multiline origin-relative paths | +| `fail-on` | no | `high` | Minimum failing severity | +| `format` | no | `json` | Primary format: `json`, `markdown`, or `sarif` | +| `output` | no | format-specific | Primary report path | +| `allow-private` | no | `false` | Permit private and loopback targets | + +Accepted boolean values for `allow-private` are `true`/`false`, `1`/`0`, `yes`/`no`, and +`on`/`off`. + +The Action does not expose the CLI's arbitrary request-header option. Use the CLI in a protected +workflow step if a preview requires custom authentication headers. + +## Outputs + +Give the step an `id` to use these outputs: + +| Output | Description | +| --- | --- | +| `report` | Path to the selected primary report | +| `score` | Numeric score for the target, or for the preview in diff mode | +| `grade` | Grade for the target, or for the preview in diff mode | +| `passed` | `true` or `false` based on the configured policy | + +```yaml +- name: Show decision + if: always() + env: + SCORE: ${{ steps.previewshield.outputs.score }} + GRADE: ${{ steps.previewshield.outputs.grade }} + PASSED: ${{ steps.previewshield.outputs.passed }} + run: echo "PreviewShield passed=$PASSED score=$SCORE grade=$GRADE" +``` + +The job summary contains the Markdown report. If the report cannot be read, the adapter writes a +small fallback summary instead. + +## Sidecar reports + +The Action always asks the engine for JSON, Markdown, and SARIF. `format` selects which one is +exposed by `report` and which exact path receives `output`; the other two use the same base name +with their natural extensions. + +For example: + +```yaml +with: + format: sarif + output: security/previewshield.sarif +``` + +creates: + +```text +security/previewshield.sarif +security/previewshield.json +security/previewshield.md +``` + +## Upload SARIF to GitHub code scanning + +GitHub code scanning requires `security-events: write`. Diff SARIF contains only regressions; +scan SARIF contains all findings. + +```yaml +permissions: + contents: read + security-events: write + +steps: + - uses: actions/checkout@v6 + + - name: Run PreviewShield + id: previewshield + uses: devUmut35/PreviewShield@v1 + with: + baseline: https://example.com + preview: ${{ vars.PREVIEW_URL }} + config: .previewshield.yml + format: sarif + output: previewshield.sarif + + - name: Upload SARIF + if: always() && hashFiles('previewshield.sarif') != '' + uses: github/codeql-action/upload-sarif@v4 + with: + sarif_file: previewshield.sarif +``` + +GitHub may restrict security-event writes for workflows from forks. Review repository permission +settings before relying on SARIF upload for untrusted pull requests. + +## Trust boundaries + +- Never construct baseline or preview URLs directly from untrusted pull-request text. +- Keep `allow-private: false` for untrusted code and public preview URLs. +- Treat changes to `.previewshield.yml` and the workflow as security-sensitive code review. +- Use `network.allowed_hosts` when preview hostnames follow a predictable pattern. +- Do not put credentials in URLs. URL user information is rejected. +- Preserve codes `2` through `4` as CI failures; they mean the gate could not produce a reliable + decision. + +For the underlying controls and remaining risks, see the +[PreviewShield security model](security-model.md). + +## Testing a checkout before release + +Inside this repository, `uses: ./` builds the local Docker Action: + +```yaml +- uses: ./ + with: + target: https://example.com + fail-on: critical +``` + +Consumers should use a released tag or immutable commit from +`devUmut35/PreviewShield`, not `uses: ./`. diff --git a/docs/migration.md b/docs/migration.md new file mode 100644 index 0000000..f8dd904 --- /dev/null +++ b/docs/migration.md @@ -0,0 +1,99 @@ +# Migrating from Security Header Auditor + +PreviewShield is the successor to Security Header Auditor. The original single-file script grew +into an installable package with policy-as-code, secure networking, multi-route scans, deployment +diffs, CI formats, and a GitHub Action. + +## Repository and package names + +| Before | Now | +| --- | --- | +| `devUmut35/security-header-auditor` | `devUmut35/PreviewShield` | +| `security_header_auditor.py` | `previewshield` CLI | +| `requirements.txt` install | `pip install previewshield` | + +GitHub redirects repository renames, but update clone URLs, badges, Action references, and local +remotes deliberately. + +```bash +git remote set-url origin https://github.com/devUmut35/PreviewShield.git +``` + +## Command changes + +Basic positional use remains available through the compatibility wrapper: + +```bash +python security_header_auditor.py https://example.com +``` + +New automation should call the installed command explicitly: + +```bash +previewshield scan https://example.com +``` + +Update old report flags: + +```bash +# Before +python security_header_auditor.py https://example.com --json -o report.json + +# Now +previewshield scan https://example.com --format json --output report.json +``` + +The former interactive prompt is not part of the new CLI. Commands are explicit and suitable for +shell scripts and CI. + +## Adopt a policy + +Generate the starter file and commit it: + +```bash +previewshield init +previewshield policy validate .previewshield.yml +git add .previewshield.yml +``` + +The balanced profile fails on high and critical findings. To preserve an existing rollout while +avoiding new debt, start with diff mode: + +```bash +previewshield diff \ + --baseline https://example.com \ + --preview https://preview.example.dev \ + --config .previewshield.yml +``` + +This default gate reports existing production findings but fails only on new findings or severity +increases at the threshold. Move to `diff.mode: absolute` when the preview must satisfy the +complete policy. + +## Output compatibility + +PreviewShield JSON uses schema version `1.0` and is not the same shape as the original script's +JSON. Update consumers to read the top-level `passed`, `score`, `grade`, and `routes` fields. Diff +reports contain nested `baseline` and `preview` scan reports plus `deltas`. + +Do not parse console text. Use JSON for automation, JUnit for test viewers, or SARIF for code +scanning. + +## Security behavior changes + +PreviewShield blocks non-public addresses by default, ignores environment proxies, validates +every redirect, and verifies TLS normally. A scan that previously reached `localhost`, an RFC1918 +address, or an internal DNS name now returns exit code `3` unless private access is explicitly +enabled. + +Before using `--allow-private`, review the [security model](security-model.md) and isolate the +runner from unrelated internal services. + +## Suggested rollout + +1. Run `previewshield scan` locally and review all findings. +2. Add explicit routes and allowed hosts to `.previewshield.yml`. +3. Add a production-to-preview diff with `fail_on: high`. +4. Archive JSON or HTML reports to establish history. +5. Triage baseline debt, documenting any disabled rule or severity override. +6. Tighten the profile or switch to absolute mode when the baseline is ready. diff --git a/docs/outputs.md b/docs/outputs.md new file mode 100644 index 0000000..4549c9f --- /dev/null +++ b/docs/outputs.md @@ -0,0 +1,126 @@ +# Output formats + +Every renderer consumes the same typed scan or diff result. Choosing another format does not +make another network request and does not change the policy decision. + +## Generate one or several reports + +```bash +previewshield scan https://example.com \ + --format console \ + --also-format json=artifacts/previewshield.json \ + --also-format html=artifacts/previewshield.html +``` + +When `--output` is omitted, the primary report is printed to standard output. Additional formats +always require `FORMAT=PATH`. PreviewShield refuses to write two reports to the same resolved +path. + +Canonical format names are `console`, `html`, `json`, `junit`, `markdown`, and `sarif`. + +## Format matrix + +| Format | Contents | Recommended consumer | +| --- | --- | --- | +| `console` | ANSI-free summary and actionable findings | Developers and plain CI logs | +| `json` | Complete, stable, sorted data model | Scripts, archives, data pipelines | +| `markdown` | Compact tables and prioritized fixes | GitHub job summaries and comments | +| `sarif` | SARIF 2.1.0 rules and results | GitHub code scanning and SARIF tools | +| `junit` | Test cases with threshold failures | CI test-report interfaces | +| `html` | Self-contained styled report | Humans, downloadable build artifacts | + +## JSON + +JSON is the canonical automation format. Objects contain `schema_version` and `tool_version` so +consumers can reject incompatible data deliberately. + +A scan report includes: + +```text +schema_version, tool_version, generated_at, policy_name +target, routes[], score, grade, fail_on, passed +``` + +Each route includes the response snapshot and findings. A snapshot contains normalized URLs, +status, duplicate-preserving response headers, resolved IP, elapsed time, redirects, and +negotiated TLS metadata. Findings contain rule ID, title, severity, category, message, +remediation, target, subject, evidence, and references. + +A diff report includes: + +```text +schema_version, tool_version, generated_at, policy_name +baseline, preview, deltas[], fail_on, passed +``` + +Each delta is `regression`, `resolved`, `changed`, or `unchanged`, with the relevant baseline and +preview finding records. Do not infer pass/fail from score; use the top-level `passed` value. + +## Markdown + +Markdown is intentionally concise. It sorts findings by severity, limits long tables and fix +lists, and works well in `GITHUB_STEP_SUMMARY`. The GitHub Action writes it automatically. + +The summary is not a replacement for JSON when a complete machine-readable history is required. + +## SARIF + +SARIF uses version 2.1.0. Stable PreviewShield rule IDs become SARIF rule IDs, and stable finding +fingerprints let code-scanning platforms correlate repeated runs. + +Because web findings have no source-code line, results use `.previewshield.yml` line 1 as a +repository-level physical anchor and put the target in a logical location and result properties. + +- Scan reports include all findings. +- Diff reports include regressions only. +- Critical/high map to SARIF `error`, medium to `warning`, and low/info to `note`. + +See the [GitHub Action guide](github-action.md#upload-sarif-to-github-code-scanning) for upload +permissions and workflow configuration. + +## JUnit XML + +Each visible finding or diff change becomes a test case. A test case becomes a JUnit failure only +when it is a failing scan finding, or a failing regression in diff mode, at or above the selected +threshold. Other observations are retained in `system-out`. + +## HTML + +HTML is a self-contained document with no remote script or stylesheet dependency. Treat it as a +build artifact and apply your normal artifact retention and access policy. + +## Scoring and grades + +PreviewShield calculates a score for each route from `100` minus finding penalties, floored at +zero, then averages route scores: + +| Severity | Penalty per finding | +| --- | --- | +| `info` | 0 | +| `low` | 3 | +| `medium` | 8 | +| `high` | 15 | +| `critical` | 25 | + +Grades are `A+` for 95+, `A` for 90+, `B` for 80+, `C` for 70+, `D` for 60+, and `F` below 60. + +The score is a prioritization aid, not a security guarantee. Pass/fail is calculated separately +from severity and policy threshold. In regression mode, a preview may have a low score and still +pass when it introduced no new threshold-crossing findings. + +## Sanitization and redaction + +All renderers sanitize untrusted response metadata. The sanitization layer: + +- removes credentials, query strings, and fragments from URLs; +- redacts values under names that look like credentials, cookies, passwords, sessions, or tokens; +- recognizes common bearer/basic credentials, JWTs, GitHub tokens, and private-key markers; +- removes control data and bounds evidence and display text; +- escapes output for Markdown, HTML, XML, JSON, and console contexts; and +- keeps duplicate response-header structure in JSON while redacting sensitive values. + +Request header values supplied with `--header` are never stored in the report model. + +Redaction is defense in depth, not a secret-management system. Do not send report artifacts to a +public location without reviewing their access policy, and never put credentials in target URL +query strings even though reports remove them. diff --git a/docs/policy-reference.md b/docs/policy-reference.md new file mode 100644 index 0000000..39ff4ec --- /dev/null +++ b/docs/policy-reference.md @@ -0,0 +1,221 @@ +# Policy reference + +PreviewShield policy files use YAML and schema version `1`. Unknown keys, wrong types, unsafe +paths, and out-of-range values fail validation. Files larger than 256 KiB are rejected. + +Generate the canonical starter file with: + +```bash +previewshield init +``` + +Validate any policy without making a network request: + +```bash +previewshield policy validate .previewshield.yml +``` + +## Complete example + +```yaml +version: 1 +name: public-web +profile: balanced +fail_on: high + +paths: + - / + - /login + - /api/health + +network: + timeout_seconds: 10 + max_redirects: 5 + allow_private: false + allowed_hosts: + - example.com + - "*.example.dev" + user_agent: "PreviewShield/1.0 (+https://github.com/devUmut35/PreviewShield)" + +checks: + min_hsts_max_age: 15552000 + certificate_warning_days: 30 + disabled: + - PS1205 + severity_overrides: + PS1204: medium + required_headers: + X-Robots-Tag: + severity: medium + contains: noindex + remediation: Set X-Robots-Tag to noindex on preview responses. + +diff: + mode: regressions +``` + +## Root fields + +| Field | Type | Default | Meaning | +| --- | --- | --- | --- | +| `version` | integer | `1` | Policy schema; only version `1` is accepted | +| `name` | string | `PreviewShield ` | Display name included in reports, limited to 80 characters | +| `profile` | string | `balanced` | Base defaults: `balanced` or `strict` | +| `fail_on` | severity | profile default | Minimum severity that fails a scan or diff | +| `paths` | string list | `[/]` | Origin-relative routes scanned on each target | +| `network` | mapping | `{}` | Network bounds and destination controls | +| `checks` | mapping | `{}` | Rule selection and rule-specific settings | +| `diff` | mapping | `{}` | Baseline comparison behavior | + +Severities, from least to most severe, are `info`, `low`, `medium`, `high`, and `critical`. + +## Profiles + +Profiles provide defaults; every setting can still be made explicit. + +| Profile | `fail_on` | Minimum HSTS `max-age` | Certificate warning | Diff mode | +| --- | --- | --- | --- | --- | +| `balanced` | `high` | 15,552,000 seconds | 30 days | `regressions` | +| `strict` | `medium` | 31,536,000 seconds | 45 days | `regressions` | + +When `--profile` and a policy file are both used, the CLI profile is only the fallback if the +file omits `profile`. + +## Paths + +Paths must: + +- be non-empty and origin-relative; +- begin with `/`; +- contain no scheme, hostname, fragment, or `..` segment; and +- optionally include a query string. + +Duplicate paths are removed while preserving order. `--path` replaces the policy list for that +invocation; repeat it for multiple routes. + +## Network + +### `timeout_seconds` + +A total timeout shared by all redirect hops. Accepted range: `0.1` to `120` seconds. System DNS +resolution cannot be portably interrupted by Python, so DNS itself may outlive this budget. + +### `max_redirects` + +Maximum redirects before the scan fails. Accepted range: `0` to `20`. + +### `allow_private` + +Defaults to `false`. When false, PreviewShield rejects non-public DNS answers and IP literals, +including private, loopback, link-local, reserved, multicast, and unspecified ranges. + +Setting this to `true`, or passing `--allow-private`, deliberately removes that boundary. Only do +so for a trusted local or internal test target. Read the [security model](security-model.md). + +### `allowed_hosts` + +An optional allowlist applied before DNS resolution to the initial target and every redirect hop. +Values are exact hostnames or a leading wildcard such as `"*.example.dev"`. A wildcard matches +subdomains but not the apex (`example.dev`). Internationalized names are normalized to IDNA form. + +When the list is empty, any otherwise-safe public hostname is permitted. A redirect to a hostname +outside a non-empty allowlist fails before its DNS lookup. + +### `user_agent` + +The request User-Agent. It must be non-empty, contain only ISO-8859-1 characters, contain no +control characters, and be at most 200 characters. Invalid values fail policy validation. + +## Checks + +### `disabled` + +A list of stable rule IDs to skip. Use `previewshield rules` to see built-in IDs. Required-header +IDs use `CUSTOM.`, for example `X-Robots-Tag` becomes +`CUSTOM.X_ROBOTS_TAG`. + +Unknown built-in IDs and custom IDs without a matching `required_headers` entry fail validation, +so a typo cannot silently disable the wrong control. + +Prefer documenting the reason in the pull request that changes the policy. Disabling a rule +hides it from both scan findings and comparisons. + +### `severity_overrides` + +A mapping from rule ID to severity: + +```yaml +checks: + severity_overrides: + PS1204: medium + CUSTOM.X_ROBOTS_TAG: high +``` + +Overrides affect pass/fail decisions, scoring, diffs, and all report formats. + +### `required_headers` + +Require an application- or organization-specific response header. A value of `true` checks only +for presence: + +```yaml +checks: + required_headers: + Cross-Origin-Resource-Policy: true +``` + +Use a mapping to configure the finding: + +```yaml +checks: + required_headers: + X-Robots-Tag: + severity: medium + contains: noindex + remediation: Prevent preview deployments from being indexed. +``` + +Options: + +| Field | Default | Meaning | +| --- | --- | --- | +| `severity` | `medium` | Severity when the requirement is not met | +| `exact` | none | Require the complete value to match, case-sensitively | +| `contains` | none | Require a case-sensitive substring | +| `remediation` | generic guidance | Replacement text shown in reports | + +`exact` and `contains` are mutually exclusive. + +### `min_hsts_max_age` + +Minimum accepted HSTS `max-age`, from `0` through `630720000` seconds. The profile supplies the +default. + +### `certificate_warning_days` + +Raise `PS1502` when the negotiated certificate is expired or has no more than this many days +remaining. Accepted range: `1` through `365`. + +## Diff mode + +`diff.mode` accepts: + +- `regressions` (default): fail only on a new finding or a severity increase at or above the + threshold; +- `absolute`: fail if any preview finding is at or above the threshold, regardless of baseline. + +A finding identity combines rule ID, route including its query, and subject. The hostname is +intentionally excluded so different production and preview hosts compare. + +## CLI precedence + +| CLI option | Effect | +| --- | --- | +| `--path` | Replaces policy paths for that invocation | +| `--fail-on` | Replaces the policy threshold | +| `--allow-private` | Enables private targets for that invocation | +| `--header` | Adds an in-memory request header; values are not stored in the policy or reports | +| `--profile` | Supplies defaults only when the policy omits its profile | + +Use [examples/previewshield.yml](../examples/previewshield.yml) for a balanced public service and +[examples/strict.previewshield.yml](../examples/strict.previewshield.yml) for a stricter baseline. diff --git a/docs/python-api.md b/docs/python-api.md new file mode 100644 index 0000000..336c797 --- /dev/null +++ b/docs/python-api.md @@ -0,0 +1,125 @@ +# Python API + +PreviewShield exposes a small synchronous Python API backed by the same scanner, policy engine, +and network controls as the CLI. + +## Scan a target + +```python +from previewshield import Severity, scan + +report = scan( + "https://example.com", + paths=("/", "/login"), + fail_on=Severity.HIGH, +) + +print(report.score, report.grade, report.passed) +for finding in report.findings: + print(finding.rule_id, finding.severity.value, finding.message) +``` + +`scan()` returns an immutable `ScanReport`. Its `routes` retain the captured response snapshot and +findings for every path. The `findings` and `counts` properties provide flattened views. + +## Compare two deployments + +```python +from previewshield import Severity, diff + +report = diff( + "https://example.com", + "https://preview.example.dev", + paths=("/", "/login"), + fail_on=Severity.HIGH, +) + +if not report.passed: + for delta in report.regressions: + print(delta.finding.rule_id, delta.finding.title) +``` + +`DiffReport` exposes `regressions`, `resolved`, and `unchanged` properties. The complete `deltas` +tuple also contains non-regressive `changed` findings. + +## Load a policy + +```python +from previewshield import scan +from previewshield.policy import load_policy + +policy = load_policy(".previewshield.yml") +report = scan("https://example.com", policy=policy) +``` + +Or use an in-memory validated mapping: + +```python +from previewshield.policy import policy_from_mapping + +policy = policy_from_mapping( + { + "version": 1, + "profile": "strict", + "paths": ["/", "/login"], + "diff": {"mode": "regressions"}, + } +) +``` + +Do not instantiate `Policy` directly from untrusted data; the loader enforces types, ranges, safe +paths, and known keys. + +## Request headers and private targets + +```python +import os + +from previewshield import scan + +report = scan( + "https://preview.example.dev", + request_headers={"Authorization": f"Bearer {os.environ['PREVIEW_TOKEN']}"}, +) +``` + +Set `allow_private=True` only for explicitly trusted internal test targets. It has the same +security implications as the CLI flag described in the [security model](security-model.md). + +## Render a report + +```python +from pathlib import Path + +from previewshield import scan +from previewshield.reporters import render + +report = scan("https://example.com") +Path("previewshield.sarif").write_text( + render(report, "sarif"), + encoding="utf-8", +) +``` + +Supported names are available from `previewshield.reporters.supported_formats()`. + +## Exceptions + +Expected failures inherit from `PreviewShieldError`: + +```python +from previewshield import scan +from previewshield.exceptions import PreviewShieldError + +try: + report = scan("https://example.com") +except PreviewShieldError as error: + print(error, error.exit_code) +``` + +`ConfigurationError`, `NetworkSafetyError`, `ScanError`, and `ReportError` distinguish common +failure classes. Policy failure is represented by `report.passed == False`; the Python API does +not raise just because the threshold was crossed. + +The API performs synchronous network I/O. Applications with an async event loop should run it in +a worker thread or process and retain their own outer timeout. diff --git a/docs/rules.md b/docs/rules.md new file mode 100644 index 0000000..650f81e --- /dev/null +++ b/docs/rules.md @@ -0,0 +1,76 @@ +# Rule catalog + +Rule IDs are stable automation keys. Titles and remediation text may improve between releases, +but an existing ID will not be silently reassigned to a different security condition. + +List the rules installed with your exact version: + +```bash +previewshield rules +previewshield rules --json +``` + +Policy severity overrides are applied after rule evaluation. Some rules can emit more than once +on a route, such as one cookie finding per cookie. + +## Built-in rules + +| ID | Default | Category | Condition | +| --- | --- | --- | --- | +| `PS0001` | high | transport | Requested or final route is not protected by end-to-end HTTPS | +| `PS0002` | critical | transport | A redirect moves from HTTPS to HTTP | +| `PS1001` | high | headers | HTTPS response has no `Strict-Transport-Security` policy | +| `PS1002` | medium | headers | HSTS is invalid or shorter than the policy minimum | +| `PS1101` | high | content-security | No enforcing CSP is present; report-only is not enforcement | +| `PS1102` | high | content-security | Enforced CSP permits `'unsafe-eval'` | +| `PS1103` | medium | content-security | CSP permits unsafe inline code without a nonce or hash | +| `PS1104` | medium | content-security | CSP includes a broad wildcard source | +| `PS1105` | medium | content-security | CSP omits the `default-src` fallback | +| `PS1106` | medium | content-security | CSP does not block object sources with an empty or `'none'` list | +| `PS1107` | low | content-security | CSP does not constrain `base-uri` | +| `PS1201` | high | headers | Neither CSP `frame-ancestors` nor valid X-Frame-Options prevents framing | +| `PS1202` | medium | headers | `X-Content-Type-Options` is missing or not `nosniff` | +| `PS1203` | low | privacy | `Referrer-Policy` is missing or unsafe | +| `PS1204` | low | privacy | `Permissions-Policy` is missing | +| `PS1205` | info | headers | Deprecated `X-XSS-Protection` is enabled instead of `0` | +| `PS1301` | medium | cors | Credentials are advertised with an invalid wildcard origin policy | +| `PS1302` | high | cors | CORS trusts a broad or opaque origin; wildcard is emitted as medium | +| `PS1303` | info | cors | A specific allowed origin may need `Vary: Origin` if selected dynamically | +| `PS1401` | medium | cookies | Cookie lacks `Secure` | +| `PS1402` | low | cookies | Cookie lacks `HttpOnly` | +| `PS1403` | low | cookies | Cookie lacks a valid `SameSite` value | +| `PS1404` | high | cookies | `SameSite=None` cookie lacks `Secure` | +| `PS1405` | high | cookies | `__Host-` or `__Secure-` prefix contract is violated | +| `PS1501` | critical | tls | Negotiated TLS protocol is legacy | +| `PS1502` | high | tls | Certificate is expired or within the configured warning window | +| `PS1503` | high | tls | Negotiated cipher contains a known weak marker | +| `PS1601` | low | information-disclosure | `Server` or `X-Powered-By` exposes technology details | +| `PS1602` | high | availability | Route returned a server error response | +| `PS1603` | medium | availability | Route returned a client error response | + +Some observations are raised above their catalog default when evidence is unequivocally worse: +invalid or zero-age HSTS and unsafe effective referrer policies are high and medium respectively, +while an expired certificate is critical. Policy overrides are applied after this context. + +## Custom required-header rules + +`checks.required_headers` creates deterministic IDs by uppercasing the header and replacing +non-alphanumeric groups with underscores: + +| Header | Rule ID | +| --- | --- | +| `X-Robots-Tag` | `CUSTOM.X_ROBOTS_TAG` | +| `Cross-Origin-Resource-Policy` | `CUSTOM.CROSS_ORIGIN_RESOURCE_POLICY` | + +Custom rules support presence, exact-value, or substring requirements. See +[Required headers](policy-reference.md#required_headers) for configuration. + +## Interpreting findings + +A PreviewShield finding is a hardening observation, not proof of exploitability. Browser support, +application behavior, CDN behavior, and threat model still matter. Use references and remediation +as a review starting point, test the change, and use policy overrides only when the risk decision +is documented. + +Rules inspect the final response and negotiated connection metadata. Redirect downgrade checks +also inspect the redirect chain. PreviewShield does not crawl links or parse response bodies. diff --git a/docs/security-model.md b/docs/security-model.md new file mode 100644 index 0000000..8bc1dcf --- /dev/null +++ b/docs/security-model.md @@ -0,0 +1,179 @@ +# Security model + +PreviewShield makes outbound requests to URLs chosen by a developer or CI workflow. Its network +layer is designed to make public-site scanning safe by default, including when a target redirects +or DNS returns surprising data. This document defines those guarantees, remaining risks, and the +effect of unsafe overrides. + +## Intended use + +PreviewShield is designed to: + +- issue bounded HTTP GET requests to one or more explicitly selected routes; +- capture response headers, redirect metadata, status, resolved address, timing, and negotiated + TLS details; +- evaluate deterministic web-hardening rules; and +- compare the resulting findings between two deployments. + +It does not crawl, submit forms, execute JavaScript, send attack payloads, or read response +bodies. It is not a penetration test and does not prove that a deployment is secure. + +Only scan systems you own or have explicit permission to test. Although GET is conventionally +safe, a broken application can attach side effects to any request. + +## Default outbound-request boundary + +### URL validation + +- Only `http` and `https` are accepted. +- Host/path shorthand defaults to HTTPS. +- URL credentials and user-supplied fragments are rejected; redirect fragments are discarded + because fragments are not sent in HTTP requests. +- Invalid ports, malformed escaping, control characters, and unsafe hostnames are rejected. +- Request `Host`, framing, connection, and proxy authorization headers cannot be overridden. + +### Address validation + +For the initial request and every redirect, PreviewShield resolves the hostname itself and checks +every returned address. Unless private access is explicitly enabled, it rejects: + +- private networks; +- loopback addresses; +- link-local addresses; +- reserved and unspecified addresses; and +- multicast addresses. + +A hostname with a mixture of public and non-public answers is rejected; PreviewShield does not +select only the convenient public answer. + +When `network.allowed_hosts` is non-empty, the initial hostname and every redirect hostname must +match it before DNS resolution. This combines a project-specific destination boundary with the +address-class boundary. + +### DNS pinning + +After validation, the TCP socket connects directly to one of the exact resolved addresses. The +HTTP client does not perform a second hostname resolution. This closes the usual validation-to- +connection gap exploited by DNS rebinding. + +For HTTPS, the connection still uses the original URL hostname for SNI and certificate hostname +verification. The default operating-system trust store and Python TLS context remain active. + +### Redirects and credentials + +- Redirects are bounded and loops fail the scan. +- Every destination is normalized, resolved, and address-validated again. +- Every caller-supplied request header is removed whenever scheme, hostname, or port changes. The + new origin receives only fresh tool-owned `User-Agent` and `Accept` headers. +- HTTPS-to-HTTP downgrade remains observable as `PS0002` even when the public destination itself + is reachable. + +### Proxies and bodies + +Environment and system proxy configuration is not consulted. Requests connect directly to the +validated DNS answer. Response bodies are not read or buffered; only metadata needed by the +checks is captured. + +## Resource bounds + +Policy validation constrains timeouts, redirects, path shape, and file size. The network layer +also bounds URLs, request-header values, and the cumulative response-header fields accepted from +each hop. A timeout is shared across redirect hops rather than reset for each hop. + +System DNS resolution cannot be portably interrupted by Python's standard library. A hostile or +unresponsive resolver can therefore exceed the configured HTTP timeout. Run untrusted scans in a +CI job with its own outer timeout and normal operating-system resource controls. + +The local browser interface additionally caps its JSON request size, route count, connection-read +time, active scan count, individual serialized report size, total retained report bytes, and +in-memory report count. Multiple routes are still scanned in sequence, so their individual network +budgets can accumulate. The UI process is not a sandbox or a multi-tenant service. + +## `allow_private` changes the boundary + +The policy setting `network.allow_private: true`, CLI flag `--allow-private`, Action input +`allow-private: true`, and UI startup flag `--allow-private-targets` permit private, loopback, and +other non-public destinations. The UI also requires a second authorization confirmation in the +browser. These controls are useful for a trusted local test service, but they deliberately remove +the primary SSRF control. + +Do not enable it when: + +- pull-request authors can influence target URLs, redirects, DNS, or policy; +- the runner can reach cloud instance metadata, control planes, databases, or internal admin + services; or +- reports or logs are exposed to people who should not learn internal addressing. + +If internal scanning is required, use a dedicated runner with narrow egress, an explicit +`network.allowed_hosts` list that covers every intended redirect hop, short timeouts, and no +ambient credentials. + +## Request headers and secrets + +The CLI accepts repeatable `--header NAME:VALUE` options for authenticated previews. Values are +held only for the request and are not stored in report models. Every caller-supplied header is +stripped on cross-origin redirects. + +All report formats apply additional secret and URL redaction. This is defense in depth. Command +arguments may still be visible to local process inspection or CI configuration readers, and a +target server receives the supplied values. Use protected environment variables and least- +privilege, short-lived preview credentials. + +## Report safety + +Response headers are attacker-controlled text. Renderers remove control data, cap long values, +redact likely credentials, and escape content for their destination format. Standalone HTML does +not load remote scripts or styles. + +No pattern-based redaction can recognize every secret. Store reports with the same care as other +security scan artifacts. JSON intentionally contains security-relevant response headers after +sanitization, plus the resolved IP and TLS certificate metadata. + +## Local browser interface + +`previewshield ui` is a single-user loopback interface, not a hosted scanner. It binds only to +`127.0.0.1` and accepts the exact numeric loopback `Host` value selected at startup. API requests +require a random HttpOnly, SameSite session cookie, an exact same-origin POST, and a separate CSRF +token. CORS is not enabled. + +The UI serves only packaged assets under a restrictive CSP. Client code builds finding cards with +text-only DOM properties and does not use `innerHTML`. Downloadable reports are rendered through +the same sanitizing reporters as the CLI, kept in bounded process memory, and addressed by random +session-local identifiers. + +Do not expose the UI through a reverse proxy, tunnel, container port publication, or shared host. +Its HTTP cookie is intentionally not marked `Secure` because the supported origin is loopback HTTP; +the exact Host, Origin, CSRF, and SameSite controls form the local request boundary. Stop the +process when the interactive session is finished. + +## Policy and comparison assumptions + +- A baseline is a comparison reference, not automatically a secure system. +- `regressions` mode allows existing baseline debt to remain without failing the diff. +- Stable fingerprints intentionally exclude hostname; both deployments must expose equivalent + routes for a meaningful comparison. +- A CDN, authentication gateway, geography, A/B test, or cache may make two responses + non-equivalent even at the same path. +- Checks see only the final response headers, except redirect-specific rules. +- Scoring is a heuristic and is separate from the severity threshold. + +Use `absolute` mode for a compliance gate that must reject all preview findings at the threshold. +Use repeated runs or controlled request headers when the serving layer is nondeterministic. + +## Out of scope + +PreviewShield does not currently assess: + +- HTML, JavaScript, source maps, or response-body content; +- application authorization, injection, business logic, or API schemas; +- all possible CSP semantics or browser-specific behavior; +- the complete TLS configuration offered by a server; it records the negotiated connection; +- DNSSEC, certificate transparency, revocation, or external reputation; +- ports, hosts, or paths discovered by crawling; or +- exploitability of a reported hardening condition. + +## Vulnerability reporting + +Potential bypasses of address validation, DNS pinning, redirect credential stripping, report +sanitization, Action input handling, or policy parsing are security-sensitive. Report them +privately according to [SECURITY.md](../SECURITY.md), not in a public issue. diff --git a/docs/web-ui.md b/docs/web-ui.md new file mode 100644 index 0000000..44e2a23 --- /dev/null +++ b/docs/web-ui.md @@ -0,0 +1,98 @@ +# Local web interface + +PreviewShield includes a guided browser interface for people who do not want to assemble every +scan as a terminal command. The CLI and GitHub Action remain the automation interfaces; the web UI +is a local presentation layer over the same policy, scanner, diff, and reporter code. + +## Start it + +```bash +previewshield ui +``` + +The command listens only on `http://127.0.0.1:8765/`, opens the default browser, and keeps running +until `Ctrl+C` is pressed in the terminal. Choose another loopback port or suppress automatic +browser launch when needed: + +```bash +previewshield ui --port 9000 +previewshield ui --no-open +``` + +## Available workflows + +### Compare deployments + +This is the recommended mode. Enter the production and preview URLs, select the routes, choose a +balanced or strict profile, and set the failure threshold. The result shows: + +- the production and preview scores on either side of the security seam; +- regressions, resolved findings, changed findings, and unchanged security debt; +- actionable rule, route, evidence, and remediation details; and +- the final policy decision as `PASS` or `BLOCK`. + +`BLOCK` means the configured policy threshold was crossed. It does not mean the application is +known to be exploitable. Likewise, `PASS` is not a security guarantee. + +### Scan one site + +Use the single-site tab for an initial posture check when no baseline is available. Findings are +grouped by severity and can be filtered before downloading the full report. + +## Download evidence + +After a successful scan request—even one whose security decision is `BLOCK`—the UI can download +the same sanitized formats as the CLI: + +- standalone HTML; +- JSON; +- Markdown; +- SARIF 2.1.0; and +- JUnit XML. + +Reports are held only in bounded process memory. They disappear when the UI stops and are never +written to disk unless the browser user explicitly downloads one. The “Copy GitHub Action” button +creates a starter workflow from the current URLs, routes, and threshold. + +## Private and localhost targets + +Private, loopback, link-local, and other non-public addresses remain blocked by default. For a +trusted local development environment, restart the UI with: + +```bash +previewshield ui --allow-private-targets +``` + +Then enable private targets inside Advanced network access and confirm that you own or are +authorized to scan them. This startup flag relaxes a core SSRF boundary for the entire UI session. +Do not use it while browsing untrusted pages, and stop the process when the local scan is complete. + +The first UI release intentionally does not accept custom request headers or arbitrary policy-file +paths. Authenticated previews and repository policy files remain available through the CLI and +GitHub Action, where secret and filesystem handling are explicit. + +## Local security controls + +The browser never connects to the target directly. It sends JSON to the loopback server, which +calls PreviewShield's Python API and safe network layer. The server: + +1. binds only to `127.0.0.1`; +2. accepts only its exact `Host` value; +3. requires a random HttpOnly, SameSite session cookie; +4. requires an exact same-origin POST and a separate CSRF token; +5. does not enable CORS; +6. limits request size, route count, response-header bytes, connection-read time, concurrent scans, + and retained report bytes; +7. renders untrusted values with text-only DOM operations; and +8. applies a restrictive CSP and other browser security headers to every handled response. + +The scan engine's documented residual risks still apply, including non-interruptible system DNS +resolution and cumulative time across multiple routes. Review the complete +[security model](security-model.md) before using PreviewShield in a sensitive network. + +## Troubleshooting + +If port 8765 is already in use, choose another port with `--port`. If the page is open but API +requests report an invalid session, close stale tabs and reopen the exact URL printed by the newest +PreviewShield process. Browser extensions that rewrite `Host`, `Origin`, cookies, or CSP may also +interfere with the local security checks. diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..1144b42 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,23 @@ +# Examples + +These files are starting points; replace example domains and risk decisions before committing +them to a real project. + +| File | Purpose | +| --- | --- | +| [previewshield.yml](previewshield.yml) | Balanced public website policy | +| [strict.previewshield.yml](strict.previewshield.yml) | Medium-threshold, one-year HSTS policy | +| [custom-headers.previewshield.yml](custom-headers.previewshield.yml) | Organization-specific required headers | +| [github-action.yml](github-action.yml) | Pull-request diff, artifact, and SARIF workflow | + +Validate a policy without contacting a target: + +```bash +previewshield policy validate examples/previewshield.yml +``` + +Use it in a scan: + +```bash +previewshield scan https://example.com --config examples/previewshield.yml +``` diff --git a/examples/custom-headers.previewshield.yml b/examples/custom-headers.previewshield.yml new file mode 100644 index 0000000..dfddf2b --- /dev/null +++ b/examples/custom-headers.previewshield.yml @@ -0,0 +1,26 @@ +# Policy focused on deployment and organization-specific response contracts. +version: 1 +name: custom-response-contract +profile: balanced +fail_on: high + +paths: + - / + +network: + allow_private: false + +checks: + required_headers: + X-Robots-Tag: + severity: high + contains: noindex + remediation: Prevent preview deployments from being indexed by search engines. + Cross-Origin-Resource-Policy: + severity: medium + exact: same-origin + remediation: Isolate same-origin resources from cross-origin embedding. + X-Organization-Security-Policy: true + +diff: + mode: regressions diff --git a/examples/github-action.yml b/examples/github-action.yml new file mode 100644 index 0000000..da2e055 --- /dev/null +++ b/examples/github-action.yml @@ -0,0 +1,48 @@ +# Copy this file to .github/workflows/previewshield.yml in the consuming repository. +# Replace the example domains and PREVIEW_URL source with your deployment provider's output. +name: PreviewShield + +on: + pull_request: + +permissions: + contents: read + security-events: write + +jobs: + security-regression: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check out policy + uses: actions/checkout@v6 + + - name: Compare preview with production + id: previewshield + uses: devUmut35/PreviewShield@v1 + with: + baseline: https://example.com + preview: ${{ vars.PREVIEW_URL }} + config: .previewshield.yml + paths: | + / + /login + /api/health + fail-on: high + format: sarif + output: artifacts/previewshield.sarif + + - name: Upload SARIF + if: always() && hashFiles('artifacts/previewshield.sarif') != '' + uses: github/codeql-action/upload-sarif@v4 + with: + sarif_file: artifacts/previewshield.sarif + + - name: Preserve all reports + if: always() + uses: actions/upload-artifact@v7 + with: + name: previewshield-${{ github.event.pull_request.number }} + path: artifacts/previewshield.* + if-no-files-found: warn + retention-days: 14 diff --git a/examples/previewshield.yml b/examples/previewshield.yml new file mode 100644 index 0000000..79e80f9 --- /dev/null +++ b/examples/previewshield.yml @@ -0,0 +1,29 @@ +# Balanced policy for a public website and ephemeral preview subdomains. +version: 1 +name: public-web +profile: balanced +fail_on: high + +paths: + - / + - /login + - /api/health + +network: + timeout_seconds: 10 + max_redirects: 5 + allow_private: false + allowed_hosts: + - example.com + - "*.example.dev" + +checks: + min_hsts_max_age: 15552000 + certificate_warning_days: 30 + disabled: [] + severity_overrides: {} + required_headers: {} + +diff: + # Existing production debt remains visible but does not block the preview. + mode: regressions diff --git a/examples/strict.previewshield.yml b/examples/strict.previewshield.yml new file mode 100644 index 0000000..62ca98b --- /dev/null +++ b/examples/strict.previewshield.yml @@ -0,0 +1,34 @@ +# Strict policy for a public application with a mature security baseline. +version: 1 +name: strict-public-web +profile: strict +fail_on: medium + +paths: + - / + - /login + - /account + - /api/health + +network: + timeout_seconds: 8 + max_redirects: 3 + allow_private: false + allowed_hosts: + - example.com + - "*.example.dev" + +checks: + min_hsts_max_age: 31536000 + certificate_warning_days: 45 + disabled: [] + severity_overrides: + PS1204: medium + required_headers: + Cross-Origin-Resource-Policy: + severity: medium + exact: same-origin + +diff: + # Every threshold-crossing preview finding fails, including baseline debt. + mode: absolute diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..34d9d20 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,136 @@ +[build-system] +requires = ["setuptools>=77", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "previewshield" +version = "1.0.0" +description = "Policy-as-code security regression testing for web previews and production deployments." +readme = "README.md" +requires-python = ">=3.10" +license = "Apache-2.0" +license-files = ["LICENSE", "THIRD_PARTY_NOTICES.md"] +authors = [ + { name = "Umutcan Altan", email = "devUmut35@users.noreply.github.com" }, +] +keywords = [ + "security", + "devsecops", + "http-headers", + "github-actions", + "policy-as-code", + "web-security", +] +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Console", + "Intended Audience :: Developers", + "Intended Audience :: System Administrators", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Security", + "Topic :: Software Development :: Quality Assurance", +] +dependencies = [ + "PyYAML>=6.0.2,<7", +] + +[project.optional-dependencies] +dev = [ + "bandit[toml]>=1.8,<2", + "build>=1.2,<2", + "mypy>=1.14,<2", + "pip-audit>=2.7,<3", + "pytest>=8.3,<9", + "pytest-cov>=6,<7", + "ruff>=0.9,<1", + "twine>=6,<7", + "types-PyYAML>=6.0.12,<7", +] + +[project.scripts] +previewshield = "previewshield.cli:main" +previewshield-action = "previewshield.action:main" + +[project.urls] +Homepage = "https://github.com/devUmut35/PreviewShield" +Documentation = "https://github.com/devUmut35/PreviewShield/tree/main/docs" +Issues = "https://github.com/devUmut35/PreviewShield/issues" +Source = "https://github.com/devUmut35/PreviewShield" + +[tool.setuptools] +package-dir = { "" = "src" } + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +previewshield = ["py.typed"] +"previewshield.webui.assets" = ["*.html", "*.css", "*.js", "*.mjs", "fonts/*.ttf", "fonts/*.txt"] + +[tool.ruff] +target-version = "py310" +line-length = 100 +extend-exclude = ["dist", "build"] + +[tool.ruff.lint] +select = [ + "A", + "B", + "BLE", + "C4", + "E", + "F", + "I", + "N", + "PERF", + "PIE", + "PL", + "RUF", + "S", + "SIM", + "UP", + "W", +] +ignore = [ + "S101", +] + +[tool.ruff.lint.per-file-ignores] +"tests/**/*.py" = ["PLR2004", "S101", "S104"] + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" + +[tool.mypy] +python_version = "3.10" +strict = true +warn_unreachable = true +pretty = true +packages = ["previewshield"] + +[tool.pytest.ini_options] +addopts = "--strict-config --strict-markers --cov=previewshield --cov-report=term-missing --cov-fail-under=85" +testpaths = ["tests"] + +[tool.coverage.run] +branch = true +source = ["previewshield"] + +[tool.coverage.report] +show_missing = true +skip_covered = true +exclude_lines = [ + "if TYPE_CHECKING:", + "if __name__ == .__main__.:", + "raise NotImplementedError", +] + +[tool.bandit] +exclude_dirs = ["tests"] +skips = ["B101"] diff --git a/requirements.txt b/requirements.txt index 0eb8cae..e0d33d1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1 @@ -requests>=2.31.0 +PyYAML>=6.0.2,<7 diff --git a/security_header_auditor.py b/security_header_auditor.py index df1f405..d33664a 100644 --- a/security_header_auditor.py +++ b/security_header_auditor.py @@ -1,473 +1,25 @@ -from __future__ import annotations - -import argparse -import atexit -import ctypes -import json -import os -import shlex -import sys -import time -from dataclasses import asdict, dataclass -from pathlib import Path -from urllib.parse import urlparse - -import requests - - -AUTHOR = "devUmut35" -TOOL_NAME = "devUmut35 Security Header Auditor" - -CYAN = "\033[96m" -BOLD = "\033[1m" -RESET = "\033[0m" - -REQUIRED_HEADERS = { - "Content-Security-Policy": "Helps reduce XSS and content injection risks.", - "Strict-Transport-Security": "Forces browsers to use HTTPS for future requests.", - "X-Frame-Options": "Helps protect against clickjacking.", - "X-Content-Type-Options": "Helps reduce MIME sniffing risks.", - "Referrer-Policy": "Controls how much referrer information is shared.", - "Permissions-Policy": "Limits browser features such as camera, microphone and geolocation.", -} - -OPTIONAL_HEADERS = { - "Cross-Origin-Opener-Policy": "Helps isolate browsing contexts.", - "Cross-Origin-Resource-Policy": "Controls which origins can load resources.", - "Cross-Origin-Embedder-Policy": "Helps strengthen cross-origin isolation.", -} - - -@dataclass -class HeaderResult: - name: str - present: bool - value: str | None - note: str - - -@dataclass -class CookieResult: - name: str - secure: bool - httponly: bool - samesite: str | None - - -@dataclass -class ScanResult: - target: str - final_url: str - status_code: int - https: bool - score: int - grade: str - headers: list[HeaderResult] - optional_headers: list[HeaderResult] - cookies: list[CookieResult] - findings: list[str] - - -def enable_windows_ansi() -> None: - if os.name != "nt": - return - try: - kernel32 = ctypes.windll.kernel32 - handle = kernel32.GetStdHandle(-11) - mode = ctypes.c_uint32() - if kernel32.GetConsoleMode(handle, ctypes.byref(mode)): - kernel32.SetConsoleMode(handle, mode.value | 4) - except Exception: - pass - - -def set_title(title: str) -> None: - if os.name == "nt": - os.system(f"title {title}") - else: - sys.stdout.write(f"\033]0;{title}\007") - - -def clear_screen() -> None: - os.system("cls" if os.name == "nt" else "clear") - - -def restore_console() -> None: - sys.stdout.write(RESET) - sys.stdout.flush() - if os.name == "nt": - os.system("color 07 >nul 2>&1") - os.system("title Command Prompt") - - -def start_style() -> None: - enable_windows_ansi() - set_title(TOOL_NAME) - clear_screen() - if os.name == "nt": - os.system("color 0B") - sys.stdout.write(CYAN + BOLD) - sys.stdout.flush() - - -def banner() -> None: - print(r""" -+----------------------------------------------------------------------------+ -| | -| ____ _ _ _ _ _ | -| / ___| ___ ___ _ _ _ __(_) |_ _ _| | | | ___ __ _ __| | ___ _ __ | -| \___ \ / _ \/ __| | | | '__| | __| | | | |_| |/ _ \/ _` |/ _` |/ _ \ '__|| -| ___) | __/ (__| |_| | | | | |_| |_| | _ | __/ (_| | (_| | __/ | | -| |____/ \___|\___|\__,_|_| |_|\__|\__, |_| |_|\___|\__,_|\__,_|\___|_| | -| |___/ | -| AUDITOR | -| signature: devUmut35 | -| | -+----------------------------------------------------------------------------+ -""") - - -def example_text() -> None: - print("Example:") - print(" scan https://example.com") - print(" scan https://example.com --json") - print(" scan https://example.com -o report.json") - print(" scan https://example.com --timeout 10") - print() - - -def normalize_url(url: str) -> str: - url = url.strip() - if not url: - raise ValueError("Target URL cannot be empty.") - if not url.startswith(("http://", "https://")): - url = "https://" + url - return url - - -def check_header(headers: dict, name: str, note: str) -> HeaderResult: - value = headers.get(name) - return HeaderResult(name=name, present=value is not None, value=value, note=note) - - -def parse_cookies(response: requests.Response) -> list[CookieResult]: - results: list[CookieResult] = [] - raw_headers = [] - - if hasattr(response.raw.headers, "get_all"): - raw_headers = response.raw.headers.get_all("Set-Cookie") or [] - - for raw_cookie in raw_headers: - parts = [part.strip() for part in raw_cookie.split(";")] - if not parts: - continue - - cookie_name = parts[0].split("=", 1)[0] - flags = {part.lower(): part for part in parts[1:]} - samesite = None - - for part in parts[1:]: - if part.lower().startswith("samesite="): - samesite = part.split("=", 1)[1] - - results.append( - CookieResult( - name=cookie_name, - secure="secure" in flags, - httponly="httponly" in flags, - samesite=samesite, - ) - ) - - return results - - -def calculate_score(headers: list[HeaderResult], cookies: list[CookieResult], https: bool) -> int: - score = 20 if https else 0 - header_points = 60 // len(headers) - - for header in headers: - if header.present: - score += header_points - - if cookies: - cookie_score = 0 - for cookie in cookies: - cookie_score += int(cookie.secure) - cookie_score += int(cookie.httponly) - cookie_score += int(bool(cookie.samesite)) - score += int((cookie_score / (len(cookies) * 3)) * 20) - else: - score += 10 - - return min(score, 100) - - -def grade(score: int) -> str: - if score >= 85: - return "A" - if score >= 70: - return "B" - if score >= 55: - return "C" - if score >= 40: - return "D" - return "F" - +"""Backward-compatible launcher for Security Header Auditor users. -def build_findings(headers: list[HeaderResult], cookies: list[CookieResult], https: bool) -> list[str]: - findings = [] +New integrations should install the package and run ``previewshield scan``. +""" - if not https: - findings.append("HTTPS is not enabled.") - - for header in headers: - if not header.present: - findings.append(f"{header.name} header is missing.") - - for cookie in cookies: - if not cookie.secure: - findings.append(f"Secure flag is missing on cookie: {cookie.name}.") - if not cookie.httponly: - findings.append(f"HttpOnly flag is missing on cookie: {cookie.name}.") - if not cookie.samesite: - findings.append(f"SameSite flag is missing on cookie: {cookie.name}.") - - if not findings: - findings.append("No critical basic header or cookie issue was detected.") - - return findings - - -def scan(url: str, timeout: float) -> ScanResult: - target = normalize_url(url) - - response = requests.get( - target, - timeout=timeout, - allow_redirects=True, - headers={"User-Agent": f"{AUTHOR}-security-header-auditor/1.0"}, - ) - - parsed = urlparse(response.url) - https = parsed.scheme == "https" - - headers = [check_header(response.headers, name, note) for name, note in REQUIRED_HEADERS.items()] - optional_headers = [check_header(response.headers, name, note) for name, note in OPTIONAL_HEADERS.items()] - cookies = parse_cookies(response) - score = calculate_score(headers, cookies, https) - findings = build_findings(headers, cookies, https) - - return ScanResult( - target=target, - final_url=response.url, - status_code=response.status_code, - https=https, - score=score, - grade=grade(score), - headers=headers, - optional_headers=optional_headers, - cookies=cookies, - findings=findings, - ) - - -def print_header_result(result: HeaderResult) -> None: - if result.present: - print(f"[OK] {result.name}: {result.value}") - else: - print(f"[MISSING] {result.name} - {result.note}") - - -def print_cookie_result(cookie: CookieResult) -> None: - secure = "OK" if cookie.secure else "MISSING" - httponly = "OK" if cookie.httponly else "MISSING" - samesite = cookie.samesite or "MISSING" - - print(f"[COOKIE] {cookie.name}") - print(f" Secure : {secure}") - print(f" HttpOnly : {httponly}") - print(f" SameSite : {samesite}") - - -def print_report(result: ScanResult) -> None: - print(f"Target : {result.target}") - print(f"Final URL : {result.final_url}") - print(f"Status : {result.status_code}") - print(f"HTTPS : {'YES' if result.https else 'NO'}") - print(f"Score : {result.score}/100") - print(f"Grade : {result.grade}") - print("-" * 80) - - print("Required Headers") - for header in result.headers: - print_header_result(header) - - print("-" * 80) - print("Optional Headers") - for header in result.optional_headers: - print_header_result(header) - - print("-" * 80) - print("Cookies") - if result.cookies: - for cookie in result.cookies: - print_cookie_result(cookie) - else: - print("No Set-Cookie header detected.") - - print("-" * 80) - print("Findings") - for item in result.findings: - print(f"- {item}") - - print("-" * 80) - - -def result_to_json(result: ScanResult) -> str: - return json.dumps(asdict(result), indent=2, ensure_ascii=False) - - -def ask(question: str, default: bool) -> bool: - suffix = "Y/n" if default else "y/N" - try: - answer = input(f"{question} [{suffix}]: ").strip().lower() - except KeyboardInterrupt: - print() - return default - if not answer: - return default - return answer in {"y", "yes"} - - -def run_scan(target: str, timeout: float, json_output: bool, output: str | None) -> int: - try: - result = scan(target, timeout) - except requests.RequestException as error: - print(f"Request error: {error}", file=sys.stderr) - return 1 - except ValueError as error: - print(f"Input error: {error}", file=sys.stderr) - return 1 - - if json_output: - print(result_to_json(result)) - else: - print_report(result) - - if output: - Path(output).write_text(result_to_json(result), encoding="utf-8") - print(f"Saved: {output}") - - return 0 - - -def parser() -> argparse.ArgumentParser: - app = argparse.ArgumentParser(prog="security_header_auditor", description="Security Header Auditor by devUmut35") - app.add_argument("target", nargs="?") - app.add_argument("-u", "--url") - app.add_argument("--timeout", type=float, default=6.0) - app.add_argument("--json", action="store_true") - app.add_argument("-o", "--output") - return app - - -def help_text() -> None: - print("Commands:") - print(" scan https://example.com") - print(" scan https://example.com --json") - print(" scan https://example.com -o report.json") - print(" scan https://example.com --timeout 10") - print(" clear") - print(" help") - print(" exit") - print() - - -def interactive() -> int: - app = parser() - example_text() - - while True: - try: - command = input("HeaderAudit> ").strip() - except KeyboardInterrupt: - print() - if ask("Exit Security Header Auditor?", False): - print("bye.") - time.sleep(0.7) - clear_screen() - return 0 - continue - except EOFError: - print() - return 0 - - if not command: - continue - - lowered = command.lower() - - if lowered in {"exit", "quit"}: - print("bye.") - time.sleep(0.7) - clear_screen() - return 0 - - if lowered in {"help", "?"}: - help_text() - continue - - if lowered in {"clear", "cls"}: - clear_screen() - banner() - example_text() - continue - - if lowered.startswith("scan "): - try: - args = app.parse_args(shlex.split(command)[1:]) - except SystemExit: - print("Invalid command. Type help.") - continue - - target = args.target or args.url - if not target: - print("Example: scan https://example.com") - continue - - run_scan(target, args.timeout, args.json, args.output) - continue - - print("Unknown command. Type help.") +from __future__ import annotations +import sys -def main() -> int: - atexit.register(restore_console) - start_style() - banner() +from previewshield.cli import main - app = parser() - args = app.parse_args() - target = args.target or args.url - if not target: - return interactive() +def legacy_main() -> int: + """Forward legacy positional arguments to the PreviewShield scan command.""" - try: - return run_scan(target, args.timeout, args.json, args.output) - except KeyboardInterrupt: - print() - if ask("Exit Security Header Auditor?", True): - print("bye.") - time.sleep(0.7) - clear_screen() - return 130 - return 0 - except Exception as error: - print(f"Error: {error}") - return 1 + arguments = sys.argv[1:] + commands = {"diff", "init", "policy", "rules", "scan"} + global_options = {"-h", "--help", "--version"} + if arguments and arguments[0] not in commands | global_options: + arguments = ["scan", *arguments] + return main(arguments) if __name__ == "__main__": - raise SystemExit(main()) + raise SystemExit(legacy_main()) diff --git a/src/previewshield/__init__.py b/src/previewshield/__init__.py new file mode 100644 index 0000000..4fc9006 --- /dev/null +++ b/src/previewshield/__init__.py @@ -0,0 +1,15 @@ +"""PreviewShield public package API.""" + +from previewshield._version import __version__ +from previewshield.api import diff, scan +from previewshield.models import DiffReport, Finding, ScanReport, Severity + +__all__ = [ + "DiffReport", + "Finding", + "ScanReport", + "Severity", + "__version__", + "diff", + "scan", +] diff --git a/src/previewshield/__main__.py b/src/previewshield/__main__.py new file mode 100644 index 0000000..682c29c --- /dev/null +++ b/src/previewshield/__main__.py @@ -0,0 +1,6 @@ +"""Run PreviewShield with ``python -m previewshield``.""" + +from previewshield.cli import main + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/previewshield/_version.py b/src/previewshield/_version.py new file mode 100644 index 0000000..20a3173 --- /dev/null +++ b/src/previewshield/_version.py @@ -0,0 +1,3 @@ +"""Single source of truth for the PreviewShield version.""" + +__version__ = "1.0.0" diff --git a/src/previewshield/action.py b/src/previewshield/action.py new file mode 100644 index 0000000..1da8bd1 --- /dev/null +++ b/src/previewshield/action.py @@ -0,0 +1,435 @@ +"""Secure GitHub Action adapter for the PreviewShield CLI. + +The adapter deliberately invokes :mod:`previewshield.cli` in-process with an +argument list. No user-provided value is ever interpreted by a shell. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import secrets +import sys +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass +from pathlib import Path +from typing import cast + +from previewshield import cli as previewshield_cli + +ACTION_USAGE_ERROR = 2 +MAX_JSON_REPORT_BYTES = 50_000_000 +MAX_SUMMARY_CHARACTERS = 200_000 +SUPPORTED_FORMATS = frozenset({"json", "markdown", "sarif"}) +_OUTPUT_NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_-]*\Z") + +CliMain = Callable[[Sequence[str] | None], int | None] + + +class ActionInputError(ValueError): + """Raised when GitHub Action inputs describe an ambiguous invocation.""" + + +@dataclass(frozen=True) +class ActionInputs: + """Normalized and validated GitHub Action inputs.""" + + target: str | None + baseline: str | None + preview: str | None + config: str | None + paths: tuple[str, ...] + fail_on: str + report_format: str + output: Path + allow_private: bool + + @classmethod + def from_values( # noqa: PLR0913 - mirrors the public action input contract + cls, + *, + target: str = "", + baseline: str = "", + preview: str = "", + config: str = "", + paths: str = "", + fail_on: str = "high", + report_format: str = "json", + output: str = "", + allow_private: str = "false", + ) -> ActionInputs: + """Normalize strings supplied by action metadata or the environment.""" + + normalized_target = _optional(target) + normalized_baseline = _optional(baseline) + normalized_preview = _optional(preview) + + if normalized_target and (normalized_baseline or normalized_preview): + raise ActionInputError("Use target or baseline+preview, not both.") + if not normalized_target and not (normalized_baseline or normalized_preview): + raise ActionInputError("Provide target or both baseline and preview.") + if bool(normalized_baseline) is not bool(normalized_preview): + raise ActionInputError("Baseline and preview must be provided together.") + + normalized_format = report_format.strip().lower() or "json" + if normalized_format == "md": + normalized_format = "markdown" + if normalized_format not in SUPPORTED_FORMATS: + allowed = ", ".join(sorted(SUPPORTED_FORMATS)) + raise ActionInputError(f"Unsupported format '{normalized_format}'. Use: {allowed}.") + + default_output = { + "json": "previewshield-report.json", + "markdown": "previewshield-report.md", + "sarif": "previewshield-report.sarif", + }[normalized_format] + normalized_output = output.strip() or default_output + if any(character in normalized_output for character in ("\x00", "\r", "\n")): + raise ActionInputError("Output must be a single valid filesystem path.") + + normalized_fail_on = fail_on.strip().lower() or "high" + return cls( + target=normalized_target, + baseline=normalized_baseline, + preview=normalized_preview, + config=_optional(config), + paths=_split_paths(paths), + fail_on=normalized_fail_on, + report_format=normalized_format, + output=Path(normalized_output), + allow_private=_parse_boolean(allow_private), + ) + + @classmethod + def from_environment(cls, environ: Mapping[str, str]) -> ActionInputs: + """Read the conventional ``INPUT_*`` variables exposed by GitHub.""" + + return cls.from_values( + target=environ.get("INPUT_TARGET", ""), + baseline=environ.get("INPUT_BASELINE", ""), + preview=environ.get("INPUT_PREVIEW", ""), + config=environ.get("INPUT_CONFIG", ""), + paths=environ.get("INPUT_PATHS", ""), + fail_on=environ.get("INPUT_FAIL_ON", environ.get("INPUT_FAIL-ON", "high")), + report_format=environ.get("INPUT_FORMAT", "json"), + output=environ.get("INPUT_OUTPUT", ""), + allow_private=environ.get( + "INPUT_ALLOW_PRIVATE", environ.get("INPUT_ALLOW-PRIVATE", "false") + ), + ) + + +@dataclass(frozen=True) +class OutputPlan: + """Locations for the three reports generated by one CLI invocation.""" + + json: Path + markdown: Path + sarif: Path + exposed: Path + + +@dataclass(frozen=True) +class ReportMetadata: + """Small report subset exposed as GitHub Action outputs.""" + + score: str + grade: str + passed: bool + + +def _optional(value: str) -> str | None: + normalized = value.strip() + return normalized or None + + +def _split_paths(value: str) -> tuple[str, ...]: + """Accept convenient comma-separated or multiline route inputs.""" + + return tuple(part.strip() for part in re.split(r"[,\r\n]+", value) if part.strip()) + + +def _parse_boolean(value: str) -> bool: + normalized = value.strip().lower() + if normalized in {"1", "true", "yes", "on"}: + return True + if normalized in {"0", "false", "no", "off", ""}: + return False + raise ActionInputError(f"allow-private must be true or false, not '{value}'.") + + +def build_output_plan(report_format: str, output: Path) -> OutputPlan: + """Map the selected public report to collision-free sidecar paths.""" + + known_suffixes = {".json", ".md", ".markdown", ".sarif"} + base = output.with_suffix("") if output.suffix.lower() in known_suffixes else output + defaults = { + "json": Path(f"{base}.json"), + "markdown": Path(f"{base}.md"), + "sarif": Path(f"{base}.sarif"), + } + candidates = {report_format: output} + used = {output} + for name in ("json", "markdown", "sarif"): + if name == report_format: + continue + candidate = defaults[name] + if candidate in used: + extension = {"json": ".json", "markdown": ".md", "sarif": ".sarif"}[name] + candidate = Path(f"{output}.previewshield{extension}") + candidates[name] = candidate + used.add(candidate) + + return OutputPlan( + json=candidates["json"], + markdown=candidates["markdown"], + sarif=candidates["sarif"], + exposed=output, + ) + + +def build_cli_argv(inputs: ActionInputs, outputs: OutputPlan) -> list[str]: + """Translate action inputs into the public CLI contract without a shell.""" + + if inputs.target is not None: + argv = ["scan", inputs.target] + else: + if inputs.baseline is None or inputs.preview is None: + raise ActionInputError("Diff mode requires both baseline and preview.") + argv = ["diff", "--baseline", inputs.baseline, "--preview", inputs.preview] + + if inputs.config is not None: + argv.extend(("--config", inputs.config)) + for route in inputs.paths: + argv.extend(("--path", route)) + argv.extend( + ( + "--fail-on", + inputs.fail_on, + "--format", + "json", + "--output", + str(outputs.json), + "--also-format", + f"markdown={outputs.markdown}", + "--also-format", + f"sarif={outputs.sarif}", + ) + ) + if inputs.allow_private: + argv.append("--allow-private") + return argv + + +def invoke_cli(cli_main: CliMain, argv: Sequence[str]) -> int: + """Invoke a ``main(argv)`` function and normalize its exit convention.""" + + try: + result = cli_main(list(argv)) + except SystemExit as error: + if error.code is None: + return 0 + if isinstance(error.code, int): + return error.code + return ACTION_USAGE_ERROR + if result is None: + return 0 + if not isinstance(result, int): + raise TypeError("previewshield.cli.main(argv) must return int or None.") + return result + + +def load_report(path: Path) -> Mapping[str, object] | None: + """Load a generated JSON report, returning ``None`` when it is unavailable.""" + + try: + if path.stat().st_size > MAX_JSON_REPORT_BYTES: + return None + with path.open(encoding="utf-8") as report_file: + raw: object = json.load(report_file) + except (OSError, json.JSONDecodeError): + return None + if not isinstance(raw, dict): + return None + return cast(dict[str, object], raw) + + +def extract_report_metadata( + payload: Mapping[str, object] | None, *, exit_code: int +) -> ReportMetadata: + """Extract scan or diff metadata while retaining sensible failure defaults.""" + + if payload is None: + return ReportMetadata(score="", grade="", passed=exit_code == 0) + + preview = payload.get("preview") + score_source = cast(Mapping[str, object], preview) if isinstance(preview, Mapping) else payload + score = _scalar_output(score_source.get("score")) + grade = _scalar_output(score_source.get("grade")) + raw_passed = payload.get("passed") + passed = raw_passed if isinstance(raw_passed, bool) else exit_code == 0 + return ReportMetadata(score=score, grade=grade, passed=passed) + + +def _scalar_output(value: object) -> str: + if isinstance(value, bool) or value is None: + return "" + if isinstance(value, (int, float, str)): + return str(value) + return "" + + +def write_github_outputs(path: Path, values: Mapping[str, str]) -> None: + """Append injection-safe multiline values to ``GITHUB_OUTPUT``.""" + + with path.open("a", encoding="utf-8", newline="\n") as output_file: + for name, value in values.items(): + if _OUTPUT_NAME.fullmatch(name) is None: + raise ValueError(f"Invalid GitHub output name: {name!r}.") + delimiter = f"PREVIEWSHIELD_{secrets.token_hex(16)}" + while delimiter in value: + delimiter = f"PREVIEWSHIELD_{secrets.token_hex(16)}" + output_file.write(f"{name}<<{delimiter}\n{value}\n{delimiter}\n") + + +def build_fallback_summary(metadata: ReportMetadata, report: Path) -> str: + """Build a concise summary when the Markdown reporter could not run.""" + + status = "Passed" if metadata.passed else "Failed" + score = _markdown_text(metadata.score or "Unavailable") + grade = _markdown_text(metadata.grade or "Unavailable") + report_path = _markdown_text(str(report)) + return ( + "# PreviewShield\n\n" + f"- Status: **{status}**\n" + f"- Score: **{score}**\n" + f"- Grade: **{grade}**\n" + f"- Report: `{report_path}`\n" + ) + + +def _markdown_text(value: str) -> str: + return value.replace("\\", "\\\\").replace("`", "\\`").replace("\r", " ").replace("\n", " ") + + +def read_markdown_summary(path: Path) -> str | None: + """Read a bounded Markdown report suitable for ``GITHUB_STEP_SUMMARY``.""" + + try: + with path.open(encoding="utf-8") as summary_file: + content = summary_file.read(MAX_SUMMARY_CHARACTERS + 1) + except (OSError, UnicodeError): + return None + if len(content) > MAX_SUMMARY_CHARACTERS: + content = content[:MAX_SUMMARY_CHARACTERS] + content += "\n\n_Report truncated by the PreviewShield Action._\n" + return content + + +def append_step_summary(path: Path, markdown: str) -> None: + """Append Markdown to the current job summary file.""" + + with path.open("a", encoding="utf-8", newline="\n") as summary_file: + summary_file.write(markdown) + if not markdown.endswith("\n"): + summary_file.write("\n") + + +def run_action( + inputs: ActionInputs, + *, + cli_main: CliMain | None = None, + environ: Mapping[str, str] | None = None, +) -> int: + """Run PreviewShield, publish action metadata, and preserve the CLI exit code.""" + + environment = os.environ if environ is None else environ + outputs = build_output_plan(inputs.report_format, inputs.output) + argv = build_cli_argv(inputs, outputs) + exit_code = invoke_cli(previewshield_cli.main if cli_main is None else cli_main, argv) + + # Exit codes 2+ cannot have a valid fresh scan report. Ignoring any file at + # that path prevents stale workspace artifacts from publishing false success. + payload = load_report(outputs.json) if exit_code in {0, 1} else None + metadata = extract_report_metadata(payload, exit_code=exit_code) + github_output = environment.get("GITHUB_OUTPUT") + if github_output: + try: + write_github_outputs( + Path(github_output), + { + "report": str(outputs.exposed), + "score": metadata.score, + "grade": metadata.grade, + "passed": str(metadata.passed).lower(), + }, + ) + except OSError as error: + print(f"PreviewShield could not write GITHUB_OUTPUT: {error}", file=sys.stderr) + + github_summary = environment.get("GITHUB_STEP_SUMMARY") + if github_summary: + markdown = read_markdown_summary(outputs.markdown) + if markdown is None: + markdown = build_fallback_summary(metadata, outputs.exposed) + try: + append_step_summary(Path(github_summary), markdown) + except OSError as error: + print(f"PreviewShield could not write GITHUB_STEP_SUMMARY: {error}", file=sys.stderr) + + return exit_code + + +def build_parser() -> argparse.ArgumentParser: + """Build the adapter-only parser used by the Docker entrypoint.""" + + parser = argparse.ArgumentParser(prog="previewshield-action") + parser.add_argument("--target", default="") + parser.add_argument("--baseline", default="") + parser.add_argument("--preview", default="") + parser.add_argument("--config", default="") + parser.add_argument("--paths", default="") + parser.add_argument("--fail-on", default="high") + parser.add_argument("--format", default="json", dest="report_format") + parser.add_argument("--output", default="") + parser.add_argument("--allow-private", default="false") + return parser + + +def parse_action_args(argv: Sequence[str]) -> ActionInputs: + """Parse fixed action metadata arguments into validated inputs.""" + + namespace = build_parser().parse_args(argv) + return ActionInputs.from_values( + target=cast(str, namespace.target), + baseline=cast(str, namespace.baseline), + preview=cast(str, namespace.preview), + config=cast(str, namespace.config), + paths=cast(str, namespace.paths), + fail_on=cast(str, namespace.fail_on), + report_format=cast(str, namespace.report_format), + output=cast(str, namespace.output), + allow_private=cast(str, namespace.allow_private), + ) + + +def main(argv: Sequence[str] | None = None) -> int: + """Run the Docker action from fixed arguments, with ``INPUT_*`` as fallback.""" + + raw_arguments = list(sys.argv[1:] if argv is None else argv) + try: + inputs = ( + parse_action_args(raw_arguments) + if raw_arguments + else ActionInputs.from_environment(os.environ) + ) + except ActionInputError as error: + print(f"PreviewShield action input error: {error}", file=sys.stderr) + return ACTION_USAGE_ERROR + return run_action(inputs) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/previewshield/api.py b/src/previewshield/api.py new file mode 100644 index 0000000..aad0493 --- /dev/null +++ b/src/previewshield/api.py @@ -0,0 +1,57 @@ +"""Small stable Python API for embedding PreviewShield in other tools.""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence + +from previewshield.diffing import diff_targets +from previewshield.models import DiffReport, ScanReport, Severity +from previewshield.policy import Policy +from previewshield.scanner import scan as scan_target + + +def scan( # noqa: PLR0913 - explicit keyword options keep the public API discoverable + target: str, + *, + policy: Policy | None = None, + paths: Sequence[str] | None = None, + request_headers: Mapping[str, str] | None = None, + fail_on: Severity | None = None, + allow_private: bool | None = None, +) -> ScanReport: + """Scan a target using the same engine as the command-line interface.""" + + return scan_target( + target, + policy=policy, + paths=paths, + request_headers=request_headers, + fail_on=fail_on, + allow_private=allow_private, + ) + + +def diff( # noqa: PLR0913 - mirrors scan options across both comparison targets + baseline: str, + preview: str, + *, + policy: Policy | None = None, + paths: Sequence[str] | None = None, + request_headers: Mapping[str, str] | None = None, + fail_on: Severity | None = None, + allow_private: bool | None = None, +) -> DiffReport: + """Compare a preview deployment with a production baseline.""" + + return diff_targets( + baseline, + preview, + policy=policy, + paths=paths, + request_headers=request_headers, + fail_on=fail_on, + allow_private=allow_private, + ) + + +__all__ = ["diff", "scan"] diff --git a/src/previewshield/checks.py b/src/previewshield/checks.py new file mode 100644 index 0000000..8a429dd --- /dev/null +++ b/src/previewshield/checks.py @@ -0,0 +1,974 @@ +"""Deterministic, policy-aware security checks for HTTP response snapshots.""" + +from __future__ import annotations + +import ipaddress +import re +from collections.abc import Iterable +from dataclasses import dataclass +from urllib.parse import urlsplit + +from previewshield.models import Finding, ResponseSnapshot, Severity +from previewshield.policy import Policy, RequiredHeaderPolicy +from previewshield.utils import clean_text + +OWASP_HEADERS = "https://owasp.org/www-project-secure-headers/" +MDN_HEADERS = "https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers" +MDN_COOKIES = "https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Cookies" +MDN_CSP = "https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP" +MDN_CORS = "https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS" +OWASP_TLS = ( + "https://cheatsheetseries.owasp.org/cheatsheets/Transport_Layer_Security_Cheat_Sheet.html" +) + + +@dataclass(frozen=True) +class Rule: + """Stable metadata for one built-in check.""" + + rule_id: str + title: str + severity: Severity + category: str + remediation: str + references: tuple[str, ...] + + +def _rule( + rule_id: str, + title: str, + severity: Severity, + category: str, + remediation: str, + *references: str, +) -> Rule: + return Rule(rule_id, title, severity, category, remediation, tuple(references)) + + +RULES: dict[str, Rule] = { + item.rule_id: item + for item in ( + _rule( + "PS0001", + "Insecure HTTP transport", + Severity.HIGH, + "transport", + "Serve the target exclusively over HTTPS and redirect HTTP at the edge.", + OWASP_TLS, + ), + _rule( + "PS0002", + "HTTPS redirect downgrade", + Severity.CRITICAL, + "transport", + "Remove redirects from HTTPS to HTTP and keep the complete chain encrypted.", + OWASP_TLS, + ), + _rule( + "PS1001", + "Strict-Transport-Security is missing", + Severity.HIGH, + "headers", + "Send Strict-Transport-Security on HTTPS responses with an appropriate max-age.", + OWASP_HEADERS, + ), + _rule( + "PS1002", + "Strict-Transport-Security is weak or invalid", + Severity.MEDIUM, + "headers", + "Use a valid HSTS max-age that meets the project policy.", + OWASP_HEADERS, + ), + _rule( + "PS1101", + "Enforced Content-Security-Policy is missing", + Severity.HIGH, + "content-security", + "Deploy an enforcing Content-Security-Policy; report-only mode is not enforcement.", + MDN_CSP, + ), + _rule( + "PS1102", + "Content-Security-Policy permits unsafe-eval", + Severity.HIGH, + "content-security", + "Remove 'unsafe-eval' and migrate evaluated code to static scripts.", + MDN_CSP, + ), + _rule( + "PS1103", + "Content-Security-Policy permits unsafe inline code", + Severity.MEDIUM, + "content-security", + "Replace 'unsafe-inline' with nonces or hashes and a strict CSP.", + MDN_CSP, + ), + _rule( + "PS1104", + "Content-Security-Policy contains a broad wildcard", + Severity.MEDIUM, + "content-security", + "Replace wildcard sources with the smallest explicit source allowlist.", + MDN_CSP, + ), + _rule( + "PS1105", + "Content-Security-Policy lacks a safe default-src", + Severity.MEDIUM, + "content-security", + "Add a restrictive default-src directive, commonly default-src 'self'.", + MDN_CSP, + ), + _rule( + "PS1106", + "Content-Security-Policy does not block object embedding", + Severity.MEDIUM, + "content-security", + "Add object-src 'none' to the enforced Content-Security-Policy.", + MDN_CSP, + ), + _rule( + "PS1107", + "Content-Security-Policy lacks base-uri protection", + Severity.LOW, + "content-security", + "Add base-uri 'none' or a narrowly scoped base-uri directive.", + MDN_CSP, + ), + _rule( + "PS1201", + "Clickjacking protection is missing", + Severity.HIGH, + "headers", + "Set CSP frame-ancestors and optionally X-Frame-Options for older clients.", + OWASP_HEADERS, + ), + _rule( + "PS1202", + "X-Content-Type-Options is missing or invalid", + Severity.MEDIUM, + "headers", + "Send X-Content-Type-Options: nosniff.", + OWASP_HEADERS, + ), + _rule( + "PS1203", + "Referrer-Policy is missing or unsafe", + Severity.LOW, + "privacy", + "Set a restrictive Referrer-Policy such as strict-origin-when-cross-origin.", + OWASP_HEADERS, + ), + _rule( + "PS1204", + "Permissions-Policy is missing", + Severity.LOW, + "privacy", + "Explicitly disable browser capabilities the application does not need.", + OWASP_HEADERS, + ), + _rule( + "PS1205", + "Deprecated X-XSS-Protection is enabled", + Severity.INFO, + "headers", + ( + "Prefer Content-Security-Policy and send X-XSS-Protection: 0 " + "if the header is retained." + ), + OWASP_HEADERS, + ), + _rule( + "PS1301", + "Credentialed wildcard CORS policy is invalid", + Severity.MEDIUM, + "cors", + "Do not combine a wildcard allowed origin with credentials; allow explicit origins.", + MDN_CORS, + ), + _rule( + "PS1302", + "CORS trusts a broad or opaque origin", + Severity.HIGH, + "cors", + "Allow only explicitly trusted origins and avoid the null origin.", + MDN_CORS, + ), + _rule( + "PS1303", + "Specific CORS response may need Vary: Origin", + Severity.INFO, + "cors", + "If the allowed origin is selected dynamically, add Origin to the Vary header.", + MDN_CORS, + ), + _rule( + "PS1401", + "Cookie lacks the Secure attribute", + Severity.MEDIUM, + "cookies", + "Mark security-sensitive cookies Secure so browsers send them only over HTTPS.", + MDN_COOKIES, + ), + _rule( + "PS1402", + "Cookie lacks the HttpOnly attribute", + Severity.LOW, + "cookies", + "Mark cookies that do not require JavaScript access as HttpOnly.", + MDN_COOKIES, + ), + _rule( + "PS1403", + "Cookie lacks a valid SameSite attribute", + Severity.LOW, + "cookies", + "Set SameSite=Lax or SameSite=Strict unless cross-site use is required.", + MDN_COOKIES, + ), + _rule( + "PS1404", + "SameSite=None cookie is not Secure", + Severity.HIGH, + "cookies", + "Cookies using SameSite=None must also use Secure.", + MDN_COOKIES, + ), + _rule( + "PS1405", + "Cookie prefix contract is violated", + Severity.HIGH, + "cookies", + "Honor __Secure- and __Host- cookie prefix requirements.", + MDN_COOKIES, + ), + _rule( + "PS1501", + "Legacy TLS protocol is in use", + Severity.CRITICAL, + "tls", + "Require TLS 1.2 or newer and disable SSL and legacy TLS versions.", + OWASP_TLS, + ), + _rule( + "PS1502", + "TLS certificate is expired or near expiry", + Severity.HIGH, + "tls", + "Renew the certificate and automate renewal monitoring.", + OWASP_TLS, + ), + _rule( + "PS1503", + "Weak TLS cipher is in use", + Severity.HIGH, + "tls", + "Configure modern AEAD cipher suites and remove legacy ciphers.", + OWASP_TLS, + ), + _rule( + "PS1601", + "Technology details are exposed", + Severity.LOW, + "information-disclosure", + "Remove or minimize Server and X-Powered-By response headers.", + OWASP_HEADERS, + ), + _rule( + "PS1602", + "Server error response was scanned", + Severity.HIGH, + "availability", + "Fix the server error before treating this route as a valid security baseline.", + MDN_HEADERS, + ), + _rule( + "PS1603", + "Client error response was scanned", + Severity.MEDIUM, + "availability", + "Confirm the route and authentication setup so the intended response is assessed.", + MDN_HEADERS, + ), + ) +} + +_CSP_DIRECTIVE = re.compile(r"^([a-z][a-z0-9-]*)\s*(.*)$", re.IGNORECASE) +_DIRECTIVE_TOKEN = re.compile(r"^[!#$%&'*+.^_`|~0-9A-Za-z-]+$") +_CSP_NONCE_OR_HASH = re.compile( + r"^'(?:nonce-[a-z0-9+/_-]+={0,2}|sha(?:256|384|512)-[a-z0-9+/_-]+={0,2})'$", + re.IGNORECASE, +) +_CSP_SCHEME_WILDCARD = re.compile(r"^[a-z][a-z0-9+.-]*://\*", re.IGNORECASE) +_CSP_ANY_HOST_SOURCE = re.compile( + r"^[a-z][a-z0-9+.-]*://\*(?::(?:\d+|\*))?(?:/.*)?$", + re.IGNORECASE, +) +_CSP_ENFORCING_DIRECTIVES = frozenset( + { + "base-uri", + "child-src", + "connect-src", + "default-src", + "fenced-frame-src", + "font-src", + "form-action", + "frame-ancestors", + "frame-src", + "img-src", + "manifest-src", + "media-src", + "object-src", + "require-trusted-types-for", + "sandbox", + "script-src", + "script-src-attr", + "script-src-elem", + "style-src", + "style-src-attr", + "style-src-elem", + "trusted-types", + "upgrade-insecure-requests", + "worker-src", + } +) +_WEAK_CIPHER_MARKERS = ("3DES", "DES-CBC", "RC4", "NULL", "EXPORT", "MD5") +_HSTS_SATURATED_MAX_AGE = 1_000_000_000_000 +_MAX_HSTS_AGE_DIGITS = 12 +_MIN_QUOTED_LENGTH = 2 +_ASCII_CONTROL_BOUNDARY = 32 +_NO_REPRESENTATION_STATUSES = frozenset({204, 205, 304}) +_REFERRER_POLICIES = frozenset( + { + "no-referrer", + "no-referrer-when-downgrade", + "origin", + "origin-when-cross-origin", + "same-origin", + "strict-origin", + "strict-origin-when-cross-origin", + "unsafe-url", + } +) +CLIENT_ERROR_STATUS = 400 +SERVER_ERROR_STATUS = 500 + + +def evaluate(snapshot: ResponseSnapshot, policy: Policy) -> tuple[Finding, ...]: + """Evaluate every enabled rule against one captured response.""" + + findings: list[Finding] = [] + _check_transport(snapshot, policy, findings) + _check_hsts(snapshot, policy, findings) + is_document = _is_document_response(snapshot) + directives = _check_csp(snapshot, policy, findings) if is_document else {} + _check_browser_headers(snapshot, policy, directives, findings, is_document=is_document) + _check_cors(snapshot, policy, findings) + _check_cookies(snapshot, policy, findings) + _check_tls(snapshot, policy, findings) + _check_response_health(snapshot, policy, findings) + _check_required_headers(snapshot, policy, findings) + return tuple(findings) + + +def _emit( # noqa: PLR0913 - centralizes policy application for all observations + output: list[Finding], + policy: Policy, + snapshot: ResponseSnapshot, + rule_id: str, + message: str, + *, + subject: str = "response", + evidence: str | None = None, + severity: Severity | None = None, +) -> None: + if not policy.rule_enabled(rule_id): + return + rule = RULES[rule_id] + effective = policy.severity_for(rule_id, severity or rule.severity) + output.append( + Finding( + rule_id=rule.rule_id, + title=rule.title, + severity=effective, + category=rule.category, + message=message, + remediation=rule.remediation, + target=snapshot.requested_url, + subject=subject, + evidence=clean_text(evidence, limit=300) if evidence else None, + references=rule.references, + ) + ) + + +def _check_transport(snapshot: ResponseSnapshot, policy: Policy, output: list[Finding]) -> None: + requested_scheme = urlsplit(snapshot.requested_url).scheme.lower() + final_scheme = urlsplit(snapshot.final_url).scheme.lower() + if requested_scheme != "https" or final_scheme != "https": + _emit( + output, + policy, + snapshot, + "PS0001", + "The requested route is not protected by end-to-end HTTPS.", + evidence=( + f"requested={requested_scheme or 'unknown'}, final={final_scheme or 'unknown'}" + ), + ) + for index, hop in enumerate(snapshot.redirects, start=1): + if ( + urlsplit(hop.url).scheme.lower() == "https" + and urlsplit(hop.location).scheme.lower() == "http" + ): + _emit( + output, + policy, + snapshot, + "PS0002", + "The redirect chain moves from HTTPS to plaintext HTTP.", + subject=f"redirect:{index}", + evidence=f"hop {index}: HTTPS to HTTP", + ) + + +def _check_hsts(snapshot: ResponseSnapshot, policy: Policy, output: list[Finding]) -> None: + parsed_url = urlsplit(snapshot.final_url) + if parsed_url.scheme.lower() != "https": + return + try: + ipaddress.ip_address(parsed_url.hostname or "") + except ValueError: + pass + else: + return + values = snapshot.header_values("strict-transport-security") + if not values: + _emit(output, policy, snapshot, "PS1001", "The HTTPS response has no HSTS policy.") + return + max_age = _parse_hsts_max_age(values[0]) + if max_age is None: + _emit( + output, + policy, + snapshot, + "PS1002", + "The HSTS header has no valid max-age directive.", + evidence="max-age is missing or invalid", + severity=Severity.HIGH, + ) + return + if max_age < policy.min_hsts_max_age: + _emit( + output, + policy, + snapshot, + "PS1002", + "The HSTS lifetime is shorter than the configured minimum.", + evidence=f"max-age={max_age}; required>={policy.min_hsts_max_age}", + severity=Severity.HIGH if max_age == 0 else None, + ) + + +def _check_csp( + snapshot: ResponseSnapshot, policy: Policy, output: list[Finding] +) -> dict[str, tuple[str, ...]]: + values = snapshot.header_values("content-security-policy") + if not values: + report_only = bool(snapshot.header_values("content-security-policy-report-only")) + _emit( + output, + policy, + snapshot, + "PS1101", + "Only a report-only CSP is present." if report_only else "No enforced CSP is present.", + evidence="report-only policy detected" if report_only else None, + ) + return {} + + directives = _parse_csp(values) + if not _CSP_ENFORCING_DIRECTIVES.intersection(directives): + _emit( + output, + policy, + snapshot, + "PS1101", + "The CSP header contains no recognized enforcing directive.", + evidence="empty or ineffective CSP header", + ) + return {} + all_sources = tuple(source for sources in directives.values() for source in sources) + if "'unsafe-eval'" in all_sources: + _emit( + output, + policy, + snapshot, + "PS1102", + "An enforced CSP source list contains 'unsafe-eval'.", + evidence="unsafe-eval", + ) + if any( + "'unsafe-inline'" in sources and not _has_nonce_or_hash(sources) + for sources in directives.values() + ): + _emit( + output, + policy, + snapshot, + "PS1103", + "Inline script or style execution is broadly allowed without a nonce or hash.", + evidence="unsafe-inline without nonce/hash", + ) + if any(_is_wildcard_source(source) for source in all_sources): + _emit( + output, + policy, + snapshot, + "PS1104", + "An enforced CSP directive trusts a wildcard source.", + evidence="wildcard source", + ) + if "default-src" not in directives: + _emit( + output, + policy, + snapshot, + "PS1105", + "The enforced CSP does not define a restrictive default-src fallback.", + ) + object_sources = tuple(token.lower() for token in directives.get("object-src", ())) + if "object-src" not in directives or object_sources not in {(), ("'none'",)}: + _emit( + output, + policy, + snapshot, + "PS1106", + "The enforced CSP does not set object-src 'none'.", + ) + if "base-uri" not in directives: + _emit( + output, + policy, + snapshot, + "PS1107", + "The enforced CSP does not constrain document base URLs.", + ) + return directives + + +def _parse_csp(values: Iterable[str]) -> dict[str, tuple[str, ...]]: + parsed: dict[str, list[str]] = {} + for value in values: + for raw_directive in value.split(";"): + match = _CSP_DIRECTIVE.match(raw_directive.strip()) + if match is None: + continue + name = match.group(1).lower() + if name in parsed: + continue + sources = match.group(2).split() + parsed[name] = [source.lower() for source in sources] + return {name: tuple(sources) for name, sources in parsed.items()} + + +def _has_nonce_or_hash(sources: Iterable[str]) -> bool: + return any(_CSP_NONCE_OR_HASH.fullmatch(source) is not None for source in sources) + + +def _is_wildcard_source(source: str) -> bool: + return ( + source == "*" or source.startswith("*.") or _CSP_SCHEME_WILDCARD.match(source) is not None + ) + + +def _parse_hsts_max_age(value: str) -> int | None: + seen: set[str] = set() + max_age: int | None = None + for raw_directive in value.split(";"): + directive = raw_directive.strip() + if not directive: + continue + raw_name, separator, raw_value = directive.partition("=") + name = raw_name.strip().lower() + if not _DIRECTIVE_TOKEN.fullmatch(name) or name in seen: + return None + seen.add(name) + directive_value = raw_value.strip() + if name == "max-age": + if not separator: + return None + if directive_value.startswith('"') and directive_value.endswith('"'): + directive_value = directive_value[1:-1] + if not directive_value.isascii() or not directive_value.isdigit(): + return None + significant_digits = directive_value.lstrip("0") or "0" + max_age = ( + _HSTS_SATURATED_MAX_AGE + if len(significant_digits) > _MAX_HSTS_AGE_DIGITS + else int(significant_digits) + ) + elif name == "includesubdomains": + if separator: + return None + elif separator and not _valid_directive_value(directive_value): + return None + return max_age + + +def _valid_directive_value(value: str) -> bool: + if _DIRECTIVE_TOKEN.fullmatch(value): + return True + if len(value) < _MIN_QUOTED_LENGTH or not value.startswith('"') or not value.endswith('"'): + return False + escaped = False + for character in value[1:-1]: + if escaped: + if ord(character) < _ASCII_CONTROL_BOUNDARY and character != "\t": + return False + escaped = False + elif character == "\\": + escaped = True + elif character == '"' or (ord(character) < _ASCII_CONTROL_BOUNDARY and character != "\t"): + return False + return not escaped + + +def _check_browser_headers( + snapshot: ResponseSnapshot, + policy: Policy, + csp: dict[str, tuple[str, ...]], + output: list[Finding], + *, + is_document: bool, +) -> None: + xfo = (snapshot.header("x-frame-options") or "").strip().upper() + frame_ancestors = csp.get("frame-ancestors") + broad_frame_sources = {"*", "http:", "https:", "data:"} + csp_blocks_broad_framing = bool(frame_ancestors) and not any( + source in broad_frame_sources or _CSP_ANY_HOST_SOURCE.fullmatch(source) is not None + for source in frame_ancestors or () + ) + if is_document and not csp_blocks_broad_framing and xfo not in {"DENY", "SAMEORIGIN"}: + _emit( + output, + policy, + snapshot, + "PS1201", + "Neither CSP frame-ancestors nor a valid X-Frame-Options value prevents framing.", + ) + + nosniff = (snapshot.header("x-content-type-options") or "").strip().lower() + if snapshot.status_code not in _NO_REPRESENTATION_STATUSES and nosniff != "nosniff": + _emit( + output, + policy, + snapshot, + "PS1202", + "The response does not opt out of MIME type sniffing.", + evidence=f"value={nosniff}" if nosniff else "header missing", + ) + + if is_document: + referrer = _effective_referrer_policy(snapshot.header_values("referrer-policy")) + unsafe_referrers = {"unsafe-url", "no-referrer-when-downgrade"} + if referrer is None or referrer in unsafe_referrers: + _emit( + output, + policy, + snapshot, + "PS1203", + "The response does not define a sufficiently restrictive referrer policy.", + evidence="header missing or invalid" if referrer is None else f"value={referrer}", + severity=Severity.MEDIUM if referrer in unsafe_referrers else None, + ) + + if snapshot.header("permissions-policy") is None: + _emit( + output, + policy, + snapshot, + "PS1204", + "Browser capabilities are not constrained with Permissions-Policy.", + ) + + xss = (snapshot.header("x-xss-protection") or "").strip() + if xss and xss != "0": + _emit( + output, + policy, + snapshot, + "PS1205", + "A deprecated browser XSS filter is enabled and can create unexpected behavior.", + evidence=f"value={xss}", + ) + + +def _is_document_response(snapshot: ResponseSnapshot) -> bool: + if snapshot.status_code in _NO_REPRESENTATION_STATUSES: + return False + content_type = (snapshot.header("content-type") or "").split(";", 1)[0].strip().lower() + return not content_type or content_type in { + "text/html", + "application/xhtml+xml", + "image/svg+xml", + } + + +def _effective_referrer_policy(values: Iterable[str]) -> str | None: + effective: str | None = None + for value in values: + for token in value.split(","): + normalized = token.strip().lower() + if normalized in _REFERRER_POLICIES: + effective = normalized + return effective + + +def _check_cors(snapshot: ResponseSnapshot, policy: Policy, output: list[Finding]) -> None: + origin = (snapshot.header("access-control-allow-origin") or "").strip() + if not origin: + return + credentials = (snapshot.header("access-control-allow-credentials") or "").strip().lower() + if origin == "*" and credentials == "true": + _emit( + output, + policy, + snapshot, + "PS1301", + "The response advertises credentials while allowing every origin.", + evidence="allow-origin=*; allow-credentials=true", + ) + elif origin in {"*", "null"}: + _emit( + output, + policy, + snapshot, + "PS1302", + "The CORS policy trusts an unrestricted or opaque origin.", + evidence=f"allow-origin={origin}", + severity=Severity.MEDIUM if origin == "*" else None, + ) + elif not _vary_contains_origin(snapshot.header_values("vary")): + _emit( + output, + policy, + snapshot, + "PS1303", + "A specific allowed origin is returned without a Vary: Origin cache key; " + "this matters only when the value is selected dynamically.", + evidence="Vary does not include Origin", + ) + + +def _vary_contains_origin(values: Iterable[str]) -> bool: + return any( + token.strip().lower() in {"origin", "*"} for value in values for token in value.split(",") + ) + + +def _check_cookies(snapshot: ResponseSnapshot, policy: Policy, output: list[Finding]) -> None: + for index, raw_cookie in enumerate(snapshot.header_values("set-cookie"), start=1): + parsed = _cookie_attributes(raw_cookie) + if parsed is None: + continue + name, attributes = parsed + subject = f"cookie:{name or index}" + if "secure" not in attributes: + _emit( + output, + policy, + snapshot, + "PS1401", + "A response cookie can be sent over an unencrypted connection.", + subject=subject, + evidence="Secure attribute missing", + ) + if "httponly" not in attributes: + _emit( + output, + policy, + snapshot, + "PS1402", + "A response cookie is accessible to client-side scripts.", + subject=subject, + evidence="HttpOnly attribute missing", + ) + same_site = attributes.get("samesite") + if same_site is None or same_site.lower() not in {"lax", "strict", "none"}: + _emit( + output, + policy, + snapshot, + "PS1403", + ( + "A response cookie relies on browser-default cross-site behavior." + if same_site is None + else "A response cookie uses an invalid SameSite value." + ), + subject=subject, + evidence=( + "SameSite attribute missing" + if same_site is None + else "SameSite attribute invalid" + ), + ) + elif same_site.lower() == "none" and "secure" not in attributes: + _emit( + output, + policy, + snapshot, + "PS1404", + "A cross-site cookie does not meet the browser Secure requirement.", + subject=subject, + evidence="SameSite=None without Secure", + ) + if _cookie_prefix_invalid(name, attributes): + _emit( + output, + policy, + snapshot, + "PS1405", + "The cookie attributes do not satisfy the guarantees promised by its prefix.", + subject=subject, + evidence="cookie prefix requirements not met", + ) + + +def _cookie_attributes(raw_cookie: str) -> tuple[str, dict[str, str]] | None: + parts = [part.strip() for part in raw_cookie.split(";")] + if not parts or "=" not in parts[0]: + return None + name = clean_text(parts[0].split("=", 1)[0].strip(), limit=100) + attributes: dict[str, str] = {} + for part in parts[1:]: + attribute, separator, value = part.partition("=") + normalized = attribute.strip().lower() + if normalized: + attributes[normalized] = value.strip() if separator else "" + return name, attributes + + +def _cookie_prefix_invalid(name: str, attributes: dict[str, str]) -> bool: + if name.startswith("__Secure-"): + return "secure" not in attributes + if name.startswith("__Host-"): + return "secure" not in attributes or attributes.get("path") != "/" or "domain" in attributes + return False + + +def _check_tls(snapshot: ResponseSnapshot, policy: Policy, output: list[Finding]) -> None: + tls = snapshot.tls + if tls is None: + return + version = (tls.version or "").upper().replace(" ", "") + if version and version not in {"TLSV1.2", "TLSV1.3"}: + _emit( + output, + policy, + snapshot, + "PS1501", + "The negotiated transport protocol is obsolete.", + subject="tls", + evidence=f"version={tls.version}", + ) + if tls.certificate_days_remaining is not None: + days = tls.certificate_days_remaining + if days <= policy.certificate_warning_days: + _emit( + output, + policy, + snapshot, + "PS1502", + "The TLS certificate is expired or approaching its renewal window.", + subject="certificate", + evidence=f"days remaining={days}", + severity=Severity.CRITICAL if days < 0 else None, + ) + cipher = (tls.cipher or "").upper() + if cipher and any(marker in cipher for marker in _WEAK_CIPHER_MARKERS): + _emit( + output, + policy, + snapshot, + "PS1503", + "The negotiated cipher contains a known legacy primitive.", + subject="tls", + evidence=f"cipher={tls.cipher}", + ) + + +def _check_response_health( + snapshot: ResponseSnapshot, policy: Policy, output: list[Finding] +) -> None: + disclosed = [name for name in ("server", "x-powered-by") if snapshot.header(name) is not None] + if disclosed: + _emit( + output, + policy, + snapshot, + "PS1601", + "The response exposes implementation or server details.", + subject="headers", + evidence=f"present: {', '.join(disclosed)}", + ) + if snapshot.status_code >= SERVER_ERROR_STATUS: + _emit( + output, + policy, + snapshot, + "PS1602", + "The route returned a server error instead of a stable application response.", + subject="status", + evidence=f"status={snapshot.status_code}", + ) + elif snapshot.status_code >= CLIENT_ERROR_STATUS: + _emit( + output, + policy, + snapshot, + "PS1603", + "The route returned a client error, so the intended page may not have been assessed.", + subject="status", + evidence=f"status={snapshot.status_code}", + ) + + +def _check_required_headers( + snapshot: ResponseSnapshot, policy: Policy, output: list[Finding] +) -> None: + for requirement in policy.required_headers: + if not policy.rule_enabled(requirement.rule_id): + continue + value = snapshot.header(requirement.name) + message = _required_header_failure(requirement, value) + if message is None: + continue + severity = policy.severity_for(requirement.rule_id, requirement.severity) + output.append( + Finding( + rule_id=requirement.rule_id, + title=f"Required header {requirement.name} is missing or invalid", + severity=severity, + category="custom-policy", + message=message, + remediation=requirement.remediation, + target=snapshot.requested_url, + subject=f"header:{requirement.name.lower()}", + evidence="header missing" if value is None else "value does not satisfy policy", + references=(), + ) + ) + + +def _required_header_failure(requirement: RequiredHeaderPolicy, value: str | None) -> str | None: + if value is None: + return f"The project requires the {requirement.name} response header." + if requirement.exact is not None and value != requirement.exact: + return f"The {requirement.name} response header does not equal the required value." + if requirement.contains is not None and requirement.contains not in value: + return f"The {requirement.name} response header lacks the required fragment." + return None + + +__all__ = ["RULES", "Rule", "evaluate"] diff --git a/src/previewshield/cli.py b/src/previewshield/cli.py new file mode 100644 index 0000000..93a184f --- /dev/null +++ b/src/previewshield/cli.py @@ -0,0 +1,383 @@ +"""Command-line interface for local scans and CI security regression gates.""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from collections.abc import Mapping, Sequence +from pathlib import Path +from typing import NoReturn, cast + +from previewshield._version import __version__ +from previewshield.checks import RULES +from previewshield.diffing import diff_targets +from previewshield.exceptions import ConfigurationError, PreviewShieldError, ReportError +from previewshield.models import DiffReport, ScanReport, Severity +from previewshield.policy import default_policy_yaml, load_policy +from previewshield.reporters import render, supported_formats +from previewshield.scanner import scan +from previewshield.utils import parse_threshold, validate_header_name + +_CONTROL_CHARACTER = re.compile(r"[\x00-\x1f\x7f]") +_MIN_UI_PORT = 1_024 +_MAX_UI_PORT = 65_535 +Report = ScanReport | DiffReport + + +class _Parser(argparse.ArgumentParser): + """Translate parser failures into the documented configuration exit code.""" + + def error(self, message: str) -> NoReturn: + raise ConfigurationError(message) + + +def build_parser() -> argparse.ArgumentParser: + """Build the complete PreviewShield command surface.""" + + parser = _Parser( + prog="previewshield", + description=( + "Policy-as-code web security checks and production-to-preview regression gates." + ), + ) + parser.add_argument("--version", action="store_true", help="Show the installed version.") + commands = parser.add_subparsers(dest="command", metavar="COMMAND") + + scan_parser = commands.add_parser("scan", help="Scan one deployment.") + scan_parser.add_argument("target", help="Origin or URL to scan (HTTPS is assumed).") + _add_scan_options(scan_parser) + + diff_parser = commands.add_parser("diff", help="Compare production with a preview.") + diff_parser.add_argument("--baseline", required=True, help="Production origin or URL.") + diff_parser.add_argument("--preview", required=True, help="Preview origin or URL.") + _add_scan_options(diff_parser) + + ui_parser = commands.add_parser("ui", help="Open the local browser interface.") + ui_parser.add_argument( + "--port", + type=_ui_port, + default=8765, + help="Loopback port (default: 8765).", + ) + ui_parser.add_argument("--no-open", action="store_true", help="Do not open a browser.") + ui_parser.add_argument( + "--allow-private-targets", + action="store_true", + help="Unlock private/loopback targets for this trusted local UI session.", + ) + + init_parser = commands.add_parser("init", help="Create a starter policy file.") + init_parser.add_argument( + "--output", + "-o", + default=".previewshield.yml", + help="Policy path (default: .previewshield.yml).", + ) + init_parser.add_argument("--force", action="store_true", help="Replace an existing file.") + + policy_parser = commands.add_parser("policy", help="Inspect policy configuration.") + policy_commands = policy_parser.add_subparsers(dest="policy_command", metavar="COMMAND") + validate_parser = policy_commands.add_parser("validate", help="Validate a policy file.") + validate_parser.add_argument("config", help="YAML policy path.") + validate_parser.add_argument( + "--profile", + choices=("balanced", "strict"), + default="balanced", + help="Fallback profile for omitted policy fields.", + ) + + rules_parser = commands.add_parser("rules", help="List built-in security checks.") + rules_parser.add_argument("--json", action="store_true", help="Emit machine-readable JSON.") + return parser + + +def _add_scan_options(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--config", "-c", help="PreviewShield YAML policy.") + parser.add_argument( + "--profile", + choices=("balanced", "strict"), + default="balanced", + help="Built-in profile or fallback for an incomplete policy.", + ) + parser.add_argument( + "--path", + dest="paths", + action="append", + metavar="ROUTE", + help="Origin-relative route; repeat to scan multiple routes.", + ) + parser.add_argument( + "--header", + dest="headers", + action="append", + metavar="NAME:VALUE", + help="Request header; repeat as needed. Values are never written to reports.", + ) + parser.add_argument( + "--fail-on", + choices=tuple(severity.value for severity in Severity), + help="Override the policy failure threshold.", + ) + parser.add_argument( + "--allow-private", + action="store_true", + default=None, + help="Allow private/loopback targets. Use only for trusted local environments.", + ) + parser.add_argument( + "--format", + "-f", + default="console", + choices=supported_formats(), + help="Primary report format.", + ) + parser.add_argument("--output", "-o", help="Write the primary report to this file.") + parser.add_argument( + "--also-format", + action="append", + default=[], + metavar="FORMAT=PATH", + help="Write another format, for example sarif=previewshield.sarif.", + ) + + +def main(argv: Sequence[str] | None = None) -> int: # noqa: PLR0911 + """Run PreviewShield and return a stable process exit code.""" + + try: + parser = build_parser() + args = parser.parse_args(argv) + if bool(getattr(args, "version", False)): + print(f"PreviewShield {__version__}") + return 0 + command = cast(str | None, getattr(args, "command", None)) + if command == "scan": + return _run_scan(args) + if command == "diff": + return _run_diff(args) + if command == "ui": + return _run_ui(args) + if command == "init": + return _run_init(args) + if command == "policy": + return _run_policy(args) + if command == "rules": + return _run_rules(args) + parser.print_help() + return 0 + except PreviewShieldError as error: + print(f"previewshield: {error}", file=sys.stderr) + return error.exit_code + except KeyboardInterrupt: + print("previewshield: interrupted", file=sys.stderr) + return 130 + except Exception: # noqa: BLE001 - CLI boundary intentionally hides internals and secrets + print( + "previewshield: an unexpected internal error occurred; please open an issue", + file=sys.stderr, + ) + return 4 + + +def _run_scan(args: argparse.Namespace) -> int: + policy = load_policy(_optional_str(args, "config"), profile=_required_str(args, "profile")) + report = scan( + _required_str(args, "target"), + policy=policy, + paths=_optional_string_list(args, "paths"), + request_headers=_parse_headers(_optional_string_list(args, "headers")), + fail_on=_threshold(args), + allow_private=_optional_bool(args, "allow_private"), + ) + _emit_reports(report, args) + return 0 if report.passed else 1 + + +def _run_diff(args: argparse.Namespace) -> int: + policy = load_policy(_optional_str(args, "config"), profile=_required_str(args, "profile")) + report = diff_targets( + _required_str(args, "baseline"), + _required_str(args, "preview"), + policy=policy, + paths=_optional_string_list(args, "paths"), + request_headers=_parse_headers(_optional_string_list(args, "headers")), + fail_on=_threshold(args), + allow_private=_optional_bool(args, "allow_private"), + ) + _emit_reports(report, args) + return 0 if report.passed else 1 + + +def _run_ui(args: argparse.Namespace) -> int: + from previewshield.webui import serve_ui # noqa: PLC0415 - optional interactive surface + + port = _required_int(args, "port") + try: + serve_ui( + port=port, + open_browser=not bool(getattr(args, "no_open", False)), + private_targets_enabled=bool(getattr(args, "allow_private_targets", False)), + ) + except OSError as error: + raise ConfigurationError( + f"Could not start the local UI on port {port}: {error}." + ) from error + return 0 + + +def _run_init(args: argparse.Namespace) -> int: + output = Path(_required_str(args, "output")) + if output.exists() and not bool(getattr(args, "force", False)): + raise ConfigurationError(f"{output} already exists; use --force to replace it.") + _write_text(output, default_policy_yaml()) + print(f"Created {output}") + return 0 + + +def _run_policy(args: argparse.Namespace) -> int: + command = cast(str | None, getattr(args, "policy_command", None)) + if command != "validate": + raise ConfigurationError("policy requires a command (try: policy validate FILE).") + policy = load_policy(_required_str(args, "config"), profile=_required_str(args, "profile")) + print(f"Policy '{policy.name}' is valid (profile={policy.profile}, version={policy.version}).") + return 0 + + +def _run_rules(args: argparse.Namespace) -> int: + rules = [RULES[rule_id] for rule_id in sorted(RULES)] + if bool(getattr(args, "json", False)): + payload = [ + { + "id": rule.rule_id, + "title": rule.title, + "severity": rule.severity.value, + "category": rule.category, + "remediation": rule.remediation, + "references": list(rule.references), + } + for rule in rules + ] + print(json.dumps(payload, indent=2, ensure_ascii=False)) + else: + for rule in rules: + print(f"{rule.rule_id:<7} {rule.severity.value:<8} {rule.title}") + return 0 + + +def _emit_reports(report: Report, args: argparse.Namespace) -> None: + primary_format = _required_str(args, "format") + primary_output = _optional_str(args, "output") + destinations: list[tuple[str, Path]] = [] + if primary_output is None: + print(render(report, primary_format)) + else: + destinations.append((primary_format, Path(primary_output))) + + raw_additional = _optional_string_list(args, "also_format") or () + for item in raw_additional: + format_name, separator, path_value = item.partition("=") + if not separator or not format_name or not path_value: + raise ConfigurationError("--also-format must use FORMAT=PATH.") + normalized = format_name.strip().lower() + if normalized not in supported_formats(): + choices = ", ".join(supported_formats()) + raise ConfigurationError( + f"Unsupported report format '{format_name}'. Expected one of: {choices}." + ) + destinations.append((normalized, Path(path_value.strip()))) + + seen: set[Path] = set() + for format_name, destination in destinations: + key = destination.resolve() + if key in seen: + raise ConfigurationError(f"Multiple reports target the same path: {destination}.") + seen.add(key) + _write_text(destination, render(report, format_name)) + + +def _write_text(path: Path, content: str) -> None: + try: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content.rstrip() + "\n", encoding="utf-8", newline="\n") + except (OSError, UnicodeError) as error: + raise ReportError(f"Cannot write report to {path}: {error}.") from error + + +def _parse_headers(values: Sequence[str] | None) -> Mapping[str, str] | None: + if values is None: + return None + headers: dict[str, str] = {} + lowered_names: set[str] = set() + for raw in values: + name, separator, value = raw.partition(":") + if not separator: + raise ConfigurationError("--header must use NAME:VALUE.") + normalized_name = validate_header_name(name.strip()) + if _CONTROL_CHARACTER.search(value): + raise ConfigurationError(f"Request header '{normalized_name}' contains control data.") + lowered = normalized_name.lower() + if lowered in lowered_names: + raise ConfigurationError(f"Duplicate request header: {normalized_name}.") + lowered_names.add(lowered) + headers[normalized_name] = value.strip() + return headers + + +def _threshold(args: argparse.Namespace) -> Severity | None: + value = _optional_str(args, "fail_on") + return parse_threshold(value) if value is not None else None + + +def _required_str(args: argparse.Namespace, name: str) -> str: + value = getattr(args, name, None) + if not isinstance(value, str): + raise ConfigurationError(f"Missing command option: {name}.") + return value + + +def _optional_str(args: argparse.Namespace, name: str) -> str | None: + value = getattr(args, name, None) + if value is None: + return None + if not isinstance(value, str): + raise ConfigurationError(f"Invalid command option: {name}.") + return value + + +def _optional_string_list(args: argparse.Namespace, name: str) -> tuple[str, ...] | None: + value = getattr(args, name, None) + if value is None: + return None + if not isinstance(value, list) or any(not isinstance(item, str) for item in value): + raise ConfigurationError(f"Invalid repeated command option: {name}.") + return tuple(value) + + +def _optional_bool(args: argparse.Namespace, name: str) -> bool | None: + value = getattr(args, name, None) + if value is None or isinstance(value, bool): + return value + raise ConfigurationError(f"Invalid command option: {name}.") + + +def _required_int(args: argparse.Namespace, name: str) -> int: + value = getattr(args, name, None) + if isinstance(value, bool) or not isinstance(value, int): + raise ConfigurationError(f"Missing command option: {name}.") + return value + + +def _ui_port(value: str) -> int: + try: + port = int(value) + except ValueError as error: + raise argparse.ArgumentTypeError("port must be an integer") from error + if not _MIN_UI_PORT <= port <= _MAX_UI_PORT: + raise argparse.ArgumentTypeError("port must be between 1024 and 65535") + return port + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main()) diff --git a/src/previewshield/diffing.py b/src/previewshield/diffing.py new file mode 100644 index 0000000..5013ac5 --- /dev/null +++ b/src/previewshield/diffing.py @@ -0,0 +1,139 @@ +"""Baseline-versus-preview security regression analysis.""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence + +from previewshield.models import ( + DeltaKind, + DiffReport, + Finding, + FindingDelta, + ScanReport, + Severity, +) +from previewshield.policy import Policy, default_policy +from previewshield.scanner import SCHEMA_VERSION, scan +from previewshield.utils import utc_now + + +def diff_targets( # noqa: PLR0913 - identical scan controls are intentional + baseline_url: str, + preview_url: str, + *, + policy: Policy | None = None, + paths: Sequence[str] | None = None, + request_headers: Mapping[str, str] | None = None, + fail_on: Severity | None = None, + allow_private: bool | None = None, +) -> DiffReport: + """Scan two deployments with identical inputs and compare their findings.""" + + active_policy = policy or default_policy() + threshold = fail_on or active_policy.fail_on + baseline = scan( + baseline_url, + policy=active_policy, + paths=paths, + request_headers=request_headers, + fail_on=threshold, + allow_private=allow_private, + ) + preview = scan( + preview_url, + policy=active_policy, + paths=paths, + request_headers=request_headers, + fail_on=threshold, + allow_private=allow_private, + ) + return compare(baseline, preview, policy=active_policy, fail_on=threshold) + + +def compare( + baseline: ScanReport, + preview: ScanReport, + *, + policy: Policy | None = None, + fail_on: Severity | None = None, +) -> DiffReport: + """Compare two completed scans by stable route-and-rule fingerprints.""" + + active_policy = policy or default_policy() + threshold = fail_on or active_policy.fail_on + baseline_findings = _by_fingerprint(baseline.findings) + preview_findings = _by_fingerprint(preview.findings) + deltas: list[FindingDelta] = [] + for fingerprint in sorted(set(baseline_findings) | set(preview_findings)): + before = baseline_findings.get(fingerprint) + after = preview_findings.get(fingerprint) + deltas.append(_classify(fingerprint, before, after)) + + delta_tuple = tuple(deltas) + if active_policy.diff_mode == "absolute": + passed = not any(finding.severity.rank >= threshold.rank for finding in preview.findings) + else: + passed = not any( + delta.finding.severity.rank >= threshold.rank + for delta in delta_tuple + if delta.kind is DeltaKind.REGRESSION + ) + return DiffReport( + schema_version=SCHEMA_VERSION, + tool_version=preview.tool_version, + generated_at=utc_now(), + policy_name=active_policy.name, + baseline=baseline, + preview=preview, + deltas=delta_tuple, + fail_on=threshold, + passed=passed, + ) + + +def _by_fingerprint(findings: Sequence[Finding]) -> dict[str, Finding]: + """Select the highest-severity instance if a route emits a duplicate identity.""" + + result: dict[str, Finding] = {} + for finding in findings: + existing = result.get(finding.fingerprint) + if existing is None or finding.severity.rank > existing.severity.rank: + result[finding.fingerprint] = finding + return result + + +def _classify( + fingerprint: str, + baseline: Finding | None, + preview: Finding | None, +) -> FindingDelta: + if baseline is None and preview is not None: + return FindingDelta(DeltaKind.REGRESSION, fingerprint, preview=preview) + if baseline is not None and preview is None: + return FindingDelta(DeltaKind.RESOLVED, fingerprint, baseline=baseline) + if baseline is None or preview is None: # pragma: no cover - union guarantees one side + raise ValueError("A comparison fingerprint must exist in at least one report.") + if preview.severity.rank > baseline.severity.rank: + kind = DeltaKind.REGRESSION + elif _content_signature(preview) == _content_signature(baseline): + kind = DeltaKind.UNCHANGED + else: + kind = DeltaKind.CHANGED + return FindingDelta(kind, fingerprint, baseline=baseline, preview=preview) + + +def _content_signature(finding: Finding) -> tuple[object, ...]: + return ( + finding.rule_id, + finding.title, + finding.severity, + finding.category, + finding.message, + finding.remediation, + finding.subject, + finding.evidence, + finding.references, + ) + + +__all__ = ["compare", "diff_targets"] diff --git a/src/previewshield/exceptions.py b/src/previewshield/exceptions.py new file mode 100644 index 0000000..5404249 --- /dev/null +++ b/src/previewshield/exceptions.py @@ -0,0 +1,39 @@ +"""Domain-specific errors with stable CLI exit codes.""" + +from __future__ import annotations + + +class PreviewShieldError(Exception): + """Base class for expected PreviewShield failures.""" + + exit_code = 4 + + +class ConfigurationError(PreviewShieldError): + """Raised when a policy file or CLI configuration is invalid.""" + + exit_code = 2 + + +class NetworkSafetyError(PreviewShieldError): + """Raised when a target violates the safe network boundary.""" + + exit_code = 3 + + +class ScanError(PreviewShieldError): + """Raised when a safe target cannot be scanned.""" + + exit_code = 3 + + +class ReportError(PreviewShieldError): + """Raised when a report cannot be rendered or written.""" + + exit_code = 4 + + +class PolicyViolationError(PreviewShieldError): + """Used by integrations when a scan crosses the configured threshold.""" + + exit_code = 1 diff --git a/src/previewshield/models.py b/src/previewshield/models.py new file mode 100644 index 0000000..4dcab22 --- /dev/null +++ b/src/previewshield/models.py @@ -0,0 +1,263 @@ +"""Typed, serializable data models used across PreviewShield.""" + +from __future__ import annotations + +import hashlib +from dataclasses import asdict, dataclass, field +from enum import Enum +from typing import Any +from urllib.parse import urlsplit + + +class Severity(str, Enum): + """Finding severity ordered from informational to critical.""" + + INFO = "info" + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + CRITICAL = "critical" + + @property + def rank(self) -> int: + """Return a stable numeric rank for comparisons and thresholds.""" + + return { + Severity.INFO: 0, + Severity.LOW: 1, + Severity.MEDIUM: 2, + Severity.HIGH: 3, + Severity.CRITICAL: 4, + }[self] + + @classmethod + def parse(cls, value: str) -> Severity: + """Parse a case-insensitive severity value.""" + + try: + return cls(value.strip().lower()) + except ValueError as error: + allowed = ", ".join(item.value for item in cls) + raise ValueError(f"Unknown severity '{value}'. Expected one of: {allowed}.") from error + + +@dataclass(frozen=True) +class TLSInfo: + """TLS details observed on the pinned connection.""" + + version: str | None = None + cipher: str | None = None + certificate_subject: str | None = None + certificate_issuer: str | None = None + certificate_expires_at: str | None = None + certificate_days_remaining: int | None = None + + +@dataclass(frozen=True) +class RedirectHop: + """One response in an HTTP redirect chain.""" + + url: str + status_code: int + location: str + resolved_ip: str + + +@dataclass(frozen=True) +class ResponseSnapshot: + """Security-relevant response metadata captured without reading the body.""" + + requested_url: str + final_url: str + status_code: int + reason: str + headers: dict[str, tuple[str, ...]] + resolved_ip: str + elapsed_ms: int + redirects: tuple[RedirectHop, ...] = () + tls: TLSInfo | None = None + + def header(self, name: str) -> str | None: + """Return the final value for a case-insensitive response header.""" + + values = self.headers.get(name.lower(), ()) + return values[-1] if values else None + + def header_values(self, name: str) -> tuple[str, ...]: + """Return every value for a repeatable response header.""" + + return self.headers.get(name.lower(), ()) + + +@dataclass(frozen=True) +class Finding: + """One actionable web-hardening observation.""" + + rule_id: str + title: str + severity: Severity + category: str + message: str + remediation: str + target: str + subject: str = "response" + evidence: str | None = None + references: tuple[str, ...] = () + + @property + def fingerprint(self) -> str: + """Build a stable identity used for baseline comparisons and SARIF.""" + + parsed = urlsplit(self.target) + route = parsed.path or "/" + if parsed.query: + route = f"{route}?{parsed.query}" + source = "\x1f".join((self.rule_id, route, self.subject)) + return hashlib.sha256(source.encode("utf-8")).hexdigest()[:24] + + def with_severity(self, severity: Severity) -> Finding: + """Return a copy with a policy-provided severity override.""" + + return Finding( + rule_id=self.rule_id, + title=self.title, + severity=severity, + category=self.category, + message=self.message, + remediation=self.remediation, + target=self.target, + subject=self.subject, + evidence=self.evidence, + references=self.references, + ) + + +@dataclass(frozen=True) +class RouteReport: + """Snapshot and findings for one scanned route.""" + + snapshot: ResponseSnapshot + findings: tuple[Finding, ...] = () + + +@dataclass(frozen=True) +class ScanReport: + """Complete report for one origin and one or more routes.""" + + schema_version: str + tool_version: str + generated_at: str + policy_name: str + target: str + routes: tuple[RouteReport, ...] + score: int + grade: str + fail_on: Severity + passed: bool + + @property + def findings(self) -> tuple[Finding, ...]: + """Flatten findings from every route in stable scan order.""" + + return tuple(finding for route in self.routes for finding in route.findings) + + @property + def counts(self) -> dict[str, int]: + """Count findings by severity for output formats and action outputs.""" + + counts = {severity.value: 0 for severity in Severity} + for finding in self.findings: + counts[finding.severity.value] += 1 + return counts + + +class DeltaKind(str, Enum): + """How one finding changed between baseline and preview.""" + + REGRESSION = "regression" + RESOLVED = "resolved" + UNCHANGED = "unchanged" + CHANGED = "changed" + + +@dataclass(frozen=True) +class FindingDelta: + """Comparison result for one stable finding identity.""" + + kind: DeltaKind + fingerprint: str + baseline: Finding | None = None + preview: Finding | None = None + + @property + def finding(self) -> Finding: + """Return the most relevant finding for display.""" + + finding = self.preview or self.baseline + if finding is None: # pragma: no cover - protected by constructors + raise ValueError("A finding delta must contain a baseline or preview finding.") + return finding + + +@dataclass(frozen=True) +class DiffReport: + """Security regression comparison between two scan reports.""" + + schema_version: str + tool_version: str + generated_at: str + policy_name: str + baseline: ScanReport + preview: ScanReport + deltas: tuple[FindingDelta, ...] + fail_on: Severity + passed: bool + + @property + def regressions(self) -> tuple[FindingDelta, ...]: + """Return newly introduced or severity-increased findings.""" + + return tuple(delta for delta in self.deltas if delta.kind is DeltaKind.REGRESSION) + + @property + def resolved(self) -> tuple[FindingDelta, ...]: + """Return findings present in the baseline but absent in preview.""" + + return tuple(delta for delta in self.deltas if delta.kind is DeltaKind.RESOLVED) + + @property + def unchanged(self) -> tuple[FindingDelta, ...]: + """Return findings whose severity and content did not change.""" + + return tuple(delta for delta in self.deltas if delta.kind is DeltaKind.UNCHANGED) + + +def to_primitive(value: Any) -> Any: + """Convert nested dataclasses and enums into JSON-safe primitives.""" + + if isinstance(value, Enum): + return value.value + if hasattr(value, "__dataclass_fields__"): + return to_primitive(asdict(value)) + if isinstance(value, dict): + return {str(key): to_primitive(item) for key, item in value.items()} + if isinstance(value, (list, tuple)): + return [to_primitive(item) for item in value] + return value + + +@dataclass +class MutableHeaderBag: + """Internal helper that preserves duplicate response headers.""" + + values: dict[str, list[str]] = field(default_factory=dict) + + def add(self, name: str, value: str) -> None: + """Append one response header using a lowercase key.""" + + self.values.setdefault(name.lower(), []).append(value) + + def freeze(self) -> dict[str, tuple[str, ...]]: + """Create an immutable-by-convention representation for snapshots.""" + + return {name: tuple(values) for name, values in self.values.items()} diff --git a/src/previewshield/network.py b/src/previewshield/network.py new file mode 100644 index 0000000..4241688 --- /dev/null +++ b/src/previewshield/network.py @@ -0,0 +1,822 @@ +"""Safe, dependency-free HTTP metadata fetching for PreviewShield. + +The fetcher deliberately bypasses environment and system proxy configuration. It +resolves each origin itself, validates every returned address, and connects directly +to one of those exact addresses. This DNS pinning prevents a second name lookup from +redirecting a scan toward an internal service after the safety check. + +Only response headers are captured. Response bodies are never read or buffered. +""" + +from __future__ import annotations + +import http.client +import ipaddress +import math +import re +import socket +import ssl +import threading +import time +from collections.abc import Mapping, Sequence +from contextlib import suppress +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any, TypeAlias +from urllib.parse import SplitResult, quote, urldefrag, urljoin, urlsplit, urlunsplit + +from previewshield.exceptions import NetworkSafetyError, ScanError +from previewshield.models import MutableHeaderBag, RedirectHop, ResponseSnapshot, TLSInfo + +_DEFAULT_TIMEOUT = 10.0 +_DEFAULT_REDIRECTS = 5 +_MAX_REDIRECTS = 20 +_MAX_URL_LENGTH = 8192 +_MAX_HOSTNAME_LENGTH = 253 +_MAX_PORT = 65_535 +_MAX_HEADER_VALUE_LENGTH = 16_384 +_MAX_RESPONSE_HEADER_BYTES = 64 * 1024 +_IPV4_SOCKADDR_LENGTH = 2 +_IPV6_SOCKADDR_LENGTH = 4 +_CERTIFICATE_ATTRIBUTE_LENGTH = 2 +_REDIRECT_STATUSES = frozenset({301, 302, 303, 307, 308}) +_FORBIDDEN_REQUEST_HEADERS = frozenset( + { + "connection", + "content-length", + "host", + "proxy-authorization", + "proxy-connection", + "transfer-encoding", + } +) +_HEADER_NAME = re.compile(r"^[!#$%&'*+.^_`|~0-9A-Za-z-]+$") +_INVALID_PERCENT_ESCAPE = re.compile(r"%(?![0-9A-Fa-f]{2})") +_HOST_LABEL = re.compile(r"^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$") +_CONTROL_CHARACTER = re.compile(r"[\x00-\x08\x0a-\x1f\x7f]") +_UNSAFE_URL_CHARACTER = re.compile(r"[\x00-\x1f\x7f]") + +_SocketAddress: TypeAlias = tuple[str, int] | tuple[str, int, int, int] +_RequestHeaders: TypeAlias = tuple[tuple[str, str], ...] + + +@dataclass(frozen=True, slots=True) +class NetworkOptions: + """Controls safe network access for one fetch operation. + + ``timeout`` is a total wall-clock budget, in seconds, shared by all redirect + hops (system DNS resolution itself cannot be portably interrupted by Python's + standard library). ``allow_private`` disables the address boundary and should + only be used for explicitly trusted local targets, such as test servers. + ``allowed_hosts`` optionally restricts every hop to exact hostnames or ``*.`` + subdomain patterns; an empty tuple preserves unrestricted legacy behavior. + + No option enables proxy discovery: requests always connect directly to a DNS + answer that this module validated. + """ + + timeout: float = _DEFAULT_TIMEOUT + max_redirects: int = _DEFAULT_REDIRECTS + allow_private: bool = False + user_agent: str = "PreviewShield/1.0" + allowed_hosts: tuple[str, ...] = () + + def __post_init__(self) -> None: + """Reject settings that could disable resource bounds or inject headers.""" + + if isinstance(self.timeout, bool) or not isinstance(self.timeout, (int, float)): + raise ValueError("timeout must be a finite positive number") + if not math.isfinite(self.timeout) or self.timeout <= 0: + raise ValueError("timeout must be a finite positive number") + if isinstance(self.max_redirects, bool) or not isinstance(self.max_redirects, int): + raise ValueError("max_redirects must be an integer") + if not 0 <= self.max_redirects <= _MAX_REDIRECTS: + raise ValueError(f"max_redirects must be between 0 and {_MAX_REDIRECTS}") + if not isinstance(self.allow_private, bool): + raise ValueError("allow_private must be a boolean") + _validate_header_value(self.user_agent, label="user_agent") + if not self.user_agent: + raise ValueError("user_agent must not be empty") + if not isinstance(self.allowed_hosts, tuple): + raise ValueError("allowed_hosts must be a tuple of host patterns") + object.__setattr__(self, "allowed_hosts", _normalize_allowed_hosts(self.allowed_hosts)) + + +@dataclass(frozen=True, slots=True) +class _NormalizedURL: + value: str + scheme: str + hostname: str + port: int + request_target: str + + @property + def origin(self) -> tuple[str, str, int]: + return self.scheme, self.hostname, self.port + + @property + def host_header(self) -> str: + host = f"[{self.hostname}]" if ":" in self.hostname else self.hostname + default_port = 443 if self.scheme == "https" else 80 + return host if self.port == default_port else f"{host}:{self.port}" + + +@dataclass(frozen=True, slots=True) +class _ResolvedAddress: + ip: str + family: socket.AddressFamily + sockaddr: _SocketAddress + + +@dataclass(frozen=True, slots=True) +class _ResponseMetadata: + status_code: int + reason: str + headers: dict[str, tuple[str, ...]] + resolved_ip: str + tls: TLSInfo | None + + +class _SocketDeadline: + """Interrupt blocking socket I/O when an absolute monotonic deadline expires.""" + + def __init__(self, sock: socket.socket, started: float, timeout: float) -> None: + self._socket = sock + self._deadline = started + timeout + self._expired = threading.Event() + remaining = self._deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError("network deadline expired") + sock.settimeout(remaining) + self._timer = threading.Timer(remaining, self._expire) + self._timer.daemon = True + self._timer.start() + self._closed = False + + def expired(self) -> bool: + """Return whether the watchdog fired or wall-clock time crossed the deadline.""" + + return self._expired.is_set() or time.monotonic() >= self._deadline + + def finish(self) -> None: + """Atomically stop the watchdog and reject a just-completed late response.""" + + self.close() + if self.expired(): + raise TimeoutError("network deadline expired") + + def close(self) -> None: + """Cancel and join the watchdog without closing the guarded socket.""" + + if self._closed: + return + self._closed = True + self._timer.cancel() + self._timer.join() + + def _expire(self) -> None: + self._expired.set() + with suppress(OSError): + self._socket.shutdown(socket.SHUT_RDWR) + + +class _PinnedHTTPConnection(http.client.HTTPConnection): + """HTTP connection that opens only a pre-resolved, validated address.""" + + def __init__( + self, + host: str, + port: int, + addresses: Sequence[_ResolvedAddress], + timeout: float, + ) -> None: + super().__init__(host, port=port, timeout=timeout) + self._pinned_addresses = addresses + self.connected_ip: str | None = None + + def connect(self) -> None: + """Connect without asking the operating system to resolve the host again.""" + + self.sock, self.connected_ip = _connect_pinned(self._pinned_addresses, self.timeout) + + +class _PinnedHTTPSConnection(http.client.HTTPSConnection): + """HTTPS connection with pinned DNS and normal hostname verification/SNI.""" + + def __init__( + self, + host: str, + port: int, + addresses: Sequence[_ResolvedAddress], + timeout: float, + context: ssl.SSLContext, + ) -> None: + super().__init__(host, port=port, timeout=timeout, context=context) + self._pinned_addresses = addresses + self._previewshield_context = context + self.connected_ip: str | None = None + + def connect(self) -> None: + """Wrap the pinned socket while retaining the URL hostname for TLS checks.""" + + started = time.monotonic() + raw_socket, self.connected_ip = _connect_pinned( + self._pinned_addresses, + self.timeout, + ) + tls_socket: ssl.SSLSocket | None = None + watchdog: _SocketDeadline | None = None + try: + tls_socket = self._previewshield_context.wrap_socket( + raw_socket, + server_hostname=self.host, + do_handshake_on_connect=False, + ) + watchdog = _SocketDeadline(tls_socket, started, _numeric_timeout(self.timeout)) + tls_socket.do_handshake() + watchdog.finish() + self.sock = tls_socket + except Exception as error: + if watchdog is not None and watchdog.expired(): + raise TimeoutError("TLS handshake exceeded the network deadline") from error + raise + finally: + if watchdog is not None: + watchdog.close() + if self.sock is None: + (tls_socket or raw_socket).close() + + +def fetch( + url: str, + options: NetworkOptions, + request_headers: Mapping[str, str] | None = None, +) -> ResponseSnapshot: + """Fetch response metadata through a direct, DNS-pinned GET request. + + The initial URL and every redirect are independently normalized, resolved, and + checked against both the hostname allowlist and public-network boundary. The + body is deliberately left unread. Caller-supplied headers are retained only for + same-origin redirects; cross-origin hops receive fresh tool-owned ``User-Agent`` + and ``Accept`` headers. Proxy settings from the environment are never consulted. + + Args: + url: An absolute HTTP(S) URL, or a host/path shorthand that defaults to HTTPS. + options: Timeout, redirect, and address-safety settings. + request_headers: Optional caller headers. Transport framing and ``Host`` + headers are controlled by this module and cannot be overridden. + + Raises: + NetworkSafetyError: If a URL, request header, or resolved address is unsafe. + ScanError: If DNS, the connection, TLS, or the HTTP exchange fails. + """ + + if not isinstance(options, NetworkOptions): + raise TypeError("options must be a NetworkOptions instance") + + started = time.monotonic() + current = _normalize_url(url) + _enforce_allowed_host(current.hostname, options.allowed_hosts) + requested_url = current.value + headers = _prepare_request_headers(request_headers, options.user_agent) + cross_origin_headers: _RequestHeaders = ( + ("User-Agent", options.user_agent), + ("Accept", "*/*"), + ) + redirects: list[RedirectHop] = [] + visited = {current.value} + tls_context: ssl.SSLContext | None = None + + while True: + _remaining_timeout(started, options.timeout, current.hostname) + addresses = _resolve(current.hostname, current.port, options.allow_private) + if current.scheme == "https" and tls_context is None: + tls_context = ssl.create_default_context(purpose=ssl.Purpose.SERVER_AUTH) + + remaining = _remaining_timeout(started, options.timeout, current.hostname) + response = _request_once(current, addresses, headers, remaining, tls_context) + location = _redirect_location(response) + if location is None: + elapsed_ms = max(0, round((time.monotonic() - started) * 1000)) + return ResponseSnapshot( + requested_url=requested_url, + final_url=current.value, + status_code=response.status_code, + reason=response.reason, + headers=response.headers, + resolved_ip=response.resolved_ip, + elapsed_ms=elapsed_ms, + redirects=tuple(redirects), + tls=response.tls, + ) + + if len(redirects) >= options.max_redirects: + raise ScanError( + f"Redirect limit exceeded while scanning '{current.hostname}' " + f"(maximum {options.max_redirects})." + ) + + redirect_url, _fragment = urldefrag(urljoin(current.value, location)) + next_url = _normalize_url(redirect_url, allow_shorthand=False) + _enforce_allowed_host(next_url.hostname, options.allowed_hosts) + if next_url.value in visited: + raise ScanError(f"Redirect loop detected while scanning '{current.hostname}'.") + + redirects.append( + RedirectHop( + url=current.value, + status_code=response.status_code, + location=next_url.value, + resolved_ip=response.resolved_ip, + ) + ) + visited.add(next_url.value) + if current.origin != next_url.origin: + headers = cross_origin_headers + current = next_url + + +def _normalize_url(url: str, *, allow_shorthand: bool = True) -> _NormalizedURL: + parsed = _parse_url(url, allow_shorthand=allow_shorthand) + scheme = parsed.scheme.lower() + try: + port_value = parsed.port + except ValueError as error: + raise NetworkSafetyError("Target URL has an invalid authority or port.") from error + + hostname = _normalize_hostname(parsed.hostname or "") + port = port_value if port_value is not None else (443 if scheme == "https" else 80) + if not 1 <= port <= _MAX_PORT: + raise NetworkSafetyError("Target URL port must be between 1 and 65535.") + + path = parsed.path or "/" + if _INVALID_PERCENT_ESCAPE.search(path) or _INVALID_PERCENT_ESCAPE.search(parsed.query): + raise NetworkSafetyError("Target URL contains an invalid percent escape.") + normalized_path = quote(path, safe="/%:@!$&'()*+,;=-._~") + normalized_query = quote(parsed.query, safe="%:@!$&'()*+,;=/?-._~") + request_target = normalized_path + if normalized_query: + request_target = f"{request_target}?{normalized_query}" + + url_host = f"[{hostname}]" if ":" in hostname else hostname + default_port = 443 if scheme == "https" else 80 + netloc = url_host if port == default_port else f"{url_host}:{port}" + normalized = urlunsplit((scheme, netloc, normalized_path, normalized_query, "")) + return _NormalizedURL(normalized, scheme, hostname, port, request_target) + + +def _parse_url(url: str, *, allow_shorthand: bool) -> SplitResult: + if not isinstance(url, str): + raise NetworkSafetyError("Target URL must be a string.") + if not url or url != url.strip(): + raise NetworkSafetyError("Target URL must not be empty or contain outer whitespace.") + if len(url) > _MAX_URL_LENGTH: + raise NetworkSafetyError(f"Target URL exceeds {_MAX_URL_LENGTH} characters.") + if _UNSAFE_URL_CHARACTER.search(url) or "\\" in url: + raise NetworkSafetyError("Target URL contains an unsafe character.") + if "#" in url: + raise NetworkSafetyError("URL fragments are not allowed.") + + candidate = url + if allow_shorthand and "://" not in candidate: + candidate = f"https://{candidate}" + + try: + parsed = urlsplit(candidate) + except (UnicodeError, ValueError) as error: + raise NetworkSafetyError("Target URL has an invalid authority or port.") from error + + scheme = parsed.scheme.lower() + if scheme not in {"http", "https"}: + raise NetworkSafetyError("Only http:// and https:// URLs are allowed.") + if not parsed.netloc or parsed.hostname is None: + raise NetworkSafetyError("Target URL must include a hostname.") + if parsed.username is not None or parsed.password is not None: + raise NetworkSafetyError("Credentials in target URLs are not allowed.") + if parsed.netloc.endswith(":"): + raise NetworkSafetyError("Target URL has an empty port.") + if parsed.fragment: + raise NetworkSafetyError("URL fragments are not allowed.") + return parsed + + +def _normalize_hostname(hostname: str) -> str: + if "%" in hostname: + raise NetworkSafetyError("IPv6 scope identifiers are not allowed in target URLs.") + + try: + return ipaddress.ip_address(hostname).compressed + except ValueError: + pass + + hostname = hostname.rstrip(".") + if not hostname: + raise NetworkSafetyError("Target URL must include a hostname.") + try: + ascii_hostname = hostname.encode("idna").decode("ascii").lower() + except UnicodeError as error: + raise NetworkSafetyError("Target URL contains an invalid hostname.") from error + if len(ascii_hostname) > _MAX_HOSTNAME_LENGTH: + raise NetworkSafetyError("Target hostname exceeds 253 characters.") + if any(not _HOST_LABEL.fullmatch(label) for label in ascii_hostname.split(".")): + raise NetworkSafetyError("Target URL contains an invalid hostname.") + return ascii_hostname + + +def _normalize_allowed_hosts(patterns: tuple[str, ...]) -> tuple[str, ...]: + normalized: list[str] = [] + for pattern in patterns: + if not isinstance(pattern, str) or not pattern: + raise ValueError("allowed_hosts entries must be non-empty strings") + wildcard = pattern.startswith("*.") + raw_hostname = pattern[2:] if wildcard else pattern + if "*" in raw_hostname: + raise ValueError(f"Invalid allowed host pattern: {pattern!r}") + try: + hostname = _normalize_hostname(raw_hostname) + except NetworkSafetyError as error: + raise ValueError(f"Invalid allowed host pattern: {pattern!r}") from error + if wildcard: + try: + ipaddress.ip_address(hostname) + except ValueError: + normalized_pattern = f"*.{hostname}" + else: + raise ValueError("Wildcard allowed_hosts entries cannot target IP addresses") + else: + normalized_pattern = hostname + if normalized_pattern not in normalized: + normalized.append(normalized_pattern) + return tuple(normalized) + + +def _enforce_allowed_host(hostname: str, allowed_hosts: tuple[str, ...]) -> None: + if not allowed_hosts: + return + for allowed in allowed_hosts: + if allowed.startswith("*."): + suffix = allowed[1:] + if hostname.endswith(suffix) and hostname != suffix[1:]: + return + elif hostname == allowed: + return + raise NetworkSafetyError(f"Target host '{hostname}' is not in the configured allowlist.") + + +def _prepare_request_headers( + request_headers: Mapping[str, str] | None, + user_agent: str, +) -> _RequestHeaders: + prepared: list[tuple[str, str]] = [] + seen: set[str] = set() + if request_headers is not None: + if not isinstance(request_headers, Mapping): + raise NetworkSafetyError("Request headers must be a string mapping.") + for name, value in request_headers.items(): + if not isinstance(name, str) or not _HEADER_NAME.fullmatch(name): + raise NetworkSafetyError("A request header has an invalid name.") + lowered = name.lower() + if lowered in _FORBIDDEN_REQUEST_HEADERS: + raise NetworkSafetyError(f"Request header '{name}' cannot be overridden.") + if not isinstance(value, str): + raise NetworkSafetyError(f"Request header '{name}' must have a string value.") + try: + _validate_header_value(value, label=f"request header '{name}'") + except ValueError as error: + raise NetworkSafetyError(str(error)) from error + prepared.append((name, value)) + seen.add(lowered) + + if "user-agent" not in seen: + prepared.append(("User-Agent", user_agent)) + if "accept" not in seen: + prepared.append(("Accept", "*/*")) + return tuple(prepared) + + +def _validate_header_value(value: str, *, label: str) -> None: + if not isinstance(value, str): + raise ValueError(f"{label} must be a string") + if len(value) > _MAX_HEADER_VALUE_LENGTH: + raise ValueError(f"{label} exceeds {_MAX_HEADER_VALUE_LENGTH} characters") + if _CONTROL_CHARACTER.search(value): + raise ValueError(f"{label} contains a control character") + try: + value.encode("latin-1") + except UnicodeEncodeError as error: + raise ValueError(f"{label} must contain ISO-8859-1 characters only") from error + + +def _resolve(hostname: str, port: int, allow_private: bool) -> tuple[_ResolvedAddress, ...]: + try: + results = socket.getaddrinfo( + hostname, + port, + family=socket.AF_UNSPEC, + type=socket.SOCK_STREAM, + proto=socket.IPPROTO_TCP, + ) + except (OSError, UnicodeError) as error: + raise ScanError(f"DNS resolution failed for '{hostname}'.") from error + + addresses: list[_ResolvedAddress] = [] + seen: set[tuple[socket.AddressFamily, _SocketAddress]] = set() + for family_value, socket_type, protocol, _canonical_name, raw_sockaddr in results: + if socket_type != socket.SOCK_STREAM or protocol not in {0, socket.IPPROTO_TCP}: + continue + if family_value not in {socket.AF_INET, socket.AF_INET6}: + continue + family = socket.AddressFamily(family_value) + sockaddr = _validated_sockaddr(family, raw_sockaddr, port) + ip_text = sockaddr[0] + try: + address = ipaddress.ip_address(ip_text) + except ValueError as error: + raise ScanError(f"DNS returned an invalid address for '{hostname}'.") from error + if (family == socket.AF_INET) != isinstance(address, ipaddress.IPv4Address): + raise ScanError(f"DNS returned an address-family mismatch for '{hostname}'.") + if not allow_private and _is_non_public(address): + raise NetworkSafetyError( + f"Target '{hostname}' resolved to blocked address '{address.compressed}'." + ) + key = (family, sockaddr) + if key not in seen: + seen.add(key) + addresses.append(_ResolvedAddress(address.compressed, family, sockaddr)) + + if not addresses: + raise ScanError(f"DNS resolution returned no usable addresses for '{hostname}'.") + return tuple(addresses) + + +def _validated_sockaddr( + family: socket.AddressFamily, + raw_sockaddr: Any, + expected_port: int, +) -> _SocketAddress: + if not isinstance(raw_sockaddr, tuple): + raise ScanError("DNS returned a malformed socket address.") + if family == socket.AF_INET: + if len(raw_sockaddr) < _IPV4_SOCKADDR_LENGTH or not isinstance(raw_sockaddr[0], str): + raise ScanError("DNS returned a malformed IPv4 socket address.") + if raw_sockaddr[1] != expected_port: + raise ScanError("DNS returned an unexpected destination port.") + return raw_sockaddr[0], expected_port + if len(raw_sockaddr) < _IPV6_SOCKADDR_LENGTH or not isinstance(raw_sockaddr[0], str): + raise ScanError("DNS returned a malformed IPv6 socket address.") + if raw_sockaddr[1] != expected_port: + raise ScanError("DNS returned an unexpected destination port.") + flow_info = raw_sockaddr[2] + scope_id = raw_sockaddr[3] + if not isinstance(flow_info, int) or not isinstance(scope_id, int): + raise ScanError("DNS returned a malformed IPv6 socket address.") + return raw_sockaddr[0], expected_port, flow_info, scope_id + + +def _is_non_public(address: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool: + if isinstance(address, ipaddress.IPv6Address): + if address.is_site_local: + return True + mapped = address.ipv4_mapped + if mapped is not None and _is_non_public(mapped): + return True + six_to_four = address.sixtofour + if six_to_four is not None and _is_non_public(six_to_four): + return True + teredo = address.teredo + if teredo is not None and any(_is_non_public(item) for item in teredo): + return True + return ( + address.is_loopback + or address.is_private + or address.is_link_local + or address.is_multicast + or address.is_reserved + or address.is_unspecified + or not address.is_global + ) + + +def _connect_pinned( + addresses: Sequence[_ResolvedAddress], + timeout: float | object, +) -> tuple[socket.socket, str]: + numeric_timeout = _numeric_timeout(timeout) + started = time.monotonic() + last_error: OSError | None = None + for address in addresses: + remaining = numeric_timeout - (time.monotonic() - started) + if remaining <= 0: + raise TimeoutError("pinned connection timed out") + sock = socket.socket(address.family, socket.SOCK_STREAM, socket.IPPROTO_TCP) + try: + sock.settimeout(remaining) + sock.connect(address.sockaddr) + except OSError as error: + last_error = error + sock.close() + continue + return sock, address.ip + if last_error is not None: + raise last_error + raise OSError("no pinned address was available") + + +def _numeric_timeout(timeout: float | object) -> float: + return float(timeout) if isinstance(timeout, (int, float)) else _DEFAULT_TIMEOUT + + +def _request_once( + target: _NormalizedURL, + addresses: Sequence[_ResolvedAddress], + request_headers: _RequestHeaders, + timeout: float, + tls_context: ssl.SSLContext | None, +) -> _ResponseMetadata: + try: + return _perform_request(target, addresses, request_headers, timeout, tls_context) + except ssl.SSLCertVerificationError as error: + raise ScanError(f"TLS certificate verification failed for '{target.hostname}'.") from error + except ssl.SSLError as error: + raise ScanError(f"TLS connection failed for '{target.hostname}'.") from error + except TimeoutError as error: + raise ScanError(f"Request timed out while scanning '{target.hostname}'.") from error + except http.client.HTTPException as error: + raise ScanError(f"Invalid HTTP response from '{target.hostname}'.") from error + except OSError as error: + raise ScanError(f"Connection failed for '{target.host_header}'.") from error + + +def _perform_request( + target: _NormalizedURL, + addresses: Sequence[_ResolvedAddress], + request_headers: _RequestHeaders, + timeout: float, + tls_context: ssl.SSLContext | None, +) -> _ResponseMetadata: + connection: _PinnedHTTPConnection | _PinnedHTTPSConnection + response: http.client.HTTPResponse | None = None + watchdog: _SocketDeadline | None = None + connection = _build_connection(target, addresses, timeout, tls_context) + started = time.monotonic() + try: + connection.connect() + connected_socket = connection.sock + if connected_socket is None: # pragma: no cover - guaranteed by connect() + raise OSError("connection socket is unavailable") + watchdog = _SocketDeadline(connected_socket, started, timeout) + tls_info = _capture_tls(connected_socket) + connection.putrequest( + "GET", + target.request_target, + skip_host=True, + skip_accept_encoding=True, + ) + connection.putheader("Host", target.host_header) + for name, value in request_headers: + connection.putheader(name, value) + connection.putheader("Connection", "close") + connection.endheaders() + _set_remaining_socket_timeout(connection.sock, started, timeout) + response = connection.getresponse() + + response_headers = response.getheaders() + header_bytes = sum(len(name) + len(value) + 4 for name, value in response_headers) + if header_bytes > _MAX_RESPONSE_HEADER_BYTES: + raise ScanError( + "HTTP response headers exceed the " + f"{_MAX_RESPONSE_HEADER_BYTES}-byte limit for '{target.hostname}'." + ) + + bag = MutableHeaderBag() + for name, value in response_headers: + bag.add(name, value) + connected_ip = connection.connected_ip + if connected_ip is None: # pragma: no cover - guaranteed by connect() + raise ScanError("Pinned connection did not report its destination address.") + metadata = _ResponseMetadata( + status_code=response.status, + reason=str(response.reason or ""), + headers=bag.freeze(), + resolved_ip=connected_ip, + tls=tls_info, + ) + watchdog.finish() + return metadata + except (OSError, http.client.HTTPException) as error: + if watchdog is not None and watchdog.expired(): + raise TimeoutError("HTTP exchange exceeded the network deadline") from error + raise + finally: + if watchdog is not None: + watchdog.close() + if response is not None: + response.close() + connection.close() + + +def _build_connection( + target: _NormalizedURL, + addresses: Sequence[_ResolvedAddress], + timeout: float, + tls_context: ssl.SSLContext | None, +) -> _PinnedHTTPConnection | _PinnedHTTPSConnection: + if target.scheme == "https": + if tls_context is None: # pragma: no cover - fetch always creates it + raise ScanError("TLS context is unavailable.") + return _PinnedHTTPSConnection( + target.hostname, + target.port, + addresses, + timeout, + tls_context, + ) + return _PinnedHTTPConnection( + target.hostname, + target.port, + addresses, + timeout, + ) + + +def _remaining_timeout(started: float, timeout: float, hostname: str) -> float: + remaining = timeout - (time.monotonic() - started) + if remaining <= 0: + raise ScanError(f"Request timed out while scanning '{hostname}'.") + return remaining + + +def _set_remaining_socket_timeout( + sock: socket.socket | None, + started: float, + timeout: float, +) -> None: + if sock is None: + raise OSError("connection socket is unavailable") + remaining = timeout - (time.monotonic() - started) + if remaining <= 0: + raise TimeoutError("HTTP exchange timed out") + sock.settimeout(remaining) + + +def _redirect_location(response: _ResponseMetadata) -> str | None: + if response.status_code not in _REDIRECT_STATUSES: + return None + values = response.headers.get("location", ()) + return values[-1] if values else None + + +def _capture_tls(sock: socket.socket | None) -> TLSInfo | None: + if not isinstance(sock, ssl.SSLSocket): + return None + + cipher_details = sock.cipher() + certificate: Mapping[str, object] = sock.getpeercert() or {} + expires_at: str | None = None + days_remaining: int | None = None + not_after = certificate.get("notAfter") + if isinstance(not_after, str): + try: + expiry = datetime.fromtimestamp( + ssl.cert_time_to_seconds(not_after), + tz=timezone.utc, + ) + except (OverflowError, ValueError): + pass + else: + expires_at = expiry.replace(microsecond=0).isoformat().replace("+00:00", "Z") + days_remaining = math.floor( + (expiry - datetime.now(timezone.utc)).total_seconds() / 86_400 + ) + + return TLSInfo( + version=sock.version(), + cipher=cipher_details[0] if cipher_details else None, + certificate_subject=_format_certificate_name(certificate.get("subject")), + certificate_issuer=_format_certificate_name(certificate.get("issuer")), + certificate_expires_at=expires_at, + certificate_days_remaining=days_remaining, + ) + + +def _format_certificate_name(value: object) -> str | None: + if not isinstance(value, tuple): + return None + parts: list[str] = [] + for relative_name in value: + if not isinstance(relative_name, tuple): + continue + parts.extend( + f"{attribute[0]}={attribute[1]}" + for attribute in relative_name + if ( + isinstance(attribute, tuple) + and len(attribute) == _CERTIFICATE_ATTRIBUTE_LENGTH + and isinstance(attribute[0], str) + and isinstance(attribute[1], str) + ) + ) + return ", ".join(parts) or None + + +__all__ = ["NetworkOptions", "fetch"] diff --git a/src/previewshield/policy.py b/src/previewshield/policy.py new file mode 100644 index 0000000..97c5760 --- /dev/null +++ b/src/previewshield/policy.py @@ -0,0 +1,491 @@ +"""Validated policy-as-code configuration for PreviewShield.""" + +from __future__ import annotations + +import ipaddress +import re +from collections.abc import Mapping +from dataclasses import dataclass +from pathlib import Path, PurePosixPath +from typing import Any +from urllib.parse import urlsplit + +import yaml + +from previewshield.exceptions import ConfigurationError +from previewshield.models import Severity +from previewshield.utils import clean_text, parse_threshold, validate_header_name + +MAX_POLICY_BYTES = 256 * 1024 +_RULE_ID = re.compile(r"^(?:PS\d{4}|CUSTOM\.[A-Z0-9_.-]+)$") +_HOST_LABEL = re.compile(r"^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$") +_MAX_HOSTNAME_LENGTH = 253 +_ALLOWED_ROOT_KEYS = { + "version", + "name", + "profile", + "fail_on", + "paths", + "network", + "checks", + "diff", +} +_ALLOWED_NETWORK_KEYS = { + "timeout_seconds", + "max_redirects", + "allow_private", + "allowed_hosts", + "user_agent", +} +_ALLOWED_CHECK_KEYS = { + "disabled", + "severity_overrides", + "required_headers", + "min_hsts_max_age", + "certificate_warning_days", +} +_ALLOWED_DIFF_KEYS = {"mode"} +_PROFILE_DEFAULTS: dict[str, dict[str, Any]] = { + "balanced": { + "fail_on": Severity.HIGH, + "min_hsts_max_age": 15_552_000, + "certificate_warning_days": 30, + "diff_mode": "regressions", + }, + "strict": { + "fail_on": Severity.MEDIUM, + "min_hsts_max_age": 31_536_000, + "certificate_warning_days": 45, + "diff_mode": "regressions", + }, +} + + +@dataclass(frozen=True) +class NetworkPolicy: + """Network safety and reliability controls.""" + + timeout_seconds: float = 10.0 + max_redirects: int = 5 + allow_private: bool = False + allowed_hosts: tuple[str, ...] = () + user_agent: str = "PreviewShield/1.0 (+https://github.com/devUmut35/PreviewShield)" + + +@dataclass(frozen=True) +class RequiredHeaderPolicy: + """A project-specific response header requirement.""" + + name: str + severity: Severity + exact: str | None = None + contains: str | None = None + remediation: str = "Configure the required response header at the application or edge." + + @property + def rule_id(self) -> str: + """Return a deterministic custom SARIF rule identifier.""" + + normalized = re.sub(r"[^A-Z0-9]+", "_", self.name.upper()).strip("_") + return f"CUSTOM.{normalized}" + + +@dataclass(frozen=True) +class Policy: + """Complete immutable PreviewShield policy.""" + + version: int + name: str + profile: str + fail_on: Severity + paths: tuple[str, ...] + network: NetworkPolicy + disabled_rules: frozenset[str] + severity_overrides: Mapping[str, Severity] + required_headers: tuple[RequiredHeaderPolicy, ...] + min_hsts_max_age: int + certificate_warning_days: int + diff_mode: str + + def severity_for(self, rule_id: str, default: Severity) -> Severity: + """Apply an optional project-level severity override.""" + + return self.severity_overrides.get(rule_id, default) + + def rule_enabled(self, rule_id: str) -> bool: + """Return whether a built-in or custom rule is active.""" + + return rule_id not in self.disabled_rules + + def host_allowed(self, url: str) -> bool: + """Enforce an optional hostname allowlist before DNS resolution.""" + + if not self.network.allowed_hosts: + return True + hostname = (urlsplit(url).hostname or "").rstrip(".").lower() + return any(_host_matches(hostname, allowed) for allowed in self.network.allowed_hosts) + + +def default_policy(profile: str = "balanced") -> Policy: + """Create the built-in policy without reading from disk.""" + + return policy_from_mapping({"version": 1, "profile": profile}) + + +def load_policy(path: str | Path | None = None, *, profile: str = "balanced") -> Policy: + """Load and strictly validate a YAML policy, or return built-in defaults.""" + + if path is None: + return default_policy(profile) + + policy_path = Path(path) + try: + size = policy_path.stat().st_size + except OSError as error: + raise ConfigurationError(f"Cannot read policy file {policy_path}: {error}.") from error + if size > MAX_POLICY_BYTES: + raise ConfigurationError( + f"Policy file is {size} bytes; the maximum is {MAX_POLICY_BYTES} bytes." + ) + + try: + raw = yaml.safe_load(policy_path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, yaml.YAMLError) as error: + raise ConfigurationError(f"Invalid policy file {policy_path}: {error}.") from error + if raw is None: + raw = {} + if not isinstance(raw, dict): + raise ConfigurationError("The policy root must be a YAML mapping.") + return policy_from_mapping(raw, fallback_profile=profile) + + +def policy_from_mapping(raw: Mapping[str, Any], *, fallback_profile: str = "balanced") -> Policy: + """Validate a mapping and create a strongly typed policy.""" + + _reject_unknown(raw, _ALLOWED_ROOT_KEYS, "policy") + version = _integer(raw.get("version", 1), "version", minimum=1, maximum=1) + profile = _string(raw.get("profile", fallback_profile), "profile").lower() + if profile not in _PROFILE_DEFAULTS: + allowed = ", ".join(sorted(_PROFILE_DEFAULTS)) + raise ConfigurationError(f"Unknown profile '{profile}'. Expected one of: {allowed}.") + defaults = _PROFILE_DEFAULTS[profile] + + name = clean_text(_string(raw.get("name", f"PreviewShield {profile}"), "name"), limit=80) + fail_on_value = raw.get("fail_on") + fail_on = ( + defaults["fail_on"] + if fail_on_value is None + else parse_threshold(_string(fail_on_value, "fail_on")) + ) + paths = _paths(raw.get("paths", ["/"])) + network = _network_policy(raw.get("network", {})) + checks = _mapping(raw.get("checks", {}), "checks") + _reject_unknown(checks, _ALLOWED_CHECK_KEYS, "checks") + disabled = frozenset(_rule_ids(checks.get("disabled", []), "checks.disabled")) + overrides = _severity_overrides(checks.get("severity_overrides", {})) + required_headers = _required_headers(checks.get("required_headers", {})) + _validate_rule_references(disabled, overrides, required_headers) + min_hsts_max_age = _integer( + checks.get("min_hsts_max_age", defaults["min_hsts_max_age"]), + "checks.min_hsts_max_age", + minimum=0, + maximum=630_720_000, + ) + certificate_warning_days = _integer( + checks.get("certificate_warning_days", defaults["certificate_warning_days"]), + "checks.certificate_warning_days", + minimum=1, + maximum=365, + ) + diff = _mapping(raw.get("diff", {}), "diff") + _reject_unknown(diff, _ALLOWED_DIFF_KEYS, "diff") + diff_mode = _string(diff.get("mode", defaults["diff_mode"]), "diff.mode").lower() + if diff_mode not in {"regressions", "absolute"}: + raise ConfigurationError("diff.mode must be 'regressions' or 'absolute'.") + + return Policy( + version=version, + name=name, + profile=profile, + fail_on=fail_on, + paths=paths, + network=network, + disabled_rules=disabled, + severity_overrides=overrides, + required_headers=required_headers, + min_hsts_max_age=min_hsts_max_age, + certificate_warning_days=certificate_warning_days, + diff_mode=diff_mode, + ) + + +def default_policy_yaml() -> str: + """Return a commented starter policy for ``previewshield init``.""" + + return """# PreviewShield policy v1 +version: 1 +name: default +profile: balanced +fail_on: high + +paths: + - / + +network: + timeout_seconds: 10 + max_redirects: 5 + allow_private: false + # allowed_hosts: [example.com, "*.example.com"] + +checks: + min_hsts_max_age: 15552000 + certificate_warning_days: 30 + disabled: [] + severity_overrides: {} + required_headers: {} + # required_headers: + # X-Robots-Tag: + # severity: medium + # contains: noindex + +diff: + # regressions: fail only on new or severity-increased findings + # absolute: fail on every preview finding at the threshold + mode: regressions +""" + + +def _network_policy(value: Any) -> NetworkPolicy: + raw = _mapping(value, "network") + _reject_unknown(raw, _ALLOWED_NETWORK_KEYS, "network") + timeout = _number( + raw.get("timeout_seconds", 10.0), + "network.timeout_seconds", + minimum=0.1, + maximum=120.0, + ) + max_redirects = _integer( + raw.get("max_redirects", 5), + "network.max_redirects", + minimum=0, + maximum=20, + ) + allow_private = _boolean(raw.get("allow_private", False), "network.allow_private") + allowed_hosts = tuple( + _normalize_host(item) + for item in _string_list(raw.get("allowed_hosts", []), "network.allowed_hosts") + ) + user_agent = _user_agent( + raw.get( + "user_agent", + "PreviewShield/1.0 (+https://github.com/devUmut35/PreviewShield)", + ) + ) + return NetworkPolicy( + timeout_seconds=timeout, + max_redirects=max_redirects, + allow_private=allow_private, + allowed_hosts=allowed_hosts, + user_agent=user_agent, + ) + + +def _required_headers(value: Any) -> tuple[RequiredHeaderPolicy, ...]: + raw = _mapping(value, "checks.required_headers") + results: list[RequiredHeaderPolicy] = [] + for header_name, options_value in raw.items(): + if not isinstance(header_name, str): + raise ConfigurationError("Required header names must be strings.") + name = validate_header_name(header_name) + if options_value is True: + options: Mapping[str, Any] = {} + else: + options = _mapping(options_value, f"checks.required_headers.{name}") + _reject_unknown( + options, + {"severity", "exact", "contains", "remediation"}, + f"checks.required_headers.{name}", + ) + severity = parse_threshold( + _string(options.get("severity", "medium"), f"required_headers.{name}.severity") + ) + exact = _optional_string(options.get("exact"), f"required_headers.{name}.exact") + contains = _optional_string(options.get("contains"), f"required_headers.{name}.contains") + if exact is not None and contains is not None: + raise ConfigurationError( + f"Required header {name} cannot define both 'exact' and 'contains'." + ) + remediation = clean_text( + _string( + options.get( + "remediation", + "Configure the required response header at the application or edge.", + ), + f"required_headers.{name}.remediation", + ), + limit=500, + ) + results.append( + RequiredHeaderPolicy( + name=name, + severity=severity, + exact=exact, + contains=contains, + remediation=remediation, + ) + ) + return tuple(sorted(results, key=lambda item: item.name.lower())) + + +def _severity_overrides(value: Any) -> Mapping[str, Severity]: + raw = _mapping(value, "checks.severity_overrides") + results: dict[str, Severity] = {} + for rule_id, severity in raw.items(): + if not isinstance(rule_id, str) or not _RULE_ID.fullmatch(rule_id): + raise ConfigurationError(f"Invalid rule ID in severity overrides: {rule_id!r}.") + results[rule_id] = parse_threshold(_string(severity, f"severity_overrides.{rule_id}")) + return results + + +def _paths(value: Any) -> tuple[str, ...]: + paths = _string_list(value, "paths") + if not paths: + raise ConfigurationError("paths must include at least one route.") + normalized: list[str] = [] + for path in paths: + parsed = urlsplit(path) + if parsed.scheme or parsed.netloc or parsed.fragment: + raise ConfigurationError(f"Path must be origin-relative without a fragment: {path!r}.") + route = parsed.path or "/" + if not route.startswith("/") or ".." in PurePosixPath(route).parts: + raise ConfigurationError(f"Unsafe route path: {path!r}.") + if parsed.query: + route = f"{route}?{parsed.query}" + if route not in normalized: + normalized.append(route) + return tuple(normalized) + + +def _rule_ids(value: Any, field: str) -> tuple[str, ...]: + values = _string_list(value, field) + for rule_id in values: + if not _RULE_ID.fullmatch(rule_id): + raise ConfigurationError(f"Invalid rule ID in {field}: {rule_id!r}.") + return tuple(values) + + +def _host_matches(hostname: str, allowed: str) -> bool: + if allowed.startswith("*."): + suffix = allowed[1:] + return hostname.endswith(suffix) and hostname != suffix[1:] + return hostname == allowed + + +def _normalize_host(value: str) -> str: + host = value.rstrip(".").lower() + if not host or "/" in host or "://" in host: + raise ConfigurationError(f"Invalid allowed host: {value!r}.") + wildcard = host.startswith("*.") + raw_hostname = host[2:] if wildcard else host + if "*" in raw_hostname: + raise ConfigurationError(f"Invalid allowed host: {value!r}.") + try: + normalized = raw_hostname.encode("idna").decode("ascii") + except UnicodeError as error: + raise ConfigurationError(f"Invalid allowed host: {value!r}.") from error + if len(normalized) > _MAX_HOSTNAME_LENGTH: + raise ConfigurationError(f"Invalid allowed host: {value!r}.") + try: + ipaddress.ip_address(normalized) + except ValueError: + if any(not _HOST_LABEL.fullmatch(label) for label in normalized.split(".")): + raise ConfigurationError(f"Invalid allowed host: {value!r}.") from None + else: + if wildcard: + raise ConfigurationError("Wildcard allowed hosts cannot target IP addresses.") + return f"*.{normalized}" if wildcard else normalized + + +def _validate_rule_references( + disabled: frozenset[str], + overrides: Mapping[str, Severity], + required_headers: tuple[RequiredHeaderPolicy, ...], +) -> None: + from previewshield.checks import RULES # noqa: PLC0415 - avoids policy/check cycle + + known = set(RULES) + known.update(requirement.rule_id for requirement in required_headers) + unknown = sorted((set(disabled) | set(overrides)) - known) + if unknown: + raise ConfigurationError(f"Unknown rule ID(s): {', '.join(unknown)}.") + + +def _reject_unknown(raw: Mapping[str, Any], allowed: set[str], field: str) -> None: + unknown = sorted(str(key) for key in raw if key not in allowed) + if unknown: + raise ConfigurationError(f"Unknown {field} option(s): {', '.join(unknown)}.") + + +def _mapping(value: Any, field: str) -> Mapping[str, Any]: + if not isinstance(value, dict): + raise ConfigurationError(f"{field} must be a YAML mapping.") + return value + + +def _string(value: Any, field: str) -> str: + if not isinstance(value, str) or not value.strip(): + raise ConfigurationError(f"{field} must be a non-empty string.") + return value.strip() + + +def _optional_string(value: Any, field: str) -> str | None: + if value is None: + return None + return clean_text(_string(value, field), limit=500) + + +def _string_list(value: Any, field: str) -> tuple[str, ...]: + if not isinstance(value, list) or any(not isinstance(item, str) for item in value): + raise ConfigurationError(f"{field} must be a YAML list of strings.") + normalized = tuple(item.strip() for item in value) + if any(not item for item in normalized): + raise ConfigurationError(f"{field} must not contain empty values.") + return normalized + + +def _user_agent(value: Any) -> str: + result = _string(value, "network.user_agent") + if clean_text(result, limit=200) != result: + raise ConfigurationError( + "network.user_agent must be at most 200 characters without control characters." + ) + try: + result.encode("latin-1") + except UnicodeEncodeError as error: + raise ConfigurationError( + "network.user_agent must contain ISO-8859-1 characters only." + ) from error + return result + + +def _boolean(value: Any, field: str) -> bool: + if not isinstance(value, bool): + raise ConfigurationError(f"{field} must be true or false.") + return value + + +def _integer(value: Any, field: str, *, minimum: int, maximum: int) -> int: + if isinstance(value, bool) or not isinstance(value, int): + raise ConfigurationError(f"{field} must be an integer.") + if not minimum <= value <= maximum: + raise ConfigurationError(f"{field} must be between {minimum} and {maximum}.") + return int(value) + + +def _number(value: Any, field: str, *, minimum: float, maximum: float) -> float: + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ConfigurationError(f"{field} must be a number.") + result = float(value) + if not minimum <= result <= maximum: + raise ConfigurationError(f"{field} must be between {minimum} and {maximum}.") + return result diff --git a/src/previewshield/py.typed b/src/previewshield/py.typed new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/previewshield/py.typed @@ -0,0 +1 @@ + diff --git a/src/previewshield/reporters/__init__.py b/src/previewshield/reporters/__init__.py new file mode 100644 index 0000000..da9cf92 --- /dev/null +++ b/src/previewshield/reporters/__init__.py @@ -0,0 +1,271 @@ +"""Safe, deterministic report rendering for PreviewShield.""" + +from __future__ import annotations + +import hashlib +import importlib +import math +import re +from collections.abc import Callable, Mapping, Sequence +from typing import TypeAlias, cast +from urllib.parse import urlsplit, urlunsplit + +from previewshield.models import DiffReport, Finding, FindingDelta, ScanReport +from previewshield.utils import clean_text, markdown_code + +Report: TypeAlias = ScanReport | DiffReport +JsonScalar: TypeAlias = str | int | float | bool | None +JsonValue: TypeAlias = JsonScalar | list["JsonValue"] | dict[str, "JsonValue"] +Renderer: TypeAlias = Callable[[Report], str] + +EVIDENCE_LIMIT = 240 +TEXT_LIMIT = 2_000 +REDACTED = "[REDACTED]" + +_CANONICAL_FORMATS = ("console", "html", "json", "junit", "markdown", "sarif") +_FORMAT_ALIASES = { + "md": "markdown", + "text": "console", + "txt": "console", + "xml": "junit", +} +_SENSITIVE_KEY_PARTS = ( + "api-key", + "apikey", + "authorization", + "cookie", + "credential", + "csrf", + "password", + "passwd", + "private-key", + "proxy-authenticate", + "secret", + "session", + "token", +) +_URL_KEYS = { + "final_url", + "helpuri", + "location", + "requested_url", + "references", + "target", + "url", +} +_SECRET_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = ( + ( + re.compile( + r"(?i)(\b(?:api[-_ ]?key|access[-_ ]?token|refresh[-_ ]?token|csrf(?:token)?|" + r"password|passwd|secret|session(?:id)?|token)[\s\"']*[:=][\s\"']*)" + r"([^\s\"',;&}]+)" + ), + rf"\g<1>{REDACTED}", + ), + (re.compile(r"(?i)\b(bearer|basic)\s+[A-Za-z0-9._~+/=-]+"), rf"\g<1> {REDACTED}"), + ( + re.compile(r"(?i)(\b(?:set-cookie|cookie|authorization|proxy-authorization)\s*:)\s*.*"), + rf"\g<1> {REDACTED}", + ), + ( + re.compile(r"\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b"), + REDACTED, + ), + ( + re.compile(r"\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})\b"), + REDACTED, + ), + ( + re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----.*", re.IGNORECASE), + REDACTED, + ), +) +_EMBEDDED_URL = re.compile(r"https?://[^\s<>\"']+", re.IGNORECASE) +_FINGERPRINT = re.compile(r"^[a-f0-9]{16,128}$", re.IGNORECASE) + + +def supported_formats() -> tuple[str, ...]: + """Return canonical output names in stable order.""" + + return _CANONICAL_FORMATS + + +def render(report: Report, format_name: str) -> str: + """Render a scan or diff report using a registered output format. + + Short aliases are accepted for CLI ergonomics, while :func:`supported_formats` + intentionally returns only canonical names. + """ + + normalized = format_name.strip().lower().lstrip(".") + normalized = _FORMAT_ALIASES.get(normalized, normalized) + renderers = _load_renderers() + try: + renderer = renderers[normalized] + except KeyError as error: + choices = ", ".join(_CANONICAL_FORMATS) + msg = f"Unsupported report format {format_name!r}. Choose one of: {choices}." + raise ValueError(msg) from error + return renderer(report) + + +def _load_renderers() -> dict[str, Renderer]: + """Import renderers lazily so helper functions remain cycle-free.""" + + locations = { + "console": ("previewshield.reporters.console", "render_console"), + "html": ("previewshield.reporters.html", "render_html"), + "json": ("previewshield.reporters.json_report", "render_json"), + "junit": ("previewshield.reporters.junit", "render_junit"), + "markdown": ("previewshield.reporters.markdown", "render_markdown"), + "sarif": ("previewshield.reporters.sarif", "render_sarif"), + } + return { + name: cast(Renderer, getattr(importlib.import_module(module_name), function_name)) + for name, (module_name, function_name) in locations.items() + } + + +def _is_sensitive_key(key: str) -> bool: + normalized = re.sub(r"[^a-z0-9]+", "-", key.lower()).strip("-") + return any(part in normalized for part in _SENSITIVE_KEY_PARTS) + + +def _redact_embedded_url(match: re.Match[str]) -> str: + value = match.group(0) + trailing = "" + while value and value[-1] in ".,;:!?)]:": + trailing = value[-1] + trailing + value = value[:-1] + return _url_without_secrets(value) + trailing + + +def _url_without_secrets(value: str) -> str: + try: + parsed = urlsplit(value) + hostname = parsed.hostname or "" + port = parsed.port + except ValueError: + return REDACTED + + if not parsed.scheme or not parsed.netloc: + return value + if ":" in hostname and not hostname.startswith("["): + hostname = f"[{hostname}]" + netloc = f"{hostname}:{port}" if port is not None else hostname + return urlunsplit((parsed.scheme, netloc, parsed.path, "", "")) + + +def _safe_text(value: str, *, limit: int = TEXT_LIMIT) -> str: + """Normalize one untrusted value, remove common secrets, and cap its size.""" + + working_limit = max(limit * 4, TEXT_LIMIT) + safe = clean_text(str(value), limit=working_limit) + safe = _EMBEDDED_URL.sub(_redact_embedded_url, safe) + for pattern, replacement in _SECRET_PATTERNS: + safe = pattern.sub(replacement, safe) + safe = clean_text(safe, limit=limit) + if len(safe) > limit: + safe = f"{safe[: limit - 1]}…" + return safe + + +def _safe_url(value: str, *, limit: int = TEXT_LIMIT) -> str: + """Remove URL credentials, query parameters, and fragments.""" + + return _safe_text(_url_without_secrets(_safe_text(value, limit=limit)), limit=limit) + + +def _safe_evidence(value: str | None) -> str | None: + if value is None: + return None + return _safe_text(value, limit=EVIDENCE_LIMIT) + + +def _markdown_value(value: str, *, limit: int = 500) -> str: + """Return a GFM table-safe code span for an untrusted value.""" + + return markdown_code(_safe_text(value, limit=limit)).replace("|", "|") + + +def _sanitize_primitive(value: object, *, key: str = "") -> JsonValue: + """Recursively retain a JSON-compatible shape while removing secrets.""" + + if value is None or isinstance(value, (bool, int)): + output: JsonValue = value + elif isinstance(value, float): + output = value if math.isfinite(value) else None + elif isinstance(value, str): + if key.lower() in _URL_KEYS or key.lower().endswith("_url"): + output = _safe_url(value) + else: + limit = EVIDENCE_LIMIT if key.lower() == "evidence" else TEXT_LIMIT + output = _safe_text(value, limit=limit) + elif isinstance(value, Mapping): + mapped_output: dict[str, JsonValue] = {} + for raw_key in sorted(value, key=str): + safe_key = _safe_text(str(raw_key), limit=200) + item = value[raw_key] + if _is_sensitive_key(safe_key): + mapped_output[safe_key] = _redacted_like(item) + else: + mapped_output[safe_key] = _sanitize_primitive(item, key=safe_key) + output = mapped_output + elif isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): + output = [_sanitize_primitive(item, key=key) for item in value] + else: + output = _safe_text(str(value)) + return output + + +def _redacted_like(value: object) -> JsonValue: + """Redact a value without changing its container shape.""" + + if isinstance(value, Mapping): + return { + _safe_text(str(key), limit=200): _redacted_like(item) for key, item in value.items() + } + if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): + return [_redacted_like(item) for item in value] + return REDACTED + + +def _finding_sort_key(finding: Finding) -> tuple[int, str, str, str, str]: + return ( + -finding.severity.rank, + finding.rule_id.casefold(), + finding.target.casefold(), + finding.subject.casefold(), + finding.fingerprint, + ) + + +def _sorted_findings(findings: Sequence[Finding]) -> tuple[Finding, ...]: + return tuple(sorted(findings, key=_finding_sort_key)) + + +def _delta_sort_key(delta: FindingDelta) -> tuple[int, int, str, str, str]: + kind_order = {"regression": 0, "resolved": 1, "changed": 2, "unchanged": 3} + finding = delta.finding + return ( + kind_order[delta.kind.value], + -finding.severity.rank, + finding.rule_id.casefold(), + finding.target.casefold(), + finding.fingerprint, + ) + + +def _sorted_deltas(deltas: Sequence[FindingDelta]) -> tuple[FindingDelta, ...]: + return tuple(sorted(deltas, key=_delta_sort_key)) + + +def _stable_fingerprint(finding: Finding) -> str: + fingerprint = finding.fingerprint + if _FINGERPRINT.fullmatch(fingerprint): + return fingerprint.lower() + source = f"{finding.rule_id}\x1f{finding.target}\x1f{finding.subject}" + return hashlib.sha256(source.encode("utf-8", errors="replace")).hexdigest()[:24] + + +__all__ = ["Report", "render", "supported_formats"] diff --git a/src/previewshield/reporters/console.py b/src/previewshield/reporters/console.py new file mode 100644 index 0000000..9921e83 --- /dev/null +++ b/src/previewshield/reporters/console.py @@ -0,0 +1,106 @@ +"""Human-readable, ANSI-free console reports.""" + +from __future__ import annotations + +from previewshield.models import DiffReport, Finding, ScanReport, Severity +from previewshield.reporters import ( + Report, + _safe_evidence, + _safe_text, + _safe_url, + _sorted_deltas, + _sorted_findings, +) + + +def _console_value(value: str, *, limit: int = 500) -> str: + return ( + _safe_text(value, limit=limit) + .replace("\t", " ") + .replace("\u2028", " ") + .replace("\u2029", " ") + ) + + +def _status(passed: bool) -> str: + return "PASS" if passed else "FAIL" + + +def _counts_line(report: ScanReport) -> str: + return " ".join(f"{severity.value}: {report.counts[severity.value]}" for severity in Severity) + + +def _finding_lines(finding: Finding, *, prefix: str = "") -> list[str]: + lines = [ + f"{prefix}[{finding.severity.value.upper()}] {_console_value(finding.rule_id, limit=100)} " + f"- {_console_value(finding.title)}", + f"{prefix} Target: {_safe_url(finding.target)}", + f"{prefix} Issue: {_console_value(finding.message)}", + f"{prefix} Fix: {_console_value(finding.remediation)}", + ] + evidence = _safe_evidence(finding.evidence) + if evidence: + lines.append(f"{prefix} Evidence: {_console_value(evidence, limit=240)}") + return lines + + +def _render_scan(report: ScanReport) -> str: + lines = [ + f"PreviewShield scan: {_status(report.passed)}", + f"Target: {_safe_url(report.target)}", + f"Policy: {_console_value(report.policy_name)}", + f"Score: {report.score}/100 Grade: {_console_value(report.grade, limit=20)}", + f"Failure threshold: {report.fail_on.value}", + f"Findings: {_counts_line(report)}", + ] + findings = _sorted_findings(report.findings) + if not findings: + lines.extend(("", "No security findings.")) + else: + lines.extend(("", f"Security findings ({len(findings)}):")) + for finding in findings: + lines.extend(_finding_lines(finding, prefix=" ")) + return "\n".join(lines) + "\n" + + +def _render_diff(report: DiffReport) -> str: + changed = sum(delta.kind.value == "changed" for delta in report.deltas) + lines = [ + f"PreviewShield diff: {_status(report.passed)}", + f"Baseline: {_safe_url(report.baseline.target)} " + f"({_console_value(report.baseline.grade, limit=20)}, {report.baseline.score}/100)", + f"Preview: {_safe_url(report.preview.target)} " + f"({_console_value(report.preview.grade, limit=20)}, {report.preview.score}/100)", + f"Policy: {_console_value(report.policy_name)}", + f"Failure threshold: {report.fail_on.value}", + f"Changes: {len(report.regressions)} regressions, {len(report.resolved)} resolved, " + f"{changed} changed, {len(report.unchanged)} unchanged", + ] + deltas = _sorted_deltas(report.deltas) + visible = tuple(delta for delta in deltas if delta.kind.value != "unchanged") + if not visible: + lines.extend(("", "No security changes.")) + else: + current_kind = "" + for delta in visible: + kind = delta.kind.value + if kind != current_kind: + lines.extend( + ("", f"{kind.upper()} ({sum(item.kind is delta.kind for item in visible)}):") + ) + current_kind = kind + lines.extend(_finding_lines(delta.finding, prefix=" ")) + return "\n".join(lines) + "\n" + + +def render_console(report: Report) -> str: + """Render a report without terminal control sequences or ANSI styling.""" + + if isinstance(report, ScanReport): + return _render_scan(report) + return _render_diff(report) + + +render = render_console + +__all__ = ["render", "render_console"] diff --git a/src/previewshield/reporters/html.py b/src/previewshield/reporters/html.py new file mode 100644 index 0000000..c2440ff --- /dev/null +++ b/src/previewshield/reporters/html.py @@ -0,0 +1,185 @@ +"""Self-contained HTML report renderer.""" + +from __future__ import annotations + +from html import escape + +from previewshield.models import DiffReport, Finding, FindingDelta, ScanReport, Severity +from previewshield.reporters import ( + Report, + _safe_evidence, + _safe_text, + _safe_url, + _sorted_deltas, + _sorted_findings, +) + +_STYLE = """ +:root { color-scheme: light dark; --bg:#0b1020; --panel:#151c30; --text:#edf2ff; + --muted:#a9b5d1; --line:#2a3553; --good:#35c98b; --bad:#ff667a; --warn:#f6c453; } +* { box-sizing:border-box; } +body { margin:0; background:var(--bg); color:var(--text); font:15px/1.55 system-ui,sans-serif; } +main { width:min(1180px,calc(100% - 32px)); margin:32px auto 56px; } +h1,h2 { line-height:1.2; } h1 { margin:0 0 8px; } h2 { margin-top:30px; } +.muted { color:var(--muted); } +.grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(180px,1fr)); gap:12px; } +.card { background:var(--panel); border:1px solid var(--line); border-radius:12px; padding:16px; } +.metric { font-size:1.55rem; font-weight:750; } +.pass { color:var(--good); } .fail { color:var(--bad); } +code { overflow-wrap:anywhere; } +table { width:100%; border-collapse:collapse; background:var(--panel); } +th,td { padding:11px 12px; text-align:left; vertical-align:top; border:1px solid var(--line); } +th { color:var(--muted); font-size:.82rem; text-transform:uppercase; letter-spacing:.04em; } +.badge { display:inline-block; border-radius:999px; padding:2px 9px; font-weight:750; + font-size:.76rem; } +.critical,.high,.regression { background:#66293a; color:#ffdce3; } +.medium,.changed { background:#644f20; color:#fff0bd; } +.low,.info { background:#25385f; color:#dbe7ff; } +.resolved { background:#174b3b; color:#c9ffe9; } +.issue { margin-bottom:7px; } .fix,.evidence { color:var(--muted); font-size:.9rem; } +.empty { padding:22px; border:1px dashed var(--line); border-radius:12px; color:var(--muted); } +footer { margin-top:32px; padding-top:18px; border-top:1px solid var(--line); + color:var(--muted); font-size:.85rem; } +@media (max-width:720px) { + main { width:min(100% - 20px,1180px); margin-top:18px; } + table { display:block; overflow-x:auto; } +} +""".strip() + + +def _html(value: str, *, limit: int = 2_000) -> str: + return escape(_safe_text(value, limit=limit), quote=True) + + +def _html_url(value: str) -> str: + return escape(_safe_url(value), quote=True) + + +def _finding_cells(finding: Finding) -> str: + evidence = _safe_evidence(finding.evidence) + evidence_html = ( + f'
Evidence: {_html(evidence, limit=240)}
' + if evidence + else "" + ) + return ( + f'' + f"{finding.severity.value.upper()}" + f"{_html(finding.rule_id, limit=100)}
" + f'{_html(finding.category, limit=100)}' + f"{_html_url(finding.target)}" + f'
{_html(finding.title, limit=500)}
' + f"{_html(finding.message)}
" + f'
Fix: {_html(finding.remediation)}
' + f"{evidence_html}" + ) + + +def _scan_table(findings: tuple[Finding, ...]) -> str: + if not findings: + return '
No security findings were detected.
' + rows = "".join(f"{_finding_cells(finding)}" for finding in findings) + return ( + "" + f"{rows}
SeverityRuleTargetFinding and remediation
" + ) + + +def _delta_table(deltas: tuple[FindingDelta, ...]) -> str: + if not deltas: + return '
No security changes were detected.
' + rows = "".join( + f'{delta.kind.value.upper()}' + f"{_finding_cells(delta.finding)}" + for delta in deltas + ) + return ( + "" + f"{rows}
ChangeSeverityRuleTargetFinding and remediation
" + ) + + +def _counts_cards(report: ScanReport) -> str: + cards = "".join( + f'
{severity.value.title()}
' + f'
{report.counts[severity.value]}
' + for severity in Severity + ) + return f'
{cards}
' + + +def _scan_body(report: ScanReport) -> str: + status_class = "pass" if report.passed else "fail" + return ( + "

PreviewShield security scan

" + f'

Generated {_html(report.generated_at, limit=100)}

' + '
' + f'
Status
' + f"{'PASS' if report.passed else 'FAIL'}
" + '
Score
' + f'
{report.score}/100
' + '
Grade
' + f'
{_html(report.grade, limit=20)}
' + '
Threshold
' + f'
{report.fail_on.value}
' + "
" + f"

Target: {_html_url(report.target)}
" + f"Policy: {_html(report.policy_name, limit=200)}

" + "

Severity summary

" + f"{_counts_cards(report)}" + f"

Findings ({len(report.findings)})

" + f"{_scan_table(_sorted_findings(report.findings))}" + ) + + +def _diff_body(report: DiffReport) -> str: + status_class = "pass" if report.passed else "fail" + deltas = _sorted_deltas(report.deltas) + regressions = sum(delta.kind.value == "regression" for delta in deltas) + resolved = sum(delta.kind.value == "resolved" for delta in deltas) + changed = sum(delta.kind.value == "changed" for delta in deltas) + visible = tuple(delta for delta in deltas if delta.kind.value != "unchanged") + return ( + "

PreviewShield security diff

" + f'

Generated {_html(report.generated_at, limit=100)}

' + '
' + f'
Status
' + f"{'PASS' if report.passed else 'FAIL'}
" + '
Regressions
' + f'
{regressions}
' + '
Resolved
' + f'
{resolved}
' + '
Changed
' + f'
{changed}
' + "
" + f"

Baseline: {_html_url(report.baseline.target)} " + f"({report.baseline.score}/100, {_html(report.baseline.grade, limit=20)})
" + f"Preview: {_html_url(report.preview.target)} " + f"({report.preview.score}/100, {_html(report.preview.grade, limit=20)})
" + f"Policy: {_html(report.policy_name, limit=200)}

" + f"

Security changes ({len(visible)})

{_delta_table(visible)}" + ) + + +def render_html(report: Report) -> str: + """Render an escaped, standalone HTML document with no external assets.""" + + body = _scan_body(report) if isinstance(report, ScanReport) else _diff_body(report) + document = ( + "\n" + '' + '' + '" + "PreviewShield security report" + f"
{body}" + f"
PreviewShield {_html(report.tool_version, limit=100)} - " + "Secret-safe deterministic report
" + "
" + ) + return f"{document}\n" + + +render = render_html + +__all__ = ["render", "render_html"] diff --git a/src/previewshield/reporters/json_report.py b/src/previewshield/reporters/json_report.py new file mode 100644 index 0000000..8256c2c --- /dev/null +++ b/src/previewshield/reporters/json_report.py @@ -0,0 +1,20 @@ +"""JSON report renderer.""" + +from __future__ import annotations + +import json + +from previewshield.models import to_primitive +from previewshield.reporters import Report, _sanitize_primitive + + +def render_json(report: Report) -> str: + """Serialize a report as pretty, stable, secret-safe JSON.""" + + payload = _sanitize_primitive(to_primitive(report)) + return f"{json.dumps(payload, indent=2, sort_keys=True, ensure_ascii=False, allow_nan=False)}\n" + + +render = render_json + +__all__ = ["render", "render_json"] diff --git a/src/previewshield/reporters/junit.py b/src/previewshield/reporters/junit.py new file mode 100644 index 0000000..5a6e4ea --- /dev/null +++ b/src/previewshield/reporters/junit.py @@ -0,0 +1,155 @@ +"""JUnit XML output for CI test-report integrations.""" + +from __future__ import annotations + +# ElementTree is used only for XML generation, never for parsing input. +import xml.etree.ElementTree as ET # nosec B405 +from typing import TypeAlias + +from previewshield.models import Finding, ScanReport +from previewshield.reporters import ( + Report, + _safe_evidence, + _safe_text, + _safe_url, + _sorted_deltas, + _sorted_findings, +) + +Case: TypeAlias = tuple[str, Finding | None, bool, str] +_XML_LOW_START = 0x20 +_XML_LOW_END = 0xD7FF +_XML_MIDDLE_START = 0xE000 +_XML_MIDDLE_END = 0xFFFD +_XML_HIGH_START = 0x10000 +_XML_HIGH_END = 0x10FFFF + + +def _xml_text(value: str, *, limit: int = 2_000) -> str: + safe = _safe_text(value, limit=limit) + return "".join( + character + for character in safe + if character in "\t\n\r" + or _XML_LOW_START <= ord(character) <= _XML_LOW_END + or _XML_MIDDLE_START <= ord(character) <= _XML_MIDDLE_END + or _XML_HIGH_START <= ord(character) <= _XML_HIGH_END + ) + + +def _cases(report: Report) -> tuple[Case, ...]: + if isinstance(report, ScanReport): + findings = _sorted_findings(report.findings) + if not findings: + return (("scan completed without findings", None, False, "clean"),) + return tuple( + ( + f"{_safe_text(finding.rule_id, limit=100)} at {_safe_url(finding.target)}", + finding, + finding.severity.rank >= report.fail_on.rank, + "finding", + ) + for finding in findings + ) + + visible = tuple( + delta for delta in _sorted_deltas(report.deltas) if delta.kind.value != "unchanged" + ) + if not visible: + return (("diff completed without security changes", None, False, "clean"),) + return tuple( + ( + f"{_safe_text(delta.finding.rule_id, limit=100)} at {_safe_url(delta.finding.target)}", + delta.finding, + delta.kind.value == "regression" and delta.finding.severity.rank >= report.fail_on.rank, + delta.kind.value, + ) + for delta in visible + ) + + +def _details(finding: Finding, status: str) -> str: + lines = [ + f"Status: {status}", + f"Severity: {finding.severity.value}", + f"Target: {_safe_url(finding.target)}", + f"Issue: {_safe_text(finding.message)}", + f"Remediation: {_safe_text(finding.remediation)}", + ] + evidence = _safe_evidence(finding.evidence) + if evidence: + lines.append(f"Evidence: {evidence}") + return _xml_text("\n".join(lines), limit=4_000) + + +def _add_properties(suite: ET.Element, report: Report) -> None: + properties = ET.SubElement(suite, "properties") + target = report.target if isinstance(report, ScanReport) else report.preview.target + values = { + "previewshield.kind": "scan" if isinstance(report, ScanReport) else "diff", + "previewshield.passed": str(report.passed).lower(), + "previewshield.policy": _safe_text(report.policy_name, limit=200), + "previewshield.target": _safe_url(target), + "previewshield.version": _safe_text(report.tool_version, limit=100), + } + for name in sorted(values): + ET.SubElement( + properties, + "property", + {"name": name, "value": _xml_text(values[name])}, + ) + + +def render_junit(report: Report) -> str: + """Render deterministic JUnit XML with threshold-crossing findings as failures.""" + + cases = _cases(report) + failures = sum(case[2] for case in cases) + suite = ET.Element( + "testsuite", + { + "name": "PreviewShield", + "tests": str(len(cases)), + "failures": str(failures), + "errors": "0", + "skipped": "0", + "time": "0", + "timestamp": _xml_text(report.generated_at, limit=100), + }, + ) + _add_properties(suite, report) + for name, finding, failed, status in cases: + case = ET.SubElement( + suite, + "testcase", + { + "name": _xml_text(name, limit=500), + "classname": "previewshield.security", + "time": "0", + }, + ) + if finding is None: + continue + details = _details(finding, status) + if failed: + failure = ET.SubElement( + case, + "failure", + { + "message": _xml_text(finding.title, limit=500), + "type": "PreviewShieldSecurityFinding", + }, + ) + failure.text = details + else: + system_out = ET.SubElement(case, "system-out") + system_out.text = details + + ET.indent(suite, space=" ") + document = ET.tostring(suite, encoding="unicode", xml_declaration=True) + return f"{document}\n" + + +render = render_junit + +__all__ = ["render", "render_junit"] diff --git a/src/previewshield/reporters/markdown.py b/src/previewshield/reporters/markdown.py new file mode 100644 index 0000000..b13ed96 --- /dev/null +++ b/src/previewshield/reporters/markdown.py @@ -0,0 +1,174 @@ +"""Concise GitHub-flavored Markdown reports.""" + +from __future__ import annotations + +from collections.abc import Sequence + +from previewshield.models import DiffReport, Finding, FindingDelta, ScanReport, Severity +from previewshield.reporters import ( + Report, + _markdown_value, + _safe_evidence, + _safe_url, + _sorted_deltas, + _sorted_findings, +) + +_MAX_TABLE_ROWS = 25 +_MAX_FIXES = 10 + + +def _status(passed: bool) -> str: + return "PASS :white_check_mark:" if passed else "FAIL :x:" + + +def _finding_row(finding: Finding) -> str: + return " | ".join( + ( + f"| **{finding.severity.value.upper()}**", + _markdown_value(finding.rule_id, limit=100), + _markdown_value(_safe_url(finding.target), limit=300), + f"{_markdown_value(finding.title, limit=200)} — " + f"{_markdown_value(finding.message, limit=300)} |", + ) + ) + + +def _findings_table(findings: Sequence[Finding]) -> list[str]: + lines = [ + "| Severity | Rule | Target | Finding |", + "| --- | --- | --- | --- |", + ] + visible = findings[:_MAX_TABLE_ROWS] + lines.extend(_finding_row(finding) for finding in visible) + if len(findings) > len(visible): + lines.append( + f"\n_{len(findings) - len(visible)} additional findings omitted from this summary._" + ) + return lines + + +def _fixes(findings: Sequence[Finding]) -> list[str]: + if not findings: + return [] + lines = ["", "### Recommended fixes", ""] + for finding in findings[:_MAX_FIXES]: + fix = _markdown_value(finding.remediation, limit=500) + lines.append(f"- {_markdown_value(finding.rule_id, limit=100)}: {fix}") + evidence = _safe_evidence(finding.evidence) + if evidence: + lines.append(f" - Evidence: {_markdown_value(evidence, limit=240)}") + if len(findings) > _MAX_FIXES: + lines.append( + f"- _{len(findings) - _MAX_FIXES} additional fixes are available in the full report._" + ) + return lines + + +def _counts(report: ScanReport) -> str: + return " · ".join( + f"{severity.value} **{report.counts[severity.value]}**" for severity in Severity + ) + + +def _render_scan(report: ScanReport) -> str: + findings = _sorted_findings(report.findings) + lines = [ + "## PreviewShield security scan", + "", + f"**{_status(report.passed)}** · Score **{report.score}/100** · " + f"Grade **{_markdown_value(report.grade, limit=20)}**", + "", + f"Target: {_markdown_value(_safe_url(report.target), limit=500)} ", + f"Policy: {_markdown_value(report.policy_name, limit=200)} ", + f"Failure threshold: **{report.fail_on.value}**", + "", + _counts(report), + ] + if findings: + lines.extend(("", f"### Findings ({len(findings)})", "")) + lines.extend(_findings_table(findings)) + lines.extend(_fixes(findings)) + else: + lines.extend(("", "No security findings were detected.")) + return "\n".join(lines) + "\n" + + +def _delta_note(delta: FindingDelta) -> str: + if delta.baseline is None or delta.preview is None: + return "" + if delta.baseline.severity is delta.preview.severity: + return "" + return f" ({delta.baseline.severity.value} → {delta.preview.severity.value})" + + +def _delta_table(deltas: Sequence[FindingDelta]) -> list[str]: + lines = [ + "| Severity | Rule | Target | Finding |", + "| --- | --- | --- | --- |", + ] + visible = deltas[:_MAX_TABLE_ROWS] + for delta in visible: + finding = delta.finding + message = f"{finding.message}{_delta_note(delta)}" + lines.append( + " | ".join( + ( + f"| **{finding.severity.value.upper()}**", + _markdown_value(finding.rule_id, limit=100), + _markdown_value(_safe_url(finding.target), limit=300), + f"{_markdown_value(finding.title, limit=200)} — " + f"{_markdown_value(message, limit=300)} |", + ) + ) + ) + if len(deltas) > len(visible): + lines.append( + f"\n_{len(deltas) - len(visible)} additional changes omitted from this summary._" + ) + return lines + + +def _render_diff(report: DiffReport) -> str: + deltas = _sorted_deltas(report.deltas) + regressions = tuple(delta for delta in deltas if delta.kind.value == "regression") + resolved = tuple(delta for delta in deltas if delta.kind.value == "resolved") + changed = tuple(delta for delta in deltas if delta.kind.value == "changed") + lines = [ + "## PreviewShield security diff", + "", + f"**{_status(report.passed)}** · **{len(regressions)}** regressions · " + f"**{len(resolved)}** resolved · **{len(changed)}** changed", + "", + f"Baseline: {_markdown_value(_safe_url(report.baseline.target), limit=500)} " + f"(**{report.baseline.score}/100**, {_markdown_value(report.baseline.grade, limit=20)}) ", + f"Preview: {_markdown_value(_safe_url(report.preview.target), limit=500)} " + f"(**{report.preview.score}/100**, {_markdown_value(report.preview.grade, limit=20)}) ", + f"Policy: {_markdown_value(report.policy_name, limit=200)}", + ] + if regressions: + lines.extend(("", f"### Regressions ({len(regressions)})", "")) + lines.extend(_delta_table(regressions)) + lines.extend(_fixes(tuple(delta.finding for delta in regressions))) + else: + lines.extend(("", "### Regressions", "", "No security regressions were detected.")) + if resolved: + lines.extend(("", f"### Resolved ({len(resolved)})", "")) + lines.extend(_delta_table(resolved)) + if changed: + lines.extend(("", f"### Other changes ({len(changed)})", "")) + lines.extend(_delta_table(changed)) + return "\n".join(lines) + "\n" + + +def render_markdown(report: Report) -> str: + """Render a compact Markdown summary suitable for a pull request comment.""" + + if isinstance(report, ScanReport): + return _render_scan(report) + return _render_diff(report) + + +render = render_markdown + +__all__ = ["render", "render_markdown"] diff --git a/src/previewshield/reporters/sarif.py b/src/previewshield/reporters/sarif.py new file mode 100644 index 0000000..417f9b5 --- /dev/null +++ b/src/previewshield/reporters/sarif.py @@ -0,0 +1,203 @@ +"""SARIF 2.1.0 output for GitHub code scanning.""" + +from __future__ import annotations + +import json +import re +from urllib.parse import urlsplit + +from previewshield.models import Finding, ScanReport, Severity +from previewshield.reporters import ( + Report, + _safe_evidence, + _safe_text, + _safe_url, + _sorted_findings, + _stable_fingerprint, +) + +_SARIF_SCHEMA = "https://json.schemastore.org/sarif-2.1.0.json" +_INFORMATION_URI = "https://github.com/devUmut35/PreviewShield" +_GLOBAL_LOCATION = ".previewshield.yml" +_RULE_ID_CHARACTER = re.compile(r"[^A-Za-z0-9._-]+") + + +def _level(severity: Severity) -> str: + if severity in {Severity.CRITICAL, Severity.HIGH}: + return "error" + if severity is Severity.MEDIUM: + return "warning" + return "note" + + +def _security_severity(severity: Severity) -> str: + return { + Severity.CRITICAL: "9.5", + Severity.HIGH: "8.0", + Severity.MEDIUM: "5.5", + Severity.LOW: "3.0", + Severity.INFO: "0.0", + }[severity] + + +def _rule_id(finding: Finding) -> str: + safe = _RULE_ID_CHARACTER.sub("-", _safe_text(finding.rule_id, limit=200)).strip("-") + return safe or f"previewshield-{_stable_fingerprint(finding)}" + + +def _help_uri(finding: Finding) -> str | None: + for reference in finding.references: + safe = _safe_url(reference) + if urlsplit(safe).scheme in {"http", "https"}: + return safe + return None + + +def _rule(finding: Finding) -> dict[str, object]: + rule: dict[str, object] = { + "id": _rule_id(finding), + "name": _rule_id(finding), + "shortDescription": {"text": _safe_text(finding.title, limit=500)}, + "fullDescription": {"text": _safe_text(finding.message)}, + "help": {"text": _safe_text(finding.remediation)}, + "properties": { + "precision": "high", + "problem.severity": finding.severity.value, + "security-severity": _security_severity(finding.severity), + "tags": ["security", _safe_text(finding.category, limit=100)], + }, + } + help_uri = _help_uri(finding) + if help_uri is not None: + rule["helpUri"] = help_uri + return rule + + +def _result( + finding: Finding, + *, + rule_index: int, + baseline_state: str | None = None, +) -> dict[str, object]: + fingerprint = _stable_fingerprint(finding) + properties: dict[str, object] = { + "category": _safe_text(finding.category, limit=100), + "severity": finding.severity.value, + "subject": _safe_text(finding.subject, limit=200), + "target": _safe_url(finding.target), + "remediation": _safe_text(finding.remediation), + } + evidence = _safe_evidence(finding.evidence) + if evidence: + properties["evidence"] = evidence + result: dict[str, object] = { + "ruleId": _rule_id(finding), + "ruleIndex": rule_index, + "level": _level(finding.severity), + "kind": "fail", + "message": { + "text": f"{_safe_text(finding.title, limit=500)}: {_safe_text(finding.message)}" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": {"uri": _GLOBAL_LOCATION}, + "region": {"startLine": 1}, + }, + "logicalLocations": [ + { + "fullyQualifiedName": _safe_url(finding.target), + "kind": "webTarget", + } + ], + } + ], + "fingerprints": {"previewshield/v1": fingerprint}, + "partialFingerprints": { + "primaryLocationLineHash": fingerprint, + "previewshieldFingerprint/v1": fingerprint, + }, + "properties": properties, + } + if baseline_state is not None: + result["baselineState"] = baseline_state + properties["deltaKind"] = "regression" + return result + + +def _selected_findings(report: Report) -> tuple[tuple[Finding, str | None], ...]: + if isinstance(report, ScanReport): + return tuple((finding, None) for finding in _sorted_findings(report.findings)) + regressions = sorted( + report.regressions, + key=lambda delta: ( + -delta.finding.severity.rank, + delta.finding.rule_id.casefold(), + delta.finding.target.casefold(), + delta.finding.fingerprint, + ), + ) + return tuple( + (delta.finding, "new" if delta.baseline is None else "updated") for delta in regressions + ) + + +def render_sarif(report: Report) -> str: + """Render SARIF with repository-level results and stable GitHub fingerprints. + + Diff reports intentionally contain only regressions, making an uploaded SARIF + run actionable and preventing resolved or unchanged observations from appearing + as current alerts. Web findings use the policy file as a stable repository-level + anchor because GitHub code scanning requires a physical location for every alert. + """ + + selected = _selected_findings(report) + representative: dict[str, Finding] = {} + for finding, _baseline_state in selected: + representative.setdefault(_rule_id(finding), finding) + rule_ids = sorted(representative) + rule_indexes = {rule_id: index for index, rule_id in enumerate(rule_ids)} + rules = [_rule(representative[rule_id]) for rule_id in rule_ids] + results = [ + _result( + finding, + rule_index=rule_indexes[_rule_id(finding)], + baseline_state=baseline_state, + ) + for finding, baseline_state in selected + ] + target = report.target if isinstance(report, ScanReport) else report.preview.target + payload: dict[str, object] = { + "$schema": _SARIF_SCHEMA, + "version": "2.1.0", + "runs": [ + { + "tool": { + "driver": { + "name": "PreviewShield", + "semanticVersion": _safe_text(report.tool_version, limit=100), + "informationUri": _INFORMATION_URI, + "rules": rules, + } + }, + "invocations": [ + { + "executionSuccessful": True, + "exitCode": 0 if report.passed else 1, + } + ], + "results": results, + "properties": { + "policy": _safe_text(report.policy_name, limit=200), + "reportKind": "scan" if isinstance(report, ScanReport) else "diff", + "target": _safe_url(target), + }, + } + ], + } + return f"{json.dumps(payload, indent=2, sort_keys=True, ensure_ascii=False, allow_nan=False)}\n" + + +render = render_sarif + +__all__ = ["render", "render_sarif"] diff --git a/src/previewshield/scanner.py b/src/previewshield/scanner.py new file mode 100644 index 0000000..519d385 --- /dev/null +++ b/src/previewshield/scanner.py @@ -0,0 +1,186 @@ +"""High-level, multi-route scanning orchestration.""" + +from __future__ import annotations + +import ipaddress +import re +from collections.abc import Mapping, Sequence +from pathlib import PurePosixPath +from urllib.parse import urlsplit, urlunsplit + +from previewshield._version import __version__ +from previewshield.checks import evaluate +from previewshield.exceptions import ConfigurationError +from previewshield.models import RouteReport, ScanReport, Severity +from previewshield.network import NetworkOptions, fetch +from previewshield.policy import Policy, default_policy +from previewshield.utils import utc_now + +SCHEMA_VERSION = "1.0" +_SCORE_PENALTIES = { + Severity.INFO: 0, + Severity.LOW: 3, + Severity.MEDIUM: 8, + Severity.HIGH: 15, + Severity.CRITICAL: 25, +} +_GRADES = ((95, "A+"), (90, "A"), (80, "B"), (70, "C"), (60, "D")) +_HOST_LABEL = re.compile(r"^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$") +_MAX_HOSTNAME_LENGTH = 253 + + +def scan( # noqa: PLR0913 - public API uses explicit, optional controls + target: str, + *, + policy: Policy | None = None, + paths: Sequence[str] | None = None, + request_headers: Mapping[str, str] | None = None, + fail_on: Severity | None = None, + allow_private: bool | None = None, +) -> ScanReport: + """Scan one web origin or route and return a serializable report. + + A target containing a path is scanned as-is when the policy uses only its + default ``/`` route. Explicit CLI or policy paths are always resolved from the + target origin, which keeps baseline and preview fingerprints comparable. + """ + + active_policy = policy or default_policy() + threshold = fail_on or active_policy.fail_on + route_paths = _validate_paths(paths) if paths is not None else active_policy.paths + route_urls, normalized_target = _route_urls(target, route_paths, paths is None) + try: + options = NetworkOptions( + timeout=active_policy.network.timeout_seconds, + max_redirects=active_policy.network.max_redirects, + allow_private=( + active_policy.network.allow_private if allow_private is None else allow_private + ), + user_agent=active_policy.network.user_agent, + allowed_hosts=active_policy.network.allowed_hosts, + ) + except ValueError as error: + raise ConfigurationError(f"Invalid network policy: {error}.") from error + + route_reports: list[RouteReport] = [] + for route_url in route_urls: + if not active_policy.host_allowed(route_url): + hostname = urlsplit(route_url).hostname or "" + raise ConfigurationError( + f"Target host '{hostname}' is not included in network.allowed_hosts." + ) + snapshot = fetch(route_url, options, request_headers=request_headers) + route_reports.append( + RouteReport(snapshot=snapshot, findings=evaluate(snapshot, active_policy)) + ) + + route_tuple = tuple(route_reports) + score = _score(route_tuple) + all_findings = tuple(finding for route in route_tuple for finding in route.findings) + passed = not any(finding.severity.rank >= threshold.rank for finding in all_findings) + return ScanReport( + schema_version=SCHEMA_VERSION, + tool_version=__version__, + generated_at=utc_now(), + policy_name=active_policy.name, + target=normalized_target, + routes=route_tuple, + score=score, + grade=_grade(score), + fail_on=threshold, + passed=passed, + ) + + +def _route_urls( + target: str, + paths: Sequence[str], + preserve_single_target_path: bool, +) -> tuple[tuple[str, ...], str]: + if not isinstance(target, str) or not target.strip(): + raise ConfigurationError("Target must be a non-empty URL or hostname.") + candidate = target.strip() + if "://" not in candidate: + candidate = f"https://{candidate}" + try: + parsed = urlsplit(candidate) + port = parsed.port + except ValueError as error: + raise ConfigurationError("Target has an invalid URL authority or port.") from error + if parsed.scheme.lower() not in {"http", "https"}: + raise ConfigurationError("Target must use http:// or https://.") + if not parsed.netloc or parsed.hostname is None: + raise ConfigurationError("Target must include a hostname.") + if parsed.username is not None or parsed.password is not None: + raise ConfigurationError("Credentials in target URLs are not allowed.") + if parsed.fragment: + raise ConfigurationError("Target URL fragments are not allowed.") + + try: + hostname = parsed.hostname.rstrip(".").encode("idna").decode("ascii").lower() + except UnicodeError as error: + raise ConfigurationError("Target contains an invalid hostname.") from error + _validate_hostname(hostname) + display_host = f"[{hostname}]" if ":" in hostname else hostname + default_port = 443 if parsed.scheme.lower() == "https" else 80 + netloc = display_host if port in {None, default_port} else f"{display_host}:{port}" + origin = urlunsplit((parsed.scheme.lower(), netloc, "", "", "")) + provided_path = parsed.path or "/" + provided_route = provided_path + (f"?{parsed.query}" if parsed.query else "") + if preserve_single_target_path and tuple(paths) == ("/",) and provided_route != "/": + return (f"{origin}{provided_route}",), f"{origin}{provided_route}" + return tuple(f"{origin}{path}" for path in paths), origin + + +def _validate_hostname(hostname: str) -> None: + if len(hostname) > _MAX_HOSTNAME_LENGTH: + raise ConfigurationError("Target hostname exceeds 253 characters.") + try: + ipaddress.ip_address(hostname) + except ValueError: + if not hostname or any(not _HOST_LABEL.fullmatch(label) for label in hostname.split(".")): + raise ConfigurationError("Target contains an invalid hostname.") from None + + +def _validate_paths(paths: Sequence[str]) -> tuple[str, ...]: + if isinstance(paths, (str, bytes)) or not paths: + raise ConfigurationError("At least one origin-relative path is required.") + result: list[str] = [] + for raw_path in paths: + if not isinstance(raw_path, str) or not raw_path.strip(): + raise ConfigurationError("Scan paths must be non-empty strings.") + parsed = urlsplit(raw_path.strip()) + route = parsed.path or "/" + if ( + parsed.scheme + or parsed.netloc + or parsed.fragment + or not route.startswith("/") + or ".." in PurePosixPath(route).parts + ): + raise ConfigurationError(f"Unsafe origin-relative path: {raw_path!r}.") + if parsed.query: + route = f"{route}?{parsed.query}" + if route not in result: + result.append(route) + return tuple(result) + + +def _score(routes: Sequence[RouteReport]) -> int: + if not routes: + return 0 + route_scores = [] + for route in routes: + penalty = sum(_SCORE_PENALTIES[finding.severity] for finding in route.findings) + route_scores.append(max(0, 100 - penalty)) + return round(sum(route_scores) / len(route_scores)) + + +def _grade(score: int) -> str: + for minimum, grade in _GRADES: + if score >= minimum: + return grade + return "F" + + +__all__ = ["SCHEMA_VERSION", "scan"] diff --git a/src/previewshield/utils.py b/src/previewshield/utils.py new file mode 100644 index 0000000..20ea354 --- /dev/null +++ b/src/previewshield/utils.py @@ -0,0 +1,64 @@ +"""Small shared helpers with output-safety guarantees.""" + +from __future__ import annotations + +import re +from datetime import datetime, timezone +from urllib.parse import urlsplit, urlunsplit + +from previewshield.exceptions import ConfigurationError +from previewshield.models import Severity + +_CONTROL_CHARACTERS = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]") +_HEADER_NAME = re.compile(r"^[!#$%&'*+.^_`|~0-9A-Za-z-]+$") + + +def utc_now() -> str: + """Return an RFC 3339 timestamp in UTC.""" + + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def clean_text(value: str, *, limit: int = 500) -> str: + """Strip control characters and cap untrusted network evidence.""" + + cleaned = _CONTROL_CHARACTERS.sub("", value).replace("\r", " ").replace("\n", " ") + if len(cleaned) <= limit: + return cleaned + return f"{cleaned[: limit - 1]}…" + + +def markdown_code(value: str) -> str: + """Render untrusted text as a safe inline Markdown code span.""" + + cleaned = clean_text(value).replace("`", "'") + return f"`{cleaned}`" + + +def parse_threshold(value: str) -> Severity: + """Parse a severity and expose a configuration-focused error.""" + + try: + return Severity.parse(value) + except ValueError as error: + raise ConfigurationError(str(error)) from error + + +def validate_header_name(name: str) -> str: + """Reject request header names that could enable header injection.""" + + if not _HEADER_NAME.fullmatch(name): + raise ConfigurationError(f"Invalid request header name: {name!r}.") + return name + + +def redact_url(url: str) -> str: + """Remove credentials and fragments before a URL enters a report.""" + + parsed = urlsplit(url) + hostname = parsed.hostname or "" + if ":" in hostname and not hostname.startswith("["): + hostname = f"[{hostname}]" + if parsed.port is not None: + hostname = f"{hostname}:{parsed.port}" + return urlunsplit((parsed.scheme, hostname, parsed.path, parsed.query, "")) diff --git a/src/previewshield/webui/__init__.py b/src/previewshield/webui/__init__.py new file mode 100644 index 0000000..f473fb0 --- /dev/null +++ b/src/previewshield/webui/__init__.py @@ -0,0 +1,5 @@ +"""Local browser interface for PreviewShield.""" + +from previewshield.webui.server import create_ui_server, serve_ui + +__all__ = ["create_ui_server", "serve_ui"] diff --git a/src/previewshield/webui/assets/__init__.py b/src/previewshield/webui/assets/__init__.py new file mode 100644 index 0000000..5904327 --- /dev/null +++ b/src/previewshield/webui/assets/__init__.py @@ -0,0 +1 @@ +"""Packaged browser assets for the local PreviewShield interface.""" diff --git a/src/previewshield/webui/assets/app.js b/src/previewshield/webui/assets/app.js new file mode 100644 index 0000000..babb976 --- /dev/null +++ b/src/previewshield/webui/assets/app.js @@ -0,0 +1,468 @@ +"use strict"; + +import { filterItems, githubAction } from "./logic.mjs"; + +const csrfToken = document.body.dataset.csrfToken || ""; +const privateTargetsEnabled = document.body.dataset.privateEnabled === "true"; +const form = document.getElementById("scan-form"); +const runButton = document.getElementById("run-button"); +const formError = document.getElementById("form-error"); +const results = document.getElementById("results"); +const resultsTitle = document.getElementById("results-title"); +const findingTemplate = document.getElementById("finding-template"); +const metricTemplate = document.getElementById("metric-template"); +const allowPrivate = document.getElementById("allow-private"); +const authorizedPrivate = document.getElementById("authorized-private"); +const authorizationRow = document.getElementById("authorization-row"); +const privateLocked = document.getElementById("private-locked"); +const privateControls = document.getElementById("private-controls"); + +const state = { + mode: "diff", + resultMode: null, + busy: false, + resultId: null, + report: null, + items: [], + activeFilter: "all", + lastRequest: null, +}; + +const extensions = { + html: "html", + json: "json", + junit: "xml", + markdown: "md", + sarif: "sarif", +}; + +function element(id) { + return document.getElementById(id); +} + +function asText(value, fallback = "—") { + if (value === null || value === undefined || value === "") { + return fallback; + } + return String(value); +} + +function asArray(value) { + return Array.isArray(value) ? value : []; +} + +function setMode(mode) { + if (state.busy) { + return; + } + state.mode = mode; + const isDiff = mode === "diff"; + document.querySelectorAll("[data-mode]").forEach((button) => { + const active = button.dataset.mode === mode; + button.classList.toggle("is-active", active); + button.setAttribute("aria-selected", String(active)); + button.tabIndex = active ? 0 : -1; + }); + element("diff-fields").hidden = !isDiff; + element("scan-fields").hidden = isDiff; + element("baseline").required = isDiff; + element("preview").required = isDiff; + element("target").required = !isDiff; + runButton.querySelector(".button-idle").textContent = isDiff + ? "Compare security" + : "Run security scan"; + clearError(); +} + +document.querySelectorAll("[data-mode]").forEach((button) => { + button.addEventListener("click", () => setMode(button.dataset.mode)); + button.addEventListener("keydown", (event) => { + if (!new Set(["ArrowLeft", "ArrowRight"]).has(event.key)) { + return; + } + event.preventDefault(); + const nextMode = button.dataset.mode === "diff" ? "scan" : "diff"; + setMode(nextMode); + document.querySelector(`[data-mode="${nextMode}"]`).focus(); + }); +}); + +if (privateTargetsEnabled) { + privateLocked.hidden = true; + privateControls.hidden = false; +} else { + privateLocked.hidden = false; + privateControls.hidden = true; +} + +allowPrivate.addEventListener("change", () => { + authorizationRow.hidden = !allowPrivate.checked; + authorizedPrivate.required = allowPrivate.checked; + if (!allowPrivate.checked) { + authorizedPrivate.checked = false; + } +}); + +function clearError() { + formError.hidden = true; + formError.textContent = ""; +} + +function showError(message) { + formError.textContent = message; + formError.hidden = false; + formError.focus?.(); +} + +function setBusy(busy) { + state.busy = busy; + runButton.disabled = busy; + runButton.classList.toggle("is-busy", busy); + form.setAttribute("aria-busy", String(busy)); + document.querySelectorAll("[data-mode]").forEach((button) => { + button.disabled = busy; + }); +} + +function requestPayload(mode) { + const routeLines = element("paths").value + .split(/\r?\n/u) + .map((route) => route.trim()) + .filter(Boolean); + const profile = form.querySelector('input[name="profile"]:checked').value; + const payload = { + profile, + paths: routeLines, + fail_on: element("fail-on").value, + allow_private: privateTargetsEnabled && allowPrivate.checked, + authorized_private: + privateTargetsEnabled && allowPrivate.checked && authorizedPrivate.checked, + }; + if (mode === "diff") { + payload.baseline = element("baseline").value.trim(); + payload.preview = element("preview").value.trim(); + } else { + payload.target = element("target").value.trim(); + } + return payload; +} + +form.addEventListener("submit", async (event) => { + event.preventDefault(); + clearError(); + if (!form.checkValidity()) { + form.reportValidity(); + return; + } + const requestMode = state.mode; + const payload = requestPayload(requestMode); + if (payload.paths.length === 0) { + showError("Add at least one route, such as /."); + return; + } + if (payload.paths.length > 20) { + showError("The browser interface accepts at most 20 routes."); + return; + } + + setBusy(true); + try { + const response = await fetch(`/api/v1/${requestMode}`, { + method: "POST", + credentials: "same-origin", + cache: "no-store", + headers: { + "Content-Type": "application/json", + "X-PreviewShield-CSRF": csrfToken, + }, + body: JSON.stringify(payload), + }); + const data = await response.json(); + if (!response.ok || data.ok !== true) { + throw new Error(asText(data.error, "The scan could not be completed.")); + } + renderResult(data); + state.lastRequest = { mode: requestMode, payload }; + } catch (error) { + showError(error instanceof Error ? error.message : "The scan could not be completed."); + } finally { + setBusy(false); + } +}); + +function renderResult(data) { + state.resultId = asText(data.result_id, ""); + state.report = data.report || {}; + const isDiff = data.kind === "diff"; + state.resultMode = isDiff ? "diff" : "scan"; + const report = state.report; + const passed = report.passed === true; + const threshold = asText(report.fail_on, "configured"); + + element("verdict").textContent = passed ? "PASS" : "BLOCK"; + element("verdict-panel").dataset.state = passed ? "pass" : "block"; + element("verdict-copy").textContent = isDiff + ? passed + ? `No new ${threshold}-or-higher regressions were detected.` + : `The preview introduced a ${threshold}-or-higher security regression.` + : passed + ? `No ${threshold}-or-higher findings crossed the configured policy.` + : `This deployment has ${threshold}-or-higher findings that need attention.`; + element("policy-name").textContent = asText(report.policy_name, "Built-in policy"); + element("generated-at").textContent = formatDate(report.generated_at); + + if (isDiff) { + renderDiffScores(report); + state.items = asArray(report.deltas).map((delta) => ({ + kind: asText(delta.kind, "changed"), + finding: delta.preview || delta.baseline || {}, + })); + renderMetrics([ + metric("Regressions", countKind("regression"), "New or more severe"), + metric("Resolved", countKind("resolved"), "Removed in preview"), + metric("Changed", countKind("changed"), "Content changed"), + metric("Unchanged", countKind("unchanged"), "Existing debt"), + ]); + element("findings-title").textContent = "What changed?"; + state.activeFilter = countKind("regression") > 0 ? "regression" : "all"; + renderFilters(["all", "regression", "resolved", "changed", "unchanged"]); + } else { + renderScanScore(report); + state.items = asArray(report.routes).flatMap((route) => + asArray(route.findings).map((finding) => ({ kind: "finding", finding })), + ); + renderMetrics([ + metric("Critical", countSeverity("critical"), "Immediate action"), + metric("High", countSeverity("high"), "Release concern"), + metric("Medium", countSeverity("medium"), "Hardening gap"), + metric("Total", state.items.length, "Across all routes"), + ]); + element("findings-title").textContent = "What to fix?"; + state.activeFilter = "all"; + renderFilters(["all", "critical", "high", "medium", "low", "info"]); + } + + renderFindings(); + results.hidden = false; + results.scrollIntoView({ behavior: "smooth", block: "start" }); + resultsTitle.focus({ preventScroll: true }); +} + +function renderDiffScores(report) { + const baseline = report.baseline || {}; + const preview = report.preview || {}; + const seam = element("security-seam"); + seam.classList.remove("is-single"); + element("preview-score-side").hidden = false; + seam.querySelector(".seam-spine").hidden = false; + element("left-label").textContent = "Production"; + element("left-grade").textContent = asText(baseline.grade); + element("left-score").textContent = `${asText(baseline.score)}/100`; + element("left-target").textContent = asText(baseline.target); + element("right-grade").textContent = asText(preview.grade); + element("right-score").textContent = `${asText(preview.score)}/100`; + element("right-target").textContent = asText(preview.target); + const baselineScore = Number(baseline.score); + const previewScore = Number(preview.score); + const delta = Number.isFinite(baselineScore) && Number.isFinite(previewScore) + ? previewScore - baselineScore + : null; + element("score-delta").textContent = delta === null ? "—" : `${delta > 0 ? "+" : ""}${delta}`; +} + +function renderScanScore(report) { + const seam = element("security-seam"); + seam.classList.add("is-single"); + element("preview-score-side").hidden = true; + seam.querySelector(".seam-spine").hidden = true; + element("left-label").textContent = "Deployment"; + element("left-grade").textContent = asText(report.grade); + element("left-score").textContent = `${asText(report.score)}/100`; + element("left-target").textContent = asText(report.target); +} + +function metric(label, value, detail) { + return { label, value, detail }; +} + +function renderMetrics(metrics) { + const container = element("metrics"); + container.replaceChildren(); + metrics.forEach((item) => { + const card = metricTemplate.content.firstElementChild.cloneNode(true); + card.querySelector(".metric-label").textContent = item.label; + card.querySelector(".metric-value").textContent = String(item.value); + card.querySelector(".metric-detail").textContent = item.detail; + container.append(card); + }); +} + +function countKind(kind) { + return state.items.filter((item) => item.kind === kind).length; +} + +function countSeverity(severity) { + return state.items.filter((item) => item.finding?.severity === severity).length; +} + +function renderFilters(filters) { + const row = element("filter-row"); + row.replaceChildren(); + filters.forEach((filter) => { + const count = filter === "all" + ? state.items.length + : state.resultMode === "diff" + ? countKind(filter) + : countSeverity(filter); + if (filter !== "all" && count === 0) { + return; + } + const button = document.createElement("button"); + button.type = "button"; + button.dataset.filter = filter; + button.setAttribute("aria-pressed", String(filter === state.activeFilter)); + button.textContent = `${labelFor(filter)} · ${count}`; + button.addEventListener("click", () => { + state.activeFilter = filter; + row.querySelectorAll("button").forEach((item) => { + item.setAttribute("aria-pressed", String(item.dataset.filter === filter)); + }); + renderFindings(); + }); + row.append(button); + }); +} + +function filteredItems() { + return filterItems(state.items, state.activeFilter, state.resultMode); +} + +function renderFindings() { + const list = element("finding-list"); + const items = filteredItems(); + list.replaceChildren(); + items.forEach((item) => list.append(findingCard(item))); + element("finding-total").textContent = `${items.length} ${items.length === 1 ? "finding" : "findings"}`; + element("empty-findings").hidden = items.length !== 0; +} + +function findingCard(item) { + const card = findingTemplate.content.firstElementChild.cloneNode(true); + const finding = item.finding || {}; + card.dataset.kind = item.kind; + card.querySelector(".finding-kind").textContent = labelFor(item.kind); + card.querySelector(".finding-severity").textContent = asText(finding.severity, "unknown"); + card.querySelector(".finding-rule").textContent = asText(finding.rule_id, "RULE"); + card.querySelector(".finding-title").textContent = asText(finding.title, "Security finding"); + card.querySelector(".finding-target").textContent = asText(finding.target, "Target unavailable"); + card.querySelector(".finding-message").textContent = asText(finding.message, "No detail provided."); + card.querySelector(".finding-remediation").textContent = asText( + finding.remediation, + "Review the affected security control.", + ); + const evidence = card.querySelector(".finding-evidence"); + if (finding.evidence) { + evidence.textContent = `Evidence: ${finding.evidence}`; + evidence.hidden = false; + } + return card; +} + +function labelFor(value) { + const labels = { + all: "All", + regression: "Regression", + resolved: "Resolved", + changed: "Changed", + unchanged: "Unchanged", + finding: "Finding", + critical: "Critical", + high: "High", + medium: "Medium", + low: "Low", + info: "Info", + }; + return labels[value] || asText(value); +} + +function formatDate(value) { + if (typeof value !== "string") { + return "Generated now"; + } + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return value; + } + return new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", + }).format(date); +} + +element("download-report").addEventListener("click", async () => { + if (!state.resultId) { + setExportStatus("Run a scan before downloading a report.", true); + return; + } + const format = element("export-format").value; + try { + const response = await fetch(`/api/v1/reports/${state.resultId}/${format}`, { + credentials: "same-origin", + cache: "no-store", + headers: { "X-PreviewShield-CSRF": csrfToken }, + }); + if (!response.ok) { + throw new Error("The report download failed."); + } + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = `previewshield-report.${extensions[format] || "txt"}`; + document.body.append(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(url); + setExportStatus(`${labelFor(format)} report downloaded.`); + } catch (error) { + setExportStatus(error instanceof Error ? error.message : "The report download failed.", true); + } +}); + +element("copy-action").addEventListener("click", async () => { + if (!state.lastRequest) { + setExportStatus("Run a scan before copying an Action example.", true); + return; + } + try { + await copyText(githubAction(state.lastRequest.mode, state.lastRequest.payload)); + setExportStatus("GitHub Action YAML copied to the clipboard."); + } catch { + setExportStatus("Clipboard access was unavailable.", true); + } +}); + +async function copyText(value) { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(value); + return; + } + const textarea = document.createElement("textarea"); + textarea.value = value; + textarea.className = "visually-hidden"; + document.body.append(textarea); + textarea.select(); + const copied = document.execCommand("copy"); + textarea.remove(); + if (!copied) { + throw new Error("Copy failed"); + } +} + +function setExportStatus(message, isError = false) { + const status = element("export-status"); + status.textContent = message; + status.dataset.state = isError ? "error" : "ok"; +} + +setMode("diff"); diff --git a/src/previewshield/webui/assets/fonts/archivo-black-OFL.txt b/src/previewshield/webui/assets/fonts/archivo-black-OFL.txt new file mode 100644 index 0000000..18a6790 --- /dev/null +++ b/src/previewshield/webui/assets/fonts/archivo-black-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2017 The Archivo Black Project Authors (https://github.com/Omnibus-Type/ArchivoBlack) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/src/previewshield/webui/assets/fonts/archivo-black.ttf b/src/previewshield/webui/assets/fonts/archivo-black.ttf new file mode 100644 index 0000000000000000000000000000000000000000..82d07de3b84ed4428f6a302b70f4b555148dfeb0 GIT binary patch literal 90988 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�s~Vb1A}`IORzExyEZ8gW->A%7u7c4 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 K5Q|D9=l=lr4cw*x literal 0 HcmV?d00001 diff --git a/src/previewshield/webui/assets/index.html b/src/previewshield/webui/assets/index.html new file mode 100644 index 0000000..c262ff4 --- /dev/null +++ b/src/previewshield/webui/assets/index.html @@ -0,0 +1,369 @@ + + + + + + + + + PreviewShield — Release Control Room + + + + + + +
+ + + PreviewShield + +
+ + Local only · nothing uploaded +
+
+ +
+
+
+

Release control / web security

+

Catch the change.
Ship with proof.

+
+
+ 01 +

+ Compare production with a preview, or inspect one site. PreviewShield reads + response metadata—never response bodies. +

+
+
+ +
+
+
+
+

Configure run

+

What should we inspect?

+
+ 01 / 03 +
+ +
+ + +
+ +
+
+ + +
+ + + +
+
+ Policy profile +
+ + +
+
+ + +
+ + + +
+ + Advanced network access + + +
+
+ Private targets are locked +

+ Restart with previewshield ui --allow-private-targets only + when you need to inspect a trusted localhost or private service. +

+
+ +
+
+ + + + +
+
+ + +
+ + +
+ +
+ PreviewShield / Release control room + Hardening evidence—not a penetration test or security guarantee. +
+ + + + + + diff --git a/src/previewshield/webui/assets/logic.mjs b/src/previewshield/webui/assets/logic.mjs new file mode 100644 index 0000000..4ba87b8 --- /dev/null +++ b/src/previewshield/webui/assets/logic.mjs @@ -0,0 +1,47 @@ +"use strict"; + +export function filterItems(items, activeFilter, resultMode) { + if (activeFilter === "all") { + return items; + } + return items.filter((item) => + resultMode === "diff" + ? item.kind === activeFilter + : item.finding?.severity === activeFilter, + ); +} + +function yamlScalar(value) { + return JSON.stringify(String(value)); +} + +export function githubAction(mode, request) { + const lines = [ + "name: Preview security", + "", + "on:", + " pull_request:", + "", + "permissions:", + " contents: read", + "", + "jobs:", + " previewshield:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: actions/checkout@v6", + " - name: Check web security", + " uses: devUmut35/PreviewShield@v1", + " with:", + ]; + if (mode === "diff") { + lines.push(` baseline: ${yamlScalar(request.baseline)}`); + lines.push(" preview: ${{ vars.PREVIEW_URL }}"); + } else { + lines.push(` target: ${yamlScalar(request.target)}`); + } + lines.push(` fail-on: ${yamlScalar(request.fail_on)}`); + lines.push(" paths: |"); + request.paths.forEach((route) => lines.push(` ${String(route)}`)); + return `${lines.join("\n")}\n`; +} diff --git a/src/previewshield/webui/assets/styles.css b/src/previewshield/webui/assets/styles.css new file mode 100644 index 0000000..6123716 --- /dev/null +++ b/src/previewshield/webui/assets/styles.css @@ -0,0 +1,1337 @@ +@font-face { + font-family: "Archivo Black"; + src: url("/fonts/archivo-black.ttf") format("truetype"); + font-display: swap; +} + +@font-face { + font-family: "IBM Plex Mono"; + src: url("/fonts/ibm-plex-mono.ttf") format("truetype"); + font-display: swap; +} + +:root { + --paper: #f2f0e8; + --paper-raised: #faf9f4; + --ink: #11150f; + --ink-soft: #3d4438; + --line: #11150f; + --line-soft: #a7aa9d; + --signal: #c7f43a; + --signal-dark: #75940d; + --danger: #a92c18; + --danger-soft: #f4d8cf; + --success: #176548; + --success-soft: #d8eadf; + --warning: #9c5c00; + --warning-soft: #f4e2bf; + --info: #285a8c; + --muted: #646a5e; + --shadow: 8px 8px 0 rgb(17 21 15 / 14%); + --font-display: "Archivo Black", Impact, sans-serif; + --font-mono: "IBM Plex Mono", "Courier New", monospace; + --space-1: 0.5rem; + --space-2: 1rem; + --space-3: 1.5rem; + --space-4: 2rem; + --space-5: 3rem; + --space-6: 4rem; +} + +* { + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; +} + +body { + margin: 0; + overflow-x: hidden; + color: var(--ink); + background: + linear-gradient(90deg, rgb(17 21 15 / 4%) 1px, transparent 1px) 0 0 / 32px 32px, + linear-gradient(rgb(17 21 15 / 4%) 1px, transparent 1px) 0 0 / 32px 32px, + var(--paper); + font-family: var(--font-mono); + font-size: 1rem; + line-height: 1.55; +} + +button, +input, +select, +textarea { + color: inherit; + font: inherit; +} + +button, +select, +input[type="radio"], +input[type="checkbox"] { + cursor: pointer; +} + +button:focus-visible, +input:focus-visible, +select:focus-visible, +textarea:focus-visible, +summary:focus-visible, +a:focus-visible, +[tabindex="-1"]:focus-visible { + outline: 3px solid var(--info); + outline-offset: 3px; +} + +[hidden] { + display: none !important; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.skip-link { + position: fixed; + z-index: 100; + top: var(--space-2); + left: var(--space-2); + padding: 0.75rem 1rem; + color: var(--ink); + background: var(--signal); + border: 2px solid var(--ink); + transform: translateY(-200%); +} + +.skip-link:focus { + transform: translateY(0); +} + +.topbar { + display: flex; + align-items: stretch; + justify-content: space-between; + min-height: 72px; + color: var(--paper-raised); + background: var(--ink); + border-bottom: 2px solid var(--ink); +} + +.brand { + display: inline-flex; + gap: 1rem; + align-items: center; + padding: 0 var(--space-4); + color: inherit; + font-family: var(--font-display); + font-size: 1.05rem; + letter-spacing: -0.02em; + text-decoration: none; +} + +.brand-mark { + display: grid; + width: 42px; + height: 42px; + color: var(--ink); + background: var(--signal); + place-items: center; + transform: rotate(-3deg); +} + +.local-signal { + display: flex; + gap: 0.75rem; + align-items: center; + padding: 0 var(--space-4); + border-left: 1px solid rgb(255 255 255 / 25%); + font-size: 0.76rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.signal-dot { + width: 10px; + height: 10px; + background: var(--signal); + border-radius: 50%; + box-shadow: 0 0 0 5px rgb(199 244 58 / 16%); +} + +main { + width: min(1440px, calc(100% - 3rem)); + margin: 0 auto; +} + +.hero { + display: grid; + grid-template-columns: minmax(0, 2.2fr) minmax(260px, 0.8fr); + min-height: 360px; + border-right: 2px solid var(--line); + border-left: 2px solid var(--line); +} + +.hero-copy { + display: flex; + flex-direction: column; + justify-content: center; + padding: var(--space-6) clamp(2rem, 7vw, 7rem); + border-right: 2px solid var(--line); +} + +.eyebrow, +.coordinate { + margin: 0 0 var(--space-2); + color: var(--muted); + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +h1, +h2, +h3, +h4, +p { + overflow-wrap: anywhere; +} + +h1, +h2, +h3, +h4 { + margin: 0; +} + +h1 { + max-width: 940px; + font-family: var(--font-display); + font-size: clamp(3.25rem, 7vw, 7.5rem); + line-height: 0.9; + letter-spacing: -0.06em; + text-transform: uppercase; +} + +h1 span { + position: relative; + z-index: 0; + display: inline-block; +} + +h1 span::after { + position: absolute; + z-index: -1; + right: -0.12em; + bottom: 0.04em; + left: -0.06em; + height: 0.2em; + content: ""; + background: var(--signal); + transform: rotate(-1deg); +} + +.hero-note { + display: flex; + flex-direction: column; + justify-content: space-between; + padding: var(--space-4); + background: var(--paper-raised); +} + +.hero-note p { + margin: var(--space-4) 0 0; + font-size: 0.95rem; +} + +.note-index { + align-self: flex-end; + font-family: var(--font-display); + font-size: clamp(4rem, 8vw, 7rem); + line-height: 1; + color: transparent; + -webkit-text-stroke: 2px var(--ink); +} + +.control-room { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(280px, 0.32fr); + border: 2px solid var(--line); +} + +.control-panel { + padding: clamp(1.5rem, 4vw, 4rem); + background: var(--paper-raised); + border-right: 2px solid var(--line); +} + +.panel-heading, +.results-heading, +.findings-heading { + display: flex; + gap: var(--space-2); + align-items: flex-start; + justify-content: space-between; + margin-bottom: var(--space-4); +} + +.panel-heading h2, +.results-heading h2, +.boundary-panel h2 { + font-family: var(--font-display); + font-size: clamp(2rem, 4vw, 3.8rem); + line-height: 0.98; + letter-spacing: -0.045em; + text-transform: uppercase; +} + +.step-badge { + flex: 0 0 auto; + padding: 0.45rem 0.6rem; + color: var(--paper-raised); + background: var(--ink); + font-size: 0.7rem; +} + +.mode-tabs { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + margin-bottom: var(--space-4); + border: 2px solid var(--line); +} + +.mode-tab { + min-height: 78px; + padding: 1rem 1.25rem; + text-align: left; + background: transparent; + border: 0; +} + +.mode-tab + .mode-tab { + border-left: 2px solid var(--line); +} + +.mode-tab span, +.mode-tab small { + display: block; +} + +.mode-tab span { + font-weight: 700; +} + +.mode-tab small { + margin-top: 0.2rem; + color: var(--muted); + font-size: 0.72rem; +} + +.mode-tab.is-active { + color: var(--ink); + background: var(--signal); + box-shadow: inset 0 -5px 0 var(--ink); +} + +.mode-tab.is-active small { + color: var(--ink-soft); +} + +.mode-tab:disabled { + cursor: wait; + opacity: 0.7; +} + +.target-fields, +.options-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-3); +} + +.single-target { + grid-template-columns: 1fr; +} + +.field { + display: block; +} + +.field-label, +fieldset legend { + display: block; + margin-bottom: 0.5rem; + font-size: 0.76rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.field input, +.field select, +.field textarea { + width: 100%; + min-height: 54px; + padding: 0.85rem 1rem; + background: var(--paper); + border: 2px solid var(--line); + border-radius: 0; +} + +.field textarea { + min-height: 112px; + resize: vertical; +} + +.field input:hover, +.field select:hover, +.field textarea:hover { + background: #fffef9; +} + +.field small, +.choice-card small, +.switch-row small { + display: block; + margin-top: 0.45rem; + color: var(--muted); + font-size: 0.7rem; +} + +.preview-field input { + box-shadow: inset 6px 0 0 var(--signal); +} + +.options-grid, +.routes-field, +.advanced-options { + margin-top: var(--space-3); +} + +.profile-fieldset { + padding: 0; + margin: 0; + border: 0; +} + +.choice-row { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + min-height: 74px; + border: 2px solid var(--line); +} + +.choice-card { + position: relative; + display: flex; + align-items: center; + padding: 0.85rem 1rem; + cursor: pointer; +} + +.choice-card + .choice-card { + border-left: 2px solid var(--line); +} + +.choice-card input { + position: absolute; + opacity: 0; +} + +.choice-card:has(input:checked) { + background: var(--ink); + color: var(--paper-raised); +} + +.choice-card:has(input:checked) small { + color: #c7cbbf; +} + +.choice-card:has(input:focus-visible) { + outline: 3px solid var(--info); + outline-offset: 3px; +} + +.advanced-options { + border-top: 2px solid var(--line); + border-bottom: 2px solid var(--line); +} + +.advanced-options summary { + display: flex; + justify-content: space-between; + min-height: 54px; + padding: 0.9rem 0; + font-weight: 700; + cursor: pointer; + list-style: none; +} + +.advanced-options summary::-webkit-details-marker { + display: none; +} + +.advanced-options[open] summary span:last-child { + transform: rotate(45deg); +} + +.advanced-body { + padding: 0 0 var(--space-3); +} + +.private-notice, +.private-controls { + padding: var(--space-2); + background: var(--warning-soft); + border-left: 6px solid var(--warning); +} + +.private-notice p { + margin: 0.4rem 0 0; + font-size: 0.78rem; +} + +code { + padding: 0.12rem 0.3rem; + color: var(--paper-raised); + background: var(--ink); + font: inherit; +} + +.switch-row, +.authorization-row { + display: flex; + gap: 0.8rem; + align-items: flex-start; + cursor: pointer; +} + +.switch-row input { + position: absolute; + opacity: 0; +} + +.switch { + position: relative; + flex: 0 0 auto; + width: 52px; + height: 28px; + background: var(--paper-raised); + border: 2px solid var(--line); +} + +.switch::after { + position: absolute; + top: 4px; + left: 4px; + width: 16px; + height: 16px; + content: ""; + background: var(--ink); + transition: transform 160ms ease; +} + +.switch-row input:checked + .switch { + background: var(--signal); +} + +.switch-row input:checked + .switch::after { + transform: translateX(24px); +} + +.switch-row input:focus-visible + .switch { + outline: 3px solid var(--info); + outline-offset: 3px; +} + +.authorization-row { + margin-top: var(--space-2); + padding-top: var(--space-2); + border-top: 1px solid rgb(17 21 15 / 35%); + font-size: 0.78rem; +} + +.authorization-row input { + width: 20px; + height: 20px; + margin: 0; + accent-color: var(--ink); +} + +.form-error { + margin-top: var(--space-3); + padding: 1rem; + color: var(--danger); + background: var(--danger-soft); + border: 2px solid var(--danger); + font-weight: 700; +} + +.run-button { + display: flex; + gap: 1rem; + align-items: center; + justify-content: space-between; + width: 100%; + min-height: 72px; + padding: 1rem 1.5rem; + margin-top: var(--space-4); + color: var(--ink); + background: var(--signal); + border: 2px solid var(--line); + box-shadow: var(--shadow); + font-family: var(--font-display); + font-size: clamp(1.1rem, 2vw, 1.55rem); + text-align: left; + text-transform: uppercase; + transition: transform 150ms ease, box-shadow 150ms ease; +} + +.run-button:hover:not(:disabled) { + box-shadow: 3px 3px 0 rgb(17 21 15 / 20%); + transform: translate(4px, 4px); +} + +.run-button:disabled { + cursor: wait; + opacity: 0.78; +} + +.button-arrow { + font-family: var(--font-mono); + font-size: 2rem; +} + +.button-busy { + display: none; +} + +.run-button.is-busy .button-idle, +.run-button.is-busy .button-arrow { + display: none; +} + +.run-button.is-busy .button-busy { + display: inline; + animation: pulse-text 1.3s steps(2, end) infinite; +} + +.boundary-panel { + position: relative; + padding: clamp(1.5rem, 3vw, 3rem); + overflow: hidden; + color: var(--paper-raised); + background: var(--ink); +} + +.boundary-panel .coordinate { + color: #acb2a4; +} + +.boundary-list { + padding: 0; + margin: var(--space-5) 0 10rem; + list-style: none; +} + +.boundary-list li { + display: grid; + grid-template-columns: 38px 1fr; + gap: 0.8rem; + padding: var(--space-2) 0; + border-top: 1px solid rgb(255 255 255 / 25%); +} + +.boundary-list li:last-child { + border-bottom: 1px solid rgb(255 255 255 / 25%); +} + +.boundary-list span { + color: var(--signal); + font-weight: 700; +} + +.boundary-list p { + margin: 0; + font-size: 0.76rem; +} + +.boundary-list strong { + display: block; + margin-bottom: 0.2rem; + color: var(--paper-raised); +} + +.boundary-stamp { + position: absolute; + right: -24px; + bottom: -20px; + width: 180px; + height: 180px; + padding: 2.6rem 2rem; + color: var(--ink); + background: var(--signal); + border-radius: 50%; + font-family: var(--font-display); + font-size: 1.15rem; + line-height: 1.2; + text-align: center; + text-transform: uppercase; + transform: rotate(-8deg); +} + +.results { + padding: clamp(1.5rem, 4vw, 4rem); + margin: var(--space-5) 0; + background: var(--paper-raised); + border: 2px solid var(--line); + animation: reveal-up 450ms ease both; +} + +.verdict-panel { + display: grid; + grid-template-columns: minmax(220px, 0.45fr) minmax(280px, 1fr) auto; + gap: var(--space-3); + align-items: center; + min-height: 150px; + padding: var(--space-3); + color: var(--paper-raised); + background: var(--ink); + border: 2px solid var(--line); +} + +.verdict-panel[data-state="pass"] { + color: var(--ink); + background: var(--signal); +} + +.verdict-panel[data-state="block"] { + background: var(--danger); +} + +.verdict-word span { + display: block; + margin-bottom: 0.25rem; + font-size: 0.7rem; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.verdict-word strong { + display: block; + font-family: var(--font-display); + font-size: clamp(3rem, 7vw, 6rem); + line-height: 0.85; +} + +#verdict-copy { + max-width: 620px; + margin: 0; + font-weight: 700; +} + +.verdict-meta { + display: grid; + gap: 0.35rem; + justify-items: end; + font-size: 0.7rem; + text-align: right; +} + +.security-seam { + display: grid; + grid-template-columns: 1fr 96px 1fr; + margin-top: var(--space-3); + border: 2px solid var(--line); +} + +.security-seam.is-single { + grid-template-columns: 1fr; +} + +.score-side { + min-width: 0; + padding: clamp(1.25rem, 3vw, 2.5rem); +} + +.score-side > span { + display: block; + margin-bottom: 1rem; + color: var(--muted); + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.score-side strong { + display: flex; + gap: 0.8rem; + align-items: baseline; + font-family: var(--font-display); + font-size: clamp(3rem, 6vw, 5.5rem); + line-height: 0.9; +} + +.score-side small { + font-family: var(--font-mono); + font-size: clamp(0.8rem, 1.5vw, 1.1rem); +} + +.score-side p { + margin: 1rem 0 0; + color: var(--ink-soft); + font-size: 0.76rem; +} + +.preview-score { + background: rgb(199 244 58 / 14%); +} + +.seam-spine { + position: relative; + z-index: 1; + display: grid; + align-content: center; + justify-items: center; + color: var(--ink); + background: var(--signal); + border-right: 2px solid var(--line); + border-left: 2px solid var(--line); +} + +.seam-spine::before, +.seam-spine::after { + width: 2px; + height: 32px; + content: ""; + background: var(--ink); +} + +.seam-spine span { + margin: 0.5rem 0 0; + font-size: 0.7rem; +} + +.seam-spine strong { + margin-bottom: 0.5rem; + font-family: var(--font-display); + font-size: 1.4rem; +} + +.metrics { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + margin-top: var(--space-3); + border: 2px solid var(--line); +} + +.metric-card { + min-width: 0; + padding: var(--space-2); +} + +.metric-card + .metric-card { + border-left: 2px solid var(--line); +} + +.metric-label, +.metric-detail { + display: block; +} + +.metric-label { + color: var(--muted); + font-size: 0.67rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.metric-value { + display: block; + margin: 0.35rem 0; + font-family: var(--font-display); + font-size: clamp(2rem, 4vw, 3.4rem); + line-height: 1; +} + +.metric-detail { + color: var(--muted); + font-size: 0.67rem; +} + +.findings-section { + margin-top: var(--space-5); +} + +.findings-heading h3, +.export-panel h3 { + font-family: var(--font-display); + font-size: clamp(1.8rem, 4vw, 3rem); + line-height: 1; + text-transform: uppercase; +} + +.finding-total { + padding: 0.5rem 0.75rem; + border: 2px solid var(--line); + font-size: 0.72rem; +} + +.filter-row { + display: flex; + flex-wrap: wrap; + gap: 0; + margin-bottom: var(--space-3); +} + +.filter-row button { + min-height: 44px; + padding: 0.65rem 0.9rem; + background: transparent; + border: 2px solid var(--line); +} + +.filter-row button + button { + margin-left: -2px; +} + +.filter-row button[aria-pressed="true"] { + color: var(--paper-raised); + background: var(--ink); +} + +.finding-list { + display: grid; + gap: var(--space-2); +} + +.finding-card { + display: grid; + grid-template-columns: 150px minmax(0, 1fr); + background: var(--paper); + border: 2px solid var(--line); + box-shadow: 4px 4px 0 rgb(17 21 15 / 10%); +} + +.finding-rail { + display: flex; + flex-direction: column; + gap: 0.55rem; + padding: var(--space-2); + color: var(--paper-raised); + background: var(--ink); + border-right: 2px solid var(--line); +} + +.finding-kind, +.finding-severity, +.finding-rule { + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.finding-card[data-kind="regression"] .finding-rail { + background: var(--danger); +} + +.finding-card[data-kind="resolved"] .finding-rail { + background: var(--success); +} + +.finding-card[data-kind="changed"] .finding-rail { + background: var(--warning); +} + +.finding-content { + min-width: 0; + padding: var(--space-2) var(--space-3); +} + +.finding-title-row { + display: flex; + gap: 0.8rem; + align-items: baseline; +} + +.finding-rule { + flex: 0 0 auto; + padding: 0.2rem 0.35rem; + color: var(--paper-raised); + background: var(--ink); +} + +.finding-title { + font-size: 1.1rem; +} + +.finding-target { + margin: 0.55rem 0; + color: var(--muted); + font-size: 0.7rem; +} + +.finding-message, +.finding-remediation, +.finding-evidence { + margin: 0.75rem 0 0; + font-size: 0.8rem; +} + +.remediation-block { + display: grid; + grid-template-columns: 42px 1fr; + gap: 0.75rem; + padding-top: 0.8rem; + margin-top: 0.8rem; + border-top: 1px solid var(--line-soft); +} + +.remediation-block > span { + color: var(--success); + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; +} + +.finding-remediation { + margin: 0; +} + +.finding-evidence { + padding: 0.65rem; + background: #e6e4dc; + border-left: 3px solid var(--ink-soft); +} + +.empty-findings { + padding: var(--space-4); + text-align: center; + border: 2px dashed var(--line-soft); +} + +.empty-findings p { + margin: 0.5rem 0 0; + color: var(--muted); + font-size: 0.78rem; +} + +.export-panel { + display: grid; + grid-template-columns: minmax(220px, 0.6fr) 1fr; + gap: var(--space-3); + align-items: center; + padding: var(--space-3); + margin-top: var(--space-5); + background: var(--signal); + border: 2px solid var(--line); +} + +.export-actions { + display: flex; + flex-wrap: wrap; + gap: 0.6rem; + justify-content: flex-end; +} + +.export-actions select, +.export-actions button { + min-height: 48px; + padding: 0.65rem 0.9rem; + background: var(--paper-raised); + border: 2px solid var(--line); +} + +.export-actions button { + font-weight: 700; +} + +.export-actions .secondary-button { + color: var(--paper-raised); + background: var(--ink); +} + +.export-status { + grid-column: 1 / -1; + min-height: 1.4em; + margin: 0; + font-size: 0.72rem; + font-weight: 700; + text-align: right; +} + +.export-status[data-state="error"] { + color: var(--danger); +} + +footer { + display: flex; + justify-content: space-between; + width: min(1440px, calc(100% - 3rem)); + padding: var(--space-3) 0 var(--space-4); + margin: 0 auto; + color: var(--muted); + font-size: 0.68rem; + text-transform: uppercase; +} + +@keyframes reveal-up { + from { + opacity: 0; + transform: translateY(18px); + } +} + +@keyframes pulse-text { + 50% { + opacity: 0.55; + } +} + +@media (max-width: 980px) { + .hero, + .control-room { + grid-template-columns: 1fr; + } + + .hero-copy, + .control-panel { + border-right: 0; + } + + .hero-copy { + min-height: 320px; + border-bottom: 2px solid var(--line); + } + + .hero-note { + min-height: 190px; + } + + .control-panel { + border-bottom: 2px solid var(--line); + } + + .boundary-panel { + min-height: 520px; + } + + .boundary-list { + max-width: 560px; + } + + .verdict-panel { + grid-template-columns: 1fr 1fr; + } + + .verdict-meta { + grid-column: 1 / -1; + justify-items: start; + text-align: left; + } + + .metrics { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .metric-card:nth-child(3) { + border-left: 0; + } + + .metric-card:nth-child(n + 3) { + border-top: 2px solid var(--line); + } +} + +@media (max-width: 680px) { + :root { + --space-4: 1.5rem; + --space-5: 2rem; + --space-6: 2.5rem; + } + + body { + font-size: 0.94rem; + } + + .topbar { + position: relative; + min-height: 62px; + } + + .brand { + max-width: calc(100% - 100px); + gap: 0.65rem; + padding: 0 0.75rem; + font-size: 0.9rem; + } + + .brand-mark { + width: 36px; + height: 36px; + } + + .local-signal { + position: absolute; + top: 0; + right: 0; + bottom: 0; + width: 100px; + max-width: none; + gap: 0.45rem; + justify-content: center; + padding: 0 0.6rem; + font-size: 0.56rem; + line-height: 1.2; + white-space: nowrap; + } + + .local-detail { + display: none; + } + + main, + footer { + width: calc(100% - 1rem); + } + + .hero { + min-height: 0; + } + + .hero-copy { + min-height: 280px; + padding: 2.5rem 1.25rem; + } + + h1 { + font-size: clamp(2.45rem, 12.2vw, 3.7rem); + letter-spacing: -0.07em; + } + + .hero-note { + min-width: 0; + min-height: 0; + overflow: hidden; + } + + .note-index { + display: none; + } + + .hero-note p { + margin-top: 0; + } + + .mode-tabs, + .target-fields, + .options-grid, + .choice-row, + .security-seam, + .export-panel { + grid-template-columns: 1fr; + } + + .mode-tab + .mode-tab, + .choice-card + .choice-card { + border-top: 2px solid var(--line); + border-left: 0; + } + + .verdict-panel { + grid-template-columns: 1fr; + } + + .security-seam { + position: relative; + } + + .seam-spine { + grid-template-columns: 1fr auto 1fr; + min-height: 58px; + border-top: 2px solid var(--line); + border-right: 0; + border-bottom: 2px solid var(--line); + border-left: 0; + } + + .seam-spine::before, + .seam-spine::after { + width: 32px; + height: 2px; + } + + .seam-spine span, + .seam-spine strong { + margin: 0 0.35rem; + } + + .metrics { + grid-template-columns: 1fr; + } + + .metric-card + .metric-card, + .metric-card:nth-child(3) { + border-top: 2px solid var(--line); + border-left: 0; + } + + .finding-card { + grid-template-columns: 1fr; + } + + .finding-rail { + flex-direction: row; + justify-content: space-between; + border-right: 0; + border-bottom: 2px solid var(--line); + } + + .finding-title-row { + align-items: flex-start; + flex-direction: column; + } + + .export-actions { + justify-content: flex-start; + } + + .export-actions label, + .export-actions select, + .export-actions button { + width: 100%; + } + + .export-status { + text-align: left; + } + + footer { + flex-direction: column; + gap: 0.5rem; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} diff --git a/src/previewshield/webui/server.py b/src/previewshield/webui/server.py new file mode 100644 index 0000000..837dd09 --- /dev/null +++ b/src/previewshield/webui/server.py @@ -0,0 +1,491 @@ +"""Hardened loopback HTTP server for the PreviewShield browser interface.""" + +from __future__ import annotations + +import json +import re +import secrets +import threading +import webbrowser +from collections import OrderedDict +from collections.abc import Mapping +from http import HTTPStatus +from http.cookies import SimpleCookie +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from importlib import resources +from typing import cast +from urllib.parse import urlsplit + +from previewshield.exceptions import ( + ConfigurationError, + NetworkSafetyError, + PreviewShieldError, + ScanError, +) +from previewshield.reporters import Report, render +from previewshield.webui.service import execute_diff, execute_scan + +MAX_REQUEST_BYTES = 32 * 1024 +MAX_REPORTS = 5 +MAX_REPORT_BYTES = 8 * 1024 * 1024 +MAX_STORED_REPORT_BYTES = 16 * 1024 * 1024 +CLIENT_SOCKET_TIMEOUT = 8.0 +REJECTED_BODY_DRAIN_TIMEOUT = 0.25 +MAX_PORT = 65_535 +SESSION_COOKIE = "previewshield_ui" +UI_CSP = ( + "default-src 'self'; base-uri 'none'; connect-src 'self'; font-src 'self'; " + "form-action 'self'; frame-ancestors 'none'; img-src 'self' data:; " + "object-src 'none'; script-src 'self'; style-src 'self'" +) +LOCKED_CSP = "default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'" +_ASSETS = { + "/": ("index.html", "text/html; charset=utf-8"), + "/app.js": ("app.js", "text/javascript; charset=utf-8"), + "/logic.mjs": ("logic.mjs", "text/javascript; charset=utf-8"), + "/styles.css": ("styles.css", "text/css; charset=utf-8"), + "/fonts/archivo-black.ttf": ("fonts/archivo-black.ttf", "font/ttf"), + "/fonts/ibm-plex-mono.ttf": ("fonts/ibm-plex-mono.ttf", "font/ttf"), +} +_REPORT_PATH = re.compile( + r"^/api/v1/reports/(?P[A-Za-z0-9_-]{20,64})/" + r"(?Phtml|json|junit|markdown|sarif)$" +) +_REPORT_METADATA = { + "html": ("text/html; charset=utf-8", "html"), + "json": ("application/json; charset=utf-8", "json"), + "junit": ("application/xml; charset=utf-8", "xml"), + "markdown": ("text/markdown; charset=utf-8", "md"), + "sarif": ("application/sarif+json; charset=utf-8", "sarif"), +} + + +class PreviewShieldUIServer(ThreadingHTTPServer): + """Single-user loopback server with bounded in-memory reports.""" + + daemon_threads = True + allow_reuse_address = True + request_queue_size = 8 + + def __init__( + self, + server_address: tuple[str, int], + *, + private_targets_enabled: bool, + ) -> None: + super().__init__(server_address, PreviewShieldUIHandler) + self.private_targets_enabled = private_targets_enabled + self.session_token = secrets.token_urlsafe(32) + self.csrf_token = secrets.token_urlsafe(32) + self.scan_slots = threading.BoundedSemaphore(value=2) + self._report_lock = threading.Lock() + self._reports: OrderedDict[str, tuple[Report, int]] = OrderedDict() + self._stored_report_bytes = 0 + + @property + def origin(self) -> str: + """Return the one browser origin accepted by the server.""" + + _, port = self.server_address[:2] + return f"http://127.0.0.1:{port}" + + @property + def expected_host(self) -> str: + """Return the exact Host header accepted by the server.""" + + _, port = self.server_address[:2] + return f"127.0.0.1:{port}" + + def store_report(self, report: Report, *, size_bytes: int | None = None) -> str: + """Keep a small number of reports in memory for browser downloads.""" + + if size_bytes is None: + size_bytes = len(render(report, "json").encode("utf-8")) + if not 0 <= size_bytes <= MAX_REPORT_BYTES: + raise ValueError(f"report size must be between 0 and {MAX_REPORT_BYTES} bytes") + + with self._report_lock: + report_id = secrets.token_urlsafe(18) + while report_id in self._reports: # pragma: no cover - cryptographically improbable + report_id = secrets.token_urlsafe(18) + self._reports[report_id] = (report, size_bytes) + self._stored_report_bytes += size_bytes + while ( + len(self._reports) > MAX_REPORTS + or self._stored_report_bytes > MAX_STORED_REPORT_BYTES + ): + _, (_, evicted_size) = self._reports.popitem(last=False) + self._stored_report_bytes -= evicted_size + return report_id + + def report(self, report_id: str) -> Report | None: + """Return one session report without exposing it across processes or restarts.""" + + with self._report_lock: + stored = self._reports.get(report_id) + if stored is not None: + self._reports.move_to_end(report_id) + return stored[0] + return None + + +class PreviewShieldUIHandler(BaseHTTPRequestHandler): + """Serve static UI assets and authenticated scan requests.""" + + protocol_version = "HTTP/1.1" + server_version = "PreviewShieldUI" + sys_version = "" + + @property + def ui_server(self) -> PreviewShieldUIServer: + return cast(PreviewShieldUIServer, self.server) + + def setup(self) -> None: + super().setup() + self.connection.settimeout(CLIENT_SOCKET_TIMEOUT) + + def do_GET(self) -> None: + if not self._host_is_valid(): + self._send_json(HTTPStatus.BAD_REQUEST, {"ok": False, "error": "Invalid Host header."}) + return + path = urlsplit(self.path).path + match = _REPORT_PATH.fullmatch(path) + if match is not None: + self._serve_report(match.group("report_id"), match.group("format")) + return + asset = _ASSETS.get(path) + if asset is None: + self._send_json(HTTPStatus.NOT_FOUND, {"ok": False, "error": "Not found."}) + return + self._serve_asset(path, asset[0], asset[1]) + + def do_HEAD(self) -> None: + self.do_GET() + + def do_POST(self) -> None: # noqa: PLR0911, PLR0912, PLR0915 + if not self._host_is_valid(): + self._reject_unread(HTTPStatus.BAD_REQUEST, "Invalid Host header.") + return + path = urlsplit(self.path).path + if path not in {"/api/v1/scan", "/api/v1/diff"}: + self._reject_unread(HTTPStatus.NOT_FOUND, "Not found.") + return + if not self._post_is_authorized(): + self._reject_unread(HTTPStatus.FORBIDDEN, "Invalid local UI session.") + return + if self.headers.get("Transfer-Encoding") is not None: + self._reject_unread(HTTPStatus.BAD_REQUEST, "Transfer-Encoding is not supported.") + return + if self.headers.get_content_type() != "application/json": + self._reject_unread(HTTPStatus.UNSUPPORTED_MEDIA_TYPE, "JSON is required.") + return + + try: + content_length = int(self.headers.get("Content-Length", "0")) + except ValueError: + self._reject_unread(HTTPStatus.BAD_REQUEST, "Invalid Content-Length.") + return + if not 0 < content_length <= MAX_REQUEST_BYTES: + self._reject_unread(HTTPStatus.REQUEST_ENTITY_TOO_LARGE, "Request is too large.") + return + + try: + payload = json.loads(self.rfile.read(content_length)) + except (json.JSONDecodeError, UnicodeDecodeError): + self._send_json(HTTPStatus.BAD_REQUEST, {"ok": False, "error": "Invalid JSON."}) + return + + if not self.ui_server.scan_slots.acquire(blocking=False): + self._send_json( + HTTPStatus.TOO_MANY_REQUESTS, + {"ok": False, "error": "Two scans are already running."}, + ) + return + + try: + report: Report + if path == "/api/v1/scan": + kind = "scan" + report = execute_scan( + payload, + private_targets_enabled=self.ui_server.private_targets_enabled, + ) + else: + kind = "diff" + report = execute_diff( + payload, + private_targets_enabled=self.ui_server.private_targets_enabled, + ) + report_json = render(report, "json") + report_size = len(report_json.encode("utf-8")) + if report_size > MAX_REPORT_BYTES: + self._send_json( + HTTPStatus.UNPROCESSABLE_ENTITY, + { + "ok": False, + "error": "The generated report is too large for the local interface.", + }, + ) + return + report_payload = json.loads(report_json) + report_id = self.ui_server.store_report(report, size_bytes=report_size) + self._send_json( + HTTPStatus.OK, + { + "ok": True, + "kind": kind, + "result_id": report_id, + "report": report_payload, + "formats": list(_REPORT_METADATA), + }, + ) + except ConfigurationError as error: + self._send_json(HTTPStatus.BAD_REQUEST, {"ok": False, "error": str(error)}) + except NetworkSafetyError as error: + self._send_json(HTTPStatus.UNPROCESSABLE_ENTITY, {"ok": False, "error": str(error)}) + except ScanError as error: + self._send_json(HTTPStatus.BAD_GATEWAY, {"ok": False, "error": str(error)}) + except PreviewShieldError as error: + self._send_json(HTTPStatus.INTERNAL_SERVER_ERROR, {"ok": False, "error": str(error)}) + except Exception as error: # noqa: BLE001 - UI boundary hides target and secret details + print(f"PreviewShield UI request failed: {type(error).__name__}") + self._send_json( + HTTPStatus.INTERNAL_SERVER_ERROR, + {"ok": False, "error": "The scan failed unexpectedly. Check the terminal."}, + ) + finally: + self.ui_server.scan_slots.release() + + def do_OPTIONS(self) -> None: + self._method_not_allowed() + + def do_PUT(self) -> None: + self._method_not_allowed() + + def do_PATCH(self) -> None: + self._method_not_allowed() + + def do_DELETE(self) -> None: + self._method_not_allowed() + + def _serve_asset(self, request_path: str, asset_name: str, content_type: str) -> None: + try: + body = resources.files("previewshield.webui.assets").joinpath(asset_name).read_bytes() + except (FileNotFoundError, ModuleNotFoundError): + self._send_json( + HTTPStatus.INTERNAL_SERVER_ERROR, + {"ok": False, "error": "A packaged UI asset is missing."}, + ) + return + if request_path == "/": + body = body.replace(b"{{CSRF_TOKEN}}", self.ui_server.csrf_token.encode("ascii")) + private_enabled = b"true" if self.ui_server.private_targets_enabled else b"false" + body = body.replace(b"{{PRIVATE_TARGETS_ENABLED}}", private_enabled) + body = body.replace(b"{{CSP}}", UI_CSP.encode("ascii")) + self._send_bytes( + HTTPStatus.OK, + body, + content_type, + establish_session=True, + content_security_policy=UI_CSP, + ) + return + self._send_bytes( + HTTPStatus.OK, + body, + content_type, + content_security_policy=UI_CSP, + ) + + def _serve_report(self, report_id: str, format_name: str) -> None: + if not self._session_and_csrf_are_valid(): + self._send_json( + HTTPStatus.FORBIDDEN, + {"ok": False, "error": "Invalid local UI session."}, + ) + return + report = self.ui_server.report(report_id) + if report is None: + self._send_json(HTTPStatus.NOT_FOUND, {"ok": False, "error": "Report not found."}) + return + content_type, extension = _REPORT_METADATA[format_name] + try: + body = render(report, format_name).encode("utf-8") + except (UnicodeError, ValueError, PreviewShieldError): + self._send_json( + HTTPStatus.INTERNAL_SERVER_ERROR, + {"ok": False, "error": "The report could not be rendered."}, + ) + return + self._send_bytes( + HTTPStatus.OK, + body, + content_type, + content_security_policy=LOCKED_CSP, + extra_headers={ + "Content-Disposition": f'attachment; filename="previewshield-report.{extension}"' + }, + ) + + def _host_is_valid(self) -> bool: + return secrets.compare_digest(self.headers.get("Host", ""), self.ui_server.expected_host) + + def _post_is_authorized(self) -> bool: + origin = self.headers.get("Origin", "") + return ( + secrets.compare_digest( + origin, + self.ui_server.origin, + ) + and self._session_and_csrf_are_valid() + ) + + def _session_and_csrf_are_valid(self) -> bool: + csrf = self.headers.get("X-PreviewShield-CSRF", "") + if not secrets.compare_digest(csrf, self.ui_server.csrf_token): + return False + cookie = SimpleCookie() + try: + cookie.load(self.headers.get("Cookie", "")) + except ValueError: + return False + session = cookie.get(SESSION_COOKIE) + return session is not None and secrets.compare_digest( + session.value, + self.ui_server.session_token, + ) + + def _security_headers(self, content_security_policy: str) -> None: + self.send_header("Cache-Control", "no-store") + self.send_header("Content-Security-Policy", content_security_policy) + self.send_header("Cross-Origin-Opener-Policy", "same-origin") + self.send_header("Cross-Origin-Resource-Policy", "same-origin") + self.send_header( + "Permissions-Policy", + "camera=(), geolocation=(), microphone=(), payment=()", + ) + self.send_header("Referrer-Policy", "no-referrer") + self.send_header("X-Content-Type-Options", "nosniff") + self.send_header("X-Frame-Options", "DENY") + + def _send_bytes( # noqa: PLR0913 + self, + status: HTTPStatus, + body: bytes, + content_type: str, + *, + establish_session: bool = False, + content_security_policy: str = LOCKED_CSP, + extra_headers: Mapping[str, str] | None = None, + ) -> None: + self.send_response(status) + self._security_headers(content_security_policy) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(body))) + if establish_session: + self.send_header( + "Set-Cookie", + ( + f"{SESSION_COOKIE}={self.ui_server.session_token}; " + "HttpOnly; SameSite=Strict; Path=/" + ), + ) + if extra_headers is not None: + for name, value in extra_headers.items(): + self.send_header(name, value) + self.end_headers() + if self.command != "HEAD": + try: + self.wfile.write(body) + except (BrokenPipeError, TimeoutError): + return + + def _send_json(self, status: HTTPStatus, payload: Mapping[str, object]) -> None: + body = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode("utf-8") + self._send_bytes(status, body, "application/json; charset=utf-8") + + def _reject_unread(self, status: HTTPStatus, message: str) -> None: + self.close_connection = True + body = json.dumps( + {"ok": False, "error": message}, + separators=(",", ":"), + ensure_ascii=False, + ).encode("utf-8") + self._send_bytes( + status, + body, + "application/json; charset=utf-8", + extra_headers={"Connection": "close"}, + ) + self._discard_bounded_request_body() + + def _discard_bounded_request_body(self) -> None: + if self.headers.get("Transfer-Encoding") is not None: + return + try: + content_length = int(self.headers.get("Content-Length", "0")) + except ValueError: + return + if not 0 < content_length <= MAX_REQUEST_BYTES: + return + try: + self.connection.settimeout(REJECTED_BODY_DRAIN_TIMEOUT) + self.rfile.read(content_length) + except OSError: + return + + def _method_not_allowed(self) -> None: + self.close_connection = True + self._send_bytes( + HTTPStatus.METHOD_NOT_ALLOWED, + b'{"ok":false,"error":"Method not allowed."}', + "application/json; charset=utf-8", + extra_headers={"Allow": "GET, HEAD, POST"}, + ) + + def log_message(self, format_string: str, *args: object) -> None: + return + + +def create_ui_server( + port: int = 8765, + *, + private_targets_enabled: bool = False, +) -> PreviewShieldUIServer: + """Create a loopback-only server. Port zero selects an available test port.""" + + if not 0 <= port <= MAX_PORT: + raise ValueError("port must be between 0 and 65535") + return PreviewShieldUIServer( + ("127.0.0.1", port), + private_targets_enabled=private_targets_enabled, + ) + + +def serve_ui( + *, + port: int = 8765, + open_browser: bool = True, + private_targets_enabled: bool = False, +) -> None: + """Run the local browser interface until interrupted.""" + + server = create_ui_server(port, private_targets_enabled=private_targets_enabled) + url = f"{server.origin}/" + print(f"PreviewShield UI is ready at {url}") + print("The interface is local-only. Press Ctrl+C to stop it.") + if private_targets_enabled: + print("Private target access is enabled for this session; scan only trusted systems.") + if open_browser: + opener = threading.Timer(0.15, webbrowser.open, args=(url,), kwargs={"new": 2}) + opener.daemon = True + opener.start() + try: + server.serve_forever(poll_interval=0.25) + except KeyboardInterrupt: + print("\nStopping PreviewShield UI.") + finally: + server.server_close() + + +__all__ = ["PreviewShieldUIServer", "create_ui_server", "serve_ui"] diff --git a/src/previewshield/webui/service.py b/src/previewshield/webui/service.py new file mode 100644 index 0000000..599ca2e --- /dev/null +++ b/src/previewshield/webui/service.py @@ -0,0 +1,159 @@ +"""Validation and API orchestration for the local browser interface.""" + +from __future__ import annotations + +import re +from collections.abc import Mapping +from typing import cast + +from previewshield import api +from previewshield.exceptions import ConfigurationError +from previewshield.models import DiffReport, ScanReport, Severity +from previewshield.policy import Policy, default_policy + +MAX_TARGET_LENGTH = 2_048 +MAX_PATH_LENGTH = 512 +MAX_PATHS = 20 +_CONTROL_CHARACTER = re.compile(r"[\x00-\x1f\x7f]") +_SCAN_KEYS = frozenset( + {"target", "profile", "paths", "fail_on", "allow_private", "authorized_private"} +) +_DIFF_KEYS = frozenset( + { + "baseline", + "preview", + "profile", + "paths", + "fail_on", + "allow_private", + "authorized_private", + } +) + + +class UIInputError(ConfigurationError): + """Raised when a browser request does not match the UI contract.""" + + +def execute_scan(payload: object, *, private_targets_enabled: bool) -> ScanReport: + """Validate one browser request and run a single-target scan.""" + + data = _payload(payload, allowed=_SCAN_KEYS) + policy, paths, threshold, allow_private = _options( + data, + private_targets_enabled=private_targets_enabled, + ) + return api.scan( + _text(data, "target", maximum=MAX_TARGET_LENGTH), + policy=policy, + paths=paths, + fail_on=threshold, + allow_private=allow_private, + ) + + +def execute_diff(payload: object, *, private_targets_enabled: bool) -> DiffReport: + """Validate one browser request and compare production with a preview.""" + + data = _payload(payload, allowed=_DIFF_KEYS) + policy, paths, threshold, allow_private = _options( + data, + private_targets_enabled=private_targets_enabled, + ) + return api.diff( + _text(data, "baseline", maximum=MAX_TARGET_LENGTH), + _text(data, "preview", maximum=MAX_TARGET_LENGTH), + policy=policy, + paths=paths, + fail_on=threshold, + allow_private=allow_private, + ) + + +def _payload(payload: object, *, allowed: frozenset[str]) -> Mapping[str, object]: + if not isinstance(payload, dict) or any(not isinstance(key, str) for key in payload): + raise UIInputError("The request body must be a JSON object.") + data = cast(Mapping[str, object], payload) + unknown = sorted(key for key in data if key not in allowed) + if unknown: + raise UIInputError(f"Unknown option(s): {', '.join(unknown)}.") + return data + + +def _options( + data: Mapping[str, object], + *, + private_targets_enabled: bool, +) -> tuple[Policy, tuple[str, ...], Severity, bool]: + profile = _choice(data, "profile", choices=("balanced", "strict"), default="balanced") + policy = default_policy(profile) + paths = _paths(data.get("paths", ["/"])) + fail_on = _choice( + data, + "fail_on", + choices=tuple(severity.value for severity in Severity), + default=policy.fail_on.value, + ) + threshold = Severity.parse(fail_on) + allow_private = _boolean(data, "allow_private", default=False) + authorized_private = _boolean(data, "authorized_private", default=False) + if allow_private and not private_targets_enabled: + raise UIInputError( + "Private targets are locked. Restart with --allow-private-targets only for " + "trusted local systems." + ) + if allow_private and not authorized_private: + raise UIInputError("Confirm authorization before scanning a private or loopback target.") + return policy, paths, threshold, allow_private + + +def _text(data: Mapping[str, object], name: str, *, maximum: int) -> str: + value = data.get(name) + if not isinstance(value, str) or not value.strip(): + raise UIInputError(f"{name} must be a non-empty string.") + normalized = value.strip() + if len(normalized) > maximum: + raise UIInputError(f"{name} must be at most {maximum} characters.") + if _CONTROL_CHARACTER.search(normalized): + raise UIInputError(f"{name} contains control characters.") + return normalized + + +def _choice( + data: Mapping[str, object], + name: str, + *, + choices: tuple[str, ...], + default: str, +) -> str: + value = data.get(name, default) + if not isinstance(value, str) or value not in choices: + raise UIInputError(f"{name} must be one of: {', '.join(choices)}.") + return value + + +def _boolean(data: Mapping[str, object], name: str, *, default: bool) -> bool: + value = data.get(name, default) + if not isinstance(value, bool): + raise UIInputError(f"{name} must be true or false.") + return value + + +def _paths(value: object) -> tuple[str, ...]: + if not isinstance(value, list) or any(not isinstance(path, str) for path in value): + raise UIInputError("paths must be a JSON list of strings.") + if not value: + raise UIInputError("paths must include at least one route.") + if len(value) > MAX_PATHS: + raise UIInputError(f"paths may contain at most {MAX_PATHS} routes.") + normalized: list[str] = [] + for raw_path in value: + path = raw_path.strip() + if not path or len(path) > MAX_PATH_LENGTH or _CONTROL_CHARACTER.search(path): + raise UIInputError(f"Each route must contain 1 to {MAX_PATH_LENGTH} safe characters.") + if path not in normalized: + normalized.append(path) + return tuple(normalized) + + +__all__ = ["UIInputError", "execute_diff", "execute_scan"] diff --git a/tests/test_action.py b/tests/test_action.py new file mode 100644 index 0000000..c01fc77 --- /dev/null +++ b/tests/test_action.py @@ -0,0 +1,269 @@ +from __future__ import annotations + +import json +from collections.abc import Sequence +from pathlib import Path + +import pytest + +from previewshield import cli +from previewshield.action import ( + ACTION_USAGE_ERROR, + ActionInputError, + ActionInputs, + build_cli_argv, + build_output_plan, + invoke_cli, + run_action, +) +from previewshield.models import ScanReport, Severity + + +def _value_after(argv: Sequence[str], option: str) -> str: + return argv[argv.index(option) + 1] + + +def _sidecar_after(argv: Sequence[str], report_format: str) -> Path: + for index, value in enumerate(argv): + if value == "--also-format" and argv[index + 1].startswith(f"{report_format}="): + return Path(argv[index + 1].split("=", maxsplit=1)[1]) + raise AssertionError(f"Missing {report_format} sidecar") + + +def test_scan_arguments_are_passed_as_an_argv_list(tmp_path: Path) -> None: + inputs = ActionInputs.from_values( + target="https://preview.example", + config="policy.yml", + paths="/\n/api, /health; echo unsafe", + fail_on="medium", + output=str(tmp_path / "report.json"), + allow_private="true", + ) + outputs = build_output_plan(inputs.report_format, inputs.output) + + argv = build_cli_argv(inputs, outputs) + + assert argv[:2] == ["scan", "https://preview.example"] + assert argv.count("--path") == 3 + assert "/health; echo unsafe" in argv + assert argv[2:4] == ["--config", "policy.yml"] + assert _value_after(argv, "--format") == "json" + assert _value_after(argv, "--output") == str(tmp_path / "report.json") + assert argv[-1] == "--allow-private" + + +def test_diff_arguments_and_public_markdown_output(tmp_path: Path) -> None: + inputs = ActionInputs.from_values( + baseline="https://www.example.com", + preview="https://pr.example.com", + report_format="markdown", + output=str(tmp_path / "security-report.md"), + ) + outputs = build_output_plan(inputs.report_format, inputs.output) + + argv = build_cli_argv(inputs, outputs) + + assert argv[:5] == [ + "diff", + "--baseline", + "https://www.example.com", + "--preview", + "https://pr.example.com", + ] + assert outputs.exposed == tmp_path / "security-report.md" + assert outputs.markdown == outputs.exposed + assert outputs.json != outputs.exposed + + +@pytest.mark.parametrize( + ("report_format", "misleading_name"), + [("json", "report.md"), ("markdown", "report.json"), ("sarif", "report.md")], +) +def test_selected_output_wins_extension_collisions( + report_format: str, misleading_name: str, tmp_path: Path +) -> None: + output = tmp_path / misleading_name + + plan = build_output_plan(report_format, output) + + assert plan.exposed == output + assert getattr(plan, report_format) == output + assert len({plan.json, plan.markdown, plan.sarif}) == 3 + + +@pytest.mark.parametrize( + ("values", "message"), + [ + ({}, "Provide target"), + ({"target": "https://a", "baseline": "https://b", "preview": "https://c"}, "not both"), + ({"baseline": "https://a"}, "provided together"), + ({"target": "https://a", "allow_private": "perhaps"}, "true or false"), + ({"target": "https://a", "report_format": "xml"}, "Unsupported format"), + ], +) +def test_invalid_inputs_are_rejected(values: dict[str, str], message: str) -> None: + with pytest.raises(ActionInputError, match=message): + ActionInputs.from_values(**values) + + +def test_environment_supports_hyphenated_github_input_names() -> None: + inputs = ActionInputs.from_environment( + { + "INPUT_TARGET": "https://preview.example", + "INPUT_FAIL-ON": "critical", + "INPUT_ALLOW-PRIVATE": "true", + } + ) + + assert inputs.fail_on == "critical" + assert inputs.allow_private is True + + +def test_invoke_cli_normalizes_main_conventions() -> None: + assert invoke_cli(lambda _argv: None, ["scan", "https://example.com"]) == 0 + + def exits(_argv: Sequence[str] | None) -> int: + raise SystemExit("usage") + + assert invoke_cli(exits, ["scan", "https://example.com"]) == ACTION_USAGE_ERROR + + +def test_run_action_publishes_scan_outputs_and_summary(tmp_path: Path) -> None: + github_output = tmp_path / "github-output" + github_summary = tmp_path / "github-summary" + captured: list[str] = [] + + def fake_cli(argv: Sequence[str] | None) -> int: + assert argv is not None + captured.extend(argv) + json_path = Path(_value_after(argv, "--output")) + markdown_path = _sidecar_after(argv, "markdown") + json_path.write_text( + json.dumps({"score": 84, "grade": "B", "passed": False}), encoding="utf-8" + ) + markdown_path.write_text("# Security report\n\nOne regression.\n", encoding="utf-8") + return 1 + + inputs = ActionInputs.from_values( + target="https://preview.example", + output=str(tmp_path / "report.json"), + ) + result = run_action( + inputs, + cli_main=fake_cli, + environ={ + "GITHUB_OUTPUT": str(github_output), + "GITHUB_STEP_SUMMARY": str(github_summary), + }, + ) + + assert result == 1 + assert captured[0] == "scan" + output_commands = github_output.read_text(encoding="utf-8") + assert "report< None: + github_output = tmp_path / "github-output" + + def fake_cli(argv: Sequence[str] | None) -> int: + assert argv is not None + Path(_value_after(argv, "--output")).write_text( + json.dumps({"preview": {"score": 71, "grade": "C"}, "passed": True}), + encoding="utf-8", + ) + return 0 + + inputs = ActionInputs.from_values( + baseline="https://production.example", + preview="https://preview.example", + report_format="sarif", + output=str(tmp_path / "report.sarif"), + ) + + result = run_action(inputs, cli_main=fake_cli, environ={"GITHUB_OUTPUT": str(github_output)}) + + assert result == 0 + commands = github_output.read_text(encoding="utf-8") + assert f"\n{tmp_path / 'report.sarif'}\n" in commands + assert "\n71\n" in commands + assert "\nC\n" in commands + assert "\ntrue\n" in commands + + +def test_cli_exit_code_survives_missing_reports(tmp_path: Path) -> None: + github_output = tmp_path / "github-output" + github_summary = tmp_path / "github-summary" + inputs = ActionInputs.from_values( + target="https://unreachable.example", + output=str(tmp_path / "missing.json"), + ) + inputs.output.write_text( + json.dumps({"score": 100, "grade": "A", "passed": True}), encoding="utf-8" + ) + + result = run_action( + inputs, + cli_main=lambda _argv: 3, + environ={ + "GITHUB_OUTPUT": str(github_output), + "GITHUB_STEP_SUMMARY": str(github_summary), + }, + ) + + assert result == 3 + assert "\nfalse\n" in github_output.read_text(encoding="utf-8") + assert "\n100\n" not in github_output.read_text(encoding="utf-8") + assert "Status: **Failed**" in github_summary.read_text(encoding="utf-8") + + +def test_action_integrates_with_real_cli_and_reporters( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + report = ScanReport( + schema_version="1.0", + tool_version="1.0.0", + generated_at="2026-07-22T12:00:00Z", + policy_name="action-smoke", + target="https://preview.example", + routes=(), + score=96, + grade="A", + fail_on=Severity.HIGH, + passed=True, + ) + + def fake_scan(*_args: object, **_kwargs: object) -> ScanReport: + return report + + monkeypatch.setattr(cli, "scan", fake_scan) + github_output = tmp_path / "github-output" + github_summary = tmp_path / "github-summary" + output = tmp_path / "real-cli-report.json" + inputs = ActionInputs.from_values( + target="https://preview.example", + paths="/\n/health", + output=str(output), + ) + + result = run_action( + inputs, + cli_main=cli.main, + environ={ + "GITHUB_OUTPUT": str(github_output), + "GITHUB_STEP_SUMMARY": str(github_summary), + }, + ) + + plan = build_output_plan("json", output) + assert result == 0 + assert json.loads(plan.json.read_text(encoding="utf-8"))["score"] == 96 + assert plan.markdown.read_text(encoding="utf-8").startswith("## PreviewShield") + assert json.loads(plan.sarif.read_text(encoding="utf-8"))["version"] == "2.1.0" + assert "\n96\n" in github_output.read_text(encoding="utf-8") + assert github_summary.read_text(encoding="utf-8").startswith("## PreviewShield") diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..1aefaac --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from typing import Any + +import pytest + +from previewshield import api +from previewshield.models import DiffReport, ResponseSnapshot, RouteReport, ScanReport, Severity + + +def scan_report() -> ScanReport: + url = "https://example.com/" + snapshot = ResponseSnapshot( + requested_url=url, + final_url=url, + status_code=200, + reason="OK", + headers={}, + resolved_ip="203.0.113.10", + elapsed_ms=1, + ) + return ScanReport( + schema_version="1.0", + tool_version="1.0.0", + generated_at="2026-01-01T00:00:00Z", + policy_name="test", + target=url, + routes=(RouteReport(snapshot=snapshot),), + score=100, + grade="A+", + fail_on=Severity.HIGH, + passed=True, + ) + + +def test_scan_wrapper_forwards_keyword_options(monkeypatch: pytest.MonkeyPatch) -> None: + expected = scan_report() + observed: dict[str, Any] = {} + + def fake_scan(target: str, **kwargs: Any) -> ScanReport: + observed["target"] = target + observed.update(kwargs) + return expected + + monkeypatch.setattr(api, "scan_target", fake_scan) + + assert api.scan("example.com", paths=("/health",), allow_private=True) is expected + assert observed["target"] == "example.com" + assert observed["paths"] == ("/health",) + assert observed["allow_private"] is True + + +def test_diff_wrapper_forwards_both_targets(monkeypatch: pytest.MonkeyPatch) -> None: + scan = scan_report() + expected = DiffReport( + schema_version="1.0", + tool_version="1.0.0", + generated_at="2026-01-01T00:00:00Z", + policy_name="test", + baseline=scan, + preview=scan, + deltas=(), + fail_on=Severity.HIGH, + passed=True, + ) + observed: dict[str, Any] = {} + + def fake_diff(baseline: str, preview: str, **kwargs: Any) -> DiffReport: + observed.update(baseline=baseline, preview=preview, **kwargs) + return expected + + monkeypatch.setattr(api, "diff_targets", fake_diff) + + assert api.diff("prod.example", "preview.example", fail_on=Severity.MEDIUM) is expected + assert observed["baseline"] == "prod.example" + assert observed["preview"] == "preview.example" + assert observed["fail_on"] is Severity.MEDIUM diff --git a/tests/test_checks.py b/tests/test_checks.py new file mode 100644 index 0000000..205d95f --- /dev/null +++ b/tests/test_checks.py @@ -0,0 +1,364 @@ +from __future__ import annotations + +import pytest + +from previewshield.checks import evaluate +from previewshield.models import RedirectHop, ResponseSnapshot, Severity, TLSInfo +from previewshield.policy import default_policy, policy_from_mapping + + +def snapshot( # noqa: PLR0913 - compact fixture factory for rule combinations + *, + requested_url: str = "https://example.com/", + final_url: str = "https://example.com/", + status_code: int = 200, + headers: dict[str, tuple[str, ...]] | None = None, + redirects: tuple[RedirectHop, ...] = (), + tls: TLSInfo | None = None, +) -> ResponseSnapshot: + return ResponseSnapshot( + requested_url=requested_url, + final_url=final_url, + status_code=status_code, + reason="OK", + headers=headers or {}, + resolved_ip="203.0.113.10", + elapsed_ms=12, + redirects=redirects, + tls=tls, + ) + + +def secure_headers() -> dict[str, tuple[str, ...]]: + return { + "strict-transport-security": ("max-age=31536000; includeSubDomains",), + "content-security-policy": ( + "default-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'", + ), + "x-content-type-options": ("nosniff",), + "referrer-policy": ("strict-origin-when-cross-origin",), + "permissions-policy": ("camera=(), microphone=()",), + } + + +def finding_ids(response: ResponseSnapshot) -> set[str]: + return {finding.rule_id for finding in evaluate(response, default_policy())} + + +def test_hardened_response_has_no_findings() -> None: + response = snapshot( + headers=secure_headers(), + tls=TLSInfo( + version="TLSv1.3", + cipher="TLS_AES_256_GCM_SHA384", + certificate_days_remaining=90, + ), + ) + + assert evaluate(response, default_policy()) == () + + +def test_missing_basics_and_plaintext_are_detected() -> None: + response = snapshot( + requested_url="http://example.com/", + final_url="http://example.com/", + ) + + assert { + "PS0001", + "PS1101", + "PS1201", + "PS1202", + "PS1203", + "PS1204", + } <= finding_ids(response) + assert "PS1001" not in finding_ids(response) + + +def test_hsts_and_csp_weaknesses_are_detected() -> None: + response = snapshot( + headers={ + "strict-transport-security": ("max-age=60",), + "content-security-policy": ( + "script-src * 'unsafe-inline' 'unsafe-eval'; object-src https:;", + ), + "x-content-type-options": ("nosniff",), + "referrer-policy": ("unsafe-url",), + "permissions-policy": ("camera=()",), + "x-frame-options": ("SAMEORIGIN",), + } + ) + + ids = finding_ids(response) + assert {"PS1002", "PS1102", "PS1103", "PS1104", "PS1105", "PS1106", "PS1107"} <= ids + referrer = next( + item for item in evaluate(response, default_policy()) if item.rule_id == "PS1203" + ) + assert referrer.severity is Severity.MEDIUM + + +def test_disabled_or_invalid_hsts_is_high_severity() -> None: + for value in ("max-age=0", "includeSubDomains"): + headers = secure_headers() + headers["strict-transport-security"] = (value,) + finding = next( + item + for item in evaluate(snapshot(headers=headers), default_policy()) + if item.rule_id == "PS1002" + ) + assert finding.severity is Severity.HIGH + + +@pytest.mark.parametrize( + "values", + [ + ("max-age=0", "max-age=31536000"), + ("max-age=31536000; max-age=0",), + ], +) +def test_hsts_uses_first_field_and_rejects_duplicate_directives( + values: tuple[str, ...], +) -> None: + headers = secure_headers() + headers["strict-transport-security"] = values + + finding = next( + item + for item in evaluate(snapshot(headers=headers), default_policy()) + if item.rule_id == "PS1002" + ) + assert finding.severity is Severity.HIGH + + +@pytest.mark.parametrize("value", ['max-age="31536000"', "max-age=" + "9" * 5000]) +def test_hsts_accepts_quoted_and_very_large_valid_ages(value: str) -> None: + headers = secure_headers() + headers["strict-transport-security"] = (value,) + + assert "PS1002" not in finding_ids(snapshot(headers=headers)) + + +@pytest.mark.parametrize( + "script_sources", + [ + "script-src 'unsafe-inline'; script-src 'nonce-valid'", + "script-src 'unsafe-inline' 'nonce-'", + ], +) +def test_duplicate_directives_and_malformed_nonces_do_not_hide_unsafe_inline( + script_sources: str, +) -> None: + headers = secure_headers() + headers["content-security-policy"] = ( + f"default-src 'self'; object-src 'none'; base-uri 'none'; " + f"frame-ancestors 'none'; {script_sources}", + ) + + assert "PS1103" in finding_ids(snapshot(headers=headers)) + + +def test_valid_nonce_suppresses_unsafe_inline_observation() -> None: + headers = secure_headers() + headers["content-security-policy"] = ( + "default-src 'self'; object-src 'none'; base-uri 'none'; " + "frame-ancestors 'none'; script-src 'unsafe-inline' 'nonce-YWJjZA=='", + ) + + assert "PS1103" not in finding_ids(snapshot(headers=headers)) + + +def test_report_only_csp_is_not_treated_as_enforcement() -> None: + response = snapshot(headers={"content-security-policy-report-only": ("default-src 'none'",)}) + + finding = next( + item for item in evaluate(response, default_policy()) if item.rule_id == "PS1101" + ) + assert finding.evidence == "report-only policy detected" + + +@pytest.mark.parametrize("value", ["", "garbage"]) +def test_empty_or_ineffective_csp_is_treated_as_missing(value: str) -> None: + headers = secure_headers() + headers["content-security-policy"] = (value,) + + finding = next( + item + for item in evaluate(snapshot(headers=headers), default_policy()) + if item.rule_id == "PS1101" + ) + assert finding.severity is Severity.HIGH + + +def test_non_document_response_skips_document_only_headers() -> None: + response = snapshot( + headers={ + "content-type": ("application/json; charset=utf-8",), + "strict-transport-security": ("max-age=31536000",), + } + ) + + ids = finding_ids(response) + assert "PS1202" in ids + assert {"PS1101", "PS1201", "PS1203", "PS1204"}.isdisjoint(ids) + + +@pytest.mark.parametrize("status_code", [204, 205, 304]) +def test_no_content_response_skips_representation_headers(status_code: int) -> None: + response = snapshot( + status_code=status_code, + headers={"strict-transport-security": ("max-age=31536000",)}, + ) + + ids = finding_ids(response) + assert {"PS1101", "PS1201", "PS1202", "PS1203", "PS1204"}.isdisjoint(ids) + + +def test_hsts_is_not_required_for_ip_literal_hosts() -> None: + response = snapshot( + requested_url="https://127.0.0.1/", + final_url="https://127.0.0.1/", + ) + + assert "PS1001" not in finding_ids(response) + + +def test_frame_ancestors_wildcard_does_not_count_as_clickjacking_protection() -> None: + headers = secure_headers() + headers["content-security-policy"] = ( + "default-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors *", + ) + + assert "PS1201" in finding_ids(snapshot(headers=headers)) + + +def test_scheme_qualified_any_host_is_not_clickjacking_protection() -> None: + headers = secure_headers() + headers["content-security-policy"] = ( + "default-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors https://*", + ) + + ids = finding_ids(snapshot(headers=headers)) + assert {"PS1104", "PS1201"} <= ids + + +def test_scoped_frame_ancestor_wildcard_still_constrains_framing() -> None: + headers = secure_headers() + headers["content-security-policy"] = ( + "default-src 'self'; object-src 'none'; base-uri 'none'; " + "frame-ancestors https://*.trusted.example", + ) + + ids = finding_ids(snapshot(headers=headers)) + assert "PS1104" in ids + assert "PS1201" not in ids + + +def test_referrer_policy_uses_the_last_recognized_fallback() -> None: + headers = secure_headers() + headers["referrer-policy"] = ("unsafe-url, no-referrer",) + assert "PS1203" not in finding_ids(snapshot(headers=headers)) + + headers["referrer-policy"] = ("no-referrer, unsafe-url",) + finding = next( + item + for item in evaluate(snapshot(headers=headers), default_policy()) + if item.rule_id == "PS1203" + ) + assert finding.severity is Severity.MEDIUM + + +def test_cors_and_cookie_failures_do_not_expose_cookie_values() -> None: + headers = secure_headers() + headers.update( + { + "access-control-allow-origin": ("*",), + "access-control-allow-credentials": ("true",), + "set-cookie": ( + "session=super-secret; Path=/", + "cross=another-secret; SameSite=None; HttpOnly", + "__Host-bad=value; Secure; Domain=example.com; Path=/", + "invalid=value; Secure; HttpOnly; SameSite=Surprise", + ), + } + ) + + findings = evaluate(snapshot(headers=headers), default_policy()) + ids = {finding.rule_id for finding in findings} + assert {"PS1301", "PS1401", "PS1402", "PS1403", "PS1404", "PS1405"} <= ids + assert "super-secret" not in " ".join(finding.evidence or "" for finding in findings) + assert "another-secret" not in " ".join(finding.message for finding in findings) + assert next(item for item in findings if item.rule_id == "PS1301").severity is Severity.MEDIUM + assert next(item for item in findings if item.rule_id == "PS1401").severity is Severity.MEDIUM + assert any(item.rule_id == "PS1403" and item.subject == "cookie:invalid" for item in findings) + + +def test_specific_cors_origin_requires_vary_origin() -> None: + headers = secure_headers() + headers["access-control-allow-origin"] = ("https://app.example",) + + assert "PS1303" in finding_ids(snapshot(headers=headers)) + headers["vary"] = ("Accept-Encoding, Origin",) + assert "PS1303" not in finding_ids(snapshot(headers=headers)) + + +def test_tls_and_response_health_checks() -> None: + headers = secure_headers() + headers.update({"server": ("Example/1",), "x-powered-by": ("Example",)}) + response = snapshot( + status_code=503, + headers=headers, + tls=TLSInfo( + version="TLSv1.0", + cipher="RC4-MD5", + certificate_days_remaining=-1, + ), + ) + + findings = evaluate(response, default_policy()) + ids = {finding.rule_id for finding in findings} + assert {"PS1501", "PS1502", "PS1503", "PS1601", "PS1602"} <= ids + certificate = next(item for item in findings if item.rule_id == "PS1502") + assert certificate.severity is Severity.CRITICAL + + +def test_client_error_and_redirect_downgrade_are_detected() -> None: + headers = secure_headers() + response = snapshot( + status_code=404, + headers=headers, + redirects=( + RedirectHop( + url="https://example.com/", + status_code=302, + location="http://example.com/login", + resolved_ip="203.0.113.10", + ), + ), + ) + + assert {"PS0002", "PS1603"} <= finding_ids(response) + + +def test_custom_required_header_override_and_disable() -> None: + policy = policy_from_mapping( + { + "version": 1, + "checks": { + "required_headers": {"X-Robots-Tag": {"contains": "noindex", "severity": "low"}}, + "severity_overrides": {"CUSTOM.X_ROBOTS_TAG": "critical"}, + "disabled": ["PS1204"], + }, + } + ) + headers = secure_headers() + headers.pop("permissions-policy") + + findings = evaluate(snapshot(headers=headers), policy) + custom = next(item for item in findings if item.rule_id == "CUSTOM.X_ROBOTS_TAG") + assert custom.severity is Severity.CRITICAL + assert "PS1204" not in {finding.rule_id for finding in findings} + + headers["x-robots-tag"] = ("noindex, nofollow",) + assert "CUSTOM.X_ROBOTS_TAG" not in { + finding.rule_id for finding in evaluate(snapshot(headers=headers), policy) + } diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..d7f6875 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,230 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from previewshield import cli +from previewshield.models import DiffReport, ResponseSnapshot, RouteReport, ScanReport, Severity + + +def scan_report(*, passed: bool = True) -> ScanReport: + url = "https://example.com/" + snapshot = ResponseSnapshot( + requested_url=url, + final_url=url, + status_code=200, + reason="OK", + headers={}, + resolved_ip="203.0.113.10", + elapsed_ms=1, + ) + return ScanReport( + schema_version="1.0", + tool_version="1.0.0", + generated_at="2026-01-01T00:00:00Z", + policy_name="test", + target=url, + routes=(RouteReport(snapshot=snapshot),), + score=100 if passed else 50, + grade="A+" if passed else "F", + fail_on=Severity.HIGH, + passed=passed, + ) + + +def diff_report(*, passed: bool = True) -> DiffReport: + baseline = scan_report(passed=True) + preview = scan_report(passed=passed) + return DiffReport( + schema_version="1.0", + tool_version="1.0.0", + generated_at="2026-01-01T00:00:00Z", + policy_name="test", + baseline=baseline, + preview=preview, + deltas=(), + fail_on=Severity.HIGH, + passed=passed, + ) + + +def test_version_and_help(capsys: pytest.CaptureFixture[str]) -> None: + assert cli.main(["--version"]) == 0 + assert "PreviewShield 1.0.0" in capsys.readouterr().out + + assert cli.main([]) == 0 + assert "production-to-preview" in capsys.readouterr().out + assert "ui" in cli.build_parser().format_help() + + +def test_parse_error_uses_configuration_exit_code(capsys: pytest.CaptureFixture[str]) -> None: + assert cli.main(["scan"]) == 2 + assert "required" in capsys.readouterr().err + + +def test_scan_forwards_safe_options_and_returns_policy_exit( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + observed: dict[str, object] = {} + + def fake_scan(target: str, **kwargs: object) -> ScanReport: + observed["target"] = target + observed.update(kwargs) + return scan_report(passed=False) + + monkeypatch.setattr(cli, "scan", fake_scan) + monkeypatch.setattr(cli, "render", lambda _report, format_name: f"format={format_name}") + + exit_code = cli.main( + [ + "scan", + "preview.example", + "--path", + "/", + "--path", + "/health", + "--header", + "Authorization:Bearer do-not-print", + "--fail-on", + "medium", + "--allow-private", + "--format", + "json", + ] + ) + + captured = capsys.readouterr() + assert exit_code == 1 + assert captured.out.strip() == "format=json" + assert "do-not-print" not in captured.out + captured.err + assert observed["target"] == "preview.example" + assert observed["paths"] == ("/", "/health") + assert observed["request_headers"] == {"Authorization": "Bearer do-not-print"} + assert observed["fail_on"] is Severity.MEDIUM + assert observed["allow_private"] is True + + +def test_duplicate_and_injected_headers_are_rejected( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + monkeypatch.setattr(cli, "scan", lambda *_args, **_kwargs: scan_report()) + + assert ( + cli.main( + [ + "scan", + "example.com", + "--header", + "X-Test:first", + "--header", + "x-test:second", + ] + ) + == 2 + ) + assert "Duplicate request header" in capsys.readouterr().err + + assert cli.main(["scan", "example.com", "--header", "BrokenHeader"]) == 2 + assert "NAME:VALUE" in capsys.readouterr().err + + +def test_diff_command_and_secondary_reports( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setattr(cli, "diff_targets", lambda *_args, **_kwargs: diff_report()) + monkeypatch.setattr(cli, "render", lambda _report, format_name: f"{format_name} report") + primary = tmp_path / "reports" / "result.json" + sarif = tmp_path / "reports" / "result.sarif" + + exit_code = cli.main( + [ + "diff", + "--baseline", + "prod.example", + "--preview", + "preview.example", + "--format", + "json", + "--output", + str(primary), + "--also-format", + f"sarif={sarif}", + ] + ) + + assert exit_code == 0 + assert primary.read_text(encoding="utf-8") == "json report\n" + assert sarif.read_text(encoding="utf-8") == "sarif report\n" + + +def test_duplicate_report_destination_is_rejected( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + monkeypatch.setattr(cli, "scan", lambda *_args, **_kwargs: scan_report()) + output = tmp_path / "same.json" + + assert ( + cli.main( + [ + "scan", + "example.com", + "--format", + "json", + "--output", + str(output), + "--also-format", + f"json={output}", + ] + ) + == 2 + ) + assert "same path" in capsys.readouterr().err + + +def test_init_and_policy_validate(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + policy_path = tmp_path / "nested" / ".previewshield.yml" + + assert cli.main(["init", "--output", str(policy_path)]) == 0 + assert "version: 1" in policy_path.read_text(encoding="utf-8") + capsys.readouterr() + + assert cli.main(["init", "--output", str(policy_path)]) == 2 + assert "already exists" in capsys.readouterr().err + + assert cli.main(["policy", "validate", str(policy_path)]) == 0 + assert "is valid" in capsys.readouterr().out + + +def test_policy_requires_a_subcommand(capsys: pytest.CaptureFixture[str]) -> None: + assert cli.main(["policy"]) == 2 + assert "requires a command" in capsys.readouterr().err + + +def test_rules_json_is_machine_readable(capsys: pytest.CaptureFixture[str]) -> None: + assert cli.main(["rules", "--json"]) == 0 + payload = json.loads(capsys.readouterr().out) + + assert payload[0]["id"].startswith("PS") + assert {item["id"] for item in payload} >= {"PS0001", "PS1501"} + + +def test_unexpected_error_is_redacted( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + def fail(*_args: object, **_kwargs: object) -> ScanReport: + raise RuntimeError("secret internal detail") + + monkeypatch.setattr(cli, "scan", fail) + + assert cli.main(["scan", "example.com"]) == 4 + error = capsys.readouterr().err + assert "unexpected internal error" in error + assert "secret internal detail" not in error diff --git a/tests/test_diffing.py b/tests/test_diffing.py new file mode 100644 index 0000000..0a3603e --- /dev/null +++ b/tests/test_diffing.py @@ -0,0 +1,189 @@ +from __future__ import annotations + +from typing import Any + +import pytest + +from previewshield import diffing +from previewshield.models import ( + DeltaKind, + Finding, + ResponseSnapshot, + RouteReport, + ScanReport, + Severity, +) +from previewshield.policy import default_policy, policy_from_mapping + + +def finding( + rule_id: str, + host: str, + path: str, + severity: Severity, + *, + message: str = "Observation", +) -> Finding: + return Finding( + rule_id=rule_id, + title=f"Finding {rule_id}", + severity=severity, + category="test", + message=message, + remediation="Fix it.", + target=f"https://{host}{path}", + subject="response", + ) + + +def report(host: str, findings: tuple[Finding, ...], *, passed: bool = True) -> ScanReport: + url = f"https://{host}/" + snapshot = ResponseSnapshot( + requested_url=url, + final_url=url, + status_code=200, + reason="OK", + headers={}, + resolved_ip="203.0.113.10", + elapsed_ms=1, + ) + return ScanReport( + schema_version="1.0", + tool_version="1.0.0", + generated_at="2026-01-01T00:00:00Z", + policy_name="test", + target=url, + routes=(RouteReport(snapshot=snapshot, findings=findings),), + score=100, + grade="A+", + fail_on=Severity.HIGH, + passed=passed, + ) + + +def test_compare_classifies_every_delta_kind_across_different_hosts() -> None: + baseline = report( + "prod.example", + ( + finding("PS9001", "prod.example", "/same", Severity.HIGH), + finding("PS9002", "prod.example", "/resolved", Severity.HIGH), + finding("PS9003", "prod.example", "/changed", Severity.LOW), + finding("PS9004", "prod.example", "/worse", Severity.LOW), + ), + ) + preview = report( + "preview.example", + ( + finding("PS9001", "preview.example", "/same", Severity.HIGH), + finding( + "PS9003", + "preview.example", + "/changed", + Severity.LOW, + message="Changed evidence", + ), + finding("PS9004", "preview.example", "/worse", Severity.HIGH), + finding("PS9005", "preview.example", "/new", Severity.HIGH), + ), + ) + + result = diffing.compare(baseline, preview, policy=default_policy()) + by_rule = {delta.finding.rule_id: delta.kind for delta in result.deltas} + + assert by_rule == { + "PS9001": DeltaKind.UNCHANGED, + "PS9002": DeltaKind.RESOLVED, + "PS9003": DeltaKind.CHANGED, + "PS9004": DeltaKind.REGRESSION, + "PS9005": DeltaKind.REGRESSION, + } + assert result.passed is False + + +def test_regression_mode_ignores_preexisting_findings() -> None: + baseline_finding = finding("PS9001", "prod.example", "/", Severity.CRITICAL) + preview_finding = finding("PS9001", "preview.example", "/", Severity.CRITICAL) + + result = diffing.compare( + report("prod.example", (baseline_finding,), passed=False), + report("preview.example", (preview_finding,), passed=False), + policy=default_policy(), + ) + + assert result.unchanged + assert result.regressions == () + assert result.passed is True + + +def test_absolute_mode_fails_on_preexisting_preview_finding() -> None: + policy = policy_from_mapping({"version": 1, "diff": {"mode": "absolute"}}) + baseline_finding = finding("PS9001", "prod.example", "/", Severity.HIGH) + preview_finding = finding("PS9001", "preview.example", "/", Severity.HIGH) + + result = diffing.compare( + report("prod.example", (baseline_finding,)), + report("preview.example", (preview_finding,)), + policy=policy, + fail_on=Severity.HIGH, + ) + + assert result.passed is False + + +def test_lowered_severity_is_changed_not_regression() -> None: + result = diffing.compare( + report( + "prod.example", + (finding("PS9001", "prod.example", "/", Severity.HIGH),), + ), + report( + "preview.example", + (finding("PS9001", "preview.example", "/", Severity.LOW),), + ), + policy=default_policy(), + ) + + assert result.deltas[0].kind is DeltaKind.CHANGED + assert result.passed is True + + +def test_duplicate_fingerprint_uses_highest_severity() -> None: + duplicate_low = finding("PS9001", "prod.example", "/", Severity.LOW) + duplicate_high = finding("PS9001", "prod.example", "/", Severity.HIGH) + preview_high = finding("PS9001", "preview.example", "/", Severity.HIGH) + + result = diffing.compare( + report("prod.example", (duplicate_low, duplicate_high)), + report("preview.example", (preview_high,)), + ) + + assert result.deltas[0].kind is DeltaKind.UNCHANGED + + +def test_diff_targets_scans_both_sides_with_the_same_options( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls: list[tuple[str, dict[str, Any]]] = [] + baseline = report("prod.example", ()) + preview = report("preview.example", ()) + + def fake_scan(target: str, **kwargs: Any) -> ScanReport: + calls.append((target, kwargs)) + return baseline if target == "prod.example" else preview + + monkeypatch.setattr(diffing, "scan", fake_scan) + policy = default_policy() + + result = diffing.diff_targets( + "prod.example", + "preview.example", + policy=policy, + paths=("/", "/health"), + request_headers={"X-Test": "safe"}, + fail_on=Severity.MEDIUM, + allow_private=True, + ) + + assert result.passed is True + assert [target for target, _kwargs in calls] == ["prod.example", "preview.example"] + assert calls[0][1] == calls[1][1] diff --git a/tests/test_models_utils.py b/tests/test_models_utils.py new file mode 100644 index 0000000..aeba095 --- /dev/null +++ b/tests/test_models_utils.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +import pytest + +from previewshield.exceptions import ConfigurationError +from previewshield.models import Finding, MutableHeaderBag, Severity, to_primitive +from previewshield.utils import ( + clean_text, + markdown_code, + parse_threshold, + redact_url, + validate_header_name, +) + + +def finding(host: str) -> Finding: + return Finding( + rule_id="PS9001", + title="Test", + severity=Severity.HIGH, + category="test", + message="Message", + remediation="Fix", + target=f"https://{host}/route?mode=test", + ) + + +def test_fingerprint_is_stable_across_deployment_hosts() -> None: + assert finding("prod.example").fingerprint == finding("preview.example").fingerprint + assert finding("prod.example").with_severity(Severity.LOW).severity is Severity.LOW + + +def test_severity_parser_and_primitive_conversion() -> None: + assert Severity.parse(" HIGH ") is Severity.HIGH + assert to_primitive(finding("example.com"))["severity"] == "high" + with pytest.raises(ValueError, match="Unknown severity"): + Severity.parse("blocker") + + +def test_mutable_header_bag_preserves_duplicate_values() -> None: + bag = MutableHeaderBag() + bag.add("Set-Cookie", "one=1") + bag.add("set-cookie", "two=2") + + assert bag.freeze() == {"set-cookie": ("one=1", "two=2")} + + +def test_output_safety_helpers() -> None: + cleaned = clean_text("safe\r\ntext") + assert "\r" not in cleaned and "\n" not in cleaned + assert clean_text("abcdef", limit=4) == "abc…" + assert markdown_code("a`b") == "`a'b`" + assert redact_url("https://user:secret@example.com:8443/path?q=1#fragment") == ( + "https://example.com:8443/path?q=1" + ) + + +def test_configuration_helpers() -> None: + assert parse_threshold("medium") is Severity.MEDIUM + assert validate_header_name("X-Safe_Header") == "X-Safe_Header" + with pytest.raises(ConfigurationError, match="Unknown severity"): + parse_threshold("urgent") + with pytest.raises(ConfigurationError, match="Invalid request header"): + validate_header_name("Bad Header") diff --git a/tests/test_network.py b/tests/test_network.py new file mode 100644 index 0000000..14aaf45 --- /dev/null +++ b/tests/test_network.py @@ -0,0 +1,748 @@ +"""Tests for PreviewShield's direct, SSRF-resistant network transport.""" + +from __future__ import annotations + +import socket +import ssl +import threading +import time +from collections.abc import Callable, Iterator +from contextlib import contextmanager, suppress +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import Any + +import pytest + +from previewshield import network +from previewshield.exceptions import NetworkSafetyError, ScanError +from previewshield.network import NetworkOptions, fetch + +_Responder = Callable[[BaseHTTPRequestHandler], None] + + +class _QuietThreadingHTTPServer(ThreadingHTTPServer): + daemon_threads = True + + def handle_error(self, request: object, client_address: object) -> None: + """Suppress expected disconnects from intentionally unread bodies.""" + + +@contextmanager +def _serve(responder: _Responder) -> Iterator[_QuietThreadingHTTPServer]: + class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def do_GET(self) -> None: + responder(self) + + def log_message(self, format_string: str, *args: object) -> None: + del format_string, args + + server = _QuietThreadingHTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield server + finally: + server.shutdown() + server.server_close() + thread.join(timeout=2) + + +def _port(server: ThreadingHTTPServer) -> int: + return int(server.server_address[1]) + + +def _empty_response(handler: BaseHTTPRequestHandler, status: int = 200) -> None: + handler.send_response(status) + handler.send_header("Content-Length", "0") + handler.end_headers() + + +def _pinned_addrinfo( + hostname: str, + port: int, + **kwargs: object, +) -> list[tuple[Any, ...]]: + del hostname, kwargs + return [ + ( + socket.AF_INET, + socket.SOCK_STREAM, + socket.IPPROTO_TCP, + "", + ("127.0.0.1", port), + ) + ] + + +def _public_addrinfo( + hostname: str, + port: int, + **kwargs: object, +) -> list[tuple[Any, ...]]: + del hostname, kwargs + return [ + ( + socket.AF_INET, + socket.SOCK_STREAM, + socket.IPPROTO_TCP, + "", + ("93.184.216.34", port), + ) + ] + + +def test_fetch_pins_dns_preserves_host_duplicates_and_skips_body( + monkeypatch: pytest.MonkeyPatch, +) -> None: + seen: dict[str, str] = {} + body_release = threading.Event() + + def respond(handler: BaseHTTPRequestHandler) -> None: + seen["host"] = handler.headers["Host"] + seen["path"] = handler.path + handler.send_response(200) + handler.send_header("X-Repeat", "first") + handler.send_header("X-Repeat", "second") + handler.send_header("Content-Length", "1000000") + handler.end_headers() + body_release.wait(timeout=1.5) + + with _serve(respond) as server: + port = _port(server) + monkeypatch.setattr(socket, "getaddrinfo", _pinned_addrinfo) + monkeypatch.setenv("HTTP_PROXY", "http://127.0.0.1:1") + monkeypatch.setenv("HTTPS_PROXY", "http://127.0.0.1:1") + + started = time.monotonic() + snapshot = fetch( + f"http://PINNED.test:{port}/a path?q=hello world", + NetworkOptions(timeout=1.0, allow_private=True), + ) + duration = time.monotonic() - started + body_release.set() + + assert duration < 0.5 + assert seen == { + "host": f"pinned.test:{port}", + "path": "/a%20path?q=hello%20world", + } + assert snapshot.requested_url == f"http://pinned.test:{port}/a%20path?q=hello%20world" + assert snapshot.final_url == snapshot.requested_url + assert snapshot.status_code == 200 + assert snapshot.reason == "OK" + assert snapshot.resolved_ip == "127.0.0.1" + assert snapshot.header("X-Repeat") == "second" + assert snapshot.header_values("x-repeat") == ("first", "second") + assert snapshot.redirects == () + assert snapshot.tls is None + + +def test_total_response_header_size_is_bounded() -> None: + def respond(handler: BaseHTTPRequestHandler) -> None: + handler.send_response(200) + for index in range(9): + handler.send_header(f"X-Large-{index}", "x" * 8192) + handler.send_header("Content-Length", "0") + handler.end_headers() + + with ( + _serve(respond) as server, + pytest.raises(ScanError, match="response headers exceed the 65536-byte limit"), + ): + fetch( + f"http://127.0.0.1:{_port(server)}/", + NetworkOptions(allow_private=True), + ) + + +def test_cross_origin_redirect_strips_all_caller_headers() -> None: + destination_headers: dict[str, str | None] = {} + + def destination(handler: BaseHTTPRequestHandler) -> None: + destination_headers.update( + authorization=handler.headers.get("Authorization"), + cookie=handler.headers.get("Cookie"), + trace=handler.headers.get("X-Trace"), + api_key=handler.headers.get("X-API-Key"), + user_agent=handler.headers.get("User-Agent"), + accept=handler.headers.get("Accept"), + ) + handler.send_response(200) + handler.send_header("Set-Cookie", "one=1") + handler.send_header("Set-Cookie", "two=2") + handler.send_header("Content-Length", "0") + handler.end_headers() + + with _serve(destination) as destination_server: + destination_url = f"http://127.0.0.1:{_port(destination_server)}/final" + + def source(handler: BaseHTTPRequestHandler) -> None: + assert handler.headers["Authorization"] == "Bearer secret" + assert handler.headers["Cookie"] == "session=secret" + handler.send_response(302) + handler.send_header("Location", destination_url) + handler.send_header("Content-Length", "0") + handler.end_headers() + + with _serve(source) as source_server: + source_url = f"http://127.0.0.1:{_port(source_server)}/start" + snapshot = fetch( + source_url, + NetworkOptions(allow_private=True), + { + "Authorization": "Bearer secret", + "Cookie": "session=secret", + "X-Trace": "caller-metadata", + "X-API-Key": "api-secret", + }, + ) + + assert destination_headers == { + "authorization": None, + "cookie": None, + "trace": None, + "api_key": None, + "user_agent": "PreviewShield/1.0", + "accept": "*/*", + } + assert snapshot.final_url == destination_url + assert snapshot.header_values("set-cookie") == ("one=1", "two=2") + assert snapshot.redirects == ( + network.RedirectHop( + url=source_url, + status_code=302, + location=destination_url, + resolved_ip="127.0.0.1", + ), + ) + + +def test_same_origin_redirect_keeps_sensitive_headers() -> None: + final_headers: dict[str, str | None] = {} + + def respond(handler: BaseHTTPRequestHandler) -> None: + if handler.path == "/start": + handler.send_response(307) + handler.send_header("Location", "/final") + handler.send_header("Content-Length", "0") + handler.end_headers() + return + final_headers["authorization"] = handler.headers.get("Authorization") + final_headers["cookie"] = handler.headers.get("Cookie") + final_headers["api_key"] = handler.headers.get("X-API-Key") + final_headers["trace"] = handler.headers.get("X-Trace") + _empty_response(handler) + + with _serve(respond) as server: + snapshot = fetch( + f"http://127.0.0.1:{_port(server)}/start", + NetworkOptions(allow_private=True), + { + "authorization": "Bearer same", + "cookie": "same=1", + "X-API-Key": "same-secret", + "X-Trace": "same-trace", + }, + ) + + assert snapshot.status_code == 200 + assert final_headers == { + "authorization": "Bearer same", + "cookie": "same=1", + "api_key": "same-secret", + "trace": "same-trace", + } + + +def test_redirect_fragment_is_not_sent_over_http() -> None: + seen_paths: list[str] = [] + + def respond(handler: BaseHTTPRequestHandler) -> None: + seen_paths.append(handler.path) + if handler.path == "/start": + handler.send_response(302) + handler.send_header("Location", "/final#section") + handler.send_header("Content-Length", "0") + handler.end_headers() + return + _empty_response(handler) + + with _serve(respond) as server: + base = f"http://127.0.0.1:{_port(server)}" + snapshot = fetch(f"{base}/start", NetworkOptions(allow_private=True)) + + assert seen_paths == ["/start", "/final"] + assert snapshot.final_url == f"{base}/final" + + +def test_explicit_user_agent_and_accept_are_not_duplicated() -> None: + seen: dict[str, list[str] | None] = {} + + def respond(handler: BaseHTTPRequestHandler) -> None: + seen["user-agent"] = handler.headers.get_all("User-Agent") + seen["accept"] = handler.headers.get_all("Accept") + _empty_response(handler) + + with _serve(respond) as server: + fetch( + f"http://127.0.0.1:{_port(server)}", + NetworkOptions(allow_private=True), + {"User-Agent": "custom-agent", "Accept": "application/json"}, + ) + + assert seen == {"user-agent": ["custom-agent"], "accept": ["application/json"]} + + +def test_redirect_limit_is_enforced() -> None: + def respond(handler: BaseHTTPRequestHandler) -> None: + handler.send_response(302) + handler.send_header("Location", "/second") + handler.send_header("Content-Length", "0") + handler.end_headers() + + with ( + _serve(respond) as server, + pytest.raises(ScanError, match="Redirect limit exceeded"), + ): + fetch( + f"http://127.0.0.1:{_port(server)}/first", + NetworkOptions(max_redirects=0, allow_private=True), + ) + + +def test_redirect_loop_is_detected() -> None: + def respond(handler: BaseHTTPRequestHandler) -> None: + handler.send_response(301) + handler.send_header("Location", handler.path) + handler.send_header("Content-Length", "0") + handler.end_headers() + + with ( + _serve(respond) as server, + pytest.raises(ScanError, match="Redirect loop detected"), + ): + fetch( + f"http://127.0.0.1:{_port(server)}/loop", + NetworkOptions(allow_private=True), + ) + + +@pytest.mark.parametrize("allowed_host", ["source.allowed.test", "*.allowed.test"]) +def test_disallowed_redirect_is_blocked_before_destination_is_hit( + allowed_host: str, + monkeypatch: pytest.MonkeyPatch, +) -> None: + source_hit = threading.Event() + destination_hit = threading.Event() + + def destination(handler: BaseHTTPRequestHandler) -> None: + destination_hit.set() + _empty_response(handler) + + monkeypatch.setattr(socket, "getaddrinfo", _pinned_addrinfo) + with _serve(destination) as destination_server: + destination_url = f"http://blocked.test:{_port(destination_server)}/final" + + def source(handler: BaseHTTPRequestHandler) -> None: + source_hit.set() + handler.send_response(302) + handler.send_header("Location", destination_url) + handler.send_header("Content-Length", "0") + handler.end_headers() + + with ( + _serve(source) as source_server, + pytest.raises(NetworkSafetyError, match="configured allowlist"), + ): + fetch( + f"http://source.allowed.test:{_port(source_server)}/start", + NetworkOptions( + allow_private=True, + allowed_hosts=(allowed_host.upper(),), + ), + ) + + assert source_hit.is_set() + assert not destination_hit.is_set() + + +def test_wildcard_allowlist_does_not_match_the_apex() -> None: + with pytest.raises(NetworkSafetyError, match="configured allowlist"): + fetch( + "http://allowed.test", + NetworkOptions(allowed_hosts=("*.allowed.test",)), + ) + + +@pytest.mark.parametrize( + "address", + [ + "127.0.0.1", + "10.10.10.10", + "169.254.10.10", + "100.64.0.1", + "224.0.0.1", + "240.0.0.1", + "0.0.0.0", + "::1", + "fe80::1", + "fec0::1", + "ff02::1", + "::", + "::ffff:127.0.0.1", + "2002:0a00:0001::", + "2001:0000:4136:e378:8000:63bf:f5ff:fffe", + ], +) +def test_non_public_dns_answers_are_blocked( + address: str, + monkeypatch: pytest.MonkeyPatch, +) -> None: + family = socket.AF_INET6 if ":" in address else socket.AF_INET + sockaddr: tuple[Any, ...] = (address, 80, 0, 0) if family == socket.AF_INET6 else (address, 80) + + def resolve(*args: object, **kwargs: object) -> list[tuple[Any, ...]]: + del args, kwargs + return [(family, socket.SOCK_STREAM, socket.IPPROTO_TCP, "", sockaddr)] + + monkeypatch.setattr(socket, "getaddrinfo", resolve) + with pytest.raises(NetworkSafetyError, match="resolved to blocked address"): + fetch("http://blocked.test", NetworkOptions()) + + +def test_one_unsafe_dns_answer_rejects_the_entire_origin( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def resolve(*args: object, **kwargs: object) -> list[tuple[Any, ...]]: + del args, kwargs + return [ + ( + socket.AF_INET, + socket.SOCK_STREAM, + socket.IPPROTO_TCP, + "", + ("93.184.216.34", 80), + ), + ( + socket.AF_INET, + socket.SOCK_STREAM, + socket.IPPROTO_TCP, + "", + ("127.0.0.1", 80), + ), + ] + + monkeypatch.setattr(socket, "getaddrinfo", resolve) + with pytest.raises(NetworkSafetyError, match=r"127\.0\.0\.1"): + fetch("http://mixed.test", NetworkOptions()) + + +def test_private_target_is_blocked_by_default() -> None: + def respond(handler: BaseHTTPRequestHandler) -> None: + _empty_response(handler) + + with ( + _serve(respond) as server, + pytest.raises(NetworkSafetyError, match="blocked address"), + ): + fetch(f"http://127.0.0.1:{_port(server)}", NetworkOptions()) + + +@pytest.mark.parametrize( + "url", + [ + "ftp://example.com/file", + "http://user:secret@example.com/", + "http://example.com/#fragment", + "http://example.com:99999/", + "http://example.com:/", + "http://invalid_host.example/", + " http://example.com/", + "http://example.com/%not-escaped", + "http://[fe80::1%25adapter]/", + "http://example.com/with\\backslash", + ], +) +def test_unsafe_urls_are_rejected_before_network_access(url: str) -> None: + with pytest.raises(NetworkSafetyError): + fetch(url, NetworkOptions()) + + +@pytest.mark.parametrize( + ("headers", "message"), + [ + ({"Host": "attacker.example"}, "cannot be overridden"), + ({"Connection": "keep-alive"}, "cannot be overridden"), + ({"X-Test\r\nInjected": "true"}, "invalid name"), + ({"X-Test": "safe\r\nInjected: true"}, "control character"), + ({"X-Test": "snowman: ☃"}, "ISO-8859-1"), + ], +) +def test_unsafe_request_headers_are_rejected( + headers: dict[str, str], + message: str, +) -> None: + with pytest.raises(NetworkSafetyError, match=message): + fetch("https://example.com", NetworkOptions(), headers) + + +@pytest.mark.parametrize( + "arguments", + [ + {"timeout": 0}, + {"timeout": float("inf")}, + {"timeout": True}, + {"max_redirects": -1}, + {"max_redirects": 21}, + {"max_redirects": True}, + {"allow_private": "yes"}, + {"user_agent": "bad\r\nheader"}, + {"allowed_hosts": ["example.com"]}, + {"allowed_hosts": ("*.",)}, + {"allowed_hosts": ("*.127.0.0.1",)}, + {"allowed_hosts": ("bad*host.example",)}, + ], +) +def test_network_options_reject_unbounded_or_unsafe_values(arguments: dict[str, Any]) -> None: + with pytest.raises(ValueError): + NetworkOptions(**arguments) + + +def test_dns_failures_have_a_stable_domain_error(monkeypatch: pytest.MonkeyPatch) -> None: + def fail(*args: object, **kwargs: object) -> list[tuple[Any, ...]]: + del args, kwargs + raise socket.gaierror + + monkeypatch.setattr(socket, "getaddrinfo", fail) + with pytest.raises(ScanError, match=r"^DNS resolution failed for 'missing\.test'\.$"): + fetch("http://missing.test", NetworkOptions()) + + +@pytest.mark.parametrize( + ("answer", "message"), + [ + ( + (9999, socket.SOCK_STREAM, 0, "", ("ignored", 80)), + "no usable addresses", + ), + ( + (socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP, "", ("not-an-ip", 80)), + "invalid address", + ), + ( + (socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP, "", ("::1", 80)), + "address-family mismatch", + ), + ( + (socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP, "", "not-a-tuple"), + "malformed socket address", + ), + ( + (socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP, "", ("93.184.216.34", 81)), + "unexpected destination port", + ), + ], +) +def test_malformed_dns_answers_are_scan_errors( + answer: tuple[Any, ...], + message: str, + monkeypatch: pytest.MonkeyPatch, +) -> None: + def resolve(*args: object, **kwargs: object) -> list[tuple[Any, ...]]: + del args, kwargs + return [answer] + + monkeypatch.setattr(socket, "getaddrinfo", resolve) + with pytest.raises(ScanError, match=message): + fetch("http://malformed.test", NetworkOptions()) + + +def test_header_timeout_has_a_stable_domain_error() -> None: + def respond(handler: BaseHTTPRequestHandler) -> None: + time.sleep(0.2) + with suppress(OSError): + _empty_response(handler) + + with ( + _serve(respond) as server, + pytest.raises(ScanError, match="Request timed out"), + ): + fetch( + f"http://127.0.0.1:{_port(server)}", + NetworkOptions(timeout=0.05, allow_private=True), + ) + + +def test_total_deadline_stops_slowloris_header_drip() -> None: + def respond(handler: BaseHTTPRequestHandler) -> None: + handler.close_connection = True + payload = b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n" + with suppress(OSError): + for byte in payload: + time.sleep(0.01) + handler.connection.sendall(bytes((byte,))) + + with _serve(respond) as server: + started = time.monotonic() + with pytest.raises(ScanError, match="Request timed out"): + fetch( + f"http://127.0.0.1:{_port(server)}", + NetworkOptions(timeout=0.05, allow_private=True), + ) + elapsed = time.monotonic() - started + + assert elapsed < 0.3 + + +def test_https_certificate_errors_are_wrapped(monkeypatch: pytest.MonkeyPatch) -> None: + def fail_tls(connection: object) -> None: + del connection + raise ssl.SSLCertVerificationError + + monkeypatch.setattr(socket, "getaddrinfo", _public_addrinfo) + monkeypatch.setattr(network._PinnedHTTPSConnection, "connect", fail_tls) + with pytest.raises(ScanError, match="TLS certificate verification failed"): + fetch("https://certificate.test", NetworkOptions()) + + +def test_https_pinning_preserves_sni_and_closes_on_wrap_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class FakeSocket: + def __init__(self) -> None: + self.closed = False + self.timeout: float | None = None + + def close(self) -> None: + self.closed = True + + def settimeout(self, timeout: float) -> None: + self.timeout = timeout + + def shutdown(self, how: int) -> None: + del how + + def do_handshake(self) -> None: + return + + class RecordingContext: + def __init__(self, wrapped: FakeSocket, *, fail: bool = False) -> None: + self.wrapped = wrapped + self.fail = fail + self.server_hostname: str | None = None + self.verify_mode = ssl.CERT_REQUIRED + self.check_hostname = True + + def wrap_socket( + self, + raw_socket: FakeSocket, + *, + server_hostname: str, + do_handshake_on_connect: bool, + ) -> FakeSocket: + self.server_hostname = server_hostname + assert not do_handshake_on_connect + if self.fail: + raise ssl.SSLError("test failure") + assert raw_socket is not self.wrapped + return self.wrapped + + raw_socket = FakeSocket() + wrapped_socket = FakeSocket() + context = RecordingContext(wrapped_socket) + + def connect(*args: object, **kwargs: object) -> tuple[FakeSocket, str]: + del args, kwargs + return raw_socket, "93.184.216.34" + + monkeypatch.setattr(network, "_connect_pinned", connect) + connection = network._PinnedHTTPSConnection( + "secure.example", + 443, + (), + 1.0, + context, # type: ignore[arg-type] + ) + connection.connect() + + assert context.server_hostname == "secure.example" + assert connection.connected_ip == "93.184.216.34" + assert connection.sock is wrapped_socket + assert not raw_socket.closed + + failing_raw = FakeSocket() + failing_context = RecordingContext(FakeSocket(), fail=True) + + def connect_failure(*args: object, **kwargs: object) -> tuple[FakeSocket, str]: + del args, kwargs + return failing_raw, "93.184.216.34" + + monkeypatch.setattr(network, "_connect_pinned", connect_failure) + failing_connection = network._PinnedHTTPSConnection( + "secure.example", + 443, + (), + 1.0, + failing_context, # type: ignore[arg-type] + ) + with pytest.raises(ssl.SSLError, match="test failure"): + failing_connection.connect() + assert failing_raw.closed + + +def test_connection_failures_are_stable( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def refuse(*args: object, **kwargs: object) -> tuple[socket.socket, str]: + del args, kwargs + raise ConnectionRefusedError + + monkeypatch.setattr(socket, "getaddrinfo", _public_addrinfo) + monkeypatch.setattr(network, "_connect_pinned", refuse) + with pytest.raises(ScanError, match=r"^Connection failed for 'public\.test'\.$"): + fetch("http://public.test", NetworkOptions()) + + +def test_invalid_http_responses_are_stable() -> None: + def invalid_http(handler: BaseHTTPRequestHandler) -> None: + handler.connection.sendall(b"NOT-HTTP\r\n\r\n") + handler.close_connection = True + + with ( + _serve(invalid_http) as server, + pytest.raises(ScanError, match="Invalid HTTP response"), + ): + fetch( + f"http://127.0.0.1:{_port(server)}", + NetworkOptions(allow_private=True), + ) + + +def test_tls_metadata_is_captured(monkeypatch: pytest.MonkeyPatch) -> None: + class FakeSSLSocket: + def version(self) -> str: + return "TLSv1.3" + + def cipher(self) -> tuple[str, str, int]: + return "TLS_AES_256_GCM_SHA384", "TLSv1.3", 256 + + def getpeercert(self) -> dict[str, object]: + return { + "subject": ((("commonName", "preview.example"),),), + "issuer": ((("organizationName", "Test CA"),),), + "notAfter": "Dec 31 23:59:59 2099 GMT", + } + + monkeypatch.setattr(network.ssl, "SSLSocket", FakeSSLSocket) + info = network._capture_tls(FakeSSLSocket()) # type: ignore[arg-type] + + assert info is not None + assert info.version == "TLSv1.3" + assert info.cipher == "TLS_AES_256_GCM_SHA384" + assert info.certificate_subject == "commonName=preview.example" + assert info.certificate_issuer == "organizationName=Test CA" + assert info.certificate_expires_at == "2099-12-31T23:59:59Z" + assert info.certificate_days_remaining is not None + assert info.certificate_days_remaining > 20_000 diff --git a/tests/test_policy.py b/tests/test_policy.py new file mode 100644 index 0000000..997b0cf --- /dev/null +++ b/tests/test_policy.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from previewshield.exceptions import ConfigurationError +from previewshield.models import Severity +from previewshield.policy import ( + MAX_POLICY_BYTES, + default_policy, + default_policy_yaml, + load_policy, + policy_from_mapping, +) + + +def test_default_balanced_policy() -> None: + policy = default_policy() + + assert policy.version == 1 + assert policy.profile == "balanced" + assert policy.fail_on is Severity.HIGH + assert policy.paths == ("/",) + assert policy.min_hsts_max_age == 15_552_000 + assert not policy.network.allow_private + + +def test_strict_profile_changes_thresholds() -> None: + policy = default_policy("strict") + + assert policy.fail_on is Severity.MEDIUM + assert policy.min_hsts_max_age == 31_536_000 + assert policy.certificate_warning_days == 45 + + +def test_policy_parses_custom_controls() -> None: + policy = policy_from_mapping( + { + "version": 1, + "name": "storefront", + "paths": ["/", "/account?view=security", "/"], + "network": { + "allowed_hosts": ["example.com", "*.preview.example.com"], + "timeout_seconds": 3.5, + }, + "checks": { + "disabled": ["PS1204"], + "severity_overrides": {"PS1601": "medium"}, + "required_headers": {"X-Robots-Tag": {"contains": "noindex", "severity": "high"}}, + }, + "diff": {"mode": "absolute"}, + } + ) + + assert policy.name == "storefront" + assert policy.paths == ("/", "/account?view=security") + assert policy.network.timeout_seconds == 3.5 + assert policy.host_allowed("https://demo.preview.example.com") + assert not policy.host_allowed("https://preview.example.com") + assert policy.severity_for("PS1601", Severity.LOW) is Severity.MEDIUM + assert not policy.rule_enabled("PS1204") + assert policy.required_headers[0].rule_id == "CUSTOM.X_ROBOTS_TAG" + assert policy.diff_mode == "absolute" + + +@pytest.mark.parametrize( + ("raw", "message"), + [ + ({"unknown": True}, "Unknown policy"), + ({"version": 2}, "between 1 and 1"), + ({"profile": "extreme"}, "Unknown profile"), + ({"paths": ["https://example.com"]}, "origin-relative"), + ({"paths": ["/../admin"]}, "Unsafe route"), + ({"network": {"allow_private": "yes"}}, "true or false"), + ({"network": {"allowed_hosts": [" "]}}, "empty values"), + ({"network": {"allowed_hosts": ["bad host"]}}, "Invalid allowed host"), + ({"network": {"allowed_hosts": ["*.127.0.0.1"]}}, "cannot target IP"), + ({"network": {"user_agent": "Preview\nShield"}}, "control characters"), + ({"network": {"user_agent": "PreviewShield ☃"}}, "ISO-8859-1"), + ({"checks": {"disabled": ["bad"]}}, "Invalid rule ID"), + ({"checks": {"disabled": ["PS9999"]}}, "Unknown rule ID"), + ( + {"checks": {"severity_overrides": {"CUSTOM.UNKNOWN": "high"}}}, + "Unknown rule ID", + ), + ( + {"checks": {"required_headers": {"X-Test": {"exact": "a", "contains": "a"}}}}, + "cannot define both", + ), + ], +) +def test_invalid_policy_is_rejected(raw: dict[str, object], message: str) -> None: + with pytest.raises(ConfigurationError, match=message): + policy_from_mapping(raw) + + +def test_load_policy_uses_safe_yaml(tmp_path: Path) -> None: + path = tmp_path / ".previewshield.yml" + path.write_text("version: 1\nfail_on: medium\n", encoding="utf-8") + + assert load_policy(path).fail_on is Severity.MEDIUM + + +def test_load_policy_rejects_oversized_file(tmp_path: Path) -> None: + path = tmp_path / "large.yml" + path.write_bytes(b"#" * (MAX_POLICY_BYTES + 1)) + + with pytest.raises(ConfigurationError, match="maximum"): + load_policy(path) + + +def test_default_policy_template_round_trips(tmp_path: Path) -> None: + path = tmp_path / ".previewshield.yml" + path.write_text(default_policy_yaml(), encoding="utf-8") + + policy = load_policy(path) + assert policy.profile == "balanced" + assert policy.paths == ("/",) diff --git a/tests/test_reporters.py b/tests/test_reporters.py new file mode 100644 index 0000000..12e7812 --- /dev/null +++ b/tests/test_reporters.py @@ -0,0 +1,244 @@ +from __future__ import annotations + +import json +import re +import xml.etree.ElementTree as ET +from dataclasses import replace + +import pytest + +from previewshield.models import ( + DeltaKind, + DiffReport, + Finding, + FindingDelta, + ResponseSnapshot, + RouteReport, + ScanReport, + Severity, +) +from previewshield.reporters import render, supported_formats + +_SENTINEL = "request-secret-4f7c9e" +_TARGET = f"https://user:password@preview.example.test/account?token={_SENTINEL}#private" + + +def _finding( + *, + rule_id: str = "PS-HEADER-001", + severity: Severity = Severity.HIGH, + target: str = _TARGET, +) -> Finding: + return Finding( + rule_id=rule_id, + title="Missing | CSP", + severity=severity, + category="headers", + message=f"The value token={_SENTINEL} is unsafe; `do not render this`.", + remediation="Set Content-Security-Policy to default-src 'self'.", + target=target, + subject="content-security-policy", + evidence="X-Evidence: " + ("e" * 600) + "\x1b[31m", + references=(f"https://docs.example.test/csp?api_key={_SENTINEL}#details",), + ) + + +def _scan( + *, + target: str = _TARGET, + findings: tuple[Finding, ...] | None = None, + passed: bool = False, + score: int = 72, + grade: str = "C", +) -> ScanReport: + active_findings = (_finding(target=target),) if findings is None else findings + snapshot = ResponseSnapshot( + requested_url=target, + final_url=target, + status_code=200, + reason="OK ", + headers={ + "content-security-policy": ("default-src 'none'",), + "set-cookie": (f"session={_SENTINEL}; Secure; HttpOnly",), + "x-debug-token": (_SENTINEL,), + }, + resolved_ip="203.0.113.10", + elapsed_ms=42, + ) + return ScanReport( + schema_version="1.0", + tool_version="1.2.3", + generated_at="2026-07-22T10:00:00Z", + policy_name="default ", + target=target, + routes=(RouteReport(snapshot=snapshot, findings=active_findings),), + score=score, + grade=grade, + fail_on=Severity.MEDIUM, + passed=passed, + ) + + +def _diff() -> DiffReport: + regression = _finding() + resolved = replace( + _finding(rule_id="PS-TLS-002", severity=Severity.MEDIUM), + title="Legacy TLS", + message="TLS 1.0 was accepted.", + evidence="TLSv1", + subject="tls", + ) + unchanged = replace( + _finding(rule_id="PS-INFO-003", severity=Severity.LOW), + title="Informational header", + message="A server header is visible.", + evidence="server: example", + subject="server", + ) + baseline = _scan( + target="https://production.example.test/", + findings=(resolved, unchanged), + passed=False, + score=78, + grade="C+", + ) + preview = _scan(findings=(regression, unchanged), score=70, grade="C-") + return DiffReport( + schema_version="1.0", + tool_version="1.2.3", + generated_at="2026-07-22T10:01:00Z", + policy_name="default ", + baseline=baseline, + preview=preview, + deltas=( + FindingDelta( + kind=DeltaKind.UNCHANGED, + fingerprint=unchanged.fingerprint, + baseline=unchanged, + preview=unchanged, + ), + FindingDelta( + kind=DeltaKind.RESOLVED, + fingerprint=resolved.fingerprint, + baseline=resolved, + ), + FindingDelta( + kind=DeltaKind.REGRESSION, + fingerprint=regression.fingerprint, + preview=regression, + ), + ), + fail_on=Severity.MEDIUM, + passed=False, + ) + + +def test_registry_supports_every_format_for_scan_and_diff() -> None: + assert supported_formats() == ("console", "html", "json", "junit", "markdown", "sarif") + + for report in (_scan(), _diff()): + for format_name in supported_formats(): + first = render(report, format_name) + assert first + assert first.endswith("\n") + assert first == render(report, format_name) + + assert render(_scan(), "MD") == render(_scan(), "markdown") + assert render(_scan(), ".json") == render(_scan(), "json") + assert render(_scan(), "xml") == render(_scan(), "junit") + with pytest.raises(ValueError, match="Unsupported report format"): + render(_scan(), "pdf") + + +def test_all_formats_remove_request_secrets_and_cap_evidence() -> None: + long_fragment = "e" * 300 + for report in (_scan(), _diff()): + for format_name in supported_formats(): + output = render(report, format_name) + assert _SENTINEL not in output + assert "user:password" not in output + assert "?token=" not in output + assert "?api_key=" not in output + assert "\x1b" not in output + assert long_fragment not in output + + +def test_json_is_pretty_schema_shaped_and_redacts_sensitive_headers() -> None: + output = render(_scan(), "json") + payload = json.loads(output) + + assert output.startswith("{\n ") + assert payload["schema_version"] == "1.0" + assert payload["target"] == "https://preview.example.test/account" + snapshot = payload["routes"][0]["snapshot"] + assert snapshot["requested_url"] == "https://preview.example.test/account" + assert snapshot["headers"]["set-cookie"] == ["[REDACTED]"] + assert snapshot["headers"]["x-debug-token"] == ["[REDACTED]"] + assert snapshot["headers"]["content-security-policy"] == ["default-src 'none'"] + assert len(payload["routes"][0]["findings"][0]["evidence"]) <= 240 + + +def test_markdown_is_github_table_safe_and_diff_is_concise() -> None: + output = render(_diff(), "markdown") + + assert output.startswith("## PreviewShield security diff") + assert "### Regressions (1)" in output + assert "### Resolved (1)" in output + assert "UNCHANGED" not in output + assert "|" in output + assert "`PS-HEADER-001`" in output + + +def test_console_is_plain_and_orders_regressions_before_resolved() -> None: + output = render(_diff(), "console") + + assert output.startswith("PreviewShield diff: FAIL") + assert "\x1b[" not in output + assert output.index("REGRESSION (1):") < output.index("RESOLVED (1):") + assert "Target: https://preview.example.test/account" in output + + +def test_sarif_is_github_compatible_and_diff_contains_only_regressions() -> None: + payload = json.loads(render(_diff(), "sarif")) + run = payload["runs"][0] + + assert payload["version"] == "2.1.0" + assert payload["$schema"].endswith("sarif-2.1.0.json") + assert len(run["tool"]["driver"]["rules"]) == 1 + assert len(run["results"]) == 1 + result = run["results"][0] + assert result["ruleId"] == "PS-HEADER-001" + assert result["baselineState"] == "new" + assert result["level"] == "error" + location = result["locations"][0] + assert location["physicalLocation"]["artifactLocation"]["uri"] == ".previewshield.yml" + assert location["physicalLocation"]["region"]["startLine"] == 1 + assert location["logicalLocations"][0]["kind"] == "webTarget" + fingerprint = result["partialFingerprints"]["previewshieldFingerprint/v1"] + assert re.fullmatch(r"[a-f0-9]{24}", fingerprint) + assert result["partialFingerprints"]["primaryLocationLineHash"] == fingerprint + + +def test_junit_is_valid_xml_and_escapes_network_markup() -> None: + output = render(_diff(), "junit") + root = ET.fromstring(output) # noqa: S314 - parses only locally generated XML + + assert root.tag == "testsuite" + assert root.attrib["tests"] == "2" + assert root.attrib["failures"] == "1" + assert len(root.findall("testcase")) == 2 + assert len(root.findall("testcase/failure")) == 1 + assert "