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
-
-
-
-
-
+[](https://github.com/devUmut35/PreviewShield/actions/workflows/ci.yml)
+[](https://www.python.org/)
+[](LICENSE)
+[](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 (
+ "Severity Rule Target "
+ f"Finding and remediation {rows}
"
+ )
+
+
+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 (
+ "Change Severity Rule Target "
+ f"Finding and remediation {rows}
"
+ )
+
+
+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}
'
+ '
'
+ '
'
+ "
"
+ 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 0000000..82d07de
Binary files /dev/null and b/src/previewshield/webui/assets/fonts/archivo-black.ttf differ
diff --git a/src/previewshield/webui/assets/fonts/ibm-plex-mono-OFL.txt b/src/previewshield/webui/assets/fonts/ibm-plex-mono-OFL.txt
new file mode 100644
index 0000000..670c6c0
--- /dev/null
+++ b/src/previewshield/webui/assets/fonts/ibm-plex-mono-OFL.txt
@@ -0,0 +1,93 @@
+Copyright © 2017 IBM Corp. with Reserved Font Name "Plex"
+
+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/ibm-plex-mono.ttf b/src/previewshield/webui/assets/fonts/ibm-plex-mono.ttf
new file mode 100644
index 0000000..0c9770d
Binary files /dev/null and b/src/previewshield/webui/assets/fonts/ibm-plex-mono.ttf differ
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
+
+
+
+
+ Skip to scan controls
+
+
+
+
+
+
+
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
+
+
+
+
+ Compare deployments
+ Recommended
+
+
+ Scan one site
+ Quick posture check
+
+
+
+
+
+
+
+
+
+
+
+
+
Release signal
+
Security result
+
+
02 / 03
+
+
+
+
+ Policy result
+ —
+
+ Run a scan to see the release signal.
+
+ —
+ —
+
+
+
+
+
+ Production
+ — —/100
+ —
+
+
+ Δ
+ —
+
+
+ Preview
+ — —/100
+ —
+
+
+
+
+
+
+
+
+
Priority queue
+
What changed?
+
+
0 findings
+
+
+
+
+
No findings in this view.
+
Change the filter or inspect the downloaded report for full route evidence.
+
+
+
+
+
+
Evidence package
+
Take the result with you.
+
+
+
+ Report format
+
+ HTML report
+ JSON
+ SARIF
+ JUnit XML
+ Markdown
+
+
+ Download report
+ Copy GitHub Action
+
+
+
+
+
+
+
+ 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 "