Skip to content
Draft
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
42 changes: 33 additions & 9 deletions .github/workflows/dependency-audit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,22 +21,46 @@ 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==1.5.0'
python -m build --wheel
wheels=(dist/*.whl)
test "${#wheels[@]}" -eq 1
python -m venv --without-pip "$RUNNER_TEMP/runtime"
python -m pip --python "$RUNNER_TEMP/runtime/bin/python" install "${wheels[0]}"
python -m venv --without-pip "$RUNNER_TEMP/otel"
python -m pip --python "$RUNNER_TEMP/otel/bin/python" install "${wheels[0]}[otel]"
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"
site_packages=$("$RUNNER_TEMP/runtime/bin/python" -c 'import sysconfig; print(sysconfig.get_path("purelib"))')
wheels=(dist/*.whl)
make security-runtime-report AUDIT_ARGS="--site-packages $site_packages --candidate-wheel ${wheels[0]} --output security-results/runtime"

- name: Install development dependencies
run: python -m pip install ".[dev]"
- name: Audit OTel runtime dependencies
if: always()
continue-on-error: true
run: |
site_packages=$("$RUNNER_TEMP/otel/bin/python" -c 'import sysconfig; print(sysconfig.get_path("purelib"))')
wheels=(dist/*.whl)
make security-runtime-report AUDIT_ARGS="--site-packages $site_packages --candidate-wheel ${wheels[0]} --output security-results/otel"

- 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-runtime-report AUDIT_ARGS="--site-packages $site_packages --candidate-wheel ${wheels[0]} --development --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
51 changes: 50 additions & 1 deletion .github/workflows/publish-pypi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -263,9 +263,58 @@ 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"]
closure: ["base", "otel"]
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
env:
CLOSURE: ${{ matrix.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
if [ "$CLOSURE" = "otel" ]; then
python -m pip --python "$RUNNER_TEMP/candidate/bin/python" install "${wheels[0]}[otel]"
else
python -m pip --python "$RUNNER_TEMP/candidate/bin/python" install "${wheels[0]}"
fi
- 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_ARGS="--site-packages $site_packages --candidate-wheel ${wheels[0]} --output security-results/${{ matrix.closure }}"
- name: Upload redacted release security evidence
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: release-security-${{ matrix.interpreter }}-${{ matrix.closure }}
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

- name: Type check security tools
run: make security-typecheck

- 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 .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ env/
.ruff_cache/
.coverage
coverage.xml
security-results/
htmlcov/

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

## Working Rules

Security gates, evidence redaction, production boundaries and expiring exceptions:
see [SECURITY.md](SECURITY.md). Use `make security`, `make security-typecheck`,
`make security-semgrep`, `make security-runtime` (blocking),
`make security-runtime-report` (reporting workflow), and `make security-host`
(opt-in host) as the shared security entry points.

### 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
19 changes: 18 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,24 @@ lint: ensure-hatch

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

# Security evidence entry points; kept separate from verification standardization.
SECURITY_PYTHON ?= python
AUDIT_ARGS ?=
SCAN_OUTCOME ?=
.PHONY: security-inventory security-bandit security-semgrep security-runtime security-runtime-report security-typecheck security-host
security-inventory security-bandit: ensure-hatch
@$(HATCH) run python -m tools.security_evidence bandit
security-semgrep:
@$(SECURITY_PYTHON) -m tools.security_evidence semgrep --outcome "$(SCAN_OUTCOME)"; code=$$?; [ $$code -eq 3 ] || exit $$code
security-runtime:
@$(SECURITY_PYTHON) -m tools.dependency_audit_policy $(AUDIT_ARGS)
security-runtime-report:
@$(SECURITY_PYTHON) -m tools.dependency_audit_policy $(AUDIT_ARGS)
security-typecheck: ensure-hatch
@$(HATCH) run security-typecheck
security-host: e2e-local

.PHONY: check
check: ensure-hatch
Expand Down
153 changes: 153 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,156 @@ If you prefer email, contact: yeongseon.choe@gmail.com
| ------- | --------- |
| Latest release | :white_check_mark: |
| Older releases | :x: |

## Automated controls and permissions

| Control | Runs where | Enforcement |
| --- | --- | --- |
| Bandit 1.9.4 | PR, main, weekly, dispatch (`security.yml`) | Blocking scan; every severity reviewed |
| SHA-pinned Semgrep `p/python` | Same workflow | Existing blocking action; execution evidence added |
| CodeQL `security-extended` | PR, main, weekly | Existing analysis/code-scanning control, unchanged |
| Runtime pip-audit 2.10.1 | Exact candidate wheel, Python 3.11–3.14, base and `otel` closures | Blocking dependency of `publish` |
| Runtime/OTel/development audit | Weekly/dispatch (`dependency-audit.yml`) | Reporting-only, shared classifier |
| SBOM | Weekly/dispatch | Existing installed-wheel inventory, unchanged |

Branch-protection requirements are repository settings, not established by these
workflow files. Existing merge checks, runtime/host smoke and real-Azure certification
remain intact. This change adds no write permission: security workflows and the new
release audit job use only `contents: read`. CodeQL retains `contents: read` and
`security-events: write`; Azure certification retains `contents: read` and
`id-token: write`; PyPI publishing retains `id-token: write` and its trusted-publisher
exception. Publishing still uploads the exact `build` artifact, without rebuilding.

## Static scope and reviewed suppressions

`make security` (also `make security-inventory` / `make security-bandit`) scans every Python file under
`src/`, `examples/` (including `e2e_app` and `otel_app`), `scripts/`, `tools/`, and
`benchmarks/`. Benchmarks are included even when not executed by a particular CI
lane. There are no per-file exclusions within these surfaces. Tests/fixtures are
excluded from this product/tool scan. Bytecode and non-Python artifacts are not
Bandit inputs. Saved host logs and deployment configuration require separate review;
this scan does not claim to analyze Bicep, workflows, or arbitrary log contents.
Inventory must equal the on-disk Python inventory, contain at least 38 files and
have positive LOC. Missing surfaces, errors and unlisted suppressions cannot pass.

Bandit uses `--ignore-nosec`, so existing markers cannot hide findings. The separate
`inline_suppressions` allow-list in `security/bandit-policy.json` requires exact
ID/file/line equality with all markers, including markers that Bandit does not
currently flag. The baseline has 26 markers across seven source files:

| Source module | Count | Rules |
| --- | ---: | --- |
| `_context.py` | 10 | B110 |
| `_filters.py` | 7 | B110 |
| `_host_config.py` | 2 | B110 |
| `_otel.py` | 4 | B110 |
| `_host_instance.py` | 1 | B110 |
| `_redaction.py` | 1 | B112 |
| `_decorator.py` | 1 | B307 |

These are existing best-effort/never-raise contracts and handler-owned annotation
evaluation, not new runtime changes. Retaining a masking-resilience exception is
not proof that runtime failures redact safely; runtime hardening is separate.
The evidence reports marker counts and retains all unsuppressed findings. Other
baseline reviews cover synthetic fixtures, type-narrowing assertions, shell-free
tool invocations, a fixed-host documentation URL checker, and a status-enum false
positive. No global rule exclusions replace review. Local full validation and CI
use the same expanded inventory. Security tools receive
a separate strict mypy invocation (`make security-typecheck`), additive to the
existing project typecheck rather than changing its scope.

## Dependency and exception policy

Every known advisory in the resolved candidate runtime closure blocks release,
regardless of severity. Missing severity is `UNKNOWN`, never assumed low. This is
stricter than a high/critical-only gate and avoids missing-CVSS false greens.
The base closure contains the wheel and required dependencies; a separate matrix
leg audits the wheel's `otel` extra. That extra declares `opentelemetry-api`, not the
example application's separately installed Azure Monitor exporter. Development,
docs and example-only dependencies are not part of the release base/OTel closure.
Each supported interpreter is audited because environment markers affect resolution.
Candidate environments use `venv --without-pip`; audit tools run outside them.
`make security-runtime AUDIT_ARGS="--site-packages <purelib> --candidate-wheel <wheel>"`
verifies wheel METADATA name/version against the installed distribution before
excluding only that first-party candidate. Every installed third-party distribution
is frozen as `name==version` and audited with `--requirement`, `--no-deps`, and
`--disable-pip`; returned inventory must match the full installed closure.
This applies independently to base and `otel` environments. Missing/mismatched
candidate identity, skipped third parties or omitted dependencies block release.
No audit-tool dependencies contaminate the candidate installation.
Scheduled reporting uses `make security-runtime-report` with the same arguments
and preserves non-PASS evidence in reporting-only workflow steps.

Both policy files require maintainer review, exact identifiers, `reason`, `owner`,
`added` and `expires` (ISO dates), and a positive lifetime at most 90 days. Static
exceptions require `reviewed-static` with exact file/line. Dependency exceptions
require exact package/advisory and `not-affected` (independently verified evidence)
or `fix-not-available` (only while no fixed version is listed). Package separators
are normalized; advisory aliases are not silently ignored. Expired entries block
even when unused; stale entries are counted for removal. The dependency policy
starts empty. Do not add blanket skips or new inline markers instead of review.
When runtime changes move existing lines, re-review the exact allow-list rather
than broadening it; automation and runtime branches must reconcile that inventory.

## Failure semantics and recovery

Helpers exit PASS=0, FAIL=1 (unexcepted findings/expired exceptions), TOOL_ERROR=2
(crash, timeout, missing/invalid JSON, invalid policy, zero audited packages or
tool/database failure), INCOMPLETE=3 (empty static scan, missing coverage, skipped
packages, unlisted inline suppressions or absent execution evidence). Every non-PASS
blocks release. Scheduled reporting preserves the same status but does not gate a
release. `make security-semgrep SCAN_OUTCOME=success` records INCOMPLETE with
"action outcome only", not PASS, because no structured results or analysed
inventory are available. The target tolerates exactly evidence exit 3 while the
SHA-pinned Semgrep action remains the gate. Failure is conservatively
TOOL_ERROR with unknown findings count, skipped/cancelled INCOMPLETE. Its original
failing step remains blocking; an action failure alone is not called a vulnerability.
`make security-host` is the opt-in local host test; it requires the e2e host prerequisites.

Advisory database outages can delay publication: this is an intentional availability
tradeoff for fail-closed verification. Bounded timeouts limit hangs. Never bypass
the gate. Retry the same unchanged tagged candidate with
`gh workflow run publish-pypi.yml --ref vX.Y.Z`. Dispatch takes no inputs; use the
existing tag as its ref, not main. Corrections to code/workflows must fix forward
to a new release candidate; never move/reuse the old tag.

## Evidence and production boundaries

Helpers emit schema-version-1 `security-results/**/summary.json` containing scope,
check/status, tool exit status, counts, tool versions, suppression counts, applied
exception identifiers and allow-listed finding IDs, relative paths/lines, severities,
confidence and package identifiers. Human-readable status/counts go to Job Summary.
Uploads run `if: always()` with unique artifact names and fail if evidence is absent.
Installation/setup failures can prevent report creation; missing artifacts remain
an explicit failure, never PASS. Generated local evidence is gitignored.

Security evidence must never contain credentials, connection strings, authorization
headers or original user payloads. Helpers do not retain raw scanner stdout/stderr,
source snippets, advisory descriptions or policy reasons; malformed diagnostics
produce fixed messages. Deterministic canary tests guard JSON and Markdown output.
This guarantee covers these evidence helpers, not all pre-existing host/exporter logs.
A finding is a **code/config review finding until runtime impact is independently
demonstrated**; a clean scan does not prove the absence of vulnerabilities.

Examples are local-only synthetic demonstrations except `e2e_app`, which is also
ephemeral public Azure certification infrastructure. Anonymous routes and disabled
sampling are test conveniences, not production defaults. Query parameters such as
`correlation_id` and `order_id` reach logs: use synthetic identifiers only, never
real payloads, credentials, headers or PII. The OTel masking example uses a dummy
value; do not rely on it as a complete privacy profile. For secure logging
configuration, see the redaction documentation. This branch adds no runtime API.

Production deployments need explicit authentication/authorization, restricted
ingress, HTTPS/minimum TLS, private blobs, managed identities and storage RBAC,
restricted telemetry access/retention, and appropriate sampling. Review of examples
found no request-body/header dump or printed connection string/instrumentation key;
user-controlled identifiers still require care. `infra/main.bicep` enforces HTTPS,
storage TLS 1.2 and private blobs, but retains shared-key storage connection strings,
public network reachability, no declared managed identity, and an Application
Insights connection-string deployment output. The Azure workflow emits deployment
outputs, so that output is a telemetry-configuration disclosure risk; do not treat
deployment logs as sanitized security evidence. Removing outputs, moving storage to
identity/RBAC, private endpoints, explicit app/SCM TLS and authenticated certification
routes require real-Azure validation. They are follow-ups, not changes in this port.
Host diagnostics/saved logs also need an independently verified allow-list and
retention review before accepting real user data. Use synthetic data only in CI.
10 changes: 10 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# Examples

These are local-only demonstrations using synthetic data, not production security
templates. `e2e_app` is additionally deployed as ephemeral public Azure certification
infrastructure; its anonymous routes must remain compatible with certification.
`otel_app` has anonymous routes for local demonstration. Configure authentication,
restricted ingress and telemetry access before production deployment. Both apps
disable sampling for demonstration/test visibility, not as production guidance.
Query identifiers (`correlation_id`, `order_id`) are logged: never send credentials,
headers, request payloads or PII. Connection strings belong in deployment environment
configuration and must not be printed. See [security boundaries](../SECURITY.md).

`azure-functions-logging` includes standalone example scripts that demonstrate
every public API feature. Each script runs without Azure Functions installed.

Expand Down
Loading
Loading