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
40 changes: 12 additions & 28 deletions .github/workflows/security.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
67 changes: 67 additions & 0 deletions tests/test_ci_security.py
Original file line number Diff line number Diff line change
@@ -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"
12 changes: 6 additions & 6 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading