Skip to content

feat(security): DAST — authenticated authorization sweep against a real loopback listener (ADR 0155, BACKLOG #318) #212

feat(security): DAST — authenticated authorization sweep against a real loopback listener (ADR 0155, BACKLOG #318)

feat(security): DAST — authenticated authorization sweep against a real loopback listener (ADR 0155, BACKLOG #318) #212

name: Dependabot auto-merge
# Scoped auto-merge for Dependabot PRs (A4 of the dependency fast-response plan).
#
# SAFE because main's required status checks are the gate: the full pytest suite, pip-audit, the
# DEP-1 lock-sync check (incl. the A3 lock-resync commit), bandit/semgrep/gitleaks all must pass
# before GitHub completes the merge. A bad bump turns CI red and never merges — auto-merge only
# removes the human-latency on the safe, common case, not the safety net.
#
# IN SCOPE (auto-merged):
# - any PATCH update (incl. security patches — most security fixes are patches)
# - MINOR updates of DEV-only dependencies
# OUT OF SCOPE (left for human review, surfaced same-day by the daily security cron + alerts):
# - MINOR/MAJOR updates of runtime deps, and ALL MAJOR updates
#
# Fresh-release supply-chain poisoning is handled upstream by the dependabot.yml `cooldown`
# (routine updates age before a PR opens); SECURITY updates bypass cooldown by design, so a real
# advisory fix still arrives immediately and (if a patch) auto-merges.
#
# SECURITY-TRACK GUARDRAILS (DEPENDENCY-POSTURE-REVIEW.md #2 and #3 — close SEC-007, CWE-829):
# #3 DENY-LIST — the auth/token/crypto stack (cryptography, argon2-cffi, fastapi, …) is NEVER
# auto-merged, not even for a patch: any such dep routes the PR to MANUAL review. A grouped
# PR that includes ANY denied dep is denied WHOLE (fail-safe — never partial-merge a group).
# #2 PUBLISHED-GHSA GATE — a SECURITY-track PR (which bypasses the cooldown by Dependabot design)
# auto-merges ONLY when a real, published, non-withdrawn advisory is confirmed against the
# dependency's PREVIOUS version (the axios-1.14.1 pattern). The advisory lookup FAILS CLOSED:
# a rate-limit/API error or no-matching-advisory routes to manual review, never auto-merge.
# The non-security VERSION track keeps its cooldown-aged auto-merge unchanged (already aged by
# dependabot.yml, so it does not need the GHSA gate).
#
# RESIDUAL ACCEPTED RISK: a malicious patch that BOTH rides a real concurrent published advisory
# AND is not on the deny-list would still auto-merge. The deny-list shields the highest-value
# (auth/crypto) deps unconditionally; the GHSA gate blocks the no-advisory fast-publish poison on
# the security track; main's required CI (pip-audit/bandit/tests/lock-sync) backstops both.
#
# Why `pull_request` (not pull_request_target): a Dependabot `pull_request` run gets a read-only
# GITHUB_TOKEN by default, which the `permissions:` block below elevates to exactly what the merge
# API needs — no untrusted-code-with-secrets exposure, no GitHub App required. This is the pattern
# GitHub documents for Dependabot auto-merge.
on: pull_request
permissions:
contents: write # complete the merge
pull-requests: write # enable auto-merge on the PR
security-events: read # read Dependabot alerts for the published-GHSA gate (#2)
concurrency:
group: dependabot-automerge-${{ github.event.pull_request.number }}
cancel-in-progress: false
jobs:
auto-merge:
runs-on: ubuntu-latest
# Only Dependabot's own PRs. pull_request.user.login is the immutable PR author, so an A3
# lock-resync `synchronize` (pushed by the App) still satisfies this — the author stays
# dependabot[bot] even though the triggering actor is the App.
if: github.event.pull_request.user.login == 'dependabot[bot]'
timeout-minutes: 10
steps:
- name: Fetch Dependabot metadata
id: meta
uses: dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98 # v3.1.0
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
# Guardrail #3 (DEPENDENCY-POSTURE-REVIEW.md): the auth/token/crypto stack NEVER auto-merges,
# not even a patch. dependency-names is comma-separated for grouped PRs; we deny the WHOLE
# group if ANY token matches (fail-safe — never partial-merge a group). Exact token match on
# the comma-split names (not a naive substring) so 'cffi' does not match 'argon2-cffi-bindings'
# and vice-versa.
- name: Reject security-critical deps (deny-list)
id: denylist
env:
DEP_NAMES: ${{ steps.meta.outputs.dependency-names }}
run: |
# Single source of truth for the security-critical deny-list.
denylist="cryptography argon2-cffi argon2-cffi-bindings paramiko ldap3 pyspnego \
fastapi starlette uvicorn pydantic pydantic-core python-jose pyjwt passlib bcrypt cffi"
deny=false
IFS=',' read -ra names <<< "$DEP_NAMES"
for raw in "${names[@]}"; do
# trim surrounding whitespace and lowercase
name="$(echo "$raw" | tr '[:upper:]' '[:lower:]' | xargs)"
[ -z "$name" ] && continue
for crit in $denylist; do
if [ "$name" = "$crit" ]; then
echo "::notice::'$name' is a security-critical dependency — routing to manual review (no auto-merge)."
deny=true
fi
done
done
echo "deny=$deny" >> "$GITHUB_OUTPUT"
# Guardrail #2 (DEPENDENCY-POSTURE-REVIEW.md): a SECURITY-track PR auto-merges ONLY when a
# real, published, non-withdrawn advisory covers the dependency's PREVIOUS version. The
# security track is detected by Dependabot's `dependency-group` (python-security/ide-security)
# — a security-update group — falling back to a present-but-empty group meaning version track.
# The advisory lookup FAILS CLOSED: any error or no-match leaves advisory_ok=false.
- name: Verify a published advisory backs the security track
id: ghsa
env:
DEP_NAMES: ${{ steps.meta.outputs.dependency-names }}
DEP_GROUP: ${{ steps.meta.outputs.dependency-group }}
PREV_VERSION: ${{ steps.meta.outputs.previous-version }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -u
# Is this the security track? Dependabot tags security-update grouped PRs with a
# *-security dependency-group. A non-security (version) PR has no security group.
is_security=false
case "$DEP_GROUP" in
*security*) is_security=true ;;
esac
if [ "$is_security" != "true" ]; then
# Version track: already cooldown-aged by dependabot.yml — the GHSA gate does not apply.
echo "security_track=false" >> "$GITHUB_OUTPUT"
echo "advisory_ok=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "security_track=true" >> "$GITHUB_OUTPUT"
# Require at least one matching published (non-withdrawn) advisory for EVERY named dep at
# the previous version. Query the global advisories endpoint (ecosystem pip — Python
# advisories are pip-keyed). Fail closed on any error.
advisory_ok=true
IFS=',' read -ra names <<< "$DEP_NAMES"
for raw in "${names[@]}"; do
name="$(echo "$raw" | tr '[:upper:]' '[:lower:]' | xargs)"
[ -z "$name" ] && continue
count="$(gh api -X GET /advisories \
-f ecosystem=pip \
-f affects="${name}@${PREV_VERSION}" \
--jq '[.[] | select(.withdrawn_at == null)] | length' 2>/dev/null || echo "ERR")"
if [ "$count" = "ERR" ] || [ -z "$count" ]; then
echo "::warning::advisory lookup failed for '$name' — failing closed (manual review)."
advisory_ok=false
break
fi
if [ "$count" -lt 1 ]; then
echo "::warning::no published advisory covers '${name}@${PREV_VERSION}' — failing closed (manual review)."
advisory_ok=false
break
fi
echo "::notice::published advisory confirmed for '${name}@${PREV_VERSION}'."
done
echo "advisory_ok=$advisory_ok" >> "$GITHUB_OUTPUT"
- name: Enable auto-merge for in-scope updates
# Auto-merge only when: (a) the update is an in-scope type (any patch, or a dev-only minor),
# AND (b) no security-critical dep is involved (deny-list #3), AND (c) for the SECURITY track
# ONLY, a published advisory is confirmed (#2). A non-security (version-track) patch keeps its
# cooldown-aged auto-merge — advisory_ok is not required there.
if: >-
steps.denylist.outputs.deny != 'true' &&
(steps.ghsa.outputs.security_track != 'true' ||
steps.ghsa.outputs.advisory_ok == 'true') &&
(steps.meta.outputs.update-type == 'version-update:semver-patch' ||
(steps.meta.outputs.update-type == 'version-update:semver-minor' &&
steps.meta.outputs.dependency-type == 'direct:development'))
run: gh pr merge --auto --squash "$PR_URL"
env:
PR_URL: ${{ github.event.pull_request.html_url }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}