diff --git a/.github/workflows/npm-audit-autofix.yml b/.github/workflows/npm-audit-autofix.yml index 01dc092..788dfe0 100644 --- a/.github/workflows/npm-audit-autofix.yml +++ b/.github/workflows/npm-audit-autofix.yml @@ -1,11 +1,29 @@ -name: npm audit auto-fix (Dependabot) +name: npm audit auto-fix on: workflow_call: inputs: + mode: + description: + "branch = PR against the triggering branch, one per SHA (covers + Dependabot and regular feature branches); scheduled = PR against the + default branch, reusing a single fix branch" + default: "branch" + required: false + type: string head_ref: - description: "branch that triggered the workflow (github.head_ref from the caller)" - required: true + description: + "branch that triggered the workflow (github.head_ref from the + caller) - required for mode=branch, ignored for mode=scheduled" + default: "" + required: false + type: string + base_branch: + description: + "base branch for the fix PR in mode=scheduled (defaults to the + repository default branch)" + default: "" + required: false type: string root_dir: description: "path to project root (same as in security-scan-source)" @@ -46,7 +64,7 @@ on: jobs: audit_fix: - name: npm audit fix → PR on Dependabot branch + name: npm audit fix → PR runs-on: ${{ inputs.runner }} timeout-minutes: 15 permissions: @@ -61,19 +79,35 @@ jobs: working-directory: ${{ inputs.root_dir }} steps: - - name: validate head_ref + - name: validate inputs env: + MODE: ${{ inputs.mode }} HEAD_REF: ${{ inputs.head_ref }} run: | - if [ -z "$HEAD_REF" ]; then - echo "::error::head_ref input is empty – cannot determine Dependabot branch name" - exit 1 - fi + case "$MODE" in + branch) + if [ -z "$HEAD_REF" ]; then + echo "::error::head_ref is required for mode=branch – cannot determine the branch to fix" + exit 3 + fi + ;; + scheduled) + ;; + *) + echo "::error::invalid mode '$MODE' – expected 'branch' or 'scheduled'" + exit 3 + ;; + esac - name: checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ inputs.head_ref }} + # mode=scheduled must fix the base branch, not the ref that + # triggered the run - an empty ref would make checkout fall back to + # the event ref, which differs from the PR base on workflow_dispatch + ref: + ${{ inputs.mode == 'branch' && inputs.head_ref || + inputs.base_branch || github.event.repository.default_branch }} token: ${{ secrets.GITHUB_TOKEN }} fetch-depth: 0 @@ -173,42 +207,65 @@ jobs: console.log(`${result.length} package(s) updated`); EOF - - name: compute branch name + - name: compute branch names id: meta if: steps.changes.outputs.has_changes == 'true' env: + MODE: ${{ inputs.mode }} HEAD_REF: ${{ inputs.head_ref }} + BASE_INPUT: ${{ inputs.base_branch }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} SHA: ${{ github.sha }} run: | - DEP_BRANCH="$HEAD_REF" - SHORT_SHA="$SHA" - SHORT_SHA="${SHORT_SHA:0:7}" - FIX_BRANCH="audit-fix/${DEP_BRANCH#dependabot/}-${SHORT_SHA}" - echo "fix_branch=$FIX_BRANCH" >> "$GITHUB_OUTPUT" - echo "dep_branch=$DEP_BRANCH" >> "$GITHUB_OUTPUT" + if [ "$MODE" = "branch" ]; then + # per-SHA branch: each push to the source branch gets its own fix PR + BASE_BRANCH="$HEAD_REF" + SHORT_SHA="${SHA:0:7}" + SLUG="${HEAD_REF#dependabot/}" + FIX_BRANCH="audit-fix/${SLUG}-${SHORT_SHA}" + else + # stable branch: the scheduled run reuses one PR instead of + # opening a new one on every cron tick + BASE_BRANCH="${BASE_INPUT:-$DEFAULT_BRANCH}" + FIX_BRANCH="audit-fix/scheduled" + fi + + echo "fix_branch=$FIX_BRANCH" >> "$GITHUB_OUTPUT" + echo "base_branch=$BASE_BRANCH" >> "$GITHUB_OUTPUT" - name: commit and push if: steps.changes.outputs.has_changes == 'true' + env: + MODE: ${{ inputs.mode }} + FIX_BRANCH: ${{ steps.meta.outputs.fix_branch }} + BASE_BRANCH: ${{ steps.meta.outputs.base_branch }} run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - git checkout -b "${{ steps.meta.outputs.fix_branch }}" + git checkout -b "$FIX_BRANCH" git add package-lock.json + git commit -m "fix(deps): npm audit fix on $BASE_BRANCH" - git commit -m "fix(deps): npm audit fix on ${{ steps.meta.outputs.dep_branch }}" - git push origin "${{ steps.meta.outputs.fix_branch }}" + if [ "$MODE" = "scheduled" ]; then + # the fix branch is disposable and rebuilt from base on every run, + # so overwriting a stale one is the intended update path + git push --force origin "$FIX_BRANCH" + else + git push origin "$FIX_BRANCH" + fi - name: open pull request id: open_pr if: steps.changes.outputs.has_changes == 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + MODE: ${{ inputs.mode }} + CLEAN: ${{ steps.verify.outputs.clean }} + BASE_BRANCH: ${{ steps.meta.outputs.base_branch }} + FIX_BRANCH: ${{ steps.meta.outputs.fix_branch }} + IS_DEPENDABOT: ${{ github.actor == 'dependabot[bot]' }} run: | - CLEAN="${{ steps.verify.outputs.clean }}" - DEP_BRANCH="${{ steps.meta.outputs.dep_branch }}" - FIX_BRANCH="${{ steps.meta.outputs.fix_branch }}" - if [ "$CLEAN" = "true" ]; then AUDIT_STATUS="✅ \`npm audit\` is **clean** after this fix – CI should pass." else @@ -227,9 +284,34 @@ jobs: }); ") + if [ "$MODE" = "branch" ]; then + PR_TITLE="fix(deps): npm audit fix for ${BASE_BRANCH}" + if [ "$IS_DEPENDABOT" = "true" ]; then + TRIGGER="\`npm audit fix\` was run automatically because \`npm audit\` failed on Dependabot PR \`$BASE_BRANCH\`." + MERGE_NOTE="### Merge order + 1. Merge this PR into \`$BASE_BRANCH\` (CI must be green) + 2. Dependabot merges \`$BASE_BRANCH\` into main as usual" + else + TRIGGER="\`npm audit fix\` was run automatically because \`npm audit\` failed on branch \`$BASE_BRANCH\`." + MERGE_NOTE="### Merge order + 1. Merge this PR into \`$BASE_BRANCH\` (CI must be green) + 2. Continue with your own PR as usual + + > A new fix PR is opened per commit. If you already fixed the finding + > yourself, just close this one." + fi + else + TRIGGER="\`npm audit fix\` was run automatically because the scheduled security scan found vulnerabilities on \`$BASE_BRANCH\`." + MERGE_NOTE="### Merge order + Merge directly into \`$BASE_BRANCH\` once CI is green. + + > This PR is updated in place by later scheduled runs – the branch \`$FIX_BRANCH\` is force-pushed, so local checkouts need a fresh pull." + PR_TITLE="fix(deps): npm audit fix (scheduled security scan)" + fi + PR_BODY="## 🔒 npm audit auto-fix - \`npm audit fix\` was run automatically because \`npm audit\` failed on Dependabot PR \`$DEP_BRANCH\`. + $TRIGGER ### Updated packages @@ -242,29 +324,38 @@ jobs: ### What changed? Only \`package-lock.json\` – no changes to \`package.json\`. - ### Merge order - 1. Merge this PR into \`$DEP_BRANCH\` (CI must be green) - 2. Dependabot merges \`$DEP_BRANCH\` into main as usual" + $MERGE_NOTE" + + EXISTING=$(gh pr list --head "$FIX_BRANCH" --base "$BASE_BRANCH" \ + --state open --json number --jq '.[0].number // empty') - PR_URL=$(gh pr create \ - --base "$DEP_BRANCH" \ - --head "$FIX_BRANCH" \ - --title "fix(deps): npm audit fix for ${DEP_BRANCH}" \ - --body "$PR_BODY") + if [ -n "$EXISTING" ]; then + gh pr edit "$EXISTING" --title "$PR_TITLE" --body "$PR_BODY" + PR_URL=$(gh pr view "$EXISTING" --json url --jq '.url') + echo "♻️ Existing PR updated: $PR_URL" + else + PR_URL=$(gh pr create \ + --base "$BASE_BRANCH" \ + --head "$FIX_BRANCH" \ + --title "$PR_TITLE" \ + --body "$PR_BODY") + echo "✅ PR opened: $PR_URL" + fi echo "pr_url=$PR_URL" >> "$GITHUB_OUTPUT" - echo "✅ PR opened: $PR_URL" - name: job summary if: always() env: - HEAD_REF: ${{ inputs.head_ref }} + MODE: ${{ inputs.mode }} + BASE_BRANCH: ${{ steps.meta.outputs.base_branch }} run: | { echo "## npm audit auto-fix" echo "| | |" echo "|---|---|" - echo "| Dependabot branch | \`$HEAD_REF\` |" + echo "| Mode | \`$MODE\` |" + echo "| Base branch | \`${BASE_BRANCH:-—}\` |" echo "| Changes found | ${{ steps.changes.outputs.has_changes }} |" if [ "${{ steps.changes.outputs.has_changes }}" = "true" ]; then echo "| Audit clean after fix | ${{ steps.verify.outputs.clean }} |" diff --git a/.github/workflows/security-scan-source.yml b/.github/workflows/security-scan-source.yml index 65c7543..b1de3ed 100644 --- a/.github/workflows/security-scan-source.yml +++ b/.github/workflows/security-scan-source.yml @@ -40,6 +40,20 @@ on: default: "moderate" required: false type: string + enable_npm_audit_autofix: + description: + "run npm audit fix and open a PR when npm audit fails (any PR branch + in this repo, plus scheduled/manual runs)" + default: true + required: false + type: boolean + audit_fix_base_branch: + description: + "base branch for the scheduled auto-fix PR (defaults to the + repository default branch)" + default: "" + required: false + type: string outputs: semgrep_result: description: "semgrep scan result" @@ -54,14 +68,20 @@ on: description: "pip-audit result" value: ${{ jobs.scan_source.outputs.pip_audit_result }} npm_audit_fix_branch: - description: "fix branch created for dependabot npm audit auto-fix" - value: ${{ jobs.dependabot-audit-fix.outputs.fix_branch }} + description: "fix branch created by the npm audit auto-fix" + value: + ${{ jobs.branch-audit-fix.outputs.fix_branch || + jobs.scheduled-audit-fix.outputs.fix_branch }} npm_audit_fix_pr: description: "url of the auto-fix PR" - value: ${{ jobs.dependabot-audit-fix.outputs.fix_pr_url }} + value: + ${{ jobs.branch-audit-fix.outputs.fix_pr_url || + jobs.scheduled-audit-fix.outputs.fix_pr_url }} npm_audit_clean_after_fix: description: "whether audit is clean after auto-fix" - value: ${{ jobs.dependabot-audit-fix.outputs.audit_clean_after_fix }} + value: + ${{ jobs.branch-audit-fix.outputs.audit_clean_after_fix || + jobs.scheduled-audit-fix.outputs.audit_clean_after_fix }} jobs: scan_source: @@ -205,18 +225,41 @@ jobs: ${{ inputs.root_dir }}/semgrep.sarif if-no-files-found: ignore - dependabot-audit-fix: + branch-audit-fix: needs: scan_source + # any PR branch in this repo - Dependabot and regular feature branches + # alike. Forks are excluded: their GITHUB_TOKEN is read-only, so the push + # would fail, and pushing into a fork is not something we want anyway. if: | always() + && inputs.enable_npm_audit_autofix && needs.scan_source.outputs.npm_audit_result == 'failure' - && github.actor == 'dependabot[bot]' && inputs.head_ref != '' && !startsWith(inputs.head_ref, 'audit-fix/') + && github.event.pull_request.head.repo.full_name == github.repository uses: ./.github/workflows/npm-audit-autofix.yml with: + mode: "branch" head_ref: ${{ inputs.head_ref }} root_dir: ${{ inputs.root_dir }} node_version: "24.16.0" omit_dev: ${{ inputs.npm_audit_omit_dev }} severity_threshold: ${{ inputs.npm_audit_severity_threshold }} + + scheduled-audit-fix: + needs: scan_source + # scheduled/manual runs have no PR branch: fix against the default branch + # so the resulting PR can be merged directly + if: | + always() + && inputs.enable_npm_audit_autofix + && needs.scan_source.outputs.npm_audit_result == 'failure' + && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') + uses: ./.github/workflows/npm-audit-autofix.yml + with: + mode: "scheduled" + base_branch: ${{ inputs.audit_fix_base_branch }} + root_dir: ${{ inputs.root_dir }} + node_version: "24.16.0" + omit_dev: ${{ inputs.npm_audit_omit_dev }} + severity_threshold: ${{ inputs.npm_audit_severity_threshold }} diff --git a/CLAUDE.md b/CLAUDE.md index 02b0f93..fda589a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -101,6 +101,48 @@ Scans source code and dependencies **before building**: - Uploads SARIF reports to GitHub Security tab - Fails fast to prevent building vulnerable code +##### npm audit auto-fix (`npm-audit-autofix.yml`) +When `npm audit` fails, this workflow runs `npm audit fix` and opens a PR with the +resulting `package-lock.json` changes. It has two modes, both dispatched +automatically from `security-scan-source.yml`: + +| | `branch` | `scheduled` | +|---|---|---| +| Trigger | any PR branch in this repo (Dependabot **and** feature branches) | `schedule` or `workflow_dispatch` | +| PR base | the triggering branch | default branch (or `audit_fix_base_branch`) | +| Fix branch | `audit-fix/-` (one per push) | `audit-fix/scheduled` (reused) | +| Existing PR | new PR each time | updated in place, branch is force-pushed | + +The `branch` mode covers everyday work: push to a feature branch, `npm audit` +fails, and a fix PR is opened against *your* branch. Merge it and carry on — the +fix travels into `main` with your own PR. The PR body adapts to whether the +trigger was Dependabot or a regular branch. + +The `scheduled` mode is the low-intervention path: the daily scan finds a new +advisory, fixes it, and leaves a single mergeable PR against `main`. Because the +fix branch is rebuilt from base on every run, later runs force-push it and edit +the same PR instead of piling up duplicates. + +**Fork PRs are skipped.** A `pull_request` from a fork gets a read-only +`GITHUB_TOKEN`, so the push could not succeed; the job is skipped via a +`head.repo.full_name == github.repository` guard rather than failing. Fixing +those requires `pull_request_target`, which runs untrusted PR code with write +permissions — deliberately not done here. + +Both modes run `npm audit fix` **without** `--force`, so only semver-compatible +updates are applied and `package.json` is never touched. Findings that need a +major upgrade stay open and are called out in the PR body. + +**Configuration:** +```yaml +inputs: + enable_npm_audit_autofix: true # Enable/disable both modes (default: enabled) + audit_fix_base_branch: "" # Base for scheduled PRs (default: repo default branch) +``` + +Calling workflows need `contents: write` and `pull-requests: write` for the +auto-fix job to push the branch and open the PR. + #### Layer 2: Post-Build Artifact Scan (`security-scan-artifacts.yml`) Scans **build artifacts** before publishing: - **Trivy**: Comprehensive filesystem scanner for packages and dependencies