Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion .github/workflows/publish-pypi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
19 changes: 19 additions & 0 deletions scripts/audit_candidate.sh
Original file line number Diff line number Diff line change
@@ -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"
29 changes: 29 additions & 0 deletions scripts/check_runtime_audit.py
Original file line number Diff line number Diff line change
@@ -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)
Comment on lines +12 to +14
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])))
13 changes: 13 additions & 0 deletions tests/test_runtime_audit_contract.py
Original file line number Diff line number Diff line change
@@ -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"])
34 changes: 34 additions & 0 deletions tests/test_runtime_audit_report.py
Original file line number Diff line number Diff line change
@@ -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
60 changes: 60 additions & 0 deletions tests/test_security_boundary_proofs.py
Original file line number Diff line number Diff line change
@@ -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
Loading