diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index cf2d302..2e5e463 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -219,9 +219,39 @@ jobs: AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + runtime-dependency-audit: + name: Candidate runtime audit (Py ${{ matrix.python-version }}) + needs: build + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.12", "3.13", "3.14"] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: ${{ matrix.python-version }} + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: dist + path: candidate-dist + - name: Audit exact candidate runtime closure + run: make audit-candidate CANDIDATE_WHEEL="$(realpath candidate-dist/*.whl)" + - name: Verify audit guards on the selected interpreter + run: | + pip install pytest pytest-cov + python -m pytest -o addopts= tests/test_runtime_audit_report.py + - name: Retain audit inventory and scanner output + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: runtime-audit-${{ matrix.python-version }} + path: test-results/runtime-audit/ + publish: name: Publish to PyPI - needs: [build, lib-tests, cookbook-smoke, cookbook-host-smoke, azure-e2e] + needs: [build, lib-tests, cookbook-smoke, cookbook-host-smoke, azure-e2e, runtime-dependency-audit] runs-on: ubuntu-latest environment: pypi permissions: diff --git a/Makefile b/Makefile index e9931ba..a03bd76 100644 --- a/Makefile +++ b/Makefile @@ -163,6 +163,10 @@ build: ensure-hatch host-candidate: @bash scripts/check_candidate_host.sh "$(CANDIDATE_WHEEL)" +.PHONY: audit-candidate +audit-candidate: + @bash scripts/audit_candidate.sh "$(CANDIDATE_WHEEL)" + .PHONY: publish-test publish-test: ensure-hatch @$(HATCH) publish --repo test diff --git a/SECURITY.md b/SECURITY.md index ce14ebb..a9e978d 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,5 +1,25 @@ # Security Policy +## Candidate runtime advisory gate + +The release `runtime-dependency-audit` matrix installs the exact built wheel in a +fresh environment on Python 3.11–3.14 and audits its resolved runtime closure. +`make audit-candidate CANDIDATE_WHEEL=/absolute/path/to/candidate.whl` reproduces +the gate on the current `python` interpreter. Scanner tools live in a different +environment, so development/scanner dependencies cannot contaminate that closure. +The unpublished candidate itself is excluded from advisory lookup; its dependencies +are not. Runtime advisories return status 1; missing, incomplete or malformed +reports and scanner failures return status 2. Both block publishing, with inventory +and JSON scanner evidence uploaded even on failure. No exception mechanism is +introduced. Development-dependency audit findings remain advisory, not a substitute +for this exact-candidate gate. + +Azure host authentication belongs to the route binding, never this decorator. +`expose_internal_errors=True` is unsafe for production: it gives custom formatters +original server-side exceptions. Default 5xx responses and formatter fallback must +not echo request bodies, credentials or Pydantic input. Server-side diagnostic logs +can retain exception details and must be protected separately. + ## Reporting a Vulnerability We take security seriously. If you discover a vulnerability, please report it diff --git a/scripts/audit_candidate.sh b/scripts/audit_candidate.sh new file mode 100644 index 0000000..d177903 --- /dev/null +++ b/scripts/audit_candidate.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -euo pipefail +wheel=$(realpath "${1:?candidate wheel required}") +output="$(pwd)/test-results/runtime-audit" +mkdir -p "$output" +scratch=$(mktemp -d) +trap 'rm -rf "$scratch"' EXIT +python -m venv "$scratch/runtime" +"$scratch/runtime/bin/python" -m pip install --disable-pip-version-check "$wheel" +"$scratch/runtime/bin/python" -m pip check +"$scratch/runtime/bin/python" -m pip freeze --exclude azure-functions-validation > "$output/runtime.txt" +test -s "$output/runtime.txt" +python -m venv "$scratch/auditor" +"$scratch/auditor/bin/python" -m pip install --disable-pip-version-check 'pip-audit==2.10.0' +rm -f "$output/audit.json" +status=0 +"$scratch/auditor/bin/python" -m pip_audit --strict --no-deps --disable-pip \ + -r "$output/runtime.txt" --format json --output "$output/audit.json" || status=$? +python scripts/check_runtime_audit.py "$output/audit.json" "$status" diff --git a/scripts/check_runtime_audit.py b/scripts/check_runtime_audit.py new file mode 100644 index 0000000..502a367 --- /dev/null +++ b/scripts/check_runtime_audit.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +import json +from pathlib import Path +import sys + + +def check_report(path: Path, status: int) -> int: + try: + report = json.loads(path.read_text()) + dependencies = report["dependencies"] + if not dependencies or any("skip_reason" in item for item in dependencies): + raise ValueError("incomplete runtime inventory") + findings = any(item["vulns"] for item in dependencies) + except (OSError, ValueError, KeyError, TypeError, AttributeError): + print("runtime audit tool error: missing, malformed or incomplete report", file=sys.stderr) + return 2 + if status not in (0, 1) or (status == 1 and not findings): + print("runtime audit tool error: scanner did not complete", file=sys.stderr) + return 2 + if findings: + print("runtime audit blocked: known runtime advisories", file=sys.stderr) + return 1 + print(f"runtime audit clean: {len(dependencies)} dependencies") + return 0 + + +if __name__ == "__main__": + raise SystemExit(check_report(Path(sys.argv[1]), int(sys.argv[2]))) diff --git a/tests/test_runtime_audit_contract.py b/tests/test_runtime_audit_contract.py new file mode 100644 index 0000000..33052c0 --- /dev/null +++ b/tests/test_runtime_audit_contract.py @@ -0,0 +1,13 @@ +from pathlib import Path + +import yaml + + +def test_publish_requires_candidate_runtime_audit_on_all_supported_interpreters() -> None: + root = Path(__file__).resolve().parents[1] + workflow = yaml.safe_load((root / ".github/workflows/publish-pypi.yml").read_text()) + assert "runtime-dependency-audit" in workflow["jobs"]["publish"]["needs"] + job = workflow["jobs"]["runtime-dependency-audit"] + assert job["strategy"]["matrix"]["python-version"] == ["3.11", "3.12", "3.13", "3.14"] + assert any("make audit-candidate" in step.get("run", "") for step in job["steps"]) + assert any(step.get("if") == "always()" for step in job["steps"]) diff --git a/tests/test_runtime_audit_report.py b/tests/test_runtime_audit_report.py new file mode 100644 index 0000000..d411123 --- /dev/null +++ b/tests/test_runtime_audit_report.py @@ -0,0 +1,34 @@ +import json +from pathlib import Path +from typing import Any + +import pytest + +from scripts.check_runtime_audit import check_report + + +@pytest.mark.parametrize( + ("report", "status", "expected"), + [ + ({"dependencies": [{"name": "dep", "vulns": []}]}, 0, 0), + ({"dependencies": [{"name": "dep", "vulns": [{"id": "CVE-test"}]}]}, 1, 1), + ({"dependencies": []}, 0, 2), + ({"dependencies": [None]}, 0, 2), + ({"dependencies": [{"name": "dep", "skip_reason": "unknown"}]}, 0, 2), + ({"dependencies": [{"name": "dep", "vulns": []}]}, 1, 2), + ({"dependencies": [{"name": "dep", "vulns": []}]}, 2, 2), + ], +) +def test_audit_report_distinguishes_advisories_from_tool_errors( + tmp_path: Path, report: dict[str, Any], status: int, expected: int +) -> None: + path = tmp_path / "audit.json" + path.write_text(json.dumps(report)) + assert check_report(path, status) == expected + + +def test_missing_and_unparsable_audit_fail_closed(tmp_path: Path) -> None: + path = tmp_path / "missing.json" + assert check_report(path, 0) == 2 + path.write_text("not JSON") + assert check_report(path, 0) == 2 diff --git a/tests/test_security_boundary_proofs.py b/tests/test_security_boundary_proofs.py new file mode 100644 index 0000000..db97f7c --- /dev/null +++ b/tests/test_security_boundary_proofs.py @@ -0,0 +1,60 @@ +import json + +import azure.functions as func +from pydantic import BaseModel +import pytest + +from azure_functions_validation import HttpError, validate_http +from azure_functions_validation.testing import MockHttpRequest + + +class Response(BaseModel): + value: int + + +@pytest.mark.anyio +@pytest.mark.parametrize("asynchronous", [False, True]) +@pytest.mark.parametrize("helper", [False, True]) +@pytest.mark.parametrize("formatter_fails", [False, True]) +async def test_default_5xx_and_failing_formatter_never_echo_input_or_credentials( + asynchronous: bool, helper: bool, formatter_fails: bool +) -> None: + secret = "CANARY-secret-credential-input" + + def formatter(exc: Exception, status: int) -> dict[str, str]: + raise RuntimeError(secret) + + def handler(req: func.HttpRequest) -> dict[str, str]: + return {"value": secret} + + async def async_handler(req: func.HttpRequest) -> dict[str, str]: + return handler(req) + + wrapped = validate_http( + response_model=Response, error_formatter=formatter if formatter_fails else None + )(async_handler if asynchronous else handler) + request_type = MockHttpRequest if helper else func.HttpRequest + request = request_type( + method="POST", + url="https://example.test", + body=secret.encode(), + headers={"Authorization": secret}, + ) + response = await wrapped(request) if asynchronous else wrapped(request) + assert response.status_code == 500 + assert secret not in response.get_body().decode() + assert secret not in repr(dict(response.headers)) + + +@pytest.mark.parametrize("auth", [func.AuthLevel.FUNCTION, func.AuthLevel.ADMIN]) +def test_validation_preserves_host_owned_authentication(auth: func.AuthLevel) -> None: + app = func.FunctionApp(http_auth_level=func.AuthLevel.ANONYMOUS) + + @app.route(route="protected", auth_level=auth) + @validate_http() + def handler(req: func.HttpRequest) -> None: + raise HttpError(500, "private") + + bindings = json.loads(app.get_functions()[0].get_function_json())["bindings"] + trigger = next(binding for binding in bindings if binding["type"] == "httpTrigger") + assert trigger["authLevel"].casefold() == auth.value