Skip to content

Auto-fix vulnerable dependencies #8

Auto-fix vulnerable dependencies

Auto-fix vulnerable dependencies #8

name: Auto-fix vulnerable dependencies
on:
schedule:
# Runs every Monday at 8:00 UTC
- cron: "0 8 * * 1"
workflow_dispatch: # Allow manual trigger
permissions:
contents: write
pull-requests: write
security-events: read
jobs:
fix-vulnerabilities:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: "24"
- name: Install dependencies
run: yarn install --frozen-lockfile
- name: Fetch Dependabot alerts and run yarn audit
id: fetch
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
echo "::group::Fetching Dependabot alerts (medium/high/critical)"
gh api \
"/repos/${{ github.repository }}/dependabot/alerts?state=open&severity=medium,high,critical&per_page=100" \
> /tmp/dependabot-alerts.json 2>/dev/null || echo "[]" > /tmp/dependabot-alerts.json
echo "::endgroup::"
echo "::group::Running yarn audit"
yarn audit --json > /tmp/yarn-audit.json 2>/dev/null || true
echo "::endgroup::"
- name: Analyze and update resolutions
id: audit
run: |
python3 << 'PYEOF'
import json, os
# "medium" = Dependabot terminology, "moderate" = yarn audit terminology
MIN_SEVERITIES = {"moderate", "medium", "high", "critical"}
advisories = {}
# --- Source 1: Dependabot alerts ---
try:
with open("/tmp/dependabot-alerts.json", "r") as f:
alerts = json.load(f)
if isinstance(alerts, list):
for alert in alerts:
vuln = alert.get("security_vulnerability", {})
name = vuln.get("package", {}).get("name", "")
severity = alert.get("security_advisory", {}).get("severity", "").lower()
if severity not in MIN_SEVERITIES or not name:
continue
patched_obj = vuln.get("first_patched_version")
if patched_obj and patched_obj.get("identifier"):
version = patched_obj["identifier"]
if name not in advisories or version > advisories[name]["version"]:
advisories[name] = {"version": version, "severity": severity, "source": "dependabot"}
print(f" Dependabot: {name} ({severity}) -> {version}")
except Exception as e:
print(f" Warning: Could not parse Dependabot alerts: {e}")
# --- Source 2: yarn audit ---
try:
with open("/tmp/yarn-audit.json", "r") as f:
for line in f:
try:
obj = json.loads(line)
if obj.get("type") == "auditAdvisory":
d = obj["data"]["advisory"]
name = d["module_name"]
patched = d.get("patched_versions", "")
severity = d.get("severity", "").lower()
if severity not in MIN_SEVERITIES:
continue
if patched.startswith(">="):
version = patched[2:].strip()
if name not in advisories or version > advisories[name]["version"]:
advisories[name] = {"version": version, "severity": severity, "source": "yarn-audit"}
print(f" yarn audit: {name} ({severity}) -> {version}")
except json.JSONDecodeError:
pass
except Exception as e:
print(f" Warning: Could not parse yarn audit: {e}")
print(f"\nFound {len(advisories)} vulnerable packages (moderate/high/critical)")
output_file = os.environ.get("GITHUB_OUTPUT", "/dev/null")
if not advisories:
print("No actionable vulnerabilities found")
with open(output_file, "a") as f:
f.write("has_updates=false\n")
raise SystemExit(0)
# Read current package.json
with open("package.json", "r") as f:
pkg = json.load(f)
resolutions = pkg.get("resolutions", {})
dependencies = pkg.get("dependencies", {})
dev_dependencies = pkg.get("devDependencies", {})
needed_resolutions = {}
needed_direct = {}
def parse_ver(v):
"""Parse a version string like '4.17.23' into a comparable tuple."""
parts = v.split(".")
return tuple(int(p) for p in parts if p.isdigit())
for name, info in advisories.items():
target = f"^{info['version']}"
# Check resolutions
current_res = resolutions.get(name, "")
if current_res != target:
needed_resolutions[name] = {
"version": target,
"severity": info["severity"],
"current": current_res,
"source": info["source"],
}
# Check direct dependencies — upgrade if current range could resolve to a vulnerable version
for dep_section, dep_label in [(dependencies, "dependencies"), (dev_dependencies, "devDependencies")]:
if name in dep_section:
current_spec = dep_section[name]
base_version = current_spec.lstrip("^~>=<")
patched_version = info["version"]
try:
current_parts = parse_ver(base_version)
patched_parts = parse_ver(patched_version)
# Skip major version bumps — too risky for automated upgrades
if current_parts[0] != patched_parts[0]:
print(f" Skipping {name}: major version bump {base_version} -> {patched_version} (requires manual upgrade)")
continue
if current_parts < patched_parts:
needed_direct[name] = {
"version": target,
"severity": info["severity"],
"current": current_spec,
"section": dep_label,
"source": info["source"],
}
except Exception:
pass
if not needed_resolutions and not needed_direct:
print("All dependencies already up to date")
with open(output_file, "a") as f:
f.write("has_updates=false\n")
raise SystemExit(0)
# Build summary table
lines = []
lines.append("| Dependency | Section | Before | After | Severity | Source |")
lines.append("|---|---|---|---|---|---|")
for name, info in sorted(needed_resolutions.items()):
before = info["current"] if info["current"] else "_(none)_"
lines.append(
f"| **{name}** | resolutions | {before} | {info['version']} | {info['severity']} | {info['source']} |"
)
for name, info in sorted(needed_direct.items()):
lines.append(
f"| **{name}** | {info['section']} | {info['current']} | {info['version']} | {info['severity']} | {info['source']} |"
)
summary = "\n".join(lines)
print(f"\nUpdates needed:\n{summary}")
# Apply resolution updates
for name, info in needed_resolutions.items():
resolutions[name] = info["version"]
pkg["resolutions"] = resolutions
# Apply direct dependency updates
for name, info in needed_direct.items():
if info["section"] == "dependencies":
pkg["dependencies"][name] = info["version"]
else:
pkg["devDependencies"][name] = info["version"]
with open("package.json", "w") as f:
json.dump(pkg, f, indent=2)
f.write("\n")
with open(output_file, "a") as f:
f.write("has_updates=true\n")
f.write(f"summary<<EOFSUM\n{summary}\nEOFSUM\n")
total = len(needed_resolutions) + len(needed_direct)
print(f"\nApplied {total} updates to package.json ({len(needed_resolutions)} resolutions, {len(needed_direct)} direct deps)")
PYEOF
- name: Reinstall with updated resolutions
if: steps.audit.outputs.has_updates == 'true'
run: yarn install
- name: Verify fixes
if: steps.audit.outputs.has_updates == 'true'
run: |
echo "Remaining vulnerabilities (if any):"
yarn audit --summary 2>/dev/null || true
- name: Create Pull Request
if: steps.audit.outputs.has_updates == 'true'
uses: peter-evans/create-pull-request@v7
with:
token: ${{ secrets.GITHUB_TOKEN }}
commit-message: "fix: update vulnerable dependencies (direct + transitive)"
branch: automated/security-resolutions
delete-branch: true
title: "Security: Update vulnerable dependencies"
body: |
## Summary
Automated update of `package.json` to fix vulnerable dependencies (both direct and transitive via resolutions).
Sources: Dependabot alerts (medium/high/critical) + yarn audit.
### Changes
${{ steps.audit.outputs.summary }}
> **Note:** Direct dependencies are upgraded to the minimum patched version (semver-compatible). Review the preview build to verify nothing breaks.
### Verify
- [ ] `yarn install` succeeds
- [ ] `yarn build` succeeds
- [ ] App runs correctly
labels: |
dependencies
security