Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
3103c7a
test(errors): prove 5xx responses never echo secrets across adapters …
yeongseon Oct 22, 2026
da6280c
fix(adapter): classify excessive JSON nesting as malformed input
yeongseon Oct 22, 2026
de36712
test(auth): prove validation preserves host authentication boundaries
yeongseon Oct 22, 2026
813103d
test(schemas): prove wheel drift checks never repair pinned bytes
yeongseon Oct 22, 2026
f8814a4
test(security): prove settings diagnostics redact credential shapes
yeongseon Oct 22, 2026
3e247f8
ci(security): add fail-closed redacted scan and advisory evidence
yeongseon Oct 22, 2026
1d3977b
ci(security): gate releases on exact-wheel runtime advisory audits
yeongseon Oct 22, 2026
322f474
ci(security): include trust-boundary proofs in worker compatibility l…
yeongseon Oct 22, 2026
0db6d46
docs(security): define production boundaries and release evidence policy
yeongseon Oct 22, 2026
d3c59f7
docs(security): keep exception tracebacks in server-side logs and doc…
yeongseon Oct 22, 2026
35c237e
ci(security): date policy entries in UTC so CI does not read them as …
yeongseon Oct 22, 2026
962699c
fix(adapter): enforce portable JSON nesting limits
yeongseon Oct 22, 2026
52c67f5
docs(configuration): describe portable JSON nesting ceiling
yeongseon Oct 22, 2026
bc481f9
docs(security): clarify default JSON depth boundary
yeongseon Oct 22, 2026
6f3a2b2
fix(json): scan hostile strings in a single pass
yeongseon Oct 23, 2026
5bb7314
test(audit): verify candidate identity and installed dependency inven…
yeongseon Oct 23, 2026
3e358fe
ci(audit): exclude only the verified candidate from frozen runtime au…
yeongseon Oct 23, 2026
ff26bda
ci(semgrep): report outcome-only evidence as incomplete
yeongseon Oct 23, 2026
966036b
ci(security): route policy and evidence through shared make targets
yeongseon Oct 23, 2026
12a8595
docs(security): explain verified candidate audits and evidence limits
yeongseon Oct 23, 2026
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
2 changes: 2 additions & 0 deletions .github/workflows/ci-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -531,3 +531,5 @@ jobs:
run: |
pytest -m compat2x -o addopts= -o pythonpath= \
tests/test_worker_compat_2x_spike.py -v
pytest -o addopts= -o pythonpath= tests/test_security_runtime.py \
tests/test_security_inputs.py tests/test_security_auth.py -v
31 changes: 22 additions & 9 deletions .github/workflows/dependency-audit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,22 +21,35 @@ jobs:
with:
python-version: "3.14"

- name: Install audit tool and runtime dependencies
- name: Build wheel and isolated reporting environments
run: |
python -m pip install --upgrade pip
python -m pip install "pip-audit==2.10.1" .
python -m pip install 'pip-audit==2.10.1' build
python -m build --wheel
python -m venv --without-pip "$RUNNER_TEMP/runtime"
python -m pip --python "$RUNNER_TEMP/runtime/bin/python" install dist/*.whl
python -m venv --without-pip "$RUNNER_TEMP/development"
python -m pip --python "$RUNNER_TEMP/development/bin/python" install '.[dev]'

- name: Audit runtime dependencies
continue-on-error: true
run: |
echo "## Runtime dependency audit" >> "$GITHUB_STEP_SUMMARY"
python -m pip_audit 2>&1 | tee -a "$GITHUB_STEP_SUMMARY"

- name: Install development dependencies
run: python -m pip install ".[dev]"
site_packages=$("$RUNNER_TEMP/runtime/bin/python" -c 'import sysconfig; print(sysconfig.get_path("purelib"))')
wheels=(dist/*.whl)
make security-audit-report SITE_PACKAGES="$site_packages" CANDIDATE_WHEEL="${wheels[0]}" SECURITY_OUTPUT=security-results/runtime

- name: Audit runtime and development dependencies
if: always()
continue-on-error: true
run: |
echo "## Runtime and development dependency audit" >> "$GITHUB_STEP_SUMMARY"
python -m pip_audit 2>&1 | tee -a "$GITHUB_STEP_SUMMARY"
site_packages=$("$RUNNER_TEMP/development/bin/python" -c 'import sysconfig; print(sysconfig.get_path("purelib"))')
wheels=(dist/*.whl)
make security-audit-report SITE_PACKAGES="$site_packages" CANDIDATE_WHEEL="${wheels[0]}" AUDIT_ARGS=--development SECURITY_OUTPUT=security-results/development

- name: Upload redacted reporting evidence
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: dependency-security-report
path: security-results/**
if-no-files-found: error
44 changes: 43 additions & 1 deletion .github/workflows/publish-pypi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -239,9 +239,51 @@ jobs:
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

runtime-dependency-audit:
name: Runtime dependency audit gate
needs: build
runs-on: ubuntu-latest
permissions:
contents: read
strategy:
fail-fast: false
matrix:
interpreter: ["3.11", "3.12", "3.13", "3.14"]
steps:
- name: Checkout security policy and tooling
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up audit interpreter
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: ${{ matrix.interpreter }}
- name: Download the exact candidate artifact
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: dist
path: candidate-dist
- name: Install only the candidate runtime closure
run: |
python -m pip install 'pip-audit==2.10.1'
python -m venv --without-pip "$RUNNER_TEMP/candidate"
wheels=(candidate-dist/*.whl)
test "${#wheels[@]}" -eq 1
python -m pip --python "$RUNNER_TEMP/candidate/bin/python" install "${wheels[0]}"
- name: Enforce runtime dependency policy
run: |
site_packages=$("$RUNNER_TEMP/candidate/bin/python" -c 'import sysconfig; print(sysconfig.get_path("purelib"))')
wheels=(candidate-dist/*.whl)
make security-runtime-audit SITE_PACKAGES="$site_packages" CANDIDATE_WHEEL="${wheels[0]}" SECURITY_OUTPUT=security-results/runtime
- name: Upload redacted release security evidence
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: release-security-${{ matrix.interpreter }}
path: security-results/**
if-no-files-found: error

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
36 changes: 35 additions & 1 deletion .github/workflows/security.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ on:
branches: [main]
schedule:
- cron: "0 2 * * 1"
workflow_dispatch:

permissions:
contents: read
Expand All @@ -29,7 +30,19 @@ jobs:
make install

- name: Run Bandit
run: make security
if: always()
run: make security-bandit SECURITY_PYTHON='hatch run python'

- name: Type check security tools
run: make security-types SECURITY_PYTHON='hatch run python'

- name: Upload redacted security evidence
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: security-bandit
path: security-results/**
if-no-files-found: error

semgrep:
runs-on: ubuntu-latest
Expand All @@ -38,6 +51,27 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Run Semgrep
id: scan
uses: semgrep/semgrep-action@713efdd345f3035192eaa63f56867b88e63e4e5d # v1
with:
config: p/python

- name: Set up evidence interpreter
if: always()
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.11"

- name: Record Semgrep execution evidence
if: always()
env:
SCAN_OUTCOME: ${{ steps.scan.outcome }}
run: make security-semgrep

- name: Upload redacted security evidence
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: security-semgrep
path: security-results/**
if-no-files-found: error
1 change: 1 addition & 0 deletions .github/workflows/worker-nightly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ jobs:
- name: Run WorkerCompat suite against pre-release worker library
run: |
python -m pytest tests/test_decorator.py tests/test_integration.py \
tests/test_security_runtime.py tests/test_security_inputs.py tests/test_security_auth.py \
-v --no-header -p no:cacheprovider --no-cov

- name: Explain a WorkerCompat break
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ htmlcov/
# Build artifacts
build/
dist/
security-results/
*.egg-info/

# IDE
Expand Down
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@

## Working Rules

Security gates, evidence redaction and expiring exceptions: see [SECURITY.md](SECURITY.md).
Use `make security` for expanded inventory scanning and the `security-*` Makefile
entry points documented there for evidence and audit policy.

### Test Coverage
- Maintain test coverage at **95% or above** for committed changes and PRs.
- Run `hatch run pytest --cov --cov-report=term-missing -q` to verify before submitting changes.
Expand Down
18 changes: 16 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,7 @@ lint: ensure-hatch
@$(HATCH) run lint

.PHONY: security
security: ensure-hatch
@$(HATCH) run security
security: security-inventory

.PHONY: check-schema-hash
check-schema-hash: ensure-hatch
Expand Down Expand Up @@ -175,3 +174,18 @@ clean-all: clean
help:
@echo "Available commands:" && \
grep -E '^\.PHONY: ' Makefile | cut -d ':' -f2 | xargs -n1 echo " - make"

# Security entry points use the selected CI interpreter without provisioning Hatch.
SECURITY_PYTHON ?= $(if $(CI),python,hatch run python)
SECURITY_OUTPUT ?= security-results
.PHONY: security-inventory security-bandit security-semgrep security-runtime-audit security-audit-report security-types
security-inventory: security-bandit
security-bandit:
@$(SECURITY_PYTHON) -m tools.security_evidence bandit --output "$(SECURITY_OUTPUT)"
security-semgrep:
@$(SECURITY_PYTHON) -c 'from pathlib import Path; import os; from tools.security_evidence import semgrep_evidence; from tools.security_report import write_report; code = write_report([semgrep_evidence(os.environ.get("SCAN_OUTCOME", ""))], Path("$(SECURITY_OUTPUT)")); raise SystemExit(0 if code == 3 else code)'
security-runtime-audit:
@$(SECURITY_PYTHON) -m tools.dependency_audit_policy --site-packages "$(SITE_PACKAGES)" --candidate-wheel "$(CANDIDATE_WHEEL)" --output "$(SECURITY_OUTPUT)" $(AUDIT_ARGS)
security-audit-report: security-runtime-audit
security-types:
@$(SECURITY_PYTHON) -m mypy --explicit-package-bases tools/security_report.py tools/security_exceptions.py tools/security_evidence.py tools/dependency_audit_policy.py tools/dependency_inventory.py
121 changes: 121 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,124 @@ If you prefer email, contact: yeongseon.choe@gmail.com
| ------- | --------- |
| Latest release | :white_check_mark: |
| Older releases | :x: |

## Production trust boundaries

Default 5xx responses and failed formatters contain a fixed public envelope.
Server-side logs intentionally keep the original exception with traceback (`exc_info`)
so operators can diagnose 5xx failures in Application Insights; they are an
operator-facing channel, not part of the HTTP response. Exception messages, Pydantic
input values and application-supplied data can therefore reach your log sink: do not
put secrets into exception messages, and restrict access to logs. Handlers returning
`HttpResponse` and successful custom formatters own their content.

`expose_internal_errors=False` is the default. Enabling it passes original 5xx
exceptions to formatters and is **unsafe for production** without application
redaction; it does not automatically recognize/redact secrets. Original 4xx
exceptions are also available to custom formatters. See
[configuration safety](docs/configuration.md#expose_internal_errors).

Authentication is host-owned. Validation preserves route auth levels, rejects the
unsupported above-route decorator order, and never authenticates direct requests.
Configure platform authentication and route auth explicitly. Body-size and JSON
content-type enforcement are opt-in. Use `max_body_bytes` and constrained models
for public input. The default Pydantic adapter rejects JSON bodies deeper than
512 object/array levels with a sanitized 400, consistently across Python versions.
Brackets inside strings do not count. No implicit error-count/response-size cap
exists: nested invalid arrays below that ceiling can amplify errors. Duplicate JSON keys are
last-value-wins; non-finite numbers require model constraints. These are follow-up
hardening topics, not demonstrated credential leaks. Schema/wheel verification
rejects drift and never rewrites pins.

## Automated controls and permissions

| Control | Runs where | Enforcement |
| --- | --- | --- |
| Bandit 1.9.4 | PR, main, weekly, dispatch | Existing blocking job; all severities reviewed |
| SHA-pinned Semgrep `p/python` | Same workflow | Existing blocking job; execution evidence added |
| CodeQL and SBOM | Existing workflows | Unchanged scanning/reporting controls |
| Runtime pip-audit 2.10.1 | Exact candidate wheel, Python 3.11–3.14 | Blocking dependency of `publish` |
| Runtime/development audit | Weekly/dispatch | Reporting-only with shared classifier |
| Offline trust-boundary tests | Full SDK matrix, 2.x wheel lane, worker nightly | Existing lane enforcement retained |

Security workflows and the new release job use `contents: read` only. Existing
CodeQL security-events permission, Azure OIDC and PyPI OIDC are unchanged. The new
gate replaces no runtime/host/Azure certification and never rebuilds the artifact.
Browser/CDN checks do not apply: this package ships no Swagger UI/browser assets.
Branch-protection requirements remain repository settings, not workflow assertions.

## Static scope and exceptions

`make security` (also `make security-inventory` / `make security-bandit`) scans every Python file under
`src/`, `examples/` (including deployed `e2e_app`), `scripts/`, and `tools/`, with
no per-file exclusions. Tests/fixtures are excluded; shipped `conformance.py` is
included. `infra/` contains Bicep, not Python, and requires configuration review.
There is no demo directory. Bytecode/non-Python generated files are not inputs.
Scanned inventory must equal disk, include at least 30 files and have positive LOC.

Bandit uses `--ignore-nosec`: existing conformance assertion markers cannot hide
findings. Every inline marker must match exact ID/file/line policy; unlisted or
blanket skips produce INCOMPLETE. Baseline exceptions cover test-only assertions,
shell-free tooling subprocesses and the literal TOOL_ERROR enum false positive.
No blanket rule skips or new inline suppressions replace review. Local full validation
and CI use this same expanded inventory. `make security-types` checks the helpers.

Both policies require exact identifiers, reason, owner, added and expiry dates,
with a positive lifetime at most 90 days. Static exceptions use `reviewed-static`
and exact file/line. Dependencies use exact package/advisory and `not-affected`
(independent affectedness evidence) or `fix-not-available` (only while no fix is
listed). Expired exceptions block even when unused; stale exceptions are counted.
Maintainers review evidence before accepting exceptions. Runtime policy starts empty.

## Runtime policy, failure semantics and recovery

Every known runtime advisory blocks, regardless of severity; missing severity is
UNKNOWN, never assumed low. Each supported interpreter is audited because markers
change dependency closures. The candidate venv is created without pip and contains
only the wheel/runtime dependencies. `make security-runtime-audit
SITE_PACKAGES=<candidate-site-packages> CANDIDATE_WHEEL=<exact-wheel>` reads wheel
METADATA and requires exactly matching installed name/version before excluding only
that first-party candidate. It freezes the entire installed third-party closure to
temporary `name==version` requirements, audited outside the venv with `--no-deps
--disable-pip`. Unpublished candidates therefore do not require PyPI publication;
missing/mismatched candidate identity, empty inventories, third-party skips and
missing/changed audit inventory remain blocking. `make security-audit-report` uses
the same policy for reporting jobs; `AUDIT_ARGS=--development` labels development scope.

Exit statuses: PASS=0; FAIL=1 (findings/expired exceptions); TOOL_ERROR=2
(crash, timeout, malformed evidence, policy/tool/DB failure); INCOMPLETE=3
(empty/missing scope, skipped dependencies, inventory gaps/unlisted suppressions).
Every non-PASS blocks release. Semgrep exposes only action outcome: success INCOMPLETE
("action outcome only"),
failure conservatively TOOL_ERROR (unknown finding count), skipped/cancelled
INCOMPLETE. `make security-semgrep` tolerates exactly evidence exit 3 without claiming
PASS; its original SHA-pinned action remains the gate and a failing action stays red.

Advisory DB outages can delay releases: a deliberate availability tradeoff for
fail-closed evidence. Never bypass it. Retry an unchanged tag with
`gh workflow run publish-pypi.yml --ref vX.Y.Z`; changed code/workflows must fix
forward to a new candidate. Trusted OIDC publishing and exact-wheel reuse remain.

## Evidence and deployment review

Helpers emit versioned `security-results/**/summary.json` with check/scope/status,
counts, exit status, tool versions, applied exceptions and allow-listed finding
IDs, relative paths/lines and severities. Markdown summaries go to Job Summary.
Artifact uploads run `always()` and fail on missing evidence. Raw scanner output,
source, advisory descriptions, policy reasons, request bodies, authorization headers
and connection strings are not retained. Generated local evidence is gitignored.
Findings remain code/config review findings until runtime impact is demonstrated.

Examples and anonymous host-smoke apps are local/synthetic demonstrations, not
production authentication templates. `examples/e2e_app` and `infra/main.bicep` are
ephemeral public certification infrastructure. Bicep enforces HTTPS, storage TLS
1.2 and private blobs, but retains shared-key connection strings, public ingress
and an Application Insights connection-string output. Managed identity/RBAC,
private endpoints, explicit SCM/app TLS and removing deployment outputs need
real-Azure certification. This work changes no IaC identity/auth settings.

Azure failure diagnostics print Application Insights exceptions/traces and raw
app logs. The settings printer hides unclassified values/package SAS URLs, but
library exception logs, host logs and app logs remain outside that guarantee. Use
synthetic data only; replace raw dumps with allow-listed diagnostics in an
Azure-verified follow-up. Never send real credentials/user data to certification.
23 changes: 22 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,14 @@ def create(req: func.HttpRequest, body: CreateBody) -> dict[str, str]:
protection. Configure Azure Functions host and/or API Management request-size
limits as the outer enforcement boundary.

Independently of the byte limit, the default Pydantic adapter accepts at most
512 nested JSON object/array levels (the outermost container counts as one).
Deeper bodies return a sanitized `400 Invalid JSON` before model validation on
every supported Python version; brackets inside strings do not count. This
portable ceiling avoids depending on interpreter-specific parser recursion
limits. Ordinary model-validation failures remain `422`. Custom adapters own
their parsing limits.

### `require_json_content_type`

Set `require_json_content_type=True` with `body=` to reject non-empty bodies
Expand Down Expand Up @@ -304,7 +312,20 @@ responses it receives a public `InternalServerError` containing only

`expose_internal_errors` defaults to `False`. Setting it to `True` passes the
original 5xx exception to `error_formatter`, restoring the old behavior. This
is unsafe because a formatter can expose internal details to clients.
is unsafe for production because a formatter can expose internal details to clients.
Formatter output is **not** automatically redacted: supply an explicitly redacting
formatter. A formatter that raises or produces unserializable output still gets
a sanitized 500 fallback. Custom 4xx formatters receive original exceptions, which
can contain Pydantic input values; do not render `str(exc)` in production.

Authentication is Azure Functions host-owned: validation does not check function
keys or bearer tokens, or establish an authenticated principal. Configure route
`auth_level` and platform authentication explicitly. Direct test calls bypass the
host. For public input, configure `max_body_bytes`, JSON media-type enforcement
and constrained models. The default adapter caps JSON depth at 512 object/array
levels; there is no implicit error-count cap.
duplicate JSON keys use last-value-wins semantics, and model configuration controls
non-finite numbers. See the root SECURITY.md for these production boundaries.

### `status_code`

Expand Down
5 changes: 5 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Examples

These are local-only demonstrations, not production security templates. Anonymous
routes, in-memory stores and custom formatter output need explicit production
authentication, limits and redaction. The E2E app is ephemeral public certification
infrastructure using synthetic data only. See [SECURITY.md](../SECURITY.md).

`azure-functions-validation` keeps a growing set of smoke-tested examples:

| Role | Path | Description |
Expand Down
Loading
Loading