-
Notifications
You must be signed in to change notification settings - Fork 29
ci(security): add fix-dependabot-alerts automation #328
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
61669ee
build: adopt Central Package Management + lockfiles + transitive audit
TalZaccai b57d385
build: drop committed packages.lock.json, keep CPM + audit
TalZaccai 0e439de
build: drop unnecessary packages.lock.json gitignore entry
TalZaccai dfb7b79
Merge branch 'main' into dev/talzacc/cpm-lockfile-modernization
TalZaccai 30f4302
ci(security): add fix-dependabot-alerts automation
TalZaccai 506adcf
ci(security): harden fix-dependabot-alerts per rubber-duck
TalZaccai 4ece179
ci(security): address PR #328 review comments
TalZaccai e074ac1
Merge branch 'main' into dev/talzacc/fix-dependabot-alerts
TalZaccai 47e13bd
ci(security): push with default GITHUB_TOKEN, not App token
TalZaccai File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,295 @@ | ||
| # Copyright (c) Microsoft Corporation. | ||
| # Licensed under the MIT License. | ||
|
|
||
| # Automatically remediate Dependabot security alerts by running the | ||
| # tools/scripts/fix-dependabot-alerts.mjs script, verifying each fix | ||
| # against dotnet restore (NuGetAudit), build, and tests, then opening | ||
| # a single squash-PR with the passing changes. | ||
| # | ||
| # Authentication: uses a GitHub App (via actions/create-github-app-token) | ||
| # because the Dependabot alerts REST API isn't reachable with the default | ||
| # GITHUB_TOKEN. Requires repo or org variables: | ||
| # - DEPENDABOT_APP_ID (variable) | ||
| # - DEPENDABOT_APP_PRIVATE_KEY (secret) | ||
|
|
||
| name: fix-dependabot-alerts | ||
|
|
||
| on: | ||
| schedule: | ||
| # Daily at 09:00 UTC | ||
| - cron: "0 9 * * *" | ||
| workflow_dispatch: | ||
| inputs: | ||
| dry-run: | ||
| description: "Dry run — analyse only, don't apply fixes" | ||
| type: boolean | ||
| default: false | ||
| skip-tests: | ||
| description: "Skip 'dotnet test' during per-fix verification (build only)" | ||
| type: boolean | ||
| default: false | ||
|
|
||
| concurrency: | ||
| group: ${{ github.workflow }} | ||
| cancel-in-progress: true | ||
|
|
||
| permissions: | ||
| contents: write | ||
| pull-requests: write | ||
|
|
||
| jobs: | ||
| fix-alerts: | ||
| runs-on: ubuntu-latest | ||
|
|
||
| steps: | ||
| - uses: actions/checkout@v4 | ||
| with: | ||
| fetch-depth: 0 | ||
| # Don't leave the default GITHUB_TOKEN in .git/config — the | ||
| # remediation script invokes ``dotnet`` against potentially | ||
| # untrusted dependency updates (MSBuild tasks, source | ||
| # generators, and test runners can execute arbitrary code), | ||
| # and we don't want push credentials reachable from those. | ||
| persist-credentials: false | ||
|
|
||
| - uses: actions/setup-dotnet@v4 | ||
| with: | ||
| dotnet-version: "8.0.x" | ||
|
|
||
| - uses: actions/setup-node@v4 | ||
| with: | ||
| # The remediation script itself is Node.js; no project depends | ||
| # on node, so we don't need a package-lock cache key. | ||
| node-version: 24 | ||
|
|
||
| - name: Cache NuGet packages | ||
| uses: actions/cache@v4 | ||
| with: | ||
| path: ~/.nuget/packages | ||
| # Hash the central props file so a CPM change invalidates the | ||
| # cache. Cache restore from any prior key prefix on miss. | ||
| key: nuget-fixer-${{ runner.os }}-${{ hashFiles('Directory.Packages.props') }} | ||
| restore-keys: | | ||
| nuget-fixer-${{ runner.os }}- | ||
|
|
||
| - name: Generate GitHub App token | ||
| id: app-token | ||
| uses: actions/create-github-app-token@v1 | ||
| with: | ||
| app-id: ${{ vars.DEPENDABOT_APP_ID }} | ||
| private-key: ${{ secrets.DEPENDABOT_APP_PRIVATE_KEY }} | ||
|
|
||
| - name: Verify gh authentication | ||
| env: | ||
| GH_TOKEN: ${{ steps.app-token.outputs.token }} | ||
| run: | | ||
| gh auth status | ||
| # Fail fast if the Dependabot API isn't reachable — otherwise | ||
| # the script would see 0 alerts and silently report "nothing | ||
| # to do", masking an infra outage as a clean run. | ||
| if ! gh api "repos/${{ github.repository }}/dependabot/alerts?per_page=1" --jq 'length'; then | ||
| echo "::error::Dependabot API probe failed — aborting before silently misreporting alerts" | ||
| exit 1 | ||
| fi | ||
|
|
||
| # Restore prior rollback-state so packages rolled back in earlier | ||
| # runs (build/test failures) aren't retried until cooldown expires | ||
| # or the underlying Directory.Packages.props SHA changes. | ||
| # | ||
| # Cache keys must be unique per save (caches are immutable per key), | ||
| # so we save under a run-id-suffixed key and restore from the prefix. | ||
| - name: Restore rollback state | ||
| id: restore-state | ||
| uses: actions/cache/restore@v4 | ||
| with: | ||
| path: ${{ runner.temp }}/fix-dependabot-alerts-rollback-state.json | ||
| key: fix-dep-rollback-state-v1-${{ github.run_id }} | ||
| restore-keys: | | ||
| fix-dep-rollback-state-v1- | ||
|
|
||
| - name: Run remediation script | ||
| id: fix | ||
| env: | ||
| GH_TOKEN: ${{ steps.app-token.outputs.token }} | ||
| GITHUB_REPOSITORY: ${{ github.repository }} | ||
| DEP_ROLLBACK_STATE_PATH: ${{ runner.temp }}/fix-dependabot-alerts-rollback-state.json | ||
| run: | | ||
| FLAGS="" | ||
| if [ "${{ inputs.dry-run }}" != "true" ]; then | ||
| FLAGS="$FLAGS --auto-fix" | ||
| fi | ||
| if [ "${{ inputs.skip-tests }}" = "true" ]; then | ||
| FLAGS="$FLAGS --skip-tests" | ||
| fi | ||
| node tools/scripts/fix-dependabot-alerts.mjs $FLAGS | ||
|
|
||
| # Always persist the (possibly updated) rollback state, even if | ||
| # later steps fail — otherwise a rollback recorded this run would | ||
| # be forgotten and the same broken upgrade re-tried tomorrow. | ||
| - name: Save rollback state | ||
| if: always() | ||
| uses: actions/cache/save@v4 | ||
| with: | ||
| path: ${{ runner.temp }}/fix-dependabot-alerts-rollback-state.json | ||
| # ``run_id`` is reused across job re-runs; include | ||
| # ``run_attempt`` so each attempt gets a unique (immutable) | ||
| # cache key. The restore step uses a shared prefix so any | ||
| # prior attempt's state is still picked up. | ||
| key: fix-dep-rollback-state-v1-${{ github.run_id }}-${{ github.run_attempt }} | ||
|
|
||
| # ── Final clean build verification ────────────────────────────── | ||
| # | ||
| # The per-fix incremental verification reuses the NuGet cache and | ||
| # MSBuild incremental restore state; a stale obj/ folder could | ||
| # theoretically mask an issue. A full clean restore + build + test | ||
| # catches anything the incremental flow missed. | ||
| - name: Final clean build verification | ||
| if: ${{ steps.fix.outputs.changes == 'true' && inputs.dry-run != 'true' }} | ||
| id: build | ||
| run: | | ||
| # Wipe per-project intermediate state so the verify build | ||
| # doesn't reuse anything from the per-fix iterations. | ||
| find . -path ./.git -prune -o -type d \( -name bin -o -name obj \) -prune -exec rm -rf {} + | ||
| dotnet restore TypeChat.sln --force | ||
| # -m:1 avoids a known parallel-build race in examples' | ||
| # Directory.Build.targets file copy. | ||
| dotnet build TypeChat.sln -c Release /warnaserror --no-restore -m:1 | ||
| if [ "${{ inputs.skip-tests }}" != "true" ]; then | ||
| dotnet test tests/TypeChat.UnitTests -c Release --no-build --nologo | ||
| fi | ||
| echo "build_ok=true" >> "$GITHUB_OUTPUT" | ||
|
|
||
| # ── Create PR ─────────────────────────────────────────────────── | ||
| # | ||
| # App tokens expire after 1 hour; the build/verify phase can | ||
| # outrun that. Re-mint immediately before any late ``gh`` calls. | ||
| - name: Refresh app token | ||
| if: ${{ steps.fix.outputs.changes == 'true' && steps.build.outputs.build_ok == 'true' }} | ||
| id: app-token-pr | ||
| uses: actions/create-github-app-token@v1 | ||
| with: | ||
| app-id: ${{ vars.DEPENDABOT_APP_ID }} | ||
| private-key: ${{ secrets.DEPENDABOT_APP_PRIVATE_KEY }} | ||
|
|
||
| - name: Create pull request | ||
| if: ${{ steps.fix.outputs.changes == 'true' && steps.build.outputs.build_ok == 'true' }} | ||
| env: | ||
| # GH_TOKEN is the App token — used by the ``gh`` CLI for | ||
| # ``gh pr create`` / labelling / closing superseded PRs so the | ||
| # PR appears under the bot's identity. | ||
| GH_TOKEN: ${{ steps.app-token-pr.outputs.token }} | ||
| # GIT_PUSH_TOKEN is the workflow's default GITHUB_TOKEN, scoped | ||
| # via the workflow-level ``permissions: contents: write`` block. | ||
| # We use it only at the very end, after all untrusted ``dotnet`` | ||
| # build/test phases have finished, to avoid persisting any push | ||
| # credential in .git/config (where MSBuild tasks / source | ||
| # generators / test runners could read it). | ||
| GIT_PUSH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | ||
| run: | | ||
| BRANCH="automated/fix-dependabot-alerts-$(date +%Y%m%d)-${{ github.run_number }}" | ||
| git config user.name "github-actions[bot]" | ||
| git config user.email "41898282+github-actions[bot]@users.noreply.github.com" | ||
| git checkout -b "$BRANCH" | ||
| git add -A | ||
|
|
||
| # Belt-and-suspenders: only commit/push if there are real | ||
| # working-tree changes. ``changes=true`` from the script means | ||
| # "applied at least one fix"; an upstream no-op update could | ||
| # still produce no diff. | ||
| if git diff --cached --quiet; then | ||
| echo "No actual file changes to commit despite applied fixes — skipping PR." | ||
| exit 0 | ||
| fi | ||
|
|
||
| git commit -F - <<EOF | ||
| fix: remediate Dependabot security alerts | ||
|
|
||
| Automated by fix-dependabot-alerts workflow. | ||
|
|
||
| Applied:${{ steps.fix.outputs.applied_packages }} | ||
| Rolled back:${{ steps.fix.outputs.rolled_back_packages }} | ||
| Unfixable: ${{ steps.fix.outputs.unfixable_count }} package(s) | ||
|
|
||
| Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> | ||
| EOF | ||
| # Push using the workflow's default GITHUB_TOKEN (scoped to | ||
| # contents:write at the workflow level). Configured here, not | ||
| # via actions/checkout's ``persist-credentials``, so the token | ||
| # isn't reachable from the dotnet build/test phase earlier in | ||
| # the job. | ||
| git remote set-url origin "https://x-access-token:${GIT_PUSH_TOKEN}@github.com/${{ github.repository }}.git" | ||
| git push origin "$BRANCH" | ||
|
|
||
| APPLIED="${{ steps.fix.outputs.applied_packages }}" | ||
| PINS="${{ steps.fix.outputs.applied_transitive_pins }}" | ||
| ROLLED="${{ steps.fix.outputs.rolled_back_packages }}" | ||
| UNFIXABLE="${{ steps.fix.outputs.unfixable_packages }}" | ||
| COOLDOWN="${{ steps.fix.outputs.cooldown_packages }}" | ||
|
|
||
| BODY=$(cat <<EOF | ||
| ## Automated Dependabot Alert Remediation | ||
|
|
||
| This PR was generated by the \`fix-dependabot-alerts\` workflow. | ||
| Each fix is a single edit to \`Directory.Packages.props\` and was verified against \`dotnet restore\` (with \`NuGetAudit\`), \`dotnet build /warnaserror\`, and \`dotnet test\` before inclusion. | ||
|
|
||
| ### Summary | ||
| - **Applied (${{ steps.fix.outputs.applied_count }}):**${APPLIED:- (none)} | ||
| - **Applied as transitive pins (new \`<PackageVersion>\` entries):**${PINS:- (none)} | ||
| - **Rolled back (${{ steps.fix.outputs.rolled_back_count }}):**${ROLLED:- (none)} | ||
| - **Unfixable (${{ steps.fix.outputs.unfixable_count }}):**${UNFIXABLE:- (none)} | ||
| - **Skipped (recent rollback cooldown, ${{ steps.fix.outputs.cooldown_count }}):**${COOLDOWN:- (none)} | ||
|
|
||
| > Packages marked **Unfixable** could not be lifted to the advisory's \`first_patched_version\` via a CPM edit — typically because the patched version doesn't yet exist in NuGet, or the upgrade introduces transitive constraint violations. Triage manually. | ||
|
|
||
| > Packages marked as **transitive pins** are tracked technical debt — the \`<PackageVersion>\` entry will hold the package at the pinned version across the whole repo until removed. Remove the entry once the natural dependency graph would resolve to a safe version on its own. | ||
|
|
||
| ### How this works | ||
| 1. Reads open Dependabot alerts via the REST API. | ||
| 2. For each alert, edits \`Directory.Packages.props\` to set the package's \`<PackageVersion>\` to the advisory's \`first_patched_version\` (adding a new entry if the package wasn't previously declared — works for transitive deps thanks to \`CentralPackageTransitivePinningEnabled\`). | ||
| 3. Runs \`dotnet restore --force\` and confirms no NU1901–NU1904 audit warnings remain for the alert's GHSA. | ||
| 4. Runs \`dotnet build -c Release /warnaserror\` and \`dotnet test\`; rolls back on failure and records a 7-day cooldown. | ||
| 5. Only fixes that pass all phases land in this PR. | ||
|
|
||
| ### Review checklist | ||
| - [ ] Verify \`Directory.Packages.props\` changes are limited to the listed packages | ||
| - [ ] Investigate any newly-rolled-back packages separately | ||
| - [ ] If transitive pins were added, confirm the pinned version is acceptable policy | ||
| EOF | ||
| ) | ||
|
|
||
| # Create the new PR FIRST, capture its number, THEN close | ||
| # superseded PRs. Otherwise a transient ``gh pr create`` failure | ||
| # could leave the repo with no open remediation PR. | ||
| NEW_PR=$(gh pr create \ | ||
| --base main \ | ||
| --head "$BRANCH" \ | ||
| --title "fix: remediate Dependabot security alerts ($(date +%Y-%m-%d))" \ | ||
| --body "$BODY" \ | ||
| | tail -1) | ||
| echo "Created $NEW_PR" | ||
| NEW_PR_NUM=$(echo "$NEW_PR" | grep -oE '[0-9]+$' || true) | ||
|
|
||
| # Best-effort labels (won't fail the workflow if a label is | ||
| # missing — the PR itself is the important artifact). | ||
| if [ -n "$NEW_PR_NUM" ]; then | ||
| gh pr edit "$NEW_PR_NUM" --add-label "dependencies,security,automated" \ | ||
| || echo "::warning::Could not apply all labels to PR #$NEW_PR_NUM" | ||
| fi | ||
|
|
||
| # Dedup older auto-PRs from this workflow. Match by branch | ||
| # prefix using a jq filter (the GH issue-search ``head:`` / | ||
| # ``in:branch`` qualifiers are not reliable for prefix | ||
| # matching). Exclude the PR we just created. | ||
| PREV_PRS=$(gh pr list \ | ||
| --state open \ | ||
| --json number,headRefName \ | ||
| --jq '.[] | select(.headRefName | startswith("automated/fix-dependabot-alerts-")) | select(.headRefName != "'"$BRANCH"'") | .number') | ||
| if [ -n "$PREV_PRS" ]; then | ||
| echo "Closing superseded Dependabot fix PRs: $PREV_PRS" | ||
| for PR in $PREV_PRS; do | ||
| gh pr close "$PR" \ | ||
| --delete-branch \ | ||
| --comment "Superseded by #${NEW_PR_NUM:-newer PR}." \ | ||
| || echo "::warning::Failed to close PR #$PR" | ||
| done | ||
| fi | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.