From 63ff1c6b4285d61142ace21b5c280dcb8814213a Mon Sep 17 00:00:00 2001 From: Mahdi Shafiei Date: Wed, 30 Sep 2026 21:33:25 -0700 Subject: [PATCH] ci: audit DiffBio's lock through substrax's audit-lock action The Security job ran `uv run --with pip-audit pip-audit --local`, which audits the environment pip-audit runs in, not DiffBio's lock, so a green audit said nothing about DiffBio's dependencies. The job now uses substrax's audit-lock action, pinned by commit, which exports every extra the lock resolves and audits each export with a pinned pip-audit and a fresh advisory cache. Bandit keeps its command in its own step, which runs after a failed audit too; either failure fails the job. The whole-lock audit reported four advisories, all in the docs extra, and the lock closes each: - mkdocs-material 9.7.6 -> 9.7.7: PYSEC-2026-3864 - pymdown-extensions 10.21.2 -> 12.1: PYSEC-2026-2999, PYSEC-2026-3609, PYSEC-2026-3654 `mkdocs build --strict` passes with the new lock, and its pages match the old lock's except for the theme version and asset hashes. --- .github/workflows/security.yml | 40 ++++++-------------- CHANGELOG.md | 8 ++++ tests/test_ci_security.py | 67 ++++++++++++++++++++++++++++++++++ uv.lock | 12 +++--- 4 files changed, 93 insertions(+), 34 deletions(-) create mode 100644 tests/test_ci_security.py diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 2e806b5..24b7da8 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -28,37 +28,21 @@ jobs: cache-suffix: security linux-editable-target: ".[dev]" - - name: Run security scans - shell: bash - run: | - set -euo pipefail - - pip_audit_status=0 - bandit_status=0 - - uv run --with pip-audit pip-audit --local --desc \ - > pip-audit-report.txt || pip_audit_status=$? - - uv run --with bandit bandit -c pyproject.toml -r src/diffbio/ -f json -o bandit-report.json \ - || bandit_status=$? - - { - echo "## Security audit" - echo - echo "This workflow participates in automatic pull-request and push enforcement." - echo "pip-audit scans the resolved dependency tree against the PyPI advisory database;" - echo "bandit scans the DiffBio source tree for common Python security issues." - } >> "$GITHUB_STEP_SUMMARY" - - if [[ "$pip_audit_status" -ne 0 || "$bandit_status" -ne 0 ]]; then - exit 1 - fi + # Every extra the lock resolves, in groups that respect [tool.uv] conflicts, each audited + # by a pinned pip-audit with a fresh advisory cache. Ignored advisories and their reasons + # live in [tool.substrax.audit-lock.ignore] in pyproject.toml; the action also fails on an + # ignore that no advisory matches any more. + - name: Audit the locked dependencies + uses: avitai/substrax/.github/actions/audit-lock@13e98b78127606be04437bd7c5de894fb765a28e + + # Runs after a failed audit too, so one run reports both scans; either failure fails the job. + - name: Bandit + if: ${{ !cancelled() }} + run: uv run --with bandit bandit -c pyproject.toml -r src/diffbio/ -f json -o bandit-report.json - name: Upload security report uses: actions/upload-artifact@v4 if: always() with: name: security-report - path: | - pip-audit-report.txt - bandit-report.json + path: bandit-report.json diff --git a/CHANGELOG.md b/CHANGELOG.md index a2b58d8..aaad3a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 imports none of those, and the layers, losses, physics and operator modules it does import are unchanged. datarax 0.1.14 and calibrax 0.1.9 only raise their substrax floor to 0.1.11, which DiffBio already requires. +- The Security job audits the lockfile through substrax's `audit-lock` action, pinned by commit. + The previous step, `uv run --with pip-audit pip-audit --local`, audited the environment + pip-audit ran in rather than DiffBio's lock. The action exports every extra the lock resolves + and audits each export with a pinned pip-audit and a fresh advisory cache; bandit runs in its + own step, after a failed audit too, and either failure fails the job. +- The lock moves mkdocs-material from 9.7.6 to 9.7.7 (PYSEC-2026-3864) and pymdown-extensions + from 10.21.2 to 12.1 (PYSEC-2026-2999, PYSEC-2026-3609, PYSEC-2026-3654), both in the `docs` + extra. The strict documentation build renders the same pages as before. ## [0.1.9] - 2026-09-18 diff --git a/tests/test_ci_security.py b/tests/test_ci_security.py new file mode 100644 index 0000000..9c9231f --- /dev/null +++ b/tests/test_ci_security.py @@ -0,0 +1,67 @@ +"""The Security workflow audits DiffBio's whole lockfile and fails on either scan. + +``uv run --with pip-audit pip-audit --local`` audits the environment pip-audit itself runs in, +not DiffBio's lock, so it could report nothing about DiffBio at all. The audit is substrax's +``audit-lock`` action, pinned by commit: it exports every extra the lock resolves and audits each +export with a pinned pip-audit. Its ignore table lives in ``pyproject.toml`` as +``[tool.substrax.audit-lock.ignore]``; the action refuses an empty reason and an entry no +advisory matches, so those rules are tested in substrax, not here. +""" + +from __future__ import annotations + +import re +from pathlib import Path +from typing import Any + +import yaml + + +GITHUB = Path(__file__).resolve().parents[1] / ".github" +SECURITY_WORKFLOW = GITHUB / "workflows" / "security.yml" +SETUP_ACTION = "./.github/actions/setup-diffbio" +AUDIT_ACTION = re.compile(r"^avitai/substrax/\.github/actions/audit-lock@[0-9a-f]{40}$") + + +def _steps() -> list[dict[str, Any]]: + jobs = yaml.safe_load(SECURITY_WORKFLOW.read_text(encoding="utf-8"))["jobs"] + return [step for job in jobs.values() for step in job["steps"]] + + +def _index(steps: list[dict[str, Any]], matches: Any) -> int: + return next(i for i, step in enumerate(steps) if matches(step)) + + +def test_the_audit_is_the_shared_action_pinned_to_a_commit() -> None: + steps = _steps() + audits = [step for step in steps if AUDIT_ACTION.match(str(step.get("uses", "")))] + + assert len(audits) == 1 + assert "continue-on-error" not in audits[0] + + +def test_uv_is_on_path_before_the_audit_runs() -> None: + """The action runs ``uv export`` and ``uvx``; the setup action installs uv.""" + steps = _steps() + setup = _index(steps, lambda step: step.get("uses") == SETUP_ACTION) + audit = _index(steps, lambda step: AUDIT_ACTION.match(str(step.get("uses", "")))) + setup_action = (GITHUB / "actions" / "setup-diffbio" / "action.yml").read_text(encoding="utf-8") + + assert setup < audit + assert "astral-sh/setup-uv@" in setup_action + + +def test_no_step_runs_a_blind_pip_audit() -> None: + commands = "\n".join(str(step.get("run", "")) for step in _steps()) + + assert "pip-audit" not in commands + assert "pip_audit" not in commands + + +def test_bandit_runs_even_after_a_failed_audit_and_its_failure_fails_the_job() -> None: + steps = _steps() + bandit = steps[_index(steps, lambda step: "bandit " in str(step.get("run", "")))] + + assert bandit.get("if") == "${{ !cancelled() }}" + assert "continue-on-error" not in bandit + assert "||" not in bandit["run"], "a captured exit status can hide bandit's failure" diff --git a/uv.lock b/uv.lock index 3d5fa9f..4e51e35 100644 --- a/uv.lock +++ b/uv.lock @@ -2795,7 +2795,7 @@ wheels = [ [[package]] name = "mkdocs-material" -version = "9.7.6" +version = "9.7.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "babel" }, @@ -2810,9 +2810,9 @@ dependencies = [ { name = "pymdown-extensions" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/45/29/6d2bcf41ae40802c4beda2432396fff97b8456fb496371d1bc7aad6512ec/mkdocs_material-9.7.6.tar.gz", hash = "sha256:00bdde50574f776d328b1862fe65daeaf581ec309bd150f7bff345a098c64a69", size = 4097959, upload-time = "2026-03-19T15:41:58.161Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/cd/c05d3a530ba7934f144fb45f7203cd236adc25c7bdcc34673d202f4b0278/mkdocs_material-9.7.7.tar.gz", hash = "sha256:c0649c065b1b0512d60aad8c10f947f8e455284475239b364b610f2deb4d0855", size = 4097923, upload-time = "2026-07-17T16:21:33.156Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/01/bc663630c510822c95c47a66af9fa7a443c295b47d5f041e5e6ae62ef659/mkdocs_material-9.7.6-py3-none-any.whl", hash = "sha256:71b84353921b8ea1ba84fe11c50912cc512da8fe0881038fcc9a0761c0e635ba", size = 9305470, upload-time = "2026-03-19T15:41:55.217Z" }, + { url = "https://files.pythonhosted.org/packages/ad/21/17c1bc9e6f47c972ad66fb2ac2568f99f90f1207eeb6fc3b34d094dba7b5/mkdocs_material-9.7.7-py3-none-any.whl", hash = "sha256:8ea9bb1737a5b524a5f9dcf2e1b4ebda8274ae3008aa7845720a97083bef708f", size = 9305438, upload-time = "2026-07-17T16:21:30.017Z" }, ] [[package]] @@ -4307,15 +4307,15 @@ wheels = [ [[package]] name = "pymdown-extensions" -version = "10.21.2" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown" }, { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/df/08/f1c908c581fd11913da4711ea7ba32c0eee40b0190000996bb863b0c9349/pymdown_extensions-10.21.2.tar.gz", hash = "sha256:c3f55a5b8a1d0edf6699e35dcbea71d978d34ff3fa79f3d807b8a5b3fa90fbdc", size = 853922, upload-time = "2026-03-29T15:01:55.233Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2a/94/858e0163cb4d83d6d8234018a01792c749a56daee41794cac7d6c46661e8/pymdown_extensions-12.1.tar.gz", hash = "sha256:fdb8f47f5d7fd069d10ef2a7d8908e613d7865ff0419d544a0ffd3984e884f11", size = 868245, upload-time = "2026-09-23T00:08:42Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/27/a2fc51a4a122dfd1015e921ae9d22fee3d20b0b8080d9a704578bf9deece/pymdown_extensions-10.21.2-py3-none-any.whl", hash = "sha256:5c0fd2a2bea14eb39af8ff284f1066d898ab2187d81b889b75d46d4348c01638", size = 268901, upload-time = "2026-03-29T15:01:53.244Z" }, + { url = "https://files.pythonhosted.org/packages/36/d1/98313da89960a604402266a115311b510254900ff1295ea426403f9423cc/pymdown_extensions-12.1-py3-none-any.whl", hash = "sha256:4a254b771acfcddc6c110a7f4590d818f1f849e2d5aacf69b4b07c8dd2a9700d", size = 277069, upload-time = "2026-09-23T00:08:40.069Z" }, ] [[package]]