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

- - - -

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

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

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

PreviewShield security scan

" + f'

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

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

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

" + "

Severity summary

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

Findings ({len(report.findings)})

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

PreviewShield security diff

" + f'

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

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

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

" + f"

Security changes ({len(visible)})

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

Release control / web security

+

Catch the change.
Ship with proof.

+
+
+ 01 +

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

+
+
+ +
+
+
+
+

Configure run

+

What should we inspect?

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

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

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