name: Security Sweep Worker
on:
workflow_dispatch:
inputs:
target_repos:
description: 'JSON array of target repositories [{owner, repo, issueNumber, vulnerabilities}]'
required: true
type: string
severity:
description: 'Minimum severity level'
required: false
default: 'critical'
type: choice
options:
- critical
- high
- medium
- low
- all
job_id:
description: 'Job ID for tracking'
required: false
type: string
dry_run:
description: 'Preview only, no changes'
required: false
default: false
type: boolean
jobs:
sweep-repo:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
issues: write
strategy:
fail-fast: false
matrix:
target: ${{ fromJson(inputs.target_repos) }}
steps:
- name: Generate token for target repo
id: app-token
uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
with:
app-id: ${{ secrets.GIT_STEER_APP_ID }}
private-key: ${{ secrets.GIT_STEER_PRIVATE_KEY }}
owner: ${{ matrix.target.owner }}
repositories: ${{ matrix.target.repo }}
- name: Checkout target repo
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
repository: ${{ matrix.target.owner }}/${{ matrix.target.repo }}
token: ${{ steps.app-token.outputs.token }}
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: '20'
- name: Setup Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: '3.12'
- name: Setup uv
uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a # v4
- name: Setup Go
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version: '1.22'
cache: false
- name: Detect ecosystem
id: ecosystem
run: |
ECOSYSTEMS=""
if [ -f "package.json" ]; then ECOSYSTEMS="${ECOSYSTEMS},npm"; fi
if [ -f "requirements.txt" ] || [ -f "pyproject.toml" ] || [ -f "setup.py" ]; then ECOSYSTEMS="${ECOSYSTEMS},pip"; fi
if [ -f "go.mod" ]; then ECOSYSTEMS="${ECOSYSTEMS},go"; fi
ECOSYSTEMS="${ECOSYSTEMS#,}"
echo "detected=$ECOSYSTEMS" >> $GITHUB_OUTPUT
echo "Detected ecosystems: $ECOSYSTEMS"
- name: Get Dependabot alerts
id: alerts
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
run: |
REPO="${{ matrix.target.owner }}/${{ matrix.target.repo }}"
ALERTS=$(gh api "repos/$REPO/dependabot/alerts" \
--jq '[.[] | select(.state == "open") | {
package: .dependency.package.name,
ecosystem: .dependency.package.ecosystem,
severity: .security_advisory.severity,
fix_version: .security_vulnerability.first_patched_version.identifier,
manifest: .dependency.manifest_path,
cve: .security_advisory.cve_id,
summary: .security_advisory.summary
}]' 2>/dev/null || echo '[]')
echo "alerts<<EOF" >> $GITHUB_OUTPUT
echo "$ALERTS" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
COUNT=$(echo "$ALERTS" | jq 'length')
echo "count=$COUNT" >> $GITHUB_OUTPUT
- name: Filter by severity
id: filter
env:
ALERTS: ${{ steps.alerts.outputs.alerts }}
run: |
SEVERITY="${{ inputs.severity }}"
case $SEVERITY in
critical) PATTERN='critical' ;;
high) PATTERN='critical|high' ;;
medium) PATTERN='critical|high|medium' ;;
low) PATTERN='critical|high|medium|low' ;;
all) PATTERN='.*' ;;
esac
FILTERED=$(echo "$ALERTS" | jq --arg pat "$PATTERN" '[.[] | select(.severity | test($pat))]')
FIXABLE=$(echo "$FILTERED" | jq '[.[] | select(.fix_version != null)]')
echo "filtered<<EOF" >> $GITHUB_OUTPUT
echo "$FIXABLE" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
COUNT=$(echo "$FIXABLE" | jq 'length')
echo "count=$COUNT" >> $GITHUB_OUTPUT
- name: Fix npm dependencies
if: steps.filter.outputs.count != '0' && contains(steps.ecosystem.outputs.detected, 'npm')
env:
ALERTS: ${{ steps.filter.outputs.filtered }}
run: |
find . -name "package.json" -not -path "*/node_modules/*" | while read pkg; do
DIR=$(dirname "$pkg")
echo "Processing $pkg..."
MANIFEST_ALERTS=$(echo "$ALERTS" | jq --arg m "$pkg" '[.[] | select(.manifest == $m or .ecosystem == "npm")]')
if [ "$(echo "$MANIFEST_ALERTS" | jq 'length')" -gt 0 ]; then
cd "$DIR"
if [ -f "package-lock.json" ]; then
rm package-lock.json
npm install --package-lock-only 2>/dev/null || true
fi
npm audit fix --force 2>/dev/null || true
cd - > /dev/null
fi
done
- name: Fix Python dependencies
if: steps.filter.outputs.count != '0' && contains(steps.ecosystem.outputs.detected, 'pip')
env:
ALERTS: ${{ steps.filter.outputs.filtered }}
run: |
find . -name "requirements*.txt" -type f | while read req; do
echo "Processing $req..."
PY_ALERTS=$(echo "$ALERTS" | jq '[.[] | select(.ecosystem == "pip")]')
if [ "$(echo "$PY_ALERTS" | jq 'length')" -gt 0 ]; then
echo "$PY_ALERTS" | jq -r '.[] | "\(.package) \(.fix_version)"' | while read PKG VER; do
if [ -n "$VER" ] && [ "$VER" != "null" ]; then
sed -i "s/^${PKG}[=<>!~].*/${PKG}>=${VER}/" "$req" 2>/dev/null || true
sed -i "s/^${PKG}$/${PKG}>=${VER}/" "$req" 2>/dev/null || true
fi
done
fi
done
find . -name "pyproject.toml" -type f | while read toml; do
DIR=$(dirname "$toml")
if [ -f "$DIR/uv.lock" ]; then
echo "Regenerating uv.lock in $DIR..."
cd "$DIR"
rm -f uv.lock
uv lock 2>/dev/null || true
cd - > /dev/null
fi
done
- name: Fix Go dependencies
if: steps.filter.outputs.count != '0' && contains(steps.ecosystem.outputs.detected, 'go')
env:
ALERTS: ${{ steps.filter.outputs.filtered }}
run: |
GO_ALERTS=$(echo "$ALERTS" | jq '[.[] | select(.ecosystem == "gomod" or .ecosystem == "go")]')
if [ "$(echo "$GO_ALERTS" | jq 'length')" -gt 0 ]; then
echo "$GO_ALERTS" | jq -r '.[] | "\(.package)@v\(.fix_version)"' | while read MOD; do
if [ -n "$MOD" ] && [[ "$MOD" != *"null"* ]]; then
go get "$MOD" 2>/dev/null || true
fi
done
go mod tidy 2>/dev/null || true
fi
- name: Bump patch version
id: version
run: |
if [ -f "package.json" ]; then
CURRENT=$(jq -r '.version // "0.0.0"' package.json)
IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT"
NEW_VERSION="$MAJOR.$MINOR.$((PATCH + 1))"
jq --arg v "$NEW_VERSION" '.version = $v' package.json > package.json.tmp && mv package.json.tmp package.json
echo "bumped=true" >> $GITHUB_OUTPUT
echo "version=$NEW_VERSION" >> $GITHUB_OUTPUT
fi
- name: Check for changes
id: changes
run: |
if git diff --quiet && git diff --cached --quiet; then
echo "has_changes=false" >> $GITHUB_OUTPUT
else
echo "has_changes=true" >> $GITHUB_OUTPUT
git diff --stat
fi
- name: Create branch and commit
if: steps.changes.outputs.has_changes == 'true' && inputs.dry_run == false
id: commit
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
ALERTS: ${{ steps.filter.outputs.filtered }}
run: |
BRANCH="security/sweep-$(date +%Y%m%d-%H%M%S)"
echo "branch=$BRANCH" >> $GITHUB_OUTPUT
git config user.name "git-steer[bot]"
git config user.email "git-steer[bot]@users.noreply.github.com"
git checkout -b "$BRANCH"
PACKAGES=$(echo "$ALERTS" | jq -r '[.[].package] | unique | join(", ")')
CVES=$(echo "$ALERTS" | jq -r '[.[].cve // empty] | unique | join(", ")')
git add -A
git commit -m "fix(security): patch ${{ steps.filter.outputs.count }} vulnerabilities
Updates packages: $PACKAGES
CVEs: $CVES
RFC: #${{ matrix.target.issueNumber }}
Generated by git-steer security sweep"
git push -u origin "$BRANCH"
- name: Ensure labels exist
if: steps.changes.outputs.has_changes == 'true' && inputs.dry_run == false
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
run: |
REPO="${{ matrix.target.owner }}/${{ matrix.target.repo }}"
gh label create "security" --color "d73a4a" --description "Security vulnerability" --repo "$REPO" 2>/dev/null || true
gh label create "dependencies" --color "0075ca" --description "Dependency updates" --repo "$REPO" 2>/dev/null || true
gh label create "automated" --color "bfd4f2" --description "Created by automation" --repo "$REPO" 2>/dev/null || true
SEVERITY="${{ inputs.severity }}"
case $SEVERITY in
critical) COLOR="b60205" ;;
high) COLOR="ff9800" ;;
medium) COLOR="fbca04" ;;
*) COLOR="0e8a16" ;;
esac
gh label create "severity:$SEVERITY" --color "$COLOR" --description "$SEVERITY severity" --repo "$REPO" 2>/dev/null || true
- name: Create Pull Request
if: steps.changes.outputs.has_changes == 'true' && inputs.dry_run == false
id: pr
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
ALERTS: ${{ steps.filter.outputs.filtered }}
run: |
COUNT=$(echo "$ALERTS" | jq 'length')
ISSUE_NUM="${{ matrix.target.issueNumber }}"
CLOSE_LINE=""
if [ "$ISSUE_NUM" -gt 0 ] 2>/dev/null; then
CLOSE_LINE="Closes #${ISSUE_NUM}"
fi
BODY=$(cat <<PREOF
## Security Sweep Fix
This PR addresses **$COUNT** security vulnerabilities.
${CLOSE_LINE}
### Vulnerabilities Fixed
| CVE | Package | Severity | Fix Version |
|-----|---------|----------|-------------|
$(echo "$ALERTS" | jq -r '.[] | "| \(.cve // "N/A") | \(.package) | \(.severity | ascii_upcase) | \(.fix_version // "N/A") |"')
### Summary
- **Critical:** $(echo "$ALERTS" | jq '[.[] | select(.severity == "critical")] | length')
- **High:** $(echo "$ALERTS" | jq '[.[] | select(.severity == "high")] | length')
- **Medium:** $(echo "$ALERTS" | jq '[.[] | select(.severity == "medium")] | length')
- **Low:** $(echo "$ALERTS" | jq '[.[] | select(.severity == "low")] | length')
---
Generated by [git-steer](https://github.com/ry-ops/git-steer) autonomous security sweep
PREOF
)
PR_URL=$(gh pr create \
--title "fix(security): Patch $COUNT ${{ inputs.severity }}+ vulnerabilities" \
--body "$BODY" \
--head "${{ steps.commit.outputs.branch }}" \
--label "security" \
--label "dependencies" \
--label "automated" \
--label "severity:${{ inputs.severity }}")
PR_NUM=$(echo "$PR_URL" | grep -oE '[0-9]+$')
echo "url=$PR_URL" >> $GITHUB_OUTPUT
echo "number=$PR_NUM" >> $GITHUB_OUTPUT
- name: Comment on RFC issue
if: steps.changes.outputs.has_changes == 'true' && inputs.dry_run == false && matrix.target.issueNumber != '' && matrix.target.issueNumber != 0
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
ALERTS: ${{ steps.filter.outputs.filtered }}
run: |
REPO="${{ matrix.target.owner }}/${{ matrix.target.repo }}"
ISSUE_NUM="${{ matrix.target.issueNumber }}"
PR_URL="${{ steps.pr.outputs.url }}"
COUNT=$(echo "$ALERTS" | jq 'length')
COMMENT="## Fix PR Created
PR: $PR_URL
Fixes **$COUNT** vulnerabilities.
| Package | Fix Version |
|---------|-------------|
$(echo "$ALERTS" | jq -r '.[] | "| \(.package) | \(.fix_version // "N/A") |"')
Status: **in_progress** — awaiting PR merge."
gh api "repos/$REPO/issues/$ISSUE_NUM/comments" -f body="$COMMENT"
- name: Persist PR number to state repo
if: steps.pr.outputs.number != '' && inputs.dry_run == false
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
run: |
REPO="${{ matrix.target.owner }}/${{ matrix.target.repo }}"
PR_NUM="${{ steps.pr.outputs.number }}"
PR_URL="${{ steps.pr.outputs.url }}"
ISSUE_NUM="${{ matrix.target.issueNumber }}"
STATE_REPO="ry-ops/git-steer-state"
# Fetch current rfcs.jsonl, update the matching RFC with prNumber/prUrl
RFCS_CONTENT=$(gh api "repos/$STATE_REPO/contents/state/rfcs.jsonl" \
--jq '.content' 2>/dev/null | base64 -d 2>/dev/null || echo "")
RFCS_SHA=$(gh api "repos/$STATE_REPO/contents/state/rfcs.jsonl" \
--jq '.sha' 2>/dev/null || echo "")
if [ -n "$RFCS_CONTENT" ]; then
# Update matching RFC line with prNumber and prUrl
UPDATED=$(echo "$RFCS_CONTENT" | while IFS= read -r line; do
if echo "$line" | jq -e --arg r "$REPO" --argjson i "${ISSUE_NUM:-0}" \
'select(.repo == $r and .issueNumber == $i)' >/dev/null 2>&1; then
echo "$line" | jq -c --arg pn "$PR_NUM" --arg pu "$PR_URL" \
'. + {prNumber: ($pn | tonumber), prUrl: $pu, status: "in_progress"}'
else
echo "$line"
fi
done)
# Commit updated rfcs.jsonl
ENCODED=$(echo "$UPDATED" | base64 -w0 2>/dev/null || echo "$UPDATED" | base64)
gh api "repos/$STATE_REPO/contents/state/rfcs.jsonl" \
-X PUT \
-f message="state: link PR #$PR_NUM to RFC for $REPO" \
-f content="$ENCODED" \
-f sha="$RFCS_SHA" 2>/dev/null \
&& echo "Updated rfcs.jsonl with PR #$PR_NUM for $REPO" \
|| echo "Warning: could not update rfcs.jsonl"
fi
- name: Report results to git-steer-state
if: always() && inputs.job_id != ''
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
run: |
STATUS="${{ job.status }}"
JOB_ID="${{ inputs.job_id }}"
REPO="${{ matrix.target.owner }}/${{ matrix.target.repo }}"
FIXED_COUNT="${{ steps.filter.outputs.count }}"
FIXED_COUNT="${FIXED_COUNT:-0}"
ENTRY=$(jq -n \
--arg id "$JOB_ID" \
--arg status "$STATUS" \
--arg repo "$REPO" \
--arg severity "${{ inputs.severity }}" \
--arg fixed "$FIXED_COUNT" \
--arg pr_url "${{ steps.pr.outputs.url || '' }}" \
--arg pr_number "${{ steps.pr.outputs.number || '' }}" \
--arg issue_number "${{ matrix.target.issueNumber }}" \
--arg timestamp "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'{id: $id, status: $status, repo: $repo, severity: $severity, fixed: ($fixed | tonumber), pr_url: $pr_url, pr_number: $pr_number, issue_number: $issue_number, timestamp: $timestamp}')
echo "Sweep result: $ENTRY"