Security Scanning in CI
Wires bandit, pip-audit, semgrep, and detect-secrets into continuous integration so every push and pull request is gated on security findings, and keeps dependencies patched.
Contents
- GitHub Actions Job
- Using the Bundled Scan Script
- Pre-commit Hook for detect-secrets
- Dependabot
- Triaging False Positives
GitHub Actions Job
Every tool is installed and run through uv, never bare pip. uv tool install places each scanner on PATH; uv run executes them in the project environment.
# .github/workflows/security.yml
name: security
on:
push:
pull_request:
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v5
- name: Install scanners
run: uv tool install bandit
&& uv tool install pip-audit
&& uv tool install semgrep
&& uv tool install detect-secrets
- name: Bandit (static analysis)
run: uv tool run bandit -r src/ -ll
- name: pip-audit (dependency CVEs)
run: uv tool run pip-audit
- name: Semgrep (SAST)
run: uv tool run semgrep --config auto --error --quiet src/
- name: detect-secrets (hardcoded credentials)
run: uv tool run detect-secrets scan --baseline .secrets.baselineEach step exits non-zero on findings, which fails the job. bandit -ll gates on medium+ severity; drop to -lll for high-only. semgrep --error turns ERROR-severity findings into a non-zero exit.
Using the Bundled Scan Script
Instead of four separate steps, run all scanners through the script this skill ships. It aggregates findings and exits non-zero when any is blocking (HIGH/CRITICAL bandit, any vulnerable dependency, ERROR-level semgrep, or any secret), so a single step gates the job.
Exit codes: 1 for a blocking finding, 2 when a requested scanner could not run at all (missing from PATH, failed to launch, or timed out), 0 only when every requested scanner ran and nothing blocking was found. A scanner that never ran leaves its class of finding unchecked, so it fails the gate rather than reporting a clean audit; the console summary and the --output JSON both name the scanners that did not run.
- name: Security scan
run: uv run python scripts/security_scan.py . --output security-report.json
- name: Upload report
if: always()
uses: actions/upload-artifact@v4
with:
name: security-report
path: security-report.jsonSkip a scanner that does not apply with --skip (choices: bandit, pip-audit, semgrep, secrets); for example --skip semgrep while tuning rules. A skipped scanner is not counted as a failure, so use --skip for a scanner you deliberately do not want. The script reports a missing scanner as an error for that tool rather than crashing, and exits 2, so install all four via uv tool install first. --allow-scanner-failure restores the tolerant exit 0 for callers that genuinely want it. if: always() uploads the JSON even when the scan fails, so findings are inspectable from the run.
Pre-commit Hook for detect-secrets
Catch secrets before they reach history, not after CI. Create a baseline once, commit it, then wire the hook.
uv tool install pre-commit
uv tool run detect-secrets scan > .secrets.baseline# .pre-commit-config.yaml
repos:
- repo: https://github.com/Yelp/detect-secrets
rev: v1.5.0
hooks:
- id: detect-secrets
args: ["--baseline", ".secrets.baseline"]uv tool run pre-commit installThe hook scans staged files and blocks the commit on any secret not already recorded in the baseline. Because it runs on the whole repo the first time, generate the baseline before installing so existing (audited) matches do not block every commit.
Dependabot
pip-audit reports vulnerable dependencies; Dependabot opens the PRs that fix them. Together they close the loop — the scan fails CI, the update PR resolves it.
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: pip
directory: "/"
schedule:
interval: weekly
groups:
dev-dependencies:
patterns: ["*"]
dependency-type: development
- package-ecosystem: github-actions
directory: "/"
schedule:
interval: weeklyThe github-actions ecosystem also keeps the setup-uv and action pins in this workflow current. Grouping dev dependencies collapses routine bumps into one PR to cut review noise.
Triaging False Positives
Suppress only after confirming a finding is genuinely safe, and record why — a bare suppression is indistinguishable from a missed bug.
bandit — inline # nosec: annotate the exact line, scoped to the specific test ID, with a reason.
# Reviewed: host is validated against ALLOWED_HOSTS above.
subprocess.run(cmd, check=True) # nosec B603Prefer per-line # nosec B<id> over a bare # nosec so unrelated new issues on the same line still surface. Project-wide skips belong in .bandit (skips: [B101]), reserved for rules that never apply to the codebase (for example B101 assert-used, which is expected in tests).
semgrep — # nosemgrep: annotate the line, scoped to the rule id.
value = eval(expr) # nosemgrep: python.lang.security.audit.eval-detecteddetect-secrets — the baseline: a match already in .secrets.baseline is treated as reviewed and does not fail. After confirming a flagged string is a false positive (a test fixture, an example key), re-audit the baseline to mark it:
uv tool run detect-secrets scan --baseline .secrets.baseline
uv tool run detect-secrets audit .secrets.baselineAuditing records the human decision in the baseline; committing the updated baseline is what silences the finding going forward. Never suppress a real secret — rotate it, then remove it from source.
pip-audit — ignore a specific advisory: when no fixed version exists yet and the code path is unreachable, pin and ignore the advisory id explicitly rather than disabling the scan.
uv tool run pip-audit --ignore-vuln GHSA-xxxx-xxxx-xxxxTrack each ignored advisory so it can be removed once a patched release ships.