diff --git a/.github/workflows/security-resolutions.yml b/.github/workflows/security-resolutions.yml new file mode 100644 index 0000000..c146008 --- /dev/null +++ b/.github/workflows/security-resolutions.yml @@ -0,0 +1,195 @@ +name: Auto-fix vulnerable transitive 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", {}) + needed = {} + + for name, info in advisories.items(): + current = resolutions.get(name, "") + target = f"^{info['version']}" + if current != target: + needed[name] = { + "version": target, + "severity": info["severity"], + "current": current, + "source": info["source"], + } + + if not needed: + print("All resolutions 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 | Before | After | Severity | Source |") + lines.append("|---|---|---|---|---|") + for name, info in sorted(needed.items()): + before = info["current"] if info["current"] else "_(none)_" + lines.append( + f"| **{name}** | {before} | {info['version']} | {info['severity']} | {info['source']} |" + ) + + summary = "\n".join(lines) + print(f"\nUpdates needed:\n{summary}") + + # Apply resolutions to package.json + for name, info in needed.items(): + resolutions[name] = info["version"] + + pkg["resolutions"] = resolutions + + 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</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 resolutions for vulnerable transitive dependencies" + branch: automated/security-resolutions + delete-branch: true + title: "Security: Update transitive dependency resolutions" + body: | + ## Summary + Automated update of `resolutions` in `package.json` to fix vulnerable transitive dependencies. + Sources: Dependabot alerts (medium/high/critical) + yarn audit. + + ### Changes + ${{ steps.audit.outputs.summary }} + + > **Note:** This only updates transitive dependencies via resolutions. Direct dependency upgrades should be done manually to avoid breaking changes. + + ### Verify + - [ ] `yarn install` succeeds + - [ ] `yarn build` succeeds + - [ ] App runs correctly + labels: | + dependencies + security