mcpField
S
security-runner

security-runner

github.com/fellowship-devSecurity3170 installsUpdated 2w ago
Repository

About security-runner

A security-triage skill that processes open Dependabot alerts for a target repository, classifies them, takes the prescribed action, and reports results. It uses a shared classification framework and routes findings to fixes, issues, digests, or dismissal as appropriate.

Key features

  1. 01Fetch open Dependabot alerts
  2. 02Classify alerts using security-check
  3. 03Open fix PRs or create issues
  4. 04Add backlog items to weekly digest
  5. 05Dismiss false positives or irrelevant alerts
  6. 06Generate and optionally post reports

Use cases

  1. 01Run weekly security sweeps across repositories
  2. 02Triage repositories after a critical CVE disclosure
  3. 03Clear alert backlogs before a sprint
  4. 04Review a spike in Snyk alerts

Install

Copy the command and run it in a terminal.

$npx skills add https://github.com/fellowship-dev/dogfooded-skills --skill security-runner

After installing, type/security-runnerin Claude Code to confirm it loaded.

Details

From the project README

security-runner

Executes the security triage pipeline for a target repo. Classifies open Dependabot alerts, takes action on each, and reports results.

Run /security-check first to understand the classification framework this skill applies.

Install via npx:

npx skills add fellowship-dev/dogfooded-skills/skills/ops/security-runner

When to Use

  • Weekly cron (Monday 05:00) — automated sweep of all active repos
  • After a critical CVE disclosure — targeted triage of affected repos
  • After a Snyk PR/alert spike — clear the backlog before sprint starts

Prerequisites

# Verify gh auth and repo access
gh repo view "$REPO" --json name -q .name || { echo "ERROR: cannot access $REPO"; exit 1; }

# Dependabot alerts require repo admin scope
gh api repos/"$REPO"/dependabot/alerts --paginate --jq '.[0].number' 2>&1 | head -1

If alerts endpoint returns 403, the token lacks security_events scope or Dependabot isn't enabled.


Step 0: Setup

REPO="${1:-$PYLOT_REPO}"
TODAY=$(date +%Y-%m-%d)
REPORT_PATH="/tmp/security-runner-${REPO//\//-}-${TODAY}.md"
# Every issue goes through the shared filing helper (pylot-cli): dedupe → comment,
# non-P0/P1 → weekly digest, max 3 new issues per run (P0 exempt).
FF="${PYLOT_WORKSPACE:-$HOME/.claude}/skills/pylot-cli/scripts/file-finding.sh"

# Check merge strategy — read from Pylot control plane, not target repo
# (crew.yml lives in $PYLOT_DIR, not in the repos being scanned)
MERGE_STRATEGY=$(grep -A5 "$(echo "$REPO" | cut -d/ -f2)" "$PYLOT_DIR/crew.yml" 2>/dev/null | \
  grep merge_strategy | head -1 | awk '{print $2}' || echo "restricted")

echo "Repo: $REPO"
echo "Merge strategy: $MERGE_STRATEGY"
echo "Report: $REPORT_PATH"

Step 1: Fetch Open Alerts

# Fetch all open Dependabot alerts
ALERTS=$(gh api repos/"$REPO"/dependabot/alerts \
  --paginate \
  --jq '.[] | select(.state=="open") | {
    number: .number,
    package: .dependency.package.name,
    ecosystem: .dependency.package.ecosystem,
    manifest: .dependency.manifest_path,
    scope: .dependency.scope,
    severity: .security_advisory.severity,
    cvss_score: .security_advisory.cvss.score,
    cve_id: .security_advisory.cve_id,
    summary: .security_advisory.summary,
    patched_versions: (.security_vulnerability.patched_versions // "none"),
    vulnerable_range: .security_vulnerability.vulnerable_version_range,
    current_version: .security_vulnerability.package.ecosystem,
    auto_dismissed: .auto_dismissed_at,
    html_url: .html_url
  }' 2>&1)

ALERT_COUNT=$(echo "$ALERTS" | python3 -c "import sys,json; data=[json.loads(l) for l in sys.stdin if l.strip()]; print(len(data))" 2>/dev/null || echo "0")
echo "Open alerts: $ALERT_COUNT"

if [ "$ALERT_COUNT" = "0" ]; then
  echo "No open alerts — nothing to do."
  echo "# Security Runner: $REPO — $TODAY" > "$REPORT_PATH"
  echo "**No open Dependabot alerts.**" >> "$REPORT_PATH"
  exit 0
fi

# Initialize counters used in Step 5 report
COUNT_P0=0; COUNT_P1=0; COUNT_P2=0; COUNT_BACKLOG=0; COUNT_DISMISS=0
DETAIL_LOG=""

Step 2: Classify Each Alert

Apply the security-check decision matrix to each alert:

classify_alert() {
  local severity="$1"   # critical|high|medium|low
  local scope="$2"      # runtime|development (from Dependabot)
  local manifest="$3"   # path to manifest file

  # Map Dependabot scope to exploitability
  local exploitability="dev-only"
  if [ "$scope" = "runtime" ]; then
    exploitability="network-reachable"
  fi
  # test-only: infer from manifest path — only when not explicitly runtime-scoped
  # (guards against false downgrades on monorepos where test/ dirs contain runtime deps)
  if [ "$scope" != "runtime" ] && echo "$manifest" | grep -qiE 'test|spec|__tests__|cypress'; then
    exploitability="test-only"
  fi

  # Decision matrix
  case "${severity}__${exploitability}" in
    "critical__network-reachable") echo "P0" ;;
    "critical__dev-only"|"high__network-reachable") echo "P1" ;;
    "critical__test-only"|"high__dev-only"|"high__test-only"|"medium__network-reachable") echo "P2" ;;
    "medium__dev-only"|"medium__test-only"|"low__network-reachable") echo "backlog" ;;
    *) echo "dismiss" ;;
  esac
}

Step 3: Act on Each Alert

For each alert, take the action prescribed by its classification:

P0 / P1 — Open fix PR (safe patch) or create issue (breaking change)
process_p0_p1_alert() {
  local pkg="$1"
  local patched="$2"
  local alert_url="$3"
  local priority="$4"

  # Check if a Dependabot PR already exists for this package
  EXISTING_PR=$(gh pr list --repo "$REPO" --state open --json number,title \
    --jq ".[] | select(.title | test(\"$pkg\"; \"i\")) | .number" 2>/dev/null | head -1)

  if [ -n "$EXISTING_PR" ]; then
    echo "  → Existing PR #$EXISTING_PR for $pkg — labeling $priority"
    gh pr edit "$EXISTING_PR" --repo "$REPO" --add-label "security,$priority" 2>/dev/null || true
    # Apply merge strategy here where $EXISTING_PR is in scope
    if [ "$MERGE_STRATEGY" = "auto-merge" ]; then
      gh pr merge "$EXISTING_PR" --repo "$REPO" --auto --squash 2>/dev/null && \
        echo "  → Auto-merge enabled on PR #$EXISTING_PR"
    else
      gh pr edit "$EXISTING_PR" --repo "$REPO" --add-label "ready-to-merge" 2>/dev/null && \
        echo "  → Labeled PR #$EXISTING_PR as ready-to-merge (restricted repo — human must merge)"
    fi
    return
  fi

  if [ "$patched" = "none" ]; then
    # No patch available — create issue with upgrade path
    bash "$FF" --repo "$REPO" --severity "$priority" --search "\"$pkg\" label:security" \
      --title "security: no patch for $pkg ($priority)" \
      --label security --label "$priority" \
      --body "## Vulnerability\n\nPackage: \`$pkg\`\nPriority: $priority\nDependabot alert: $alert_url\n\nNo patched version available. Options:\n- [ ] Pin to last non-vulnerable version\n- [ ] Find alternative package\n- [ ] Remove dependency if unused\n\ncc: @maxfindel" 2>/dev/null
  else
    # No public GitHub API endpoint exists to trigger Dependabot PR creation directly.
    # Create a tracking issue and direct the team to bump manually or await Dependabot's schedule.
    echo "  → Patch available ($patched) — creating tracking issue for $pkg"
    bash "$FF" --repo "$REPO" --severity "$priority" --search "\"$pkg\" label:security" \
      --title "security: bump $pkg to $patched ($priority)" \
      --label security --label "$priority" \
      --body "## Action Required\n\nPackage: \`$pkg\`\nFixed in: \`$patched\`\nPriority: $priority\nDependabot alert: $alert_url\n\nDependabot has not auto-created a PR. Options:\n- [ ] Wait for Dependabot's next scheduled run (Mon 05:00)\n- [ ] Manually bump \`$pkg\` to \`$patched\` and open a PR\n\nMonitor: https://github.com/$REPO/security/dependabot" 2>/dev/null && \
      echo "  → Tracking finding recorded for $pkg → $patched"
  fi
}
P2 / Backlog — Weekly digest entry
process_p2_backlog_alert() {
  local pkg="$1"
  local severity="$2"
  local summary="$3"
  local alert_url="$4"
  local priority="$5"

  # Non-blocking: the helper comments on an open issue for $pkg or appends to the weekly digest.
  bash "$FF" --repo "$REPO" --search "\"$pkg\" label:security" \
    --title "security: upgrade $pkg ($severity — $priority)" \
    --body "## Vulnerability\n\nPackage: \`$pkg\`\nSeverity: $severity\nSummary: $summary\nDependabot alert: $alert_url\n\nBatch in next monthly dependency cycle. Verify no breaking changes before upgrading." 2>/dev/null
}
Dismiss — False positive or irrelevant
dismiss_alert() {
  local alert_number="$1"
  local reason="$2"  # tolerated_risk | inaccurate | not_used | no_bandwidth

  gh api repos/"$REPO"/dependabot/alerts/"$alert_number" \
    --method PATCH \
    --field state=dismissed \
    --field dismissed_reason="$reason" \
    --field dismissed_comment="Dismissed by security-runner: $reason. Review quarterly." 2>/dev/null
  echo "  → Dismissed alert #$alert_number (reason: $reason)"
}

Step 3b: Orchestrate — Iterate Over All Alerts

After defining the functions above, iterate over $ALERTS and route each alert:

# Process substitution keeps the loop in the current shell so counter variables
# (COUNT_P0, DETAIL_LOG, etc.) survive to Step 5. A pipe would run the body in a
# subshell and silently discard every assignment.
while IFS= read -r alert_json; do
  [ -z "$alert_json" ] && continue

  pkg=$(echo "$alert_json"       | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['package'])")
  severity=$(echo "$alert_json"  | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['severity'])")
  scope=$(echo "$alert_json"     | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('scope','development'))")
  manifest=$(echo "$alert_json"  | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['manifest'])")
  patched=$(echo "$alert_json"   | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['patched_versions'])")
  summary=$(echo "$alert_json"   | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['summary'])")
  alert_url=$(echo "$alert_json" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['html_url'])")
  alert_num=$(echo "$alert_json" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['number'])")

  priority=$(classify_alert "$severity" "$scope" "$manifest")
  echo "[$priority] $pkg ($severity, scope=$scope)"
  DETAIL_LOG="${DETAIL_LOG}\n- [$priority] \`$pkg\` — $severity — $summary"

  case "$priority" in
    P0|P1)
      process_p0_p1_alert "$pkg" "$patched" "$alert_url" "$priority"
      [ "$priority" = "P0" ] && COUNT_P0=$((COUNT_P0 + 1)) || COUNT_P1=$((COUNT_P1 + 1))
      ;;
    P2)
      process_p2_backlog_alert "$pkg" "$severity" "$summary" "$alert_url" "$priority"
      COUNT_P2=$((COUNT_P2 + 1))
      ;;
    backlog)
      process_p2_backlog_alert "$pkg" "$severity" "$summary" "$alert_url" "$priority"
      COUNT_BACKLOG=$((COUNT_BACKLOG + 1))
      ;;
    dismiss)
      dismiss_alert "$alert_num" "tolerated_risk"
      COUNT_DISMISS=$((COUNT_DISMISS + 1))
      ;;
  esac
done < <(echo "$ALERTS")

Step 4: Respect Merge Strategy

Merge-strategy enforcement is applied inside process_p0_p1_alert (Step 3 above) where the PR number is already in scope via $EXISTING_PR. The strategy is read once in Step 0 ($MERGE_STRATEGY) and is available to the function as a global.


Step 5: Generate Summary Report

cat > "$REPORT_PATH" << REPORT
# Security Runner: $REPO

**Date:** $TODAY
**Alerts scanned:** $ALERT_COUNT
**Merge strategy:** $MERGE_STRATEGY

## Summary

| Priority | Count | Action |
|---|---|---|
| P0 | $COUNT_P0 | PRs opened / existing PRs labeled |
| P1 | $COUNT_P1 | PRs opened / existing PRs labeled |
| P2 | $COUNT_P2 | Issues created for batch cycle |
| Backlog | $COUNT_BACKLOG | Issues created (no urgency) |
| Dismissed | $COUNT_DISMISS | Dismissed via API |

## Detail

$DETAIL_LOG

## Next Steps

- P0/P1 PRs: review and merge within priority window
- P2 issues: include in next monthly dependency batch (/deps-runner)
- Dismissed alerts: review quarterly for changed risk posture
- Re-run: \`/security-runner $REPO\` after merges to confirm 0 open alerts
REPORT

echo "Report saved: $REPORT_PATH"
cat "$REPORT_PATH"

Step 6: Post Report (optional)

If running in Pylot context:

# Post to Quest
if [ -n "$PYLOT_API" ] && [ -n "$GH_TOKEN" ]; then
  curl -s -X POST "${PYLOT_API}/admin/events" \
    -H "Authorization: Bearer $GH_TOKEN" \
    -H "Content-Type: application/json" \
    -d "$(python3 -c "
import json, sys
content = open('$REPORT_PATH').read()
print(json.dumps({
  'source': 'security-runner',
  'type': 'security.triage',
  'title': 'Security Triage: $REPO',
  'meta': {'content': content, 'repo': '$REPO', 'alert_count': $ALERT_COUNT}
}))
")" 2>/dev/null || true
fi

Full Pipeline (orchestration example)

#!/bin/bash
# Run security-runner against all active fellowship-dev repos
REPOS="fellowship-dev/booster-pack fellowship-dev/inbox-angel fellowship-dev/pylot fellowship-dev/quest fellowship-dev/spec-kit fellowship-dev/v0-operator fellowship-dev/dogfooded-skills fellowship-dev/flowchad"

for REPO in $REPOS; do
  echo "=== Triaging $REPO ==="
  /security-runner "$REPO" || echo "WARN: runner failed for $REPO"
done

Cron Integration

# In crew.yml, per team:
cron:
  - schedule: "0 5 * * 1"   # Every Monday at 05:00
    task: "Weekly security triage: process open Dependabot/Snyk alerts, open fix PRs for safe patches"

Output: Pylot Outcome Marker

Emit on completion:

[pylot:$PYLOT_OUTCOME_NONCE] outcome="N alerts triaged: X PRs, Y issues, Z dismissed" status=success
[pylot:$PYLOT_OUTCOME_NONCE] outcome="blocked: Dependabot API returned 403 — token missing security_events scope" status=blocked

Related Skills

  • /security-check — the classification framework this skill executes
  • /deps-runner — non-security dependency updates; same PR pattern
  • /entropy-check — can incorporate security scores into domain grades
  • Check directly whether Dependabot is configured; the maintenance skill was retired 2026-09-14

Comments

Markdown supported