Repository navigation
ci(release): block publishing on known runtime dependency advisories - #630
Conversation
The release-time dependency audit (next commit) needs a way to waive an advisory that has no fix yet without turning the gate into a permanent ignore list. tools/runtime_audit_args.py reads security/runtime-advisory-exceptions.txt, one 'ADVISORY-ID owner added expires reason...' line each, and prints the matching --ignore-vuln arguments. A line fails the run (exit 1, nothing printed) when it is malformed, when its advisory id is not [A-Z]+-[0-9A-Za-z-]+ (the arguments reach the shell unquoted), when it has expired, when it is dated in the future, or when it is valid for more than 90 days. Without the future-date check an 'added' date far ahead would defeat the 90-day cap. The file ships with comments only, so no advisory is waived. The gate runs only on release tags, so these rules are pinned by tests rather than by PR CI. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
publish waited for tests and certification only, so a tag could be uploaded with a known advisory in the wheel's runtime closure. dependency-audit.yml cannot gate that (both of its audit steps are continue-on-error), and pip-audit itself passes vacuously: an empty requirements file, or a package it cannot audit, exits 0 unless --strict is set (reproduced with pip-audit 2.10.1). Add the runtime-dependency-audit job (needs: build, contents: read) and make publish wait for it. Each matrix cell installs the downloaded dist wheel, the exact artifact publish uploads, into a clean venv, runs 'pip freeze --exclude azure-functions-logging', 'test -s' and 'pip-audit==2.10.1 --no-deps --disable-pip --strict', and uploads the JSON report even on failure. Advisory waivers come from security/runtime-advisory-exceptions.txt through tools/runtime_audit_args.py; an invalid or expired waiver stops the step before pip-audit runs. The matrix is base and [otel] on Python 3.11 and 3.14. azure-functions resolves to 1.25.0 on 3.11-3.12 and to 2.3.0 on 3.13-3.14 (resolved on all four), so the oldest and newest interpreter cover every distinct closure with four cells instead of eight. The matrix key is 'python', not 'python-version': tools/lint_hatch_matrix.py reads any python-version list as a Hatch test matrix and would pass this file only because an unrelated 'pytest-results.xml' path happens to match its heuristic. Untouched: OIDC, the permissions of every other job, exact-artifact reuse, the e2e certification, the pinned publisher and dependency-audit.yml (still reporting-only). The job cannot run on a pull request, because the workflow starts only on v* tags and manual dispatch. tests/test_runtime_audit_workflow.py therefore parses the YAML and fails if the job leaves publish.needs, gains continue-on-error, loses --strict, --no-deps, --disable-pip or 'test -s', stops auditing the dist artifact, or drops a closure or the oldest/newest supported Python. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Add an 'Automated security controls' section to SECURITY.md that states what runs where: Bandit, Semgrep and CodeQL on pull requests as required checks, the reporting-only weekly pip-audit, and the new release gate that blocks publish. It records why the gate audits Python 3.11 and 3.14, that any advisory blocks because pip-audit reports no severity, that an unreachable PyPI blocks the release until a retry passes, the waiver format with its 90-day limit, that the waiver file is read from the tagged commit, and that pull-request CI does not run the gate. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
AGENTS.md, docs/release_process.md and docs/contributing.md each enumerate the tiers that gate a release. Add runtime-dependency-audit to the chain in all three and to the tier tables in the first two, so none of them describes a release that no longer matches publish-pypi.yml. The three change together because they state the same list. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
release-please.yml lists the publish tiers in a header comment and in the Job Summary written after a release is created. Add runtime-dependency-audit to both. Text only: no step, permission or trigger changes. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
There was a problem hiding this comment.
🔵 Needs a closer look
The security-sensitive publishing gate needs independent human sign-off and validation of the new job in GitHub Actions before release use.
No open issues 🎉
What changed in this PR
Adds the runtime-advisory release gate for #624, complementing the Bandit safeguards in #629. Publishing now depends on auditing the candidate wheel’s runtime dependencies while preserving existing release gates and exact-artifact reuse.
Changes:
- Audit base and
[otel]dependencies on Python 3.11 and 3.14, blocking publication on advisories, empty dependency lists, or unauditable packages. - Add validated, dated advisory waivers and regression tests for waiver rules and workflow safeguards.
- Document the new gate and retain per-cell audit reports.
| File | Description |
|---|---|
tools/runtime_audit_args.py |
Validates dated waivers and generates audit arguments. |
tests/test_runtime_audit_workflow.py |
Checks publish dependencies, audit safeguards, and matrix coverage. |
tests/test_runtime_advisory_exceptions.py |
Tests waiver validation and command failure behavior. |
security/runtime-advisory-exceptions.txt |
Adds the waiver file with no active exceptions. |
SECURITY.md |
Explains security controls, blocking behavior, and waivers. |
docs/release_process.md |
Adds the audit to release-gate documentation. |
docs/contributing.md |
Updates the documented publishing sequence. |
AGENTS.md |
Includes the audit in release guidance. |
.github/workflows/release-please.yml |
Updates comments and job-summary text only. |
.github/workflows/publish-pypi.yml |
Adds the audit matrix, report uploads, and publish dependency. |
Review effort: Balanced
|
R3 authorization for head The maintainer delegated the merge decision to an Oracle review. The Oracle (AI) reviewed this change at head |
Related Issue
Refs #624
This is the release-gate half of #624; the Bandit half merged in #629. I am deliberately not using
Closes: every item of #624 is implemented except that "on supported Pythons" is met with the oldest and newest supported Python (3.11, 3.14), not all four minors (see "Decisions to confirm"). If you read that item as "every minor", add"3.12"and"3.13"to the matrix (four more cells) and close #624 with this PR.Summary
publishwaits for the tests and the Azure certification, but nothing stops a tag from being uploaded while the wheel's runtime dependencies carry a known advisory.dependency-audit.ymlcannot be that gate (both audit steps arecontinue-on-error), andpip-audititself passes vacuously: with--no-deps --disable-pipan empty requirements file exits 0, and without--stricta package it cannot audit exits 0 (both reproduced withpip-audit==2.10.1, see the tables below).This adds one job, one small module and one waiver file, and nothing else in the release chain changes: OIDC, the pinned PyPA publisher, exact-artifact reuse, the e2e certification and
dependency-audit.yml(still reporting-only) are untouched.Changes
.github/workflows/publish-pypi.yml(+63/-2): new jobruntime-dependency-audit(needs: build,permissions: contents: read,fail-fast: false) andruntime-dependency-auditadded topublish.needs. Each cell: checkout (persist-credentials: false),setup-python,download-artifactof thedistthatbuildproduced, then in one steppython -m venv,pip install "$(ls dist/*.whl)${EXTRAS:-}",pip freeze --exclude azure-functions-logging > req.txt,test -s req.txt,ignores=$(python tools/runtime_audit_args.py security/runtime-advisory-exceptions.txt),pip install pip-audit==2.10.1,pip_audit --no-deps --disable-pip --strict --desc off -f json -r req.txt $ignores | tee pip-audit.json. The JSON report is uploaded withif: always()(30 days; artifact name carriesgithub.run_attemptso a "re-run failed jobs" cannot hit a duplicate-name conflict). Alluses:reuse the canonical pins thatlint_release_workflows.pyenforces.$ignoresis intentionally unquoted (one--ignore-vuln=IDword per waiver); ids are regex-validated by the tool, and a comment plus# shellcheck disable=SC2086record that.tools/runtime_audit_args.py(new, 56 lines, the only new module): readssecurity/runtime-advisory-exceptions.txt, where each non-comment line isADVISORY-ID owner added expires reason..., and prints--ignore-vuln=IDarguments. It exits 1 (printing nothing) on a malformed line, an id that does not match[A-Z]+-[0-9A-Za-z-]+, an expired line, a line whoseaddedis in the future, or a window longer than 90 days. The future-date rule is needed: without it, anaddeddate far ahead defeats the 90-day cap.security/runtime-advisory-exceptions.txt(new): comments only, so nothing is waived.tests/test_runtime_advisory_exceptions.py,tests/test_runtime_audit_workflow.py(new, 135 lines, 21 tests): parser rules one by one plus the command's exit codes; and a YAML-parsing test thatpublish.needscontains the job, it has nocontinue-on-error/|| true,contents: readonly, audits thedistartifact, keeps--strict --no-deps --disable-pipandtest -s, and covers base and[otel]on the oldest and newest Python in the package classifiers.SECURITY.md(+21): what runs where, the gate's rules and trade-offs, and the waiver format.AGENTS.md,docs/release_process.md,docs/contributing.md: add the tier to the release chain/tier tables..github/workflows/release-please.yml: the same tier name in a header comment and in the post-release Job Summary text (text only, no step, permission or trigger changes). These five files are outside the planned file list; say so if you would rather drop them.Not in this PR: any Bandit change, policy JSON or schema, changes to the three workflow lints (none hardcodes the old
needslist),dependency-audit.yml, and #616/#617/#618 and the Release PR (untouched).Size: 10 files, +288/-8, against the plan's ~+140. The difference is tests (135 lines for 21 cases; the plan sketched three), the Python matrix plus the report upload, and the doc consistency edits above.
Decisions to confirm
Python coverage: 3.11 and 3.14, not one and not all four. The runtime closure is not the same on every supported Python. I installed the real wheel (and
[otel]) on each interpreter and froze it:[otel]addsA single interpreter would leave one azure-functions major unaudited. 3.11 and 3.14 cover both closures (3.12 resolved like 3.11, 3.13 like 3.14), so the matrix is 4 cells instead of the 8 that ci(security): verify scan results and audit release dependencies #618 used. This relies on dependency support being monotonic across minors, which holds for
Requires-Python-driven resolution (an environment marker keyed to one exact minor would escape it); a test ties the matrix to the package classifiers so adding 3.15 fails until the matrix is updated. All eight closures audit clean today. (Related context, not addressed here: test(compat): certify azure-functions SDK major-version compatibility explicitly #613 asks to certify the azure-functions major versions; this PR only audits both for advisories.)Any known advisory blocks.
pip-auditreports no severity. The gate therefore also blocks when PyPI or the advisory service is unreachable, or when a new advisory lands between the Release PR and the tag. Recovery is a retry (gh workflow run publish-pypi.yml --ref vX.Y.Z) or, for an unfixable advisory, a dated waiver onmainbefore the Release PR is merged (the job reads the file from the tagged commit).Matrix key is
python, notpython-version.tools/lint_hatch_matrix.pyreads anypython-versionlist as a Hatch test matrix. With that key the lint passes today only because the unrelatedpytest-results.xmlartifact path matches its "direct pytest" heuristic; I confirmed it flagspublish-pypi.ymlonce that path is renamed. Withpythonit is clean either way.Verification
Local rows ran on Python 3.11.15 with Hatch 1.18.1 (worktree
.venvfromCI=1 make install) on the final tree (headf728a95).make check-allruff checkandruff format --checkOK (83 files); mypy strictsrc testsOK (62 files); 870 passed, 4 skipped, 4 deselected; coverage 96.26% (gate 95%);make securityprintedbandit: 4647 lines analysed, no findingsmake buildmypy tools/runtime_audit_args.py(strict, alone)mkdocs build --strictpytest tests/test_runtime_advisory_exceptions.py tests/test_runtime_audit_workflow.py --no-covunshare -rn(no network;curl pypi.orgfails to resolve)git archive: new tests,test_release_workflow_pins.py,test_workflow_pin_hygiene.py, the 3 lintsactionlint1.7.12 with shellcheck on both edited workflowsname:/artifact-name expressions validatezizmor1.31.1 onpublish-pypi.yml, regular/pedantic/auditor personasSimulated step. The
run:block is extracted from the committed YAML (byte-identical, checked) and run withbash -ein a scratch "runner": a fresh interpreter venv first onPATH(3.11 or 3.14), a copy of the checkout, the candidate wheel indist/,RUNNER_TEMP,EXTRASfrom the matrix, an empty cache, the real PyPI and the real advisory service,pip-audit==2.10.1. Synthetic candidates are real wheels namedazure-functions-loggingbuilt for the negative cases.[otel], on 3.11 and on 3.14 (4 cells)No known vulnerabilities found; the JSON report lists the audited closure (3 packages base, 5 with[otel], 0 vulnerabilities)urllib3==1.24.1Found 25 known vulnerabilities in 1 package(pipefail propagates throughtee)No known vulnerabilities found, 25 ignored; the log prints the waivers in effectFound 1 known vulnerability, ignored 24Dependency not found on PyPI and could not be audited--strictdeleted from the step (control)No known vulnerabilities found: the vacuous pass that--strictclosestest -s req.txt;pip-auditis never installed. (Why the check exists:pip-audit==2.10.1 --no-deps --disable-pip --strict -ron an empty file exits 0 withNo known vulnerabilities found.)addedin the future / no reason / impossible date / idGHSA-x;touch-pwned/ valid line followed by an expired onepip-auditis installed; the message names the line; the step dies at the tool, so the id never reaches a shell expansionAdvisory exceptions in effect: --ignore-vuln=...Mutation checks (each applied to a scratch copy of the committed files, new tests run): 19 of 20 mutations are killed: dropping the job from
publish.needs;continue-on-errorat job or step level;|| true; deleting--strict,--no-deps,--disable-piportest -s req.txt; auditing a different artifact; dropping[otel]or 3.14 from the matrix; addingid-token: write;needsno longerbuild; and in the tool: ignoring expiry, the 90-day cap, the future-addedrule, the advisory-id check, the exit code, or comment handling. The one survivor is a deliberate control (a comment-only edit). My first version of the workflow test let "delete--strict" survive because a comment mentioned the flag; the committed test inspects only non-comment command tokens.GitHub Actions on the final head
f728a95(23 check-runs, polled until none was pending):bandit,semgrep,Analyze,PR title,ci-requireddependabot-auto-merge), 0 failed or incomplete;mergeable_state: cleanbanditjob logbandit: 4647 lines analysed, no findings(the new tool is in the scanned scope)Test (Py 3.11)/Test (Py 3.14)Lint, type-check & security (Py 3.11)ruff format --checkand mypy strict clean; the changed-files format check covered the 3 new Python filespublish-pypi.ymlNot run
actions/setup-pythonfor 3.14,download-artifact/upload-artifactbehavior with the new names, runner image differences, and a PyPI/advisory-service outage during the step.publish-pypi.ymlitself, any tag, any release, anddependency-audit.yml.[otel]together with a waiver, and a waiver for an alias id rather than the primary id.Review Evidence
f728a95). Independent review requested.Compatibility and documentation
SECURITY.mddescribes the gate and the waiver format;AGENTS.md,docs/release_process.mdanddocs/contributing.mdlist the new tier. No public API change; the gate can block a release only, never an install.