Auto-fix vulnerable dependencies #8
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |