From fe2645de49d14bce5007e65b7573f28996401a47 Mon Sep 17 00:00:00 2001 From: Omry Yadan Date: Fri, 23 Oct 2026 00:12:18 +0800 Subject: [PATCH] Add maintenance App controls and validated follow-up repairs Add shared GitHub App controls, a Cloudflare webhook receiver, and validated follow-up repairs for approved pnpm override patches. Document repository installation and service operation. Normalize direct dependency importer records while preserving unrelated lockfile data. --- .github/workflows/node-repair.yml | 140 ++ .github/workflows/validate.yml | 18 + README.md | 10 +- docs/audit-schedules.md | 2 +- docs/node-coordination.md | 2 +- docs/node-maintenance-operator.md | 102 ++ docs/node-maintenance.md | 161 +- docs/shared-defaults.md | 9 +- node/publish/publish.sh | 12 + node/repair-caller.yml | 42 + node/repair/action.yml | 52 + node/runtime/repair.py | 497 ++++++ node/runtime/repair_choices.py | 101 ++ node/runtime/templates/dependency_audit.py | 108 +- node/tests/test_integration_contract.py | 5 +- node/tests/test_maintenance_options.py | 4 +- node/tests/test_node_standard.py | 48 +- node/tests/test_security_reporting.py | 2 +- node/tests/test_selected_repairs.py | 740 +++++++++ node/tests/verify_node_project.py | 60 + repair-app/.gitignore | 4 + repair-app/package-lock.json | 1590 ++++++++++++++++++++ repair-app/package.json | 11 + repair-app/runtime-smoke.mjs | 62 + repair-app/schema.sql | 13 + repair-app/worker.mjs | 187 +++ repair-app/worker.test.mjs | 119 ++ repair-app/wrangler.jsonc | 14 + 28 files changed, 4024 insertions(+), 91 deletions(-) create mode 100644 .github/workflows/node-repair.yml create mode 100644 docs/node-maintenance-operator.md create mode 100644 node/repair-caller.yml create mode 100644 node/repair/action.yml create mode 100644 node/runtime/repair.py create mode 100644 node/runtime/repair_choices.py create mode 100644 node/tests/test_selected_repairs.py create mode 100644 repair-app/.gitignore create mode 100644 repair-app/package-lock.json create mode 100644 repair-app/package.json create mode 100644 repair-app/runtime-smoke.mjs create mode 100644 repair-app/schema.sql create mode 100644 repair-app/worker.mjs create mode 100644 repair-app/worker.test.mjs create mode 100644 repair-app/wrangler.jsonc diff --git a/.github/workflows/node-repair.yml b/.github/workflows/node-repair.yml new file mode 100644 index 0000000..b77a9d1 --- /dev/null +++ b/.github/workflows/node-repair.yml @@ -0,0 +1,140 @@ +name: Repair selected Node pins +'on': + workflow_call: + inputs: + request-id: + required: true + type: string + pull-request: + required: true + type: string + expected-head: + required: true + type: string + expected-base: + required: true + type: string + selections: + required: true + type: string +permissions: + contents: read +jobs: + guard: + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + pull-requests: read + steps: + - uses: $/node/repair + env: + GH_TOKEN: ${{ github.token }} + with: + operation: guard + request-id: ${{ inputs.request-id }} + pull-request: ${{ inputs.pull-request }} + expected-head: ${{ inputs.expected-head }} + expected-base: ${{ inputs.expected-base }} + selections: ${{ inputs.selections }} + prepare: + needs: guard + runs-on: ubuntu-latest + timeout-minutes: 60 + permissions: + contents: read + env: + npm_config_ignore_pnpmfile: 'true' + pnpm_config_ignore_pnpmfile: 'true' + outputs: + candidate-artifact-id: ${{ steps.candidate.outputs.artifact-id }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ inputs.expected-head }} + persist-credentials: false + fetch-depth: 0 + - uses: $/node/repair + with: + operation: prepare + request-id: ${{ inputs.request-id }} + pull-request: ${{ inputs.pull-request }} + expected-head: ${{ inputs.expected-head }} + expected-base: ${{ inputs.expected-base }} + selections: ${{ inputs.selections }} + - name: Seal candidate before validation + id: candidate + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: node-repair-candidate-${{ inputs.request-id }} + path: ${{ runner.temp }}/node-repair/candidate/ + if-no-files-found: error + validate: + needs: prepare + runs-on: ubuntu-latest + timeout-minutes: 60 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ inputs.expected-head }} + persist-credentials: false + fetch-depth: 0 + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c + with: + artifact-ids: ${{ needs.prepare.outputs.candidate-artifact-id }} + path: ${{ runner.temp }}/node-repair/candidate + merge-multiple: true + - uses: $/node/repair + with: + operation: validate + request-id: ${{ inputs.request-id }} + pull-request: ${{ inputs.pull-request }} + expected-head: ${{ inputs.expected-head }} + expected-base: ${{ inputs.expected-base }} + selections: ${{ inputs.selections }} + - name: Retain repair evidence + if: ${{ always() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: node-repair-${{ inputs.request-id }} + path: ${{ runner.temp }}/node-repair/evidence/ + if-no-files-found: warn + - name: Show repair evidence + if: ${{ always() }} + shell: bash + run: | + for file in "$RUNNER_TEMP/node-repair/evidence/"*.md; do + if [ -f "$file" ]; then cat "$file" >> "$GITHUB_STEP_SUMMARY"; fi + done + publish: + needs: [prepare, validate] + runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: node-dependency-proposal-${{ github.repository }} + cancel-in-progress: false + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ inputs.expected-head }} + fetch-depth: 0 + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c + with: + artifact-ids: ${{ needs.prepare.outputs.candidate-artifact-id }} + path: ${{ runner.temp }}/node-repair/candidate + merge-multiple: true + - uses: $/node/repair + env: + GH_TOKEN: ${{ github.token }} + with: + operation: publish + request-id: ${{ inputs.request-id }} + pull-request: ${{ inputs.pull-request }} + expected-head: ${{ inputs.expected-head }} + expected-base: ${{ inputs.expected-base }} + selections: ${{ inputs.selections }} diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index aedc078..aed6bc1 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -7,6 +7,24 @@ name: Validate shared GitHub Actions permissions: contents: read jobs: + repair-app: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 + with: + node-version: '24' + check-latest: true + - run: npm ci --ignore-scripts + working-directory: repair-app + - run: npm test + working-directory: repair-app + - run: npm run test:runtime + working-directory: repair-app + - run: npm run check + working-directory: repair-app test: runs-on: ubuntu-latest steps: diff --git a/README.md b/README.md index efeae7f..78df458 100644 --- a/README.md +++ b/README.md @@ -25,12 +25,17 @@ checks. With the released two-job caller, all audit kinds maintain reruns update that PR rather than opening one PR per dependency. Reports show proposed versions, validation, and blocked findings. -For separate security and routine-upgrade PRs, see the opt-in -[coordinated caller](docs/node-coordination.md). It validates dependency-file +The [App-based setup](docs/node-maintenance.md#set-it-up) includes App installation +and coordinated workflows for separate security and routine-upgrade PRs. The +[coordinated caller](docs/node-coordination.md) validates dependency-file pushes and refreshes existing proposals after either PR merges. This new caller requires a workflow revision containing that interface; existing callers keep their combined-PR behavior. +The shared maintenance App supplies GitHub controls. Maintainer-selected +follow-up repairs are one use of that integration; tests and changes run in the +repository's Actions. See [reviewing findings and requesting repairs](docs/node-maintenance.md#review-findings-and-request-follow-up-repairs). + | Maintenance setup | Automatic security checks | Automatic routine upgrades | | --- | --- | --- | | Basic setup in the guide | Weekly | Disabled; run upgrades manually | @@ -64,6 +69,7 @@ repositories at a time in one runner, with one combined report and issue. - [Fleet setup checks: inventory, caller, results, and limits](docs/node-fleet-setup.md) - [Maintenance schedules and manual audits](docs/audit-schedules.md) - [Separate proposal channels and automatic refresh](docs/node-coordination.md) +- [Maintenance App operators: host the App and Cloudflare service](docs/node-maintenance-operator.md) - [Shared defaults for multiple installations](docs/shared-defaults.md) - [Maintenance behavior, reports, and permissions](node/maintenance.md) - [Migrate an earlier Docusaurus installation](docs/node-migration.md) diff --git a/docs/audit-schedules.md b/docs/audit-schedules.md index 591e32b..76ba2e7 100644 --- a/docs/audit-schedules.md +++ b/docs/audit-schedules.md @@ -40,7 +40,7 @@ routine upgrades at 02:00 UTC on the first day of January, April, July, and October. Both kinds obey the release cooldown and project constraints. Routine upgrades also obey `maintenance.upgradeScope`. -In the [setup guide's caller](node-maintenance.md#3-add-the-caller-workflow), use: +In the [setup guide's caller](node-maintenance.md#4-add-the-workflow-files), use: ```yaml 'on': diff --git a/docs/node-coordination.md b/docs/node-coordination.md index a3d87dc..7dfa4a7 100644 --- a/docs/node-coordination.md +++ b/docs/node-coordination.md @@ -38,7 +38,7 @@ Do not point this caller at an older revision that lacks the workflow. For manual-only routine upgrades, omit the quarterly entry. For no scheduled work, remove `schedule`. Keep the manual input and push trigger in all cases. -4. Keep the existing PR-creation permission or optional App configuration. +4. Keep the existing PR-creation permission or optional publisher App configuration. Replace the previous caller rather than installing a second scheduled file. Keep the `dependency-audit` concurrency group with `cancel-in-progress: false` and `queue: max` so a new request does not cancel a pending push check. diff --git a/docs/node-maintenance-operator.md b/docs/node-maintenance-operator.md new file mode 100644 index 0000000..72d6065 --- /dev/null +++ b/docs/node-maintenance-operator.md @@ -0,0 +1,102 @@ +# Operate the shared maintenance App + +This guide is for the maintainer who hosts the shared GitHub App and Cloudflare +service. Repository users follow [the maintenance setup guide](node-maintenance.md); they +do not need to deploy this service or manage its credentials. + +## Deploy the service + +One public GitHub App and one Cloudflare Worker serve every installation. Only +the operator needs a Cloudflare account and App credentials. The Worker performs +authentication, consent checks, check updates, and workflow dispatch. It does +not install dependencies or run repository scripts. + +1. Register a public GitHub App. Set its webhook URL to your Worker's + `https:///webhook`. Generate a strong webhook secret and App + private key. Request repository permissions: **Contents: read**, **Pull + requests: read**, **Checks: write**, and **Actions: write**. Metadata read + is implicit. Subscribe to **Pull request**, **Check run**, and **Workflow run** + events. No user authorization callback or external selection page is needed. +2. Run `npm ci --ignore-scripts` in `repair-app`. Use a supported Node 24 runtime. + Authenticate Wrangler with your Cloudflare account. +3. Create one D1 database with `npx wrangler d1 create node-dependency-repair`. + Put its returned ID in [wrangler.jsonc](../repair-app/wrangler.jsonc), along + with the GitHub App's numeric ID and slug. Do not put secrets in that file. +4. Apply the schema with `npx wrangler d1 execute node-dependency-repair + --remote --file schema.sql` from `repair-app`. +5. Convert GitHub's downloaded RSA key to unencrypted PKCS#8 for Web Crypto: + `openssl pkcs8 -topk8 -nocrypt -in github-app.pem -out worker-app.pem`. + Store it with `npx wrangler secret put APP_PRIVATE_KEY < worker-app.pem`. + Store the matching webhook secret with `npx wrangler secret put WEBHOOK_SECRET`. + Delete temporary key files according to your credential handling policy. +6. Run `npm test`, `npm run test:runtime`, and `npm run check`. The latter + bundles without deploying. + Deploy deliberately with `npx wrangler deploy`, then test GitHub's webhook + ping and one selected repository before inviting other installations. + +## Prepare the user installation instructions + +Keep the App's installation URL in your installation instructions. It depends +on the registered App's slug; this source repository does not yet name or host +a deployed shared service. Use a custom domain for a maintained production +service if appropriate. `workers.dev` is sufficient to try the integration. + +Before publishing the user guide as generally available, supply the App +installation URL and a released workflow reference that includes the repair +workflow and report renderer. For a pilot, supply the reviewed reference +explicitly. Update the maintenance setup guide's availability notice when these are ready. + +## Permissions and request handling + +The Worker verifies each webhook's HMAC signature, fetches the current PR from +GitHub, checks write access for the person clicking, and binds consent to the +PR head, base, and exact selected versions. The runner independently checks the +same state. A read-only preparation job uploads the candidate before dependency +or project validation scripts run. Validation uses a separate runner without +write credentials. The publisher downloads the original candidate by the +preparation job's artifact ID. That sealed artifact also contains the audit +report. The publisher does not download validation-runner output; it links to +the Actions logs and reports the successful validation job. It checks the +candidate paths and scope without executing project scripts. + +Candidate resolution disables lifecycle scripts and pnpmfile hooks. Repair +commits carry a trailer that normal audit publication checks before replacing +the branch. This preserves a pushed repair even if refreshing its PR report fails. + +GitHub grants **Actions: write** per selected repository, not per workflow. +The service restricts itself to `dependency-repair.yml`, but that restriction is +in this code. The App has no contents-write permission and cannot push repair +commits itself; the repository's publisher job owns that permission. + +## Stored data and operations + +D1 stores request IDs, repository names, PR numbers, revisions, check IDs, and +run status for 90 days. It prevents duplicate webhook delivery from starting +the same request twice. A daily cleanup removes old records. Treat these records +as private installation data. Do not enable request-body or token logging. + +Monitor delivery failures, failed Actions runs, Worker errors, D1 storage and +request limits, and App credential expiry or rotation. A failure after recording +a request but before confirmed dispatch needs inspection; the service favors +avoiding duplicate execution over automatically retrying an ambiguous POST. + +Use the App's webhook delivery logs to investigate failures. Redelivering a +recorded request does not dispatch it again. Inspect the repository's Actions +runs before arranging a new attempt when dispatch could not be confirmed. + +## Cost and capacity + +Workers Free and Paid both support D1. Account quotas are shared by installations; +repair jobs consume the repository's normal Actions allocation. Workers Paid +starts at US$5 per account per month; D1 and other usage can add charges beyond +included allowances. Measure actual Worker CPU and database use during the pilot +before choosing the plan. Creating additional Workers does not multiply a free +account's quota. + +References: [GitHub App installation](https://docs.github.com/en/apps/using-github-apps/installing-a-github-app-from-a-third-party), +[Checks API](https://docs.github.com/en/rest/checks/runs), +[workflow dispatch](https://docs.github.com/en/rest/actions/workflows#create-a-workflow-dispatch-event), +[webhook validation](https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries), +[Cloudflare pricing](https://developers.cloudflare.com/workers/platform/pricing/), +[D1 pricing](https://developers.cloudflare.com/d1/platform/pricing/), +and [Worker secrets](https://developers.cloudflare.com/workers/configuration/secrets/). diff --git a/docs/node-maintenance.md b/docs/node-maintenance.md index 34b1632..bd5d4f2 100644 --- a/docs/node-maintenance.md +++ b/docs/node-maintenance.md @@ -2,7 +2,7 @@ Use these workflows for a standalone Node application, library, or website. They attempt compatible security fixes, optionally propose routine upgrades, -and validate the result before opening or updating one maintenance PR. +and validate the result before opening or updating a proposal for that channel. This guide installs maintenance in one repository. To monitor setup across several installations, use the separate [fleet setup checker](node-fleet-setup.md). @@ -10,9 +10,14 @@ installations, use the separate [fleet setup checker](node-fleet-setup.md). Already using the earlier Docusaurus workflows? Follow the [migration checklist](node-migration.md) instead. -For separate security and routine-upgrade proposals with dependency-file-triggered -validation and refresh, use the [coordinated caller](node-coordination.md). -The released two-job caller below retains one combined maintenance PR. +**App integration availability: preview.** The installation link and released +workflow reference are pending. For the pilot setup below, obtain the App +installation link and reviewed workflow reference from the service operator. +Existing installations keep their current behavior until they adopt this setup. + +The setup uses [coordinated maintenance](node-coordination.md) for separate +security and routine-upgrade proposals. App installation is part of the overall +maintenance setup; it is not specific to an individual repair. ## Supported projects @@ -183,23 +188,35 @@ To inherit policy for several installations, use the configuration in the [shared-defaults guide](shared-defaults.md#2-point-each-installation-at-the-source) instead. Do not keep explicit local fields for policy you want to inherit. The recommended shared file selects weekly security **and quarterly upgrades**, -so its caller needs both cron entries in step 3. +so its caller needs both cron entries in step 4. + +### 3. Install the maintenance App + +Install the shared GitHub App on your organization or personal account using the +operator's installation link. Choose **Only select repositories**, then select +this repository. One installation can serve several selected repositories in +that account. Each other organization or personal account installs it separately; +an organization may require owner approval. + +The App supplies the maintenance controls in GitHub. It does not install workflow +files. Repository maintainers do not need a Cloudflare account, server, App +private key, or callback secret. The service operator handles hosting and +credentials; see the separate [operator guide](node-maintenance-operator.md). -### 3. Add the caller workflow +### 4. Add the workflow files -Create `.github/workflows/dependency-audit.yml` using the following caller. -Both workflow calls use `@v1`, a moving release tag. Compatible fixes reach your -installation without a local workflow edit. Breaking changes use a new major -release and require an explicit update. +Use the same reviewed workflow revision for both files below. Replace +`REVIEWED_REVISION` with the reference supplied for the pilot; the selected +revision must contain the App integration and repair workflow. Use a released +reference once this integration is published. See [workflow releases and +immutable pins](workflow-releases.md) for the release policy. -To review every workflow update yourself, replace **both** `v1` references with -the same reviewed full 40-character commit SHA. That revision must declare Node -integration version **6** in `node/contract.json`; use its caller template. -See [workflow releases and immutable pins](workflow-releases.md) for the release -policy and tradeoff. +Create `.github/workflows/dependency-audit.yml` from the +[coordinated caller](../node/coordinated-caller.yml). For the basic configuration +in step 2, with `main` as the default branch, it is: ```yaml -name: Node dependency audit +name: Node dependency maintenance 'on': schedule: @@ -213,6 +230,15 @@ name: Node dependency audit options: - security - upgrades + push: + branches: + - main + paths: + - '**/pnpm-lock.yaml' + - '**/package-lock.json' + - '.github/node-maintenance.json' + - '**/.nvmrc' + - '**/.node-version' permissions: contents: read @@ -220,60 +246,68 @@ permissions: concurrency: group: dependency-audit cancel-in-progress: false + queue: max jobs: - audit: - permissions: - contents: read - issues: write - uses: hydra-ecosystem/github-actions/.github/workflows/node-audit.yml@v1 - with: - audit-kind: ${{ inputs.audit-kind || 'auto' }} - - pull-request: - needs: audit + maintenance: permissions: contents: write pull-requests: write - uses: hydra-ecosystem/github-actions/.github/workflows/node-publish.yml@v1 + issues: write + uses: hydra-ecosystem/github-actions/.github/workflows/node-maintenance.yml@REVIEWED_REVISION with: - validation-passed: ${{ needs.audit.outputs.validation_passed }} + audit-kind: ${{ inputs.audit-kind || 'auto' }} app-client-id: ${{ vars.NODE_AUDIT_APP_CLIENT_ID }} secrets: app-private-key: ${{ secrets.NODE_AUDIT_APP_PRIVATE_KEY }} ``` -This caller matches the basic configuration in step 2. If you chose the -recommended shared defaults, add `- cron: '0 2 1 */3 *'` under `on.schedule`. -For other policies, use the unique cron entries from the resolved security and -upgrades lists. Omit `on.schedule` when both lists are empty. See +Replace `main` under `push.branches` if your default branch has another name. +If adopting this caller from an earlier combined installation, first review and +merge or close the old `maintenance/dependency-audit` PR. Replace the previous +audit caller rather than adding a second scheduled file. + +If you chose the recommended shared defaults, add `- cron: '0 2 1 */3 *'` under +`on.schedule`. For other policies, use the unique cron entries from the resolved +security and upgrades lists. Omit `on.schedule` when both lists are empty. See [schedules and manual runs](audit-schedules.md) for complete examples. -The dependency job is read-only. A separate reporter can update one configuration -issue; the publisher can write the maintenance branch and PR. Leave the optional -App variable and secret unset to use `GITHUB_TOKEN`. +Also copy [node/repair-caller.yml](../node/repair-caller.yml) to +`.github/workflows/dependency-repair.yml`. Keep this filename so the App can find +it. Replace `__CENTRAL_REPOSITORY__` with `hydra-ecosystem/github-actions` and +`__CENTRAL_REVISION__` with the same reviewed reference as the audit caller. +This workflow runs only when dispatched; installing it does not apply repairs. + +Dependency validation runs with read-only permissions. A separate publisher can +update the maintenance branch and PR, and configuration reporting can update one +issue. The `NODE_AUDIT_APP_CLIENT_ID` and `NODE_AUDIT_APP_PRIVATE_KEY` values in +the audit caller are for a separate, optional publisher App described below. +Leave them unset to publish with `GITHUB_TOKEN`. Installing the shared maintenance +App does not require putting its private key in repository secrets. -### 4. Enable publication and run the first audit +### 5. Enable publication and run the first audit In **Settings → Actions → General → Workflow permissions**, enable **Allow GitHub Actions to create and approve pull requests**, or configure the optional -GitHub App below. Default token permissions can stay read-only. Enable repository -issues for configuration diagnostics. Organization restrictions still apply. +publisher App below. The shared maintenance App installed in step 3 does not +replace this repository publication permission. Default token permissions can +stay read-only. Enable repository issues for configuration diagnostics. +Organization restrictions still apply. Merge the setup into your repository's default branch. Open **Actions → Node -dependency audit → Run workflow**, select the default branch, and choose +dependency maintenance → Run workflow**, select the default branch, and choose **security**. Manual dispatch from other branches is rejected. -Check the preflight logs or `node-audit-configuration` artifact for resolved +Check the preflight logs or configuration report artifact for resolved tool requirements and defaults sources. Read the run summary for frozen installation, validation, and the audit result: | Result | What happens | | --- | --- | -| Validated dependency changes | Opens or updates the single maintenance PR. | +| Validated dependency changes | Opens or updates the selected security or upgrades PR. | | Dependency changes with failed installation or checks | Retains a draft proposal when possible and fails the workflow. | | Incomplete or unavailable security audit | Fails the run and skips publication, even if project checks pass; retains the diagnostic summary and report artifact. | -| No dependency changes | Creates no PR and leaves any existing proposal untouched; read the current run summary or report artifact. | +| No dependency changes | Creates no new PR. A completed refresh can close an existing proposal when no changes remain; read the current run summary or report artifact. | | Invalid configuration | Stops before dependency changes and updates one reusable configuration issue. Successful preflight adds a verification comment and closes it, preserving the last failure description. | Choose **upgrades** for a separate manual run when you want routine updates. @@ -281,6 +315,45 @@ It also checks security and still obeys the cooldown and upgrade scope. It does not enable automatic upgrades. Nothing merges automatically. See [reports and PR behavior](../node/maintenance.md#reports-and-prs) for details. +## Review findings and request follow-up repairs + +The security PR shows proposed fixes and remaining findings. Yellow **Needs +review** means a maintainer decision is needed; red **Blocked** means the checked +constraints do not permit a fix. Open a package's folded assessment for its +evidence. **Fix proposed** describes a change in the PR, subject to your review. + +For an eligible finding, select the exact changes you allow in **Choose fixes to +try** in the PR description. Open **Checks → Dependency repair**, confirm the +selected versions, and click **Run selected fixes**. Checkboxes alone do not +start a run. A stale selection refreshes the check before another confirmation. +Merging remains a separate decision. + +The first supported follow-up repairs are exact, unqualified pnpm override pins +with same-minor patch upgrades on the security PR. npm overrides, ranges, +qualified selectors, major or minor changes, forks, and issues are not supported. +The initial assessment identifies a compatible published patch; it explicitly +states when repair validation has not run. Historical pin rationale is not +collected automatically. + +A repair rechecks publication, parent and manifest constraints, release age, +frozen installation, configured project and local patch checks, and the audit. +Every selected fix must be confirmed and no new findings may appear. A successful +batch adds one commit to the same PR and refreshes its report. Other findings can +remain, and passing checks do not establish compatibility on untested platforms. + +Failed validation leaves the PR files unchanged. Read the linked Actions run and +its retained evidence, resolve the cause or change the selection, then request +another attempt explicitly. A changed PR head, base, open state, or selection +stops publication. A publication or report-refresh failure can occur after the +commit was pushed, so inspect the PR before retrying. If dispatch could not be +confirmed, inspect Actions and contact the operator before requesting another run. + +Normal audits retain a selected repair batch while its security PR awaits review. +Merge or close that PR before requesting a replacement proposal. If its base +changes first, reconcile the PR explicitly. Repair publication uses `GITHUB_TOKEN` +and does not automatically start ordinary push or PR workflows; run any additional +checks required by your merge rules before merging. + ## Toolchain selection The basic setup follows project declarations. Explicit or inherited constraints @@ -311,9 +384,9 @@ See the [tested versions](../node/runtime/toolchain.json). The [maintenance policy](../node/maintenance.md) explains security-fix evidence, package-manager cooldown behavior, proposal rollback, and permissions. -## Optional: publish with a GitHub App +## Optional: use a separate App for PR publication -An App can publish without enabling the repository-wide built-in-token PR setting. +A separate publisher App can publish without enabling the repository-wide built-in-token PR setting. Create an App with repository **Contents: read/write** and **Pull requests: read/write**, then install it on each repository you maintain. Set these in each repository's Actions configuration: diff --git a/docs/shared-defaults.md b/docs/shared-defaults.md index 84fd9d6..71a07ba 100644 --- a/docs/shared-defaults.md +++ b/docs/shared-defaults.md @@ -70,7 +70,7 @@ Keep explicit local values only when you intend to override shared policy. ## 3. Match the caller and run an audit -Continue with [step 3 of the setup guide](node-maintenance.md#3-add-the-caller-workflow). +Continue with [App installation and workflow setup](node-maintenance.md#3-install-the-maintenance-app). For the recommended defaults, the caller must contain both cron entries: ```yaml @@ -79,9 +79,10 @@ schedule: - cron: '0 2 1 */3 *' ``` -Place this under `on` in the caller. Keep both reusable workflow calls at `@v1`, or use the same reviewed full -version-6 SHA for immutable pinning. Enable repository issues and publication as -shown in [step 4](node-maintenance.md#4-enable-publication-and-run-the-first-audit), +Place this under `on` in the caller. Use the same reviewed integration revision +for the maintenance and follow-up workflow files, as described in that guide. +Enable repository issues and publication as +shown in [step 5](node-maintenance.md#5-enable-publication-and-run-the-first-audit), then run a manual security audit. Preflight checks the resolved tools, cooldown, and schedules before dependency changes. diff --git a/node/publish/publish.sh b/node/publish/publish.sh index fcc6ad5..a5df9db 100644 --- a/node/publish/publish.sh +++ b/node/publish/publish.sh @@ -109,6 +109,18 @@ if [ "$REFRESH_ONLY" = true ] && { [ -z "${EXPECTED_PR:-}" ] || [ "$pr_number" ! echo 'The original proposal is no longer open; refresh will not create or replace a PR.' exit 0 fi +if [ "$PROPOSAL_KIND" = security ] && [ -n "$pr_number" ]; then + git fetch --no-tags origin "$PR_BRANCH:refs/remotes/origin/$PR_BRANCH" + expected_head=$(git rev-parse --verify "refs/remotes/origin/$PR_BRANCH") + repair_state=$(git log --format='%(trailers:key=Node-Repair,valueonly)' "$AUDIT_SHA..$expected_head") + case "$repair_state" in + *validated-selected-pins-v1*) + echo '::notice::Selected pin repairs await review on the existing security PR; automatic publication will retain that batch. Merge or close that PR before replacing it with a fresh proposal.' + validation_status + exit 0 + ;; + esac +fi git checkout -B "$PR_BRANCH" git add -- "$NPM_PROJECT/package.json" for file in package-lock.json pnpm-lock.yaml pnpm-workspace.yaml; do diff --git a/node/repair-caller.yml b/node/repair-caller.yml new file mode 100644 index 0000000..493c295 --- /dev/null +++ b/node/repair-caller.yml @@ -0,0 +1,42 @@ +name: Node dependency repair +run-name: 'Node repair #${{ inputs.pull-request }} · ${{ inputs.request-id }}' +'on': + workflow_dispatch: + inputs: + request-id: + description: Request identifier supplied by the repair App. + required: true + type: string + pull-request: + description: Managed security PR number. + required: true + type: string + expected-head: + description: Approved PR head SHA. + required: true + type: string + expected-base: + description: Default branch SHA at approval. + required: true + type: string + selections: + description: Exact package and version selections supplied by the App. + required: true + type: string +permissions: + contents: read +concurrency: + group: node-repair-${{ inputs.pull-request }} + cancel-in-progress: false +jobs: + repair: + permissions: + contents: write + pull-requests: write + uses: __CENTRAL_REPOSITORY__/.github/workflows/node-repair.yml@__CENTRAL_REVISION__ + with: + request-id: ${{ inputs.request-id }} + pull-request: ${{ inputs.pull-request }} + expected-head: ${{ inputs.expected-head }} + expected-base: ${{ inputs.expected-base }} + selections: ${{ inputs.selections }} diff --git a/node/repair/action.yml b/node/repair/action.yml new file mode 100644 index 0000000..dcc1e9a --- /dev/null +++ b/node/repair/action.yml @@ -0,0 +1,52 @@ +name: Selected Node pin repair +description: Guard, prepare, validate, or publish one explicitly selected batch. +inputs: + operation: + description: guard, prepare, validate, or publish. + required: true + request-id: + required: true + description: Webhook delivery identifier. + pull-request: + required: true + description: Managed security PR number. + expected-head: + required: true + description: Approved PR head SHA. + expected-base: + required: true + description: Default branch SHA at approval. + selections: + required: true + description: Exact package, previous version, and target version triples. +runs: + using: composite + steps: + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 + with: + python-version: '3.13' + - shell: bash + run: python -m pip install PyYAML==6.0.3 + - name: Resolve project toolchain + if: ${{ inputs.operation == 'prepare' || inputs.operation == 'validate' }} + shell: bash + run: python "$GITHUB_ACTION_PATH/../runtime/project.py" --github-env "$GITHUB_ENV" --resolved-config "$RUNNER_TEMP/node-repair-config.json" --configuration-report "$RUNNER_TEMP/node-repair-configuration.json" + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 + if: ${{ inputs.operation == 'prepare' || inputs.operation == 'validate' }} + with: + node-version: ${{ env.NODE_AUDIT_NODE_VERSION }} + check-latest: true + - name: Enable pinned pnpm + if: ${{ inputs.operation == 'prepare' || inputs.operation == 'validate' }} + shell: bash + run: corepack enable + - name: Execute selected operation + shell: bash + env: + OPERATION: ${{ inputs.operation }} + REPAIR_REQUEST: ${{ inputs.request-id }} + REPAIR_PR: ${{ inputs.pull-request }} + REPAIR_HEAD: ${{ inputs.expected-head }} + REPAIR_BASE: ${{ inputs.expected-base }} + REPAIR_SELECTIONS: ${{ inputs.selections }} + run: python "$GITHUB_ACTION_PATH/../runtime/repair.py" "$OPERATION" --artifact "$RUNNER_TEMP/node-repair" diff --git a/node/runtime/repair.py b/node/runtime/repair.py new file mode 100644 index 0000000..f097587 --- /dev/null +++ b/node/runtime/repair.py @@ -0,0 +1,497 @@ +"""Validate consent, prepare a bounded pnpm pin repair, and publish atomically.""" + +import argparse +import copy +import json +import os +import re +import runpy +import subprocess +import time +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import yaml + +ROOT = Path(__file__).parent +CHOICES = runpy.run_path(str(ROOT / "repair_choices.py")) +ENGINE = runpy.run_path(str(ROOT / "templates/dependency_audit.py")) +PROJECT = runpy.run_path(str(ROOT / "project.py")) +CANDIDATE_INSTALL = ( + "install", + "--lockfile-only", + "--ignore-scripts", + "--ignore-pnpmfile", +) + + +def git(*args): + return subprocess.check_output(["git", *args], text=True).strip() + + +def gh(path): + return json.loads(subprocess.check_output(["gh", "api", path], text=True)) + + +def request(): + number = os.environ["REPAIR_PR"] + if not re.fullmatch(r"[1-9][0-9]*", number): + raise ValueError("Invalid PR number") + for key in ("REPAIR_HEAD", "REPAIR_BASE"): + if not re.fullmatch(r"[a-f0-9]{40}", os.environ[key]): + raise ValueError("Invalid expected revision") + if not re.fullmatch(r"[a-f0-9-]{36}", os.environ["REPAIR_REQUEST"]): + raise ValueError("Invalid request identifier") + return CHOICES["validate"](json.loads(os.environ["REPAIR_SELECTIONS"])) + + +def guard(items): + repo = os.environ["GITHUB_REPOSITORY"] + repository = gh(f"repos/{repo}") + pr = gh(f"repos/{repo}/pulls/{os.environ['REPAIR_PR']}") + if ( + pr["state"] != "open" + or pr["head"]["repo"]["full_name"] != repo + or pr["base"]["repo"]["full_name"] != repo + or pr["head"]["ref"] != "maintenance/node-security" + or pr["base"]["ref"] != repository["default_branch"] + or pr["user"]["type"] != "Bot" + or pr["head"]["sha"] != os.environ["REPAIR_HEAD"] + or pr["base"]["sha"] != os.environ["REPAIR_BASE"] + or CHOICES["parse"](pr["body"] or "") != items + ): + raise ValueError("PR state or selections changed; request a fresh repair") + return pr + + +def update_policy(text, items): + """Change scalar values only, retaining the existing comments and formatting.""" + tree = yaml.compose(text) + if not isinstance(tree, yaml.MappingNode): + raise ValueError("Invalid pnpm policy") + overrides = [value for key, value in tree.value if key.value == "overrides"] + if len(overrides) != 1 or not isinstance(overrides[0], yaml.MappingNode): + raise ValueError("Exactly one plain overrides mapping is required") + edits = [] + for item in items: + values = [v for k, v in overrides[0].value if k.value == item["name"]] + if ( + len(values) != 1 + or not isinstance(values[0], yaml.ScalarNode) + or values[0].tag != "tag:yaml.org,2002:str" + or values[0].value != item["from"] + ): + raise ValueError(f"The exact pin for {item['name']} changed") + value = values[0] + edits.append( + (value.start_mark.index, value.end_mark.index, json.dumps(item["to"])) + ) + for start, end, replacement in sorted(edits, reverse=True): + text = text[:start] + replacement + text[end:] + return text + + +def check_scope(before, after, items): + if before is None or after is None or before[0] != after[0]: + raise ValueError("The repair changed package.json or invalidated package files") + old = yaml.safe_load(before[1]["pnpm-workspace.yaml"]) + new = yaml.safe_load(after[1]["pnpm-workspace.yaml"]) + expected = copy.deepcopy(old) + for item in items: + if old.get("overrides", {}).get(item["name"]) != item["from"]: + raise ValueError("The approved pin changed") + expected["overrides"][item["name"]] = item["to"] + if expected != new: + raise ValueError("The repair changed unapproved pnpm policy") + old_versions, new_versions = ( + ENGINE["locked_version_sets"](snapshot[3]) for snapshot in (before, after) + ) + selected = {item["name"] for item in items} + for name in old_versions.keys() | new_versions.keys(): + if name not in selected and old_versions.get(name) != new_versions.get(name): + raise ValueError(f"The repair changed an unselected package: {name}") + for item in items: + if new_versions.get(item["name"]) != {item["to"]}: + raise ValueError("The approved target was not installed everywhere") + selected_versions = {item["name"]: {item["from"], item["to"]} for item in items} + + def selected_version(name, value): + if name in selected_versions and isinstance(value, str): + version, separator, peers = value.partition("(") + if version in selected_versions[name]: + return "" + separator + peers + return value + + def unrelated(value): + if isinstance(value, dict): + return { + key: unrelated(selected_version(key, child)) + for key, child in value.items() + if not any( + key.split("(", 1)[0] == f"{name}@{version}" + for name, versions in selected_versions.items() + for version in versions + ) + } + if isinstance(value, list): + return [unrelated(child) for child in value] + return value + + def lock_scope(snapshot): + lock = yaml.safe_load(snapshot[1]["pnpm-lock.yaml"]) + for importer in lock.get("importers", {}).values(): + for kind in ("dependencies", "devDependencies", "optionalDependencies"): + for name, dependency in importer.get(kind, {}).items(): + if name in selected_versions and isinstance(dependency, dict): + for field in ("specifier", "version"): + if field in dependency: + dependency[field] = selected_version( + name, dependency[field] + ) + return unrelated(lock) + + if lock_scope(before) != lock_scope(after): + raise ValueError( + "The repair changed unrelated lockfile metadata or dependency edges" + ) + + +def verified_targets(directory, before, snapshot, items, deadline, age): + """Ignore only the selected pins when checking the original parent ranges.""" + proof = copy.deepcopy(before) + if not isinstance(proof, dict) or proof.get("package_manager") != "pnpm": + raise ValueError("A complete pnpm audit is required") + files = dict(snapshot[1]) + policy = yaml.safe_load(files["pnpm-workspace.yaml"]) + for item in items: + if policy.get("overrides", {}).get(item["name"]) != item["from"]: + raise ValueError("Approved exact pin is missing") + del policy["overrides"][item["name"]] + files["pnpm-workspace.yaml"] = yaml.safe_dump(policy).encode() + notes = [] + ENGINE["describe_pnpm_fixes"]( + directory, proof, files, notes, deadline, {}, collect_proof=True + ) + cutoff = datetime.now(timezone.utc) - timedelta(minutes=age) + for item in items: + finding = proof["vulnerabilities"].get(item["name"], {}) + details = finding.get("via", []) + if not details or any( + not isinstance(detail, dict) + or not detail.get("fixVersion") + or item["to"] not in detail.get("_verifiedPatchedVersions", []) + or item["from"] not in detail.get("_verifiedVulnerableVersions", []) + for detail in details + ): + raise ValueError( + f"Published patch compatibility is unverified for {item['name']}" + ) + # fixVersion is the lowest common compatible patch for each advisory. + # Query the requested target explicitly as well; later patches may have + # narrower parent ranges than the minimum compatible version. + for detail in details: + if detail["fixVersion"] != item["to"]: + raise ValueError( + "Target differs from the verified compatible patch; rerun the audit" + ) + for field in ("dependencies", "devDependencies", "optionalDependencies"): + constraint = snapshot[2].get(field, {}).get(item["name"]) + if constraint is None: + continue + permitted = False + if isinstance(constraint, str): + for alternative in constraint.split("||"): + check = ENGINE["run"]( + directory, + "view", + f"{item['name']}@{alternative.strip()} {item['to']}", + "version", + "--json", + deadline=deadline, + ) + try: + values = json.loads(check.stdout) + except ValueError: + values = None + if check.returncode == 0 and ( + values == item["to"] + or isinstance(values, list) + and item["to"] in values + ): + permitted = True + break + if not permitted: + raise ValueError( + "The direct manifest constraint does not permit the selected patch, or compatibility could not be verified" + ) + result = ENGINE["run"]( + directory, "view", item["name"], "time", "--json", deadline=deadline + ) + if result.returncode: + raise ValueError("Release publication time is unavailable") + published = json.loads(result.stdout).get(item["to"]) + if ( + not isinstance(published, str) + or datetime.fromisoformat(published.replace("Z", "+00:00")) > cutoff + ): + raise ValueError("The selected patch has not passed the release cooldown") + if ENGINE["valid_snapshot"](directory) != snapshot: + raise ValueError("Metadata lookup changed dependency files") + return proof + + +def export_patch(paths): + """Export validated files without executing repository-configured diff helpers.""" + return subprocess.check_output( + [ + "git", + "diff", + "--no-ext-diff", + "--no-textconv", + "--binary", + "HEAD", + "--", + *paths, + ] + ) + + +def project_context(): + root = Path.cwd().resolve() + config_path = ( + Path(os.environ.get("RUNNER_TEMP", "/tmp")) / "node-repair-config.json" + ) + directory, selected = PROJECT["resolve_for_run"](root, config_path, []) + PROJECT["verify_runtime"](directory, selected) + if selected["manager"] != "pnpm": + raise ValueError( + "Repair attempts currently support exact pnpm override pins only" + ) + snapshot = ENGINE["valid_snapshot"](directory) + if snapshot is None or any( + (directory / name).is_symlink() + for name in ("package.json", "pnpm-workspace.yaml", "pnpm-lock.yaml") + ): + raise ValueError("Invalid project snapshot") + return directory, selected, snapshot, config_path + + +def audit_candidate(directory, snapshot, candidate, inputs, items, deadline): + after = ENGINE["audit"](directory, deadline=deadline) + if ENGINE["valid_snapshot"](directory) != candidate or not isinstance(after, dict): + raise ValueError("Final audit is unavailable or changed dependency files") + fixed = ENGINE["confirmed_fixes"]( + inputs["proof"], after, "pnpm", snapshot, candidate + ) + required = { + detail["url"] + for item in items + for detail in inputs["proof"]["vulnerabilities"][item["name"]]["via"] + } + if not required <= fixed or ENGINE["newly_reported_findings"]( + inputs["before"], after, "pnpm" + ): + raise ValueError( + "The batch did not confirm all selected fixes, or introduced findings" + ) + return after, fixed + + +def prepare(items, artifact): + """Resolve and seal a candidate without running dependency/project scripts.""" + root = Path.cwd().resolve() + directory, selected, snapshot, _ = project_context() + deadline = time.monotonic() + 40 * 60 + before = ENGINE["audit"](directory, deadline=deadline) + proof = verified_targets( + directory, before, snapshot, items, deadline, selected["minimumReleaseAge"] + ) + workspace = directory / "pnpm-workspace.yaml" + workspace.write_text(update_policy(workspace.read_text(), items)) + result = ENGINE["run"]( + directory, + *CANDIDATE_INSTALL, + deadline=deadline, + ) + if result.returncode: + raise ValueError("Candidate lockfile resolution failed") + candidate = ENGINE["valid_snapshot"](directory) + check_scope(snapshot, candidate, items) + inputs = {"before": before, "proof": proof} + after, fixed = audit_candidate( + directory, snapshot, candidate, inputs, items, deadline + ) + ENGINE["describe_pnpm_fixes"](directory, after, candidate[1], [], deadline, {}) + if ENGINE["valid_snapshot"](directory) != candidate: + raise ValueError("Audit metadata lookup changed the candidate dependency files") + paths = [ + (directory / name).relative_to(root).as_posix() + for name in ("pnpm-workspace.yaml", "pnpm-lock.yaml") + ] + patch = export_patch(paths) + if not patch: + raise ValueError("No repair changes were produced") + candidate_artifact = artifact / "candidate" + candidate_artifact.mkdir(parents=True, exist_ok=True) + (candidate_artifact / "audit.json").write_text(json.dumps(inputs)) + (candidate_artifact / "candidate.patch").write_bytes(patch) + package_report = ENGINE["format_audit_summary"]( + proof, after, "pnpm", fixed_ids=fixed, locks=(snapshot[3], candidate[3]) + ) + (candidate_artifact / "result.md").write_text( + "## Package security\n\n" + + package_report + + "\n\n" + + CHOICES["render"](CHOICES["candidates"](after)) + + "\n
\nCandidate audit evidence\n\n### Before\n\n" + + ENGINE["format_remaining_audit"](proof, snapshot[3]) + + "\n\n### Candidate\n\n" + + ENGINE["format_remaining_audit"](after, candidate[3]) + + "\n\n
\n" + ) + + +def validate(items, artifact): + """Validate the sealed candidate on a separate runner.""" + directory, selected, snapshot, config_path = project_context() + source = artifact / "candidate" + evidence = artifact / "evidence" + inputs = json.loads((source / "audit.json").read_text()) + subprocess.run(["git", "apply", str(source / "candidate.patch")], check=True) + candidate = ENGINE["valid_snapshot"](directory) + check_scope(snapshot, candidate, items) + deadline = time.monotonic() + 40 * 60 + validation = {} + config = json.loads(config_path.read_text())["configuration"] + details = ENGINE["prepare"]( + directory, + validation=validation, + validation_script=selected["validationScript"], + minimum_release_age_days=selected["minimumReleaseAge"] // 1440, + maintenance_mode="security-only", + local_patches=config.get("localPatches", []), + validate_only=True, + ) + evidence.mkdir(parents=True, exist_ok=True) + (evidence / "validation.md").write_text(details) + if not validation.get("passed") or validation.get("audit_errors"): + raise ValueError("Frozen install or configured project validation failed") + if ENGINE["valid_snapshot"](directory) != candidate: + raise ValueError("Validation changed the candidate dependency files") + audit_candidate(directory, snapshot, candidate, inputs, items, deadline) + + +def publish(items, artifact): + pr = guard(items) + if git("rev-parse", "HEAD") != os.environ["REPAIR_HEAD"]: + raise ValueError("Publisher checkout differs from the approved head") + directory, _ = PROJECT["project_directory"]( + Path.cwd().resolve(), + PROJECT["CONFIGURATION"]["local_configuration"](Path.cwd()), + ) + before = ENGINE["valid_snapshot"](directory) + paths = { + (directory / name).relative_to(Path.cwd()).as_posix() + for name in ("pnpm-workspace.yaml", "pnpm-lock.yaml") + } + patch = artifact / "candidate" / "candidate.patch" + subprocess.run(["git", "apply", "--check", str(patch)], check=True) + subprocess.run(["git", "apply", str(patch)], check=True) + changed = set( + subprocess.check_output(["git", "diff", "--name-only", "-z"], text=True) + .strip("\0") + .split("\0") + ) + if changed != paths: + raise ValueError("Candidate contains unapproved paths") + check_scope(before, ENGINE["valid_snapshot"](directory), items) + if any( + (directory / name).is_symlink() + for name in ("package.json", "pnpm-workspace.yaml", "pnpm-lock.yaml") + ): + raise ValueError("Symlinked dependency files are not supported for repair") + subprocess.run(["git", "add", "--", *sorted(paths)], check=True) + subprocess.run( + [ + "git", + "-c", + "user.name=github-actions[bot]", + "-c", + "user.email=41898282+github-actions[bot]@users.noreply.github.com", + "commit", + "-m", + "Node: apply selected security pin repairs\n\nNode-Repair: validated-selected-pins-v1", + ], + check=True, + ) + guard(items) + branch = pr["head"]["ref"] + subprocess.run( + [ + "git", + "push", + f"--force-with-lease=refs/heads/{branch}:{os.environ['REPAIR_HEAD']}", + "origin", + f"HEAD:refs/heads/{branch}", + ], + check=True, + ) + body = artifact / "published.md" + previous = pr["body"] or "" + platform = "" + if "## Platform security\n" in previous and "## Package security\n" in previous: + platform = ( + "## Platform security\n" + + previous.split("## Platform security\n", 1)[1].split( + "## Package security\n", 1 + )[0] + ) + body.write_text( + "**Selected pin repairs · Validation job passed · Review before merging**\n\n" + + platform + + (artifact / "candidate" / "result.md").read_text() + + f"\n[Validation and audit logs](https://github.com/{os.environ['GITHUB_REPOSITORY']}/actions/runs/{os.environ['GITHUB_RUN_ID']})\n" + ) + subprocess.run( + ["gh", "pr", "edit", os.environ["REPAIR_PR"], "--body-file", str(body)], + check=True, + ) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "operation", choices=("guard", "prepare", "validate", "publish") + ) + parser.add_argument( + "--artifact", + type=Path, + default=Path(os.environ.get("RUNNER_TEMP", "/tmp")) / "node-repair", + ) + args = parser.parse_args() + try: + items = request() + if args.operation == "guard": + guard(items) + elif args.operation == "prepare": + os.environ["npm_config_ignore_pnpmfile"] = "true" + os.environ["pnpm_config_ignore_pnpmfile"] = "true" + prepare(items, args.artifact) + elif args.operation == "validate": + validate(items, args.artifact) + else: + publish(items, args.artifact) + except (OSError, ValueError, KeyError, subprocess.SubprocessError) as error: + evidence = args.artifact / "evidence" + evidence.mkdir(parents=True, exist_ok=True) + (evidence / "failure.md").write_text( + "## Repair attempt failed\n\nThe batch did not complete. " + "No automatic retry was requested. Inspect this run's logs before trying again.\n\n" + + str(error)[:2000] + + "\n" + ) + parser.exit(1, str(error) + "\n") + + +if __name__ == "__main__": + main() diff --git a/node/runtime/repair_choices.py b/node/runtime/repair_choices.py new file mode 100644 index 0000000..aba1cda --- /dev/null +++ b/node/runtime/repair_choices.py @@ -0,0 +1,101 @@ +"""The PR's editable consent list; it grants a bounded attempt, not a merge.""" + +import re + +START = "" +END = "" +NAME = r"(?:@[a-z0-9._-]+/)?[a-z0-9][a-z0-9._-]*" +VERSION = r"(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)" +LINE = re.compile( + rf"- \[([ xX])\] Allow `({NAME})` `({VERSION}) → ({VERSION})` by updating its explicit pin\." +) + + +def validate(items): + if not isinstance(items, list) or not 1 <= len(items) <= 20: + raise ValueError("Select between one and twenty fixes") + names = set() + for item in items: + if not isinstance(item, dict) or set(item) != {"name", "from", "to"}: + raise ValueError("Invalid repair selection") + if not isinstance(item["name"], str) or not re.fullmatch(NAME, item["name"]): + raise ValueError("Invalid package name") + if any( + not isinstance(item[key], str) or not re.fullmatch(VERSION, item[key]) + for key in ("from", "to") + ): + raise ValueError("Repair versions must be exact stable versions") + old, new = (tuple(map(int, item[key].split("."))) for key in ("from", "to")) + if old[:2] != new[:2] or new <= old or item["name"] in names: + raise ValueError("Only distinct, same-minor patch upgrades are supported") + names.add(item["name"]) + return sorted(items, key=lambda item: item["name"]) + + +def parse(body): + if body.count(START) != 1 or body.count(END) != 1: + raise ValueError("Missing or duplicate repair selection block") + block = body.split(START)[1].split(END)[0] + items = [] + for line in block.strip().splitlines(): + match = LINE.fullmatch(line) + if match is None: + raise ValueError("The repair selection block was changed; rerun the audit") + if match[1].lower() == "x": + items.append(dict(zip(("name", "from", "to"), match.groups()[1:]))) + return validate(items) + + +def candidates(data): + """Offer only exact pin patch attempts with verified published compatibility. + + Registry status supplies a feasibility hint. The repair runner independently + verifies every advisory, parent constraint and release age before changing it. + """ + if not isinstance(data, dict) or data.get("package_manager") != "pnpm": + return [] + result = [] + for name, finding in sorted(data.get("vulnerabilities", {}).items()): + choices = set() + for detail in finding.get("via", []): + status = detail.get("fixStatus", "") if isinstance(detail, dict) else "" + match = re.fullmatch( + rf"Published patch: `{re.escape(name)}@({VERSION})`\. " + rf"Blocked by retained override `pnpm-workspace\.yaml overrides\.{re.escape(name)}` = `({VERSION})`\. " + r"Override preserved for manual compatibility review\.", + status, + ) + if match is None: + break + choices.add((match[2], match[1])) + else: + if len(choices) == 1: + old, new = choices.pop() + try: + result.extend(validate([{"name": name, "from": old, "to": new}])) + except ValueError: + pass + return result[:20] + + +def render(items): + if not items: + return "" + return ( + "\n## Choose fixes to try\n\n" + "These published patches appear compatible with checked parent constraints. " + "The pin changes have not been tested in this run. Selecting them permits " + "one repair attempt; it does not approve merging.\n\n" + + START + + "\n" + + "\n".join( + f"- [ ] Allow `{item['name']}` `{item['from']} → {item['to']}` by updating its explicit pin." + for item in items + ) + + "\n" + + END + + "\n\nWith the repair App and workflow installed, open **Checks → Dependency repair** " + "and click **Run selected fixes**. The check shows the selected versions. " + "Checkboxes alone do not start a run. Failed validation leaves this PR's files " + "unchanged. See the repository's repair setup guide for installation.\n" + ) diff --git a/node/runtime/templates/dependency_audit.py b/node/runtime/templates/dependency_audit.py index ded2727..cd55f83 100644 --- a/node/runtime/templates/dependency_audit.py +++ b/node/runtime/templates/dependency_audit.py @@ -3,6 +3,7 @@ import argparse import copy import hashlib +import html import json import re import runpy @@ -17,6 +18,7 @@ LOCAL_PATCHES = runpy.run_path(str(Path(__file__).parents[1] / "local_patches.py")) NODE_RELEASES = runpy.run_path(str(Path(__file__).parents[1] / "node_releases.py")) +REPAIR_CHOICES = runpy.run_path(str(Path(__file__).parents[1] / "repair_choices.py")) SEVERITY_ORDER = {"critical": 0, "high": 1, "moderate": 2, "low": 3, "info": 4} DIRECTORY_PATTERN = re.compile(r"[A-Za-z0-9_./ -]+") @@ -1163,6 +1165,7 @@ def published(specifier: str) -> set[str] | None: detail["_verifiedPatchedVersions"] = sorted(patches) vulnerability["fixAvailable"] = True blocked = [] + checked_parents = [] unknown = False compatible_patches = None for parent, snapshot in lock.get("snapshots", {}).items(): @@ -1191,6 +1194,7 @@ def published(specifier: str) -> set[str] | None: if not isinstance(constraint, str): unknown = True continue + checked_parents.append(f"{parent} declares {name}: {constraint}") intersections = [ published(f"{name}@{parent_range.strip()} {patch_range.strip()}") for parent_range in constraint.split("||") @@ -1212,6 +1216,7 @@ def published(specifier: str) -> set[str] | None: if compatible_patches is None else compatible_patches & parent_patches ) + detail["_checkedParentConstraints"] = sorted(set(checked_parents)) override = overrides.get(name) if isinstance(overrides, dict) else None override_status = "" override_patches = None @@ -2403,8 +2408,6 @@ def package_advisory_links(before, after, name, source, resolved, pending): def audit_fix_reason(detail, source): status = detail.get("fixStatus", "") - if "Blocked by retained override" in status: - return "review", "A retained override blocks the published patch." if "no single patch satisfies all" in status: parents = sorted(set(re.findall(r"`([^`]+)` requires `[^`]+`", status))) if len(parents) == 1: @@ -2413,6 +2416,8 @@ def audit_fix_reason(detail, source): f"Blocked by the `{parents[0].rsplit('@', 1)[0]}` version constraint.", ) return "blocked", "No published patch fits all checked parent constraints." + if "Blocked by retained override" in status: + return "review", "A retained override blocks the published patch." if status.startswith("No published stable patch"): return "blocked", "No published patch matches the fix range." if status.startswith("No patched version declared"): @@ -2464,7 +2469,7 @@ def format_blocked_findings(data, source): audit_fix_reason(detail, source)[1] for detail in findings.values() ) ) - rows.append(f"| `{name}` | {severities} | {reasons} |") + rows.append(f"| 🔴 `{name}` | {severities} | {reasons} |") return "\n".join(rows) @@ -2478,6 +2483,7 @@ def format_audit_summary( reported_parents=None, locks=None, summary=None, + repair_options=None, ) -> str: if isinstance(after, str): return "" if fixes_only else f"⚪ **Security:** {after}" @@ -2518,6 +2524,7 @@ def complete(data, rows): ) fixed_packages, review_packages, blocked_packages = set(), set(), set() new_packages, unconfirmed_packages = set(), set() + repair_names = {item["name"] for item in repair_options or []} for name in old_groups.keys() | groups.keys(): previous, pending = old_groups.get(name, {}), groups.get(name, {}) fixed = len(resolved_ids.intersection(previous)) @@ -2534,7 +2541,7 @@ def complete(data, rows): outcomes = [audit_fix_reason(detail, source) for detail in details] statuses = [detail.get("fixStatus", "") for detail in details] pinned = any("Blocked by retained override" in status for status in statuses) - review = any(kind == "review" for kind, _ in outcomes) + review = pinned or any(kind == "review" for kind, _ in outcomes) blocked = any(kind == "blocked" for kind, _ in outcomes) or any( "no single patch satisfies all" in status for status in statuses ) @@ -2610,6 +2617,8 @@ def complete(data, rows): + ", ".join(f"`{other}`" for other in locations) + "." ) + if name in repair_names: + action = "Likely feasible. Repair validation has not run. " + action new_count = len(new_ids.intersection(pending)) if new_count: new_packages.add(name) @@ -2646,12 +2655,12 @@ def complete(data, rows): counts.insert(0, "fixes unconfirmed") if comparable and not pending_count and not unconfirmed_disappeared: badge, rank, action = "🟢", 0, "Resolved." - elif review: - badge, rank = "🟠", 1 elif new_count: badge, rank = "🔴", 2 elif blocked: badge, rank = "", 2 + elif review: + badge, rank = "🟠", 1 else: badge, rank = "⚪", 3 severity_details = details + unconfirmed_details + list(previous.values()) @@ -2678,11 +2687,13 @@ def complete(data, rows): ) label = { "🟢": "✅ Fix proposed. ", - "🟠": "🟠 Needs review. ", + "🟠": "🟡 Needs review. ", "🔴": "🔴 New findings. ", "⚪": "⚪ Needs verification. ", - "": "Blocked. ", + "": "🔴 Blocked. ", }[badge] + if blocked and review: + label += "🟡 Needs review. " rows.append( ( (rank, severity, name), @@ -2708,9 +2719,9 @@ def packages(number): headline += f" · 🔴 **New findings in {packages(len(new_packages))}**" if review_packages: verb = "needs" if len(review_packages) == 1 else "need" - headline += f" · 🟠 **{packages(len(review_packages))} {verb} your review**" + headline += f" · 🟡 **{packages(len(review_packages))} {verb} your review**" if blocked_packages: - headline += f" · **Fixes blocked for {packages(len(blocked_packages))}**" + headline += f" · 🔴 **Fixes blocked for {packages(len(blocked_packages))}**" if unconfirmed_packages: verb = "needs" if len(unconfirmed_packages) == 1 else "need" headline += ( @@ -2724,6 +2735,77 @@ def packages(number): ) if rows: unit = "Advisories" if source == "pnpm" else "Affected package entries" + if repair_options: + + def cell(value): + value = html.escape(html.unescape(value.strip())) + value = re.sub(r"`([^`]+)`", r"\1", value) + return re.sub( + r"\[([^\]]+)\]\((https://[^ )]+)\)", r'\1', value + ) + + options = {item["name"]: item for item in repair_options} + table = [ + "\n" + + "".join( + f"" + for heading in ( + "Package", + "Update", + "Severity", + unit, + "Action / blocker", + "Security evidence", + ) + ) + + "\n" + ] + for (_, _, name), row in sorted(rows): + table.append( + "" + + "".join( + f"" for value in row.strip("|").split("|") + ) + + "" + ) + if name in options: + item = options[name] + table.append( + '" + ) + table.append("\n
{heading}
{cell(value)}
Pin repair assessment for ' + + html.escape(name) + + "

A compatible published patch was found: " + + html.escape(item["to"]) + + ". This pin change has not been tested in this run. " + "Select it below to allow an isolated attempt. The attempt must pass a frozen install, " + "the configured project checks, and a fresh audit before publication.

" + "The existing pin remains unchanged. Historical rationale has not been collected automatically.

    " + + "".join( + "
  • " + html.escape(parent) + "
  • " + for parent in sorted( + { + parent + for detail in groups.get(name, {}).values() + for parent in detail.get( + "_checkedParentConstraints", [] + ) + } + ) + ) + + "".join( + "
  • " + + cell( + f"[{detail.get('title', 'Advisory')}]({detail['url']})" + ) + + "
  • " + for detail in groups.get(name, {}).values() + if re.fullmatch( + r"https://[^\s<>()\[\]|`]+", detail.get("url", "") + ) + ) + + "
") + return headline + "\n\n" + "\n".join(table) headline += ( f"\n\n| Package | Update | Severity | {unit} | Action / blocker | Security evidence |" + "\n| --- | --- | --- | --- | --- | --- |" @@ -2797,7 +2879,7 @@ def format_security_overview( ) if review_count: parts.append( - f"🟠 {review_count} package" + f"🟡 {review_count} package" + (" needs" if review_count == 1 else "s need") + " your review" ) @@ -3705,6 +3787,7 @@ def published_patch_versions(specifier, *, all_versions=False): else None ) package_metrics = {} + repair_options = REPAIR_CHOICES["candidates"](after) package_report = format_audit_summary( before, after, @@ -3713,6 +3796,7 @@ def published_patch_versions(specifier, *, all_versions=False): reported_parents=reported_parents, locks=(before_lock, after_lock), summary=package_metrics, + repair_options=repair_options, ) background_blockers = bool(routine_only and blocked_findings) if background_blockers: @@ -3823,7 +3907,7 @@ def published_patch_versions(specifier, *, all_versions=False): "Checked committed dependencies without attempting repairs or routine upgrades.\n\n" + report.partition("\n")[2] ) - return report + return report + ("" if validate_only else REPAIR_CHOICES["render"](repair_options)) def main() -> None: diff --git a/node/tests/test_integration_contract.py b/node/tests/test_integration_contract.py index 6193dd2..8b8e76a 100644 --- a/node/tests/test_integration_contract.py +++ b/node/tests/test_integration_contract.py @@ -91,10 +91,11 @@ def test_setup_guide_uses_the_current_caller_and_standalone_schedule(): if "jobs:" in block ) expected = ( - (ROOT / "node/caller.yml") + (ROOT / "node/coordinated-caller.yml") .read_text() .replace("__CENTRAL_REPOSITORY__", "hydra-ecosystem/github-actions") - .replace("__CENTRAL_REVISION__", "v1") + .replace("__CENTRAL_REVISION__", "REVIEWED_REVISION") + .replace("__DEFAULT_BRANCH__", "main") ) schedule = yaml.safe_dump({"schedule": [{"cron": "0 2 * * 1"}]}, sort_keys=False) expected = expected.replace( diff --git a/node/tests/test_maintenance_options.py b/node/tests/test_maintenance_options.py index d9054b2..22b7d63 100644 --- a/node/tests/test_maintenance_options.py +++ b/node/tests/test_maintenance_options.py @@ -2204,7 +2204,7 @@ def audit(*args, **kwargs): assert "4 advisories are blocked, unchanged by this PR." in visible assert "critical" not in visible assert "Blocked findings and constraints" in details - assert "| `worker` | 2 critical |" in details + assert "| 🔴 `worker` | 2 critical |" in details assert "The registry supplies no patched version." in details else: assert "unchanged by this PR" not in report @@ -2222,7 +2222,7 @@ def audit(*args, **kwargs): assert "worker" in visible assert "Validation script:" not in visible assert "Validation script:" in details - assert "🔴" not in report + assert "🔴 **New findings" not in report def test_new_security_findings_stay_visible_above_collapsed_details(site, monkeypatch): diff --git a/node/tests/test_node_standard.py b/node/tests/test_node_standard.py index 4b2da47..e4e2e42 100644 --- a/node/tests/test_node_standard.py +++ b/node/tests/test_node_standard.py @@ -901,7 +901,7 @@ def test_compact_security_summary_keeps_severity_and_count_meaning( result = summarize(before, after, package_manager) assert "Fixes unconfirmed" in result assert "Resolution unconfirmed" in result - assert "🟠" not in result and "| Package |" not in result + assert "🟡" not in result and "| Package |" not in result # Full findings retain the registry's units and severities. detailed = audit_module["format_remaining_audit"]( { @@ -992,7 +992,7 @@ def run(command, **kwargs): "**0 platform security updates · 1 routine dependency update · 0 package security fixes · Validation passed**" ) assert "1 affected package entry is blocked, unchanged by this PR." in visible - assert "🔴" not in report + assert "🔴 **New findings" not in report else: assert "routine dependency" not in visible assert "0 security fixes · 1 affected package entry remains" in visible @@ -1083,7 +1083,7 @@ def test_security_outcomes_report_partial_resolution_for_one_package(security_fi assert "| `leaf` | Not recorded | High | 1 fixed · 1 pending |" in result assert result.count("`leaf`") == 1 assert all(f"`aaa-{i}`" in result for i in range(7)) - assert "🟠" not in result + assert "🔴 Blocked." in result and "Needs review" not in result def test_security_outcomes_keep_missing_initial_evidence_unconfirmed(security_findings): @@ -1110,23 +1110,25 @@ def test_security_outcomes_summarize_concrete_blockers(security_findings): ) result = audit_module["format_audit_summary"](data, data, "pnpm") assert ( - "| `tinypool` | Not recorded | Critical | 2 pending | Blocked. Blocked by the `@docusaurus/core` version constraint. |" + "| `tinypool` | Not recorded | Critical | 2 pending | 🔴 Blocked. Blocked by the `@docusaurus/core` version constraint. |" in result ) assert "No published patch matches the fix range." in result assert result.count("`tinypool`") == 1 - assert "🔴" not in result + assert "🔴 **New findings" not in result background = audit_module["format_blocked_findings"](data, "pnpm") assert "| Package | Blocked advisories | Reason |" in background assert ( - "| `tinypool` | 2 critical | Blocked by the `@docusaurus/core` version constraint. |" + "| 🔴 `tinypool` | 2 critical | Blocked by the `@docusaurus/core` version constraint. |" in background ) assert ( - "| `braces` | 1 high | No published patch matches the fix range. |" + "| 🔴 `braces` | 1 high | No published patch matches the fix range. |" in background ) - assert "No security fixes" not in background and "🔴" not in background + assert ( + "No security fixes" not in background and "🔴 **New findings" not in background + ) def test_security_outcomes_bound_the_visible_package_list(security_findings): @@ -2050,7 +2052,7 @@ def test_attention_lists_all_blocking_pins_before_other_findings(security_findin ) assert result.index("`pinned-6`") < result.index("`aaa-parent`") assert result.count("| `pinned-") == 7 - assert "| `pinned-0` | Not recorded | High | 2 pending | 🟠 Needs review." in result + assert "| `pinned-0` | Not recorded | High | 2 pending | 🟡 Needs review." in result assert ( "Pin `1.0.0` blocks fix `1.2.0`; review removing or updating the pin." in result ) @@ -2076,7 +2078,7 @@ def test_attention_escapes_disjunctive_pin_and_preserves_scoped_name(security_fi data = security_findings([("@scope/leaf", "one", "high", status)]) result = audit_module["format_audit_summary"](data, data, "pnpm") assert ( - "| `@scope/leaf` | Not recorded | High | 1 pending | 🟠 Needs review. Pin `1.0.0 || 2.0.0` blocks fix `2.0.1`" + "| `@scope/leaf` | Not recorded | High | 1 pending | 🟡 Needs review. Pin `1.0.0 || 2.0.0` blocks fix `2.0.1`" in result ) @@ -2084,7 +2086,7 @@ def test_attention_escapes_disjunctive_pin_and_preserves_scoped_name(security_fi def test_attention_is_absent_when_no_blocking_pin_was_verified(security_findings): data = security_findings([("leaf", "one", "high", "Could not verify publication.")]) result = audit_module["format_audit_summary"](data, data, "pnpm") - assert "🟠" not in result and "your review" not in result + assert "🟡" not in result and "your review" not in result assert ( "| `leaf` | Not recorded | High | 1 pending | ⚪ Needs verification." in result ) @@ -2109,7 +2111,7 @@ def test_single_table_keeps_other_advisories_for_a_pinned_package_visible( result = audit_module["format_audit_summary"](data, data, "pnpm") assert ( result.count( - "| `leaf` | Not recorded | Critical | 2 pending | 🟠 Needs review." + "| `leaf` | Not recorded | Critical | 2 pending | 🔴 Blocked. 🟡 Needs review." ) == 1 ) @@ -2301,7 +2303,7 @@ def test_new_parent_is_background_only_with_unchanged_advisory_and_paths( before, after, "npm", fixed_ids=set(), reported_parents=parents ) if change in ("none", "middle-version"): - assert "🔴" not in report + assert "🔴 **New findings" not in report assert "Newly reported parent of an existing finding in `leaf`" in report assert "affected versions and installed paths are unchanged" in report else: @@ -2616,7 +2618,7 @@ def test_security_headline_separates_fixes_pin_decisions_and_all_blockers( ) assert ( result.splitlines()[0] - == "🟢 **Fixes in 1 package** · 🟠 **1 package needs your review** · **Fixes blocked for 7 packages**" + == "🟢 **Fixes in 1 package** · 🟡 **1 package needs your review** · 🔴 **Fixes blocked for 7 packages**" ) assert result.count("| `blocked-") == 7 @@ -2630,7 +2632,7 @@ def test_security_headline_does_not_call_unknown_fixes_blocked(security_findings result.splitlines()[0] == "⚪ **No security fixes** · ⚪ **1 package needs verification**" ) - assert "Fixes blocked" not in result and "🟠" not in result + assert "Fixes blocked" not in result and "🟡" not in result def test_security_headline_does_not_infer_fixes_from_totals(): @@ -2638,7 +2640,7 @@ def test_security_headline_does_not_infer_fixes_from_totals(): after = {"metadata": {"vulnerabilities": {"critical": 1}}} result = audit_module["format_audit_summary"](before, after, "pnpm") assert result.splitlines()[0] == "⚪ **Fixes unconfirmed**" - assert "Fixes in 1" not in result and "🟠" not in result + assert "Fixes in 1" not in result and "🟡" not in result @pytest.mark.parametrize("initial_unavailable", [False, True]) @@ -2731,13 +2733,13 @@ def update(command, **kwargs): ) -def test_partial_package_is_orange_only_when_pending_work_needs_user_decision( +def test_partial_package_distinguishes_review_blocked_and_unverified_work( security_findings, ): for status, badge in [ ( "Published patch: `leaf@1.2.0`. Blocked by retained override `pnpm-workspace.yaml overrides.leaf` = `1.0.0`.", - "🟠", + "🟡", ), ("No patched version declared by the registry.", ""), ("Publication could not be fully verified.", "⚪"), @@ -2750,8 +2752,9 @@ def test_partial_package_is_orange_only_when_pending_work_needs_user_decision( ) assert "| `leaf` | Not recorded | High | 1 fixed · 1 pending |" in result assert result.count("| `leaf`") == 1 - assert ("🟠 Needs review." in result) == (badge == "🟠") - assert ("your review" in result) == (badge == "🟠") + assert ("🟡 Needs review." in result) == (badge == "🟡") + assert ("your review" in result) == (badge == "🟡") + assert ("🔴 Blocked." in result) == (badge == "") def test_package_advisory_counts_deduplicate_repeated_identity(security_findings): @@ -2768,7 +2771,8 @@ def test_missing_initial_evidence_cannot_color_a_package_resolved(security_findi ) result = audit_module["format_audit_summary"]("Audit unavailable", current, "pnpm") assert "| `leaf` | Not recorded | High | fixes unconfirmed · 1 pending |" in result - assert "🟢" not in result and "🟠" not in result + assert "🟢" not in result and "✅" not in result + assert "🔴 Blocked." in result def test_npm_major_upgrade_requires_user_attention(): @@ -2780,7 +2784,7 @@ def test_npm_major_upgrade_requires_user_attention(): } result = audit_module["format_audit_summary"](data, data, "npm") assert ( - "| `leaf` | Not recorded | High | 1 pending | 🟠 Needs review. The offered fix requires a major upgrade." + "| `leaf` | Not recorded | High | 1 pending | 🟡 Needs review. The offered fix requires a major upgrade." in result ) assert "your review" in result diff --git a/node/tests/test_security_reporting.py b/node/tests/test_security_reporting.py index da652ff..e7050d1 100644 --- a/node/tests/test_security_reporting.py +++ b/node/tests/test_security_reporting.py @@ -98,7 +98,7 @@ def test_package_severity_is_independent_from_review_status_and_versions(): in text ) assert ( - "| `package-b` | `2.0.0` (unchanged) | High | 1 pending | 🟠 Needs review." + "| `package-b` | `2.0.0` (unchanged) | High | 1 pending | 🟡 Needs review." in text ) assert summary == {"fixed_packages": 1, "review_count": 1} diff --git a/node/tests/test_selected_repairs.py b/node/tests/test_selected_repairs.py new file mode 100644 index 0000000..c6d8973 --- /dev/null +++ b/node/tests/test_selected_repairs.py @@ -0,0 +1,740 @@ +import copy +import json +import os +import runpy +import subprocess +from pathlib import Path +from types import SimpleNamespace + +import pytest +import yaml + +ROOT = Path(__file__).parents[2] +REPAIR = runpy.run_path(str(ROOT / "node/runtime/repair.py")) +GLOBALS = REPAIR["prepare"].__globals__ +CHOICES = REPAIR["CHOICES"] +ITEMS = [{"name": "launch-editor", "from": "2.14.1", "to": "2.14.2"}] + + +def snapshot(version="2.14.1"): + lock = { + "lockfileVersion": "9.0", + "importers": {".": {}}, + "overrides": {"launch-editor": version}, + "packages": { + f"launch-editor@{version}": {"resolution": {"integrity": version}}, + "parent@1.0.0": {"resolution": {"integrity": "parent"}}, + }, + "snapshots": { + "parent@1.0.0": {"dependencies": {"launch-editor": version}}, + f"launch-editor@{version}": {}, + }, + } + policy = { + "packages": ["."], + "minimumReleaseAge": 14400, + "overrides": {"launch-editor": version}, + } + return ( + b'{"name":"test","scripts":{"build":"true"}}', + { + "pnpm-workspace.yaml": yaml.safe_dump(policy).encode(), + "pnpm-lock.yaml": yaml.safe_dump(lock).encode(), + }, + {}, + { + "packages": { + f"launch-editor@{version}": { + "name": "launch-editor", + "version": version, + }, + "parent@1.0.0": {"name": "parent", "version": "1.0.0"}, + } + }, + ) + + +def data(): + return { + "package_manager": "pnpm", + "vulnerabilities": { + "launch-editor": { + "severity": "high", + "via": [ + { + "url": "https://github.com/advisories/GHSA-example", + "title": "Example advisory", + "range": "<2.14.2", + "severity": "high", + "fixStatus": "Published patch: `launch-editor@2.14.2`. Blocked by retained override `pnpm-workspace.yaml overrides.launch-editor` = `2.14.1`. Override preserved for manual compatibility review.", + } + ], + } + }, + "metadata": {"vulnerabilities": {"high": 1}}, + } + + +def test_consent_is_exact_and_shared_with_the_renderer(): + body = CHOICES["render"](ITEMS) + assert "have not been tested" in body + assert CHOICES["parse"](body.replace("[ ]", "[x]")) == ITEMS + for text in ( + body, + body + body, + body.replace("2.14.2", "3.0.0"), + body.replace("Allow", "Run"), + ): + with pytest.raises(ValueError): + CHOICES["parse"](text) + + +def test_candidates_require_all_advisories_to_have_the_same_compatible_patch(): + finding = data() + assert CHOICES["candidates"](finding) == ITEMS + finding["vulnerabilities"]["launch-editor"]["via"].append( + {"fixStatus": "No patched version declared by the registry."} + ) + assert CHOICES["candidates"](finding) == [] + + +def test_policy_edit_preserves_comments_and_unrelated_settings(): + text = '# security policy\nminimumReleaseAge: 14400\noverrides:\n launch-editor: "2.14.1" # retained pin\n other: "1.0.0"\n' + result = REPAIR["update_policy"](text, ITEMS) + assert result == text.replace('"2.14.1"', '"2.14.2"') + for invalid in ( + text.replace("2.14.1", "^2.14.1"), + text + ' launch-editor: "2.14.1"\n', + ): + with pytest.raises(ValueError): + REPAIR["update_policy"](invalid, ITEMS) + + +def test_scope_accepts_only_selected_pins_and_their_lockfile_edges(): + REPAIR["check_scope"](snapshot(), snapshot("2.14.2"), ITEMS) + + +@pytest.mark.parametrize( + "kind", ["dependencies", "devDependencies", "optionalDependencies"] +) +@pytest.mark.parametrize("specifier", ["override", "^2.14.1"]) +def test_scope_accepts_selected_direct_importers(kind, specifier): + before, after = snapshot(), snapshot("2.14.2") + for state, version in ((before, "2.14.1"), (after, "2.14.2")): + lock = yaml.safe_load(state[1]["pnpm-lock.yaml"]) + lock["importers"]["packages/app"] = { + kind: { + "launch-editor": { + "specifier": version if specifier == "override" else specifier, + "version": version + "(peer@1.0.0)", + } + } + } + state[1]["pnpm-lock.yaml"] = yaml.safe_dump(lock).encode() + REPAIR["check_scope"](before, after, ITEMS) + + # Only the approved version may change; importer fields and peer edges stay bound. + for field, value in (("specifier", "*"), ("version", "2.14.2(peer@2.0.0)")): + invalid = copy.deepcopy(after) + lock = yaml.safe_load(invalid[1]["pnpm-lock.yaml"]) + lock["importers"]["packages/app"][kind]["launch-editor"][field] = value + invalid[1]["pnpm-lock.yaml"] = yaml.safe_dump(lock).encode() + with pytest.raises(ValueError, match="unrelated lockfile"): + REPAIR["check_scope"](before, invalid, ITEMS) + + +@pytest.mark.parametrize( + "mutation", ["manifest", "policy", "metadata", "other-version", "wrong-target"] +) +def test_scope_rejects_unapproved_changes(mutation): + candidate = list(copy.deepcopy(snapshot("2.14.2"))) + if mutation == "manifest": + candidate[0] = b"{}" + elif mutation == "policy": + policy = yaml.safe_load(candidate[1]["pnpm-workspace.yaml"]) + policy["minimumReleaseAge"] = 0 + candidate[1]["pnpm-workspace.yaml"] = yaml.safe_dump(policy).encode() + elif mutation == "metadata": + lock = yaml.safe_load(candidate[1]["pnpm-lock.yaml"]) + lock["packages"]["parent@1.0.0"]["resolution"]["integrity"] = "tampered" + candidate[1]["pnpm-lock.yaml"] = yaml.safe_dump(lock).encode() + elif mutation == "other-version": + candidate[3]["packages"]["parent@2.0.0"] = { + "name": "parent", + "version": "2.0.0", + } + else: + candidate = snapshot("2.14.3") + with pytest.raises(ValueError): + REPAIR["check_scope"](snapshot(), candidate, ITEMS) + + +def test_folded_assessment_is_under_its_row_and_truthful(): + audit = data() + audit["vulnerabilities"]["other"] = { + "severity": "low", + "via": [ + { + "url": "https://github.com/advisories/other", + "severity": "low", + "fixStatus": "No patched version declared by the registry.", + } + ], + } + text = REPAIR["ENGINE"]["format_audit_summary"]( + audit, audit, "pnpm", repair_options=ITEMS + ) + assert ( + text.index("launch-editor") + < text.index("
") + < text.index("other") + ) + assert "has not been tested in this run" in text + assert "Historical rationale has not been collected" in text + assert "🟡 Needs review." in text and "🔴 Blocked." in text + + +def test_guard_rejects_changed_consent_or_revisions(monkeypatch): + env = { + "GITHUB_REPOSITORY": "example/repo", + "REPAIR_PR": "1", + "REPAIR_HEAD": "a" * 40, + "REPAIR_BASE": "b" * 40, + } + for key, value in env.items(): + monkeypatch.setenv(key, value) + pr = { + "state": "open", + "head": { + "repo": {"full_name": "example/repo"}, + "ref": "maintenance/node-security", + "sha": "a" * 40, + }, + "base": {"repo": {"full_name": "example/repo"}, "ref": "main", "sha": "b" * 40}, + "user": {"type": "Bot"}, + "body": CHOICES["render"](ITEMS).replace("[ ]", "[x]"), + } + monkeypatch.setitem( + GLOBALS, + "gh", + lambda path: pr if "/pulls/" in path else {"default_branch": "main"}, + ) + assert REPAIR["guard"](ITEMS) == pr + pr["head"]["sha"] = "c" * 40 + with pytest.raises(ValueError, match="changed"): + REPAIR["guard"](ITEMS) + + +@pytest.mark.parametrize("problem", ["parent", "age", "audit"]) +def test_unverified_or_young_patch_is_rejected(monkeypatch, tmp_path, problem): + engine = GLOBALS["ENGINE"] + initial = snapshot() + monkeypatch.setitem(engine, "valid_snapshot", lambda _: initial) + + def describe(_directory, proof, *_args, **_kwargs): + detail = proof["vulnerabilities"]["launch-editor"]["via"][0] + detail.update( + fixVersion=None if problem == "parent" else "2.14.2", + _verifiedPatchedVersions=["2.14.2"], + _verifiedVulnerableVersions=["2.14.1"], + ) + + monkeypatch.setitem(engine, "describe_pnpm_fixes", describe) + monkeypatch.setitem( + engine, + "run", + lambda *_a, **_k: SimpleNamespace( + returncode=0, stdout=json.dumps({"2.14.2": "2099-01-01T00:00:00Z"}) + ), + ) + with pytest.raises(ValueError): + REPAIR["verified_targets"]( + tmp_path, + "unavailable" if problem == "audit" else data(), + initial, + ITEMS, + 1000, + 14400, + ) + + +def test_candidate_preparation_does_not_execute_validation(monkeypatch, tmp_path): + directory = tmp_path / "website" + directory.mkdir() + initial, candidate = snapshot(), snapshot("2.14.2") + (directory / "pnpm-workspace.yaml").write_bytes(initial[1]["pnpm-workspace.yaml"]) + artifact = tmp_path / "artifacts" + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("RUNNER_TEMP", str(tmp_path)) + (tmp_path / "node-repair-config.json").write_text('{"configuration":{}}') + monkeypatch.setitem( + GLOBALS["PROJECT"], + "resolve_for_run", + lambda *_a: ( + directory, + { + "manager": "pnpm", + "minimumReleaseAge": 14400, + "validationScript": "build", + }, + ), + ) + monkeypatch.setitem(GLOBALS["PROJECT"], "verify_runtime", lambda *_a: None) + monkeypatch.setitem(GLOBALS, "verified_targets", lambda *_a: data()) + exported = [] + monkeypatch.setitem( + GLOBALS, "export_patch", lambda paths: exported.append(paths) or b"candidate" + ) + engine = GLOBALS["ENGINE"] + reads = iter([initial, candidate]) + monkeypatch.setitem(engine, "valid_snapshot", lambda _: next(reads, candidate)) + clean = data() + clean["vulnerabilities"] = {} + clean["metadata"]["vulnerabilities"] = {} + audits = iter([data(), clean]) + monkeypatch.setitem(engine, "audit", lambda *_a, **_k: next(audits)) + monkeypatch.setitem( + engine, + "confirmed_fixes", + lambda *_args: {"https://github.com/advisories/GHSA-example"}, + ) + monkeypatch.setitem(engine, "run", lambda *_a, **_k: SimpleNamespace(returncode=0)) + + def unexpected(*_args, **_kwargs): + pytest.fail("Candidate sealing must precede project validation") + + monkeypatch.setitem(engine, "prepare", unexpected) + REPAIR["prepare"](ITEMS, artifact) + assert exported == [["website/pnpm-workspace.yaml", "website/pnpm-lock.yaml"]] + assert (artifact / "candidate/candidate.patch").read_bytes() == b"candidate" + assert ( + json.loads((artifact / "candidate/audit.json").read_text())["proof"] == data() + ) + assert not (artifact / "evidence").exists() + report = (artifact / "candidate/result.md").read_text() + assert "Candidate audit evidence" in report and "GHSA-example" in report + assert "Validation job passed" not in report + + +def test_failed_validation_does_not_create_publishable_evidence(monkeypatch, tmp_path): + source = tmp_path / "candidate" + source.mkdir() + (source / "candidate.patch").write_bytes(b"sealed candidate") + (source / "audit.json").write_text(json.dumps({"before": data(), "proof": data()})) + config = tmp_path / "config.json" + config.write_text('{"configuration":{}}') + monkeypatch.setitem( + GLOBALS, + "project_context", + lambda: ( + tmp_path, + {"validationScript": "build", "minimumReleaseAge": 14400}, + snapshot(), + config, + ), + ) + monkeypatch.setattr(subprocess, "run", lambda *_args, **_kwargs: None) + monkeypatch.setitem( + GLOBALS["ENGINE"], "valid_snapshot", lambda _: snapshot("2.14.2") + ) + + def failed(_directory, *, validation, **_kwargs): + validation["passed"] = False + return "Build failed" + + monkeypatch.setitem(GLOBALS["ENGINE"], "prepare", failed) + with pytest.raises(ValueError, match="validation failed"): + REPAIR["validate"](ITEMS, tmp_path) + assert (tmp_path / "evidence/validation.md").read_text() == "Build failed" + assert not (tmp_path / "evidence/result.md").exists() + assert (source / "candidate.patch").read_bytes() == b"sealed candidate" + + +def test_validation_applies_sealed_candidate_on_fresh_checkout(monkeypatch, tmp_path): + directory = tmp_path / "website" + directory.mkdir() + before, after = snapshot(), snapshot("2.14.2") + (directory / "package.json").write_bytes(before[0]) + for name, content in before[1].items(): + (directory / name).write_bytes(content) + + def git(*args): + return subprocess.check_output(["git", *args], cwd=tmp_path) + + git("init", "-q") + git("config", "user.name", "Test fixture") + git("config", "user.email", "test@example.invalid") + git("add", ".") + git("commit", "-qm", "baseline") + for name, content in after[1].items(): + (directory / name).write_bytes(content) + artifact = tmp_path / "artifact" + source = artifact / "candidate" + source.mkdir(parents=True) + sealed = git("diff", "--binary") + (source / "candidate.patch").write_bytes(sealed) + (source / "audit.json").write_text(json.dumps({"before": data(), "proof": data()})) + git("restore", ".") + config = tmp_path / "config.json" + config.write_text('{"configuration":{}}') + monkeypatch.chdir(tmp_path) + monkeypatch.setitem( + GLOBALS, + "project_context", + lambda: ( + directory, + {"validationScript": "build", "minimumReleaseAge": 14400}, + before, + config, + ), + ) + + def build(_directory, *, validation, **_kwargs): + assert (directory / "pnpm-lock.yaml").read_bytes() == after[1]["pnpm-lock.yaml"] + # This runner's local download is mutable. Publication must download the + # original artifact from the preparation job, never reupload this copy. + (source / "candidate.patch").write_bytes(b"tampered local download") + validation["passed"] = True + return "Frozen install and build passed" + + engine = GLOBALS["ENGINE"] + monkeypatch.setitem(engine, "prepare", build) + monkeypatch.setitem(engine, "valid_snapshot", lambda _: after) + clean = data() + clean["vulnerabilities"] = {} + clean["metadata"]["vulnerabilities"] = {} + monkeypatch.setitem(engine, "audit", lambda *_args, **_kwargs: clean) + monkeypatch.setitem( + engine, + "confirmed_fixes", + lambda *_args: {"https://github.com/advisories/GHSA-example"}, + ) + monkeypatch.setitem(engine, "describe_pnpm_fixes", lambda *_args: None) + REPAIR["validate"](ITEMS, artifact) + assert {path.name for path in (artifact / "evidence").iterdir()} == { + "validation.md", + } + assert (source / "candidate.patch").read_bytes() != sealed + + +def test_publisher_stops_before_git_when_pr_is_stale(monkeypatch, tmp_path): + def stale(_items): + raise ValueError("stale PR") + + monkeypatch.setitem(GLOBALS, "guard", stale) + calls = [] + monkeypatch.setattr(subprocess, "run", lambda *args, **kwargs: calls.append(args)) + with pytest.raises(ValueError, match="stale"): + REPAIR["publish"](ITEMS, tmp_path) + assert calls == [] + + +@pytest.mark.parametrize("driver", ["external", "attribute-command", "textconv"]) +def test_patch_export_ignores_repository_diff_helpers(monkeypatch, tmp_path, driver): + def git(*args): + return subprocess.check_output(["git", *args], cwd=tmp_path) + + git("init", "-q") + git("config", "user.name", "Test fixture") + git("config", "user.email", "test@example.invalid") + path = tmp_path / "pnpm-lock.yaml" + path.write_text("validated: before\n") + (tmp_path / ".gitattributes").write_text("pnpm-lock.yaml diff=untrusted\n") + git("add", ".") + git("commit", "-qm", "baseline") + path.write_text("validated: after\n") + helper = tmp_path / "untrusted-diff" + helper.write_text("#!/bin/sh\ntouch helper-ran\nprintf 'forged patch\\n'\n") + helper.chmod(0o700) + setting = ( + "diff.external" + if driver == "external" + else f"diff.untrusted.{driver.split('-')[-1]}" + ) + git("config", setting, str(helper)) + # Confirm this repository configuration is executable without the safeguards. + git("diff", "--ext-diff", "--textconv", "HEAD", "--", path.name) + assert (tmp_path / "helper-ran").exists() + (tmp_path / "helper-ran").unlink() + monkeypatch.chdir(tmp_path) + patch = REPAIR["export_patch"]([path.name]) + assert not (tmp_path / "helper-ran").exists() + assert b"+validated: after" in patch and b"forged patch" not in patch + git("restore", "--", path.name) + subprocess.run(["git", "apply", "-"], input=patch, cwd=tmp_path, check=True) + assert path.read_text() == "validated: after\n" + + +@pytest.mark.parametrize("permitted", [True, False]) +def test_direct_manifest_constraint_needs_independent_registry_proof( + monkeypatch, tmp_path, permitted +): + initial = list(snapshot()) + initial[2] = {"dependencies": {"launch-editor": "^2.14.1"}} + engine = GLOBALS["ENGINE"] + monkeypatch.setitem(engine, "valid_snapshot", lambda _: initial) + + def describe(_directory, proof, *_args, **_kwargs): + proof["vulnerabilities"]["launch-editor"]["via"][0].update( + fixVersion="2.14.2", + _verifiedPatchedVersions=["2.14.2"], + _verifiedVulnerableVersions=["2.14.1"], + ) + + monkeypatch.setitem(engine, "describe_pnpm_fixes", describe) + + def lookup(_directory, _view, package, field, *_args, **_kwargs): + if field == "version": + assert package == "launch-editor@^2.14.1 2.14.2" + return SimpleNamespace( + returncode=0, stdout=json.dumps(["2.14.2"] if permitted else []) + ) + return SimpleNamespace( + returncode=0, stdout=json.dumps({"2.14.2": "2020-01-01T00:00:00Z"}) + ) + + monkeypatch.setitem(engine, "run", lookup) + if permitted: + REPAIR["verified_targets"](tmp_path, data(), initial, ITEMS, 1000, 14400) + else: + with pytest.raises(ValueError, match="direct manifest constraint"): + REPAIR["verified_targets"](tmp_path, data(), initial, ITEMS, 1000, 14400) + + +def test_workflow_has_no_write_credentials_in_validation(): + workflow = yaml.safe_load((ROOT / ".github/workflows/node-repair.yml").read_text()) + jobs = workflow["jobs"] + assert jobs["prepare"]["env"]["npm_config_ignore_pnpmfile"] == "true" + assert jobs["prepare"]["env"]["pnpm_config_ignore_pnpmfile"] == "true" + for name in ("prepare", "validate"): + assert jobs[name]["permissions"] == {"contents": "read"} + assert jobs[name]["steps"][0]["with"]["persist-credentials"] is False + assert jobs["validate"]["needs"] == "prepare" + assert jobs["publish"]["needs"] == ["prepare", "validate"] + sealed_id = "${{ needs.prepare.outputs.candidate-artifact-id }}" + for name in ("validate", "publish"): + candidate = next( + step + for step in jobs[name]["steps"] + if step.get("with", {}).get("artifact-ids") == sealed_id + ) + assert candidate["with"]["path"].endswith("/candidate") + uploads = [ + step + for step in jobs["validate"]["steps"] + if "upload-artifact@" in step.get("uses", "") + ] + assert len(uploads) == 1 and uploads[0]["with"]["path"].endswith("/evidence/") + downloads = [ + step + for step in jobs["publish"]["steps"] + if "download-artifact@" in step.get("uses", "") + ] + assert len(downloads) == 1 + assert downloads[0]["with"]["artifact-ids"] == sealed_id + assert not any("pull_request_target" in key for key in workflow["on"]) + + +@pytest.mark.parametrize("failure", [None, "metadata", "stale", "lease", "report"]) +def test_publisher_with_real_git_preserves_atomic_batch(monkeypatch, tmp_path, failure): + root, remote, artifact = ( + tmp_path / "repo", + tmp_path / "remote.git", + tmp_path / "artifact", + ) + root.mkdir() + artifact.mkdir() + (artifact / "candidate").mkdir() + (artifact / "evidence").mkdir() + directory = root / "website" + directory.mkdir() + before, after = snapshot(), snapshot("2.14.2") + (directory / "package.json").write_bytes(before[0]) + for name, content in before[1].items(): + (directory / name).write_bytes(content) + + def git(*args): + return subprocess.check_output( + ["git", "-C", str(root), *args], text=True + ).strip() + + subprocess.run( + ["git", "init", "--bare", str(remote)], check=True, capture_output=True + ) + subprocess.run(["git", "init", str(root)], check=True, capture_output=True) + git("config", "user.name", "Test fixture") + git("config", "user.email", "test@example.invalid") + git("add", ".") + git("commit", "-qm", "baseline") + head = git("rev-parse", "HEAD") + git("remote", "add", "origin", str(remote)) + git("push", "-q", "origin", "HEAD:refs/heads/maintenance/node-security") + for name, content in after[1].items(): + (directory / name).write_bytes(content) + if failure == "metadata": + lock = yaml.safe_load((directory / "pnpm-lock.yaml").read_text()) + lock["packages"]["parent@1.0.0"]["resolution"]["integrity"] = "changed" + (directory / "pnpm-lock.yaml").write_text(yaml.safe_dump(lock)) + (artifact / "candidate/candidate.patch").write_text(git("diff", "--binary") + "\n") + (artifact / "candidate/result.md").write_text( + "## Package security\n\nTrusted sealed audit\n" + ) + (artifact / "evidence/result.md").write_text( + "Forged validation result https://example.invalid/forged\n" + ) + # Even a report artifact containing a replacement patch cannot shadow the + # candidate downloaded separately from the preparation job's artifact ID. + (artifact / "evidence/candidate.patch").write_bytes(b"forged report-side patch") + git("restore", ".") + if failure == "lease": + git("commit", "--allow-empty", "-qm", "concurrent change") + newer = git("rev-parse", "HEAD") + git("push", "-q", "origin", "HEAD:refs/heads/maintenance/node-security") + git("reset", "--hard", head) + for key, value in { + "REPAIR_HEAD": head, + "REPAIR_PR": "1", + "GITHUB_REPOSITORY": "test/repo", + "GITHUB_RUN_ID": "123", + }.items(): + monkeypatch.setenv(key, value) + monkeypatch.chdir(root) + checks = [] + + def guard(_items): + checks.append(True) + if failure == "stale" and len(checks) == 2: + raise ValueError("PR changed") + return { + "head": {"ref": "maintenance/node-security"}, + "body": "## Platform security\n\nNode evidence retained\n\n## Package security\n\nOld report", + } + + monkeypatch.setitem(GLOBALS, "guard", guard) + monkeypatch.setitem( + GLOBALS["PROJECT"], "project_directory", lambda *_args: (directory, {}) + ) + monkeypatch.setitem( + GLOBALS["PROJECT"]["CONFIGURATION"], "local_configuration", lambda *_args: {} + ) + original_run = subprocess.run + + def run(command, **kwargs): + if command[0] == "gh": + assert command[1:3] == ["pr", "edit"] + assert "Node evidence retained" in Path(command[-1]).read_text() + assert "Trusted sealed audit" in Path(command[-1]).read_text() + assert "Forged validation result" not in Path(command[-1]).read_text() + assert "Validation job passed" in Path(command[-1]).read_text() + if failure == "report": + raise subprocess.CalledProcessError(1, command) + return SimpleNamespace(returncode=0) + return original_run(command, **kwargs) + + monkeypatch.setattr(subprocess, "run", run) + if failure: + error_type = ( + subprocess.CalledProcessError + if failure in ("lease", "report") + else ValueError + ) + message = ( + None + if failure in ("lease", "report") + else "metadata" + if failure == "metadata" + else "PR changed" + ) + with pytest.raises(error_type, match=message): + REPAIR["publish"](ITEMS, artifact) + else: + REPAIR["publish"](ITEMS, artifact) + published = subprocess.check_output( + [ + "git", + "--git-dir", + str(remote), + "rev-parse", + "refs/heads/maintenance/node-security", + ], + text=True, + ).strip() + if failure == "lease": + assert published == newer + elif failure and failure != "report": + assert published == head + else: + assert published != head + assert git("rev-parse", "HEAD^") == head + assert ( + git("log", "-1", "--format=%(trailers:key=Node-Repair,valueonly)") + == "validated-selected-pins-v1" + ) + + +@pytest.mark.parametrize("child_commit", [False, True]) +def test_normal_publisher_retains_repair_without_pr_body_marker(tmp_path, child_commit): + root, remote = tmp_path / "repo", tmp_path / "remote.git" + root.mkdir() + (root / "website").mkdir() + for name in ("package.json", "pnpm-lock.yaml", "pnpm-workspace.yaml"): + (root / "website" / name).write_text("reviewed repair\n") + + def git(*args): + return subprocess.check_output( + ["git", "-C", str(root), *args], text=True + ).strip() + + subprocess.run( + ["git", "init", "--bare", str(remote)], check=True, capture_output=True + ) + subprocess.run( + ["git", "init", "-b", "main", str(root)], check=True, capture_output=True + ) + git("config", "user.name", "Test fixture") + git("config", "user.email", "test@example.invalid") + git("add", ".") + git("commit", "-qm", "baseline") + head = git("rev-parse", "HEAD") + git("remote", "add", "origin", str(remote)) + git("push", "-q", "origin", "HEAD:main") + (root / "website/pnpm-lock.yaml").write_text("validated selected repair\n") + git("add", ".") + git( + "commit", + "-qm", + "Node: apply selected security pin repairs\n\nNode-Repair: validated-selected-pins-v1", + ) + if child_commit: + git("commit", "--allow-empty", "-qm", "Maintainer review adjustment") + repair_head = git("rev-parse", "HEAD") + git("push", "-q", "origin", "HEAD:maintenance/node-security") + git("reset", "--hard", head) + (root / "website/pnpm-lock.yaml").write_text("fresh default-branch audit\n") + binary = tmp_path / "bin" + binary.mkdir() + gh = binary / "gh" + gh.write_text('#!/bin/sh\ncase "$1:$2" in\npr:list) echo 1;;\n*) exit 3;;\nesac\n') + gh.chmod(0o755) + (tmp_path / "dependency-audit-pr.md").write_text("New audit report") + result = subprocess.run( + ["bash", str(ROOT / "node/publish/publish.sh")], + cwd=root, + env={ + **os.environ, + "PATH": str(binary) + os.pathsep + os.environ["PATH"], + "PROPOSAL_KIND": "security", + "BASE_BRANCH": "main", + "AUDIT_SHA": head, + "RUNNER_TEMP": str(tmp_path), + "VALIDATION_PASSED": "true", + "PR_FOOTER": "", + "NPM_PROJECT": "website", + }, + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + assert "retain that batch" in result.stdout + assert git("rev-parse", "origin/maintenance/node-security") == repair_head + assert git("diff", "--cached", "--name-only") == "" diff --git a/node/tests/verify_node_project.py b/node/tests/verify_node_project.py index 2b64e85..55082d4 100644 --- a/node/tests/verify_node_project.py +++ b/node/tests/verify_node_project.py @@ -10,9 +10,67 @@ from pathlib import Path from unittest.mock import patch +import yaml + ROOT = Path(__file__).parents[1] +def check_pnpm_repair_hooks(project, command): + """Exercise the production candidate command against an executable pnpmfile.""" + repair = runpy.run_path(str(ROOT / "runtime/repair.py")) + marker = project / "hook-executed" + hook = project / ".pnpmfile.cjs" + hook.write_text( + "require('node:fs').writeFileSync('hook-executed', 'executed');\n" + "module.exports = {};\n" + ) + global_hook = project / "global-pnpmfile.cjs" + global_hook.write_text(hook.read_text()) + workspace = project / "pnpm-workspace.yaml" + original_workspace = workspace.read_text() + workspace.write_text( + original_workspace + f"\nglobalPnpmfile: {json.dumps(str(global_hook))}\n" + ) + subprocess.run( + [*command, "install", "--lockfile-only", "--ignore-scripts"], + cwd=project, + check=True, + timeout=180, + ) + assert marker.exists(), "The unsafe command must reproduce pnpmfile execution" + marker.unlink() + subprocess.run( + [*command, *repair["CANDIDATE_INSTALL"]], + cwd=project, + check=True, + timeout=180, + ) + assert not marker.exists(), "Candidate preparation executed a pnpmfile" + workflow = yaml.safe_load( + (ROOT.parent / ".github/workflows/node-repair.yml").read_text() + ) + environment = os.environ | workflow["jobs"]["prepare"]["env"] + for arguments in ( + ("audit", "--json"), + ("view", "is-number@6.0.0", "version", "--json"), + ): + result = subprocess.run( + [*command, *arguments], + cwd=project, + env=environment, + capture_output=True, + timeout=180, + ) + assert result.returncode in (0, 1), result.stderr + if arguments[0] == "view": + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == "6.0.0" + assert not marker.exists(), "Preparation metadata reads executed a pnpmfile" + hook.unlink() + global_hook.unlink() + workspace.write_text(original_workspace) + + def check_npm_policy(version): """Prove real security repair and complete coverage under inherited config.""" with tempfile.TemporaryDirectory(prefix="npm-policy-") as temporary: @@ -166,6 +224,8 @@ def check(node, manager, version): check=True, timeout=180, ) + if manager == "pnpm": + check_pnpm_repair_hooks(project, command) preflight = runpy.run_path(str(ROOT / "runtime/project.py")) plan_path = root / "node-update.json" directory, selected = preflight["resolve_for_run"]( diff --git a/repair-app/.gitignore b/repair-app/.gitignore new file mode 100644 index 0000000..cc700cd --- /dev/null +++ b/repair-app/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +.wrangler/ +.dev.vars +*.pem diff --git a/repair-app/package-lock.json b/repair-app/package-lock.json new file mode 100644 index 0000000..907412b --- /dev/null +++ b/repair-app/package-lock.json @@ -0,0 +1,1590 @@ +{ + "name": "node-dependency-repair-app", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "node-dependency-repair-app", + "devDependencies": { + "wrangler": "4.151.3" + } + }, + "node_modules/@cloudflare/kv-asset-handler": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", + "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@cloudflare/unenv-preset": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.2.tgz", + "integrity": "sha512-JBP1+Z7ZSNG/d4mRP+y8VC5dka3tZVMLEZRvS+rzQ4DGV1EoxRFQckcJTTkXbHSQiTj0DtNI01Zwb/V2fX0mvQ==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.24", + "workerd": ">1.20260305.0 <2.0.0-0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20261019.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20261019.1.tgz", + "integrity": "sha512-5ZILbAta/fBQvhmAYeCE7dKMMYIr7N9wJEX50fEGLKSIwGtvT0HzSfHNIK/O7cBQ4gHQ2WfIiPClvwcye6E0fA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20261019.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20261019.1.tgz", + "integrity": "sha512-cww2Ab6lVMxpTiEEjKmPI58p5EMo0vYTZSH+3WkqXJdQgc3ES66GuL247+U2B2YWajhhu2tqj7jySrXneU749g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20261019.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20261019.1.tgz", + "integrity": "sha512-4++VRi9vkQROYqrpZYOixIJuVDmujkwrT4kszsxNFlvcmsX6Mr2GumvxIN3gIixS3vJUh3Umdtkthqj5X0S3Xw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20261019.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20261019.1.tgz", + "integrity": "sha512-75PVtJOwfjScQ53sbLtQXI7D8yP1c21t+WEUPaYbqkzaz4ZqfR5wAd+zuWs8G5Q7kOj2SQIvEfY5Sn8Y+UVrnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20261019.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20261019.1.tgz", + "integrity": "sha512-z49fvKTETMCnG6wnMcQwxx9CpryFceiKz04FRjq26EkhCyzRNJ6c3UXhyZz3r5f1CXnnBZc5DcU8ZouPkUJCqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz", + "integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz", + "integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz", + "integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz", + "integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz", + "integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz", + "integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz", + "integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz", + "integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz", + "integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz", + "integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz", + "integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz", + "integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz", + "integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz", + "integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz", + "integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz", + "integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz", + "integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz", + "integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz", + "integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz", + "integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz", + "integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz", + "integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==", + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz", + "integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz", + "integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz", + "integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz", + "integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@poppinss/colors": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", + "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^4.1.5" + } + }, + "node_modules/@poppinss/dumper": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz", + "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@sindresorhus/is": "^7.0.2", + "supports-color": "^10.0.0" + } + }, + "node_modules/@poppinss/exception": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz", + "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sindresorhus/is": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", + "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@speed-highlight/core": { + "version": "1.2.24", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.24.tgz", + "integrity": "sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/blake3-wasm": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/error-stack-parser-es": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", + "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/miniflare": { + "version": "5.20261019.1-beta", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20261019.1-beta.tgz", + "integrity": "sha512-1n7MN2UqZnPniyYAqNuV1YEAaja66ozKGVCc5ZTmYQhTubnoAwGLccwb+ZWYLOajuw3pOaJ70u//bZMuURlRew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "sharp": "0.35.5", + "undici": "7.29.1", + "workerd": "1.20261019.1", + "ws": "8.21.0", + "youch": "4.1.0-beta.10" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz", + "integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.5", + "@img/sharp-darwin-x64": "0.35.5", + "@img/sharp-freebsd-wasm32": "0.35.5", + "@img/sharp-libvips-darwin-arm64": "1.3.4", + "@img/sharp-libvips-darwin-x64": "1.3.4", + "@img/sharp-libvips-linux-arm": "1.3.4", + "@img/sharp-libvips-linux-arm64": "1.3.4", + "@img/sharp-libvips-linux-ppc64": "1.3.4", + "@img/sharp-libvips-linux-riscv64": "1.3.4", + "@img/sharp-libvips-linux-s390x": "1.3.4", + "@img/sharp-libvips-linux-x64": "1.3.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", + "@img/sharp-libvips-linuxmusl-x64": "1.3.4", + "@img/sharp-linux-arm": "0.35.5", + "@img/sharp-linux-arm64": "0.35.5", + "@img/sharp-linux-ppc64": "0.35.5", + "@img/sharp-linux-riscv64": "0.35.5", + "@img/sharp-linux-s390x": "0.35.5", + "@img/sharp-linux-x64": "0.35.5", + "@img/sharp-linuxmusl-arm64": "0.35.5", + "@img/sharp-linuxmusl-x64": "0.35.5", + "@img/sharp-webcontainers-wasm32": "0.35.5", + "@img/sharp-win32-arm64": "0.35.5", + "@img/sharp-win32-ia32": "0.35.5", + "@img/sharp-win32-x64": "0.35.5" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/undici": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.1.tgz", + "integrity": "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/unenv": { + "version": "2.0.0-rc.24", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", + "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3" + } + }, + "node_modules/workerd": { + "version": "1.20261019.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20261019.1.tgz", + "integrity": "sha512-UUYRUq/7DmeCl11YriNbcVbjUZVLtt8uuCXFu+nz77hGFf8d4DEVXoCYtnA+MlBatE4+wxoj47DVqzK3xGBAiw==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20261019.1", + "@cloudflare/workerd-darwin-arm64": "1.20261019.1", + "@cloudflare/workerd-linux-64": "1.20261019.1", + "@cloudflare/workerd-linux-arm64": "1.20261019.1", + "@cloudflare/workerd-windows-64": "1.20261019.1" + } + }, + "node_modules/wrangler": { + "version": "4.151.3", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.151.3.tgz", + "integrity": "sha512-mzPQLmkAhZUV7CdiAhE+hiIAJlS9xPL6vZe1B8oQFuWxFu7nmQnvHAVjcAcabwSGkXFXH9uspzXwHOFrfbQmqQ==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.5.0", + "@cloudflare/unenv-preset": "2.16.2", + "blake3-wasm": "2.1.5", + "esbuild": "0.28.2", + "miniflare": "5.20261019.1-beta", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.24", + "workerd": "1.20261019.1" + }, + "bin": { + "cf-wrangler": "bin/cf-wrangler.js", + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=22.0.0" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^5.20261019.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/youch": { + "version": "4.1.0-beta.10", + "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", + "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@poppinss/dumper": "^0.6.4", + "@speed-highlight/core": "^1.2.7", + "cookie": "^1.0.2", + "youch-core": "^0.3.3" + } + }, + "node_modules/youch-core": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz", + "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/exception": "^1.2.2", + "error-stack-parser-es": "^1.0.5" + } + } + } +} diff --git a/repair-app/package.json b/repair-app/package.json new file mode 100644 index 0000000..f59a1ed --- /dev/null +++ b/repair-app/package.json @@ -0,0 +1,11 @@ +{ + "name": "node-dependency-repair-app", + "private": true, + "type": "module", + "scripts": { + "test": "node --test --test-isolation=none worker.test.mjs", + "test:runtime": "node runtime-smoke.mjs", + "check": "wrangler deploy --dry-run --outdir /tmp/node-repair-worker-build" + }, + "devDependencies": {"wrangler": "4.151.3"} +} diff --git a/repair-app/runtime-smoke.mjs b/repair-app/runtime-smoke.mjs new file mode 100644 index 0000000..68f7ba9 --- /dev/null +++ b/repair-app/runtime-smoke.mjs @@ -0,0 +1,62 @@ +// Exercise the bundled service and real local D1 binding in Cloudflare's runtime. +import assert from 'node:assert/strict'; +import {readFileSync} from 'node:fs'; +import {Miniflare, convertV4MiniflareOptions} from 'miniflare'; +import {digest} from './worker.mjs'; + +const items = [{name: 'launch-editor', from: '2.14.1', to: '2.14.2'}]; +const bodyText = '\n- [x] Allow `launch-editor` `2.14.1 → 2.14.2` by updating its explicit pin.\n'; +const pr = {number: 1, state: 'open', body: bodyText, user: {type: 'Bot'}, head: {ref: 'maintenance/node-security', sha: 'a'.repeat(40), repo: {full_name: 'test/repo'}}, base: {ref: 'main', sha: 'b'.repeat(40), repo: {full_name: 'test/repo'}}}; +const check = {id: 2, app: {id: 42}, head_sha: pr.head.sha, external_id: `node-repair:1:${await digest(pr, items)}`, status: 'completed'}; +const keys = await crypto.subtle.generateKey({name: 'RSASSA-PKCS1-v1_5', modulusLength: 2048, publicExponent: new Uint8Array([1,0,1]), hash: 'SHA-256'}, true, ['sign', 'verify']); +const privateKey = '-----BEGIN PRIVATE KEY-----\n' + Buffer.from(await crypto.subtle.exportKey('pkcs8', keys.privateKey)).toString('base64') + '\n-----END PRIVATE KEY-----'; +const dispatched = []; + +const service = new Miniflare(convertV4MiniflareOptions({ + modules: true, + scriptPath: new URL('./worker.mjs', import.meta.url).pathname, + compatibilityDate: '2026-10-22', + bindings: {APP_ID: '42', APP_SLUG: 'test-repair', APP_PRIVATE_KEY: privateKey, WEBHOOK_SECRET: 'test-secret'}, + d1Databases: {DB: 'local-repair-test'}, + outboundService: async request => { + const path = new URL(request.url).pathname; + let response; + if (path.endsWith('/access_tokens')) response = {token: 'test-token'}; + else if (path.endsWith('/pulls/1')) response = pr; + else if (path.endsWith('/permission')) response = {permission: 'write'}; + else if (path.endsWith('/check-runs/2')) { + if (request.method === 'PATCH') Object.assign(check, await request.json()); + response = check; + } else if (path.endsWith('/dispatches')) { + dispatched.push(await request.json()); + return new Response(null, {status: 204}); + } else throw new Error(`Unexpected external request: ${path}`); + return new Response(JSON.stringify(response), {headers: {'Content-Type': 'application/json'}}); + }, +})); +try { + const database = await service.getD1Database('DB'); + await database.exec(readFileSync(new URL('./schema.sql', import.meta.url), 'utf8').replaceAll('\n', ' ')); + const statement = 'INSERT OR IGNORE INTO requests (id, repository, pr, head, check_id, fingerprint, installation, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'; + const values = ['request', 'test/repo', 1, 'head', 2, 'fingerprint', 3, 'dispatched', Date.now()]; + assert.equal((await database.prepare(statement).bind(...values).run()).meta.changes, 1); + assert.equal((await database.prepare(statement).bind(...values).run()).meta.changes, 0); + const body = JSON.stringify({zen: 'local runtime test'}); + const key = await crypto.subtle.importKey('raw', new TextEncoder().encode('test-secret'), {name: 'HMAC', hash: 'SHA-256'}, false, ['sign']); + const signature = 'sha256=' + Buffer.from(await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(body))).toString('hex'); + const result = await service.dispatchFetch('https://worker.test/webhook', {method: 'POST', body, headers: {'x-github-event': 'ping', 'x-hub-signature-256': signature}}); + assert.equal(result.status, 200); + const unsigned = await service.dispatchFetch('https://worker.test/webhook', {method: 'POST', body}); + assert.equal(unsigned.status, 401); + const payload = {action: 'requested_action', installation: {id: 3}, repository: {id: 4, full_name: 'test/repo', default_branch: 'main'}, sender: {login: 'maintainer'}, requested_action: {identifier: 'repair-selected'}, check_run: {...check, pull_requests: [{number: 1}]}}; + const click = JSON.stringify(payload); + const clickSignature = 'sha256=' + Buffer.from(await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(click))).toString('hex'); + const options = {method: 'POST', body: click, headers: {'x-github-event': 'check_run', 'x-hub-signature-256': clickSignature, 'x-github-delivery': 'e330b54a-89e6-4d55-b766-0e46dd037055'}}; + assert.equal((await service.dispatchFetch('https://worker.test/webhook', options)).status, 200); + assert.equal((await service.dispatchFetch('https://worker.test/webhook', options)).status, 200); + assert.equal(dispatched.length, 1); + assert.deepEqual(JSON.parse(dispatched[0].inputs.selections), items); + console.log('Cloudflare runtime: signed ping, unsigned rejection, native crypto, D1 deduplication, and one selected dispatch passed.'); +} finally { + await service.dispose(); +} diff --git a/repair-app/schema.sql b/repair-app/schema.sql new file mode 100644 index 0000000..a98236c --- /dev/null +++ b/repair-app/schema.sql @@ -0,0 +1,13 @@ +CREATE TABLE IF NOT EXISTS requests ( + id TEXT PRIMARY KEY, + repository TEXT NOT NULL, + pr INTEGER NOT NULL, + head TEXT NOT NULL, + check_id INTEGER NOT NULL, + fingerprint TEXT NOT NULL, + installation INTEGER NOT NULL, + status TEXT NOT NULL, + run_id INTEGER, + created_at INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS requests_created_at ON requests (created_at); diff --git a/repair-app/worker.mjs b/repair-app/worker.mjs new file mode 100644 index 0000000..e5dce0f --- /dev/null +++ b/repair-app/worker.mjs @@ -0,0 +1,187 @@ +// No repository code runs here. GitHub Actions owns candidate preparation. +const encoder = new TextEncoder(); +const START = ''; +const END = ''; +const VERSION = '(?:0|[1-9][0-9]*)\\.(?:0|[1-9][0-9]*)\\.(?:0|[1-9][0-9]*)'; +const LINE = new RegExp('^- \\[([ xX])\\] Allow `((?:@[a-z0-9._-]+/)?[a-z0-9][a-z0-9._-]*)` `(' + VERSION + ') → (' + VERSION + ')` by updating its explicit pin\\.$'); +const WORKFLOW = 'dependency-repair.yml'; +const CHECK = 'Dependency repair'; +const ACTION = {label: 'Run selected fixes', description: 'Prepare and validate the selected pins', identifier: 'repair-selected'}; + +export function selections(body) { + if (typeof body !== 'string' || body.split(START).length !== 2 || body.split(END).length !== 2) throw new Error('Missing repair selections'); + const block = body.split(START)[1].split(END)[0].trim(); + const result = [], names = new Set(); + for (const line of block.split('\n')) { + const m = LINE.exec(line.trimEnd()); + if (!m) throw new Error('Invalid repair selection block'); + if (m[1].toLowerCase() !== 'x') continue; + const old = m[3].split('.').map(Number), next = m[4].split('.').map(Number); + if (names.has(m[2]) || old[0] !== next[0] || old[1] !== next[1] || old[2] >= next[2]) throw new Error('Only distinct patch upgrades are allowed'); + names.add(m[2]); + result.push({name: m[2], from: m[3], to: m[4]}); + } + if (result.length > 20) throw new Error('Too many selections'); + return result.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0); +} + +export async function verifySignature(secret, body, signature) { + if (!/^sha256=[a-f0-9]{64}$/.test(signature || '')) return false; + const key = await crypto.subtle.importKey('raw', encoder.encode(secret), {name: 'HMAC', hash: 'SHA-256'}, false, ['verify']); + const bytes = Uint8Array.from(signature.slice(7).match(/../g), x => parseInt(x, 16)); + return crypto.subtle.verify('HMAC', key, bytes, body); +} + +function base64url(value) { + const bytes = typeof value === 'string' ? encoder.encode(value) : new Uint8Array(value); + return btoa(String.fromCharCode(...bytes)).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', ''); +} + +async function jwt(env) { + const now = Math.floor(Date.now() / 1000); + const header = base64url(JSON.stringify({alg: 'RS256', typ: 'JWT'})); + const claim = base64url(JSON.stringify({iat: now - 60, exp: now + 540, iss: env.APP_ID})); + const pem = env.APP_PRIVATE_KEY.replace(/-----[^-]+-----/g, '').replace(/\s/g, ''); + const key = await crypto.subtle.importKey('pkcs8', Uint8Array.from(atob(pem), c => c.charCodeAt(0)), {name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256'}, false, ['sign']); + return `${header}.${claim}.${base64url(await crypto.subtle.sign('RSASSA-PKCS1-v1_5', key, encoder.encode(`${header}.${claim}`)))}`; +} + +export async function api(token, path, method = 'GET', body) { + const response = await fetch(`https://api.github.com${path}`, { + method, redirect: 'manual', + headers: {Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json', 'User-Agent': 'node-dependency-repair', 'X-GitHub-Api-Version': '2026-03-10', 'Content-Type': 'application/json'}, + body: body === undefined ? undefined : JSON.stringify(body), + signal: AbortSignal.timeout(15000), + }); + if (!response.ok) throw new Error(`GitHub ${method} ${path.split('?')[0]}: ${response.status}`); + return response.status === 204 ? null : response.json(); +} + +function validPR(pr, repo, defaultBranch) { + return pr.state === 'open' && pr.head?.repo?.full_name === repo && pr.base?.repo?.full_name === repo && + pr.head.ref === 'maintenance/node-security' && pr.base.ref === defaultBranch && pr.user?.type === 'Bot' && + /^[a-f0-9]{40}$/.test(pr.head.sha) && /^[a-f0-9]{40}$/.test(pr.base.sha); +} + +export async function digest(pr, items) { + return [...new Uint8Array(await crypto.subtle.digest('SHA-256', encoder.encode(JSON.stringify([pr.number, pr.head.sha, pr.base.sha, items]))))].map(x => x.toString(16).padStart(2, '0')).join(''); +} + +export async function handle(event, payload, delivery, env, call = api) { + if (event === 'ping') return; + if (!((event === 'pull_request' && ['opened', 'edited', 'synchronize', 'reopened'].includes(payload.action)) || + (event === 'check_run' && payload.action === 'requested_action') || + (event === 'workflow_run' && payload.action === 'completed'))) return; + if (event === 'pull_request' && payload.pull_request?.head?.ref !== 'maintenance/node-security') return; + if (event === 'workflow_run' && payload.workflow_run?.path !== `.github/workflows/${WORKFLOW}`) return; + if (event === 'check_run' && (payload.requested_action?.identifier !== ACTION.identifier || String(payload.check_run?.app?.id) !== String(env.APP_ID))) return; + if (!Number.isSafeInteger(payload.installation?.id) || !Number.isSafeInteger(payload.repository?.id) || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(payload.repository?.full_name || '')) return; + const repo = payload.repository.full_name, prefix = `/repos/${repo}`; + const installation = payload.installation.id; + const token = (await call(await jwt(env), `/app/installations/${installation}/access_tokens`, 'POST', { + repository_ids: [payload.repository.id], + permissions: {contents: 'read', pull_requests: 'read', checks: 'write', actions: 'write'}, + })).token; + + if (event === 'workflow_run' && payload.action === 'completed') { + const run = payload.workflow_run; + const match = /^Node repair #[1-9][0-9]* · ([a-f0-9-]{36})$/.exec(run?.display_title || ''); + if (!match || run.path !== `.github/workflows/${WORKFLOW}` || run.event !== 'workflow_dispatch') return; + const row = await env.DB.prepare('SELECT * FROM requests WHERE id = ?').bind(match[1]).first(); + if (!row || row.repository !== repo || row.installation !== installation || (row.run_id && row.run_id !== run.id)) return; + // Fetch the canonical run instead of trusting a mutable event snapshot. + const current = await call(token, `${prefix}/actions/runs/${run.id}`); + if (current.display_title !== run.display_title || current.status !== 'completed' || current.path !== run.path || current.event !== 'workflow_dispatch' || current.actor?.login !== `${env.APP_SLUG}[bot]`) return; + const check = await call(token, `${prefix}/check-runs/${row.check_id}`); + if (check.external_id !== `node-repair-request:${row.id}`) return; + const success = current.conclusion === 'success'; + await call(token, `${prefix}/check-runs/${row.check_id}`, 'PATCH', { + status: 'completed', conclusion: success ? 'success' : 'failure', details_url: current.html_url, external_id: row.fingerprint, + output: {title: success ? 'Repair validation and publication completed' : 'Repair did not complete', summary: success ? 'The selected batch passed validation and was published. Review the changes before merging.' : 'See the run for the failed step. Failed validation leaves the PR files unchanged. A publication or report failure may need inspection of the PR before another attempt.'}, + actions: success ? [] : [ACTION], + }); + await env.DB.prepare('UPDATE requests SET status = ?, run_id = ? WHERE id = ?').bind(current.conclusion, run.id, row.id).run(); + return; + } + + let number; + if (event === 'pull_request' && ['opened', 'edited', 'synchronize', 'reopened'].includes(payload.action)) number = payload.number; + else if (event === 'check_run' && payload.action === 'requested_action' && payload.requested_action?.identifier === ACTION.identifier && String(payload.check_run?.app?.id) === String(env.APP_ID)) { + const matches = payload.check_run.pull_requests || []; + if (matches.length !== 1) return; + number = matches[0].number; + } else return; + if (!Number.isSafeInteger(number) || number < 1) return; + const pr = await call(token, `${prefix}/pulls/${number}`); + if (!validPR(pr, repo, payload.repository.default_branch)) return; + let items; + try { items = selections(pr.body); } catch { items = []; } + const fingerprint = await digest(pr, items); + const external = `node-repair:${number}:${fingerprint}`; + + if (event === 'pull_request') { + // A missing caller is a setup gap, not a repair button that cannot work. + try { await call(token, `${prefix}/actions/workflows/${WORKFLOW}`); } catch { return; } + const checks = await call(token, `${prefix}/commits/${pr.head.sha}/check-runs?check_name=Dependency%20repair&per_page=100`); + const check = checks.check_runs.find(c => String(c.app?.id) === String(env.APP_ID) && c.name === CHECK); + if (check?.status === 'in_progress') return; + const output = {title: items.length ? `${items.length} selected fixes` : 'Select fixes in the PR description', summary: items.length ? items.map(i => `- \`${i.name}\` \`${i.from} → ${i.to}\``).join('\n') + '\n\nRun selected fixes prepares and validates this batch. Merging is a separate decision.' : 'Select the allowed pin changes in the PR description, then return here. Checkboxes alone do not start a run.'}; + await call(token, `${prefix}/check-runs${check ? `/${check.id}` : ''}`, check ? 'PATCH' : 'POST', { + ...(check ? {} : {name: CHECK, head_sha: pr.head.sha}), external_id: external, + status: 'completed', conclusion: 'neutral', output, actions: items.length ? [ACTION] : [], + }); + return; + } + + if (!items.length || !/^[a-f0-9-]{36}$/.test(delivery)) return; + if (await env.DB.prepare('SELECT id FROM requests WHERE id = ?').bind(delivery).first()) return; + const check = await call(token, `${prefix}/check-runs/${payload.check_run.id}`); + if (String(check.app?.id) !== String(env.APP_ID) || check.head_sha !== pr.head.sha || check.status === 'in_progress') return; + if (check.external_id !== external) { + await call(token, `${prefix}/check-runs/${check.id}`, 'PATCH', { + status: 'completed', conclusion: 'neutral', external_id: external, + output: {title: 'Selections changed; confirm the refreshed batch', summary: items.map(i => `- \`${i.name}\` \`${i.from} → ${i.to}\``).join('\n') + '\n\nReview these versions, then click Run selected fixes again. No repair was dispatched by this click.'}, + actions: [ACTION], + }); + return; + } + const login = payload.sender?.login; + if (!/^[A-Za-z0-9-]+$/.test(login || '')) return; + const permission = await call(token, `${prefix}/collaborators/${login}/permission`); + if (!['write', 'maintain', 'admin'].includes(permission.permission)) return; + const inserted = await env.DB.prepare('INSERT OR IGNORE INTO requests (id, repository, pr, head, check_id, fingerprint, installation, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)').bind(delivery, repo, number, pr.head.sha, check.id, external, installation, 'dispatching', Date.now()).run(); + if (inserted.meta.changes !== 1) return; + await call(token, `${prefix}/check-runs/${check.id}`, 'PATCH', {status: 'in_progress', external_id: `node-repair-request:${delivery}`, details_url: `https://github.com/${repo}/actions/workflows/${WORKFLOW}`, output: {title: 'Repair requested', summary: `Requested by @${login}.\n\n` + items.map(i => `- \`${i.name}\` \`${i.from} → ${i.to}\``).join('\n') + `\n\n[Open the repair workflow](https://github.com/${repo}/actions/workflows/${WORKFLOW}).`}, actions: []}); + try { + await call(token, `${prefix}/actions/workflows/${WORKFLOW}/dispatches`, 'POST', { + ref: payload.repository.default_branch, + inputs: {'request-id': delivery, 'pull-request': String(number), 'expected-head': pr.head.sha, 'expected-base': pr.base.sha, selections: JSON.stringify(items)}, + }); + await env.DB.prepare('UPDATE requests SET status = ? WHERE id = ?').bind('dispatched', delivery).run(); + } catch (error) { + // A timed-out POST may have reached GitHub. Never automatically dispatch twice. + await call(token, `${prefix}/check-runs/${check.id}`, 'PATCH', {status: 'completed', conclusion: 'action_required', output: {title: 'Dispatch could not be confirmed', summary: 'Inspect Actions before requesting another attempt. The request may have reached GitHub; this service will not retry it automatically.'}, actions: []}); + throw error; + } +} + +export default { + async fetch(request, env) { + if (request.method !== 'POST' || new URL(request.url).pathname !== '/webhook') return new Response('Not found', {status: 404}); + if (!env.WEBHOOK_SECRET || !env.APP_PRIVATE_KEY || !env.APP_SLUG || !/^[1-9][0-9]*$/.test(env.APP_ID || '')) return new Response('Not configured', {status: 503}); + if (Number(request.headers.get('content-length') || 0) > 2_000_000) return new Response('Too large', {status: 413}); + const body = await request.arrayBuffer(); + if (body.byteLength > 2_000_000) return new Response('Too large', {status: 413}); + if (!await verifySignature(env.WEBHOOK_SECRET, body, request.headers.get('x-hub-signature-256'))) return new Response('Invalid signature', {status: 401}); + try { + await handle(request.headers.get('x-github-event'), JSON.parse(new TextDecoder().decode(body)), request.headers.get('x-github-delivery'), env); + return new Response('Accepted', {status: 200}); + } catch { + // Do not log bodies, tokens, private repo names, or upstream response bodies. + return new Response('Webhook processing failed; inspect the check and redeliver if no request was dispatched', {status: 500}); + } + }, + async scheduled(_event, env) { + await env.DB.prepare('DELETE FROM requests WHERE created_at < ?').bind(Date.now() - 90 * 86400000).run(); + }, +}; diff --git a/repair-app/worker.test.mjs b/repair-app/worker.test.mjs new file mode 100644 index 0000000..3409a6d --- /dev/null +++ b/repair-app/worker.test.mjs @@ -0,0 +1,119 @@ +import assert from 'node:assert/strict'; +import {readFileSync} from 'node:fs'; +import {DatabaseSync} from 'node:sqlite'; +import {test} from 'node:test'; +import worker, {digest, handle, selections, verifySignature} from './worker.mjs'; + +const selection = [{name: 'launch-editor', from: '2.14.1', to: '2.14.2'}]; +const body = '\n- [x] Allow `launch-editor` `2.14.1 → 2.14.2` by updating its explicit pin.\n'; +const keys = await crypto.subtle.generateKey({name: 'RSASSA-PKCS1-v1_5', modulusLength: 2048, publicExponent: new Uint8Array([1,0,1]), hash: 'SHA-256'}, true, ['sign', 'verify']); +const pem = '-----BEGIN PRIVATE KEY-----\n' + Buffer.from(await crypto.subtle.exportKey('pkcs8', keys.privateKey)).toString('base64') + '\n-----END PRIVATE KEY-----'; +const delivery = 'e330b54a-89e6-4d55-b766-0e46dd037055'; + +async function fixture() { + const db = new DatabaseSync(':memory:'); + db.exec(readFileSync(new URL('./schema.sql', import.meta.url), 'utf8')); + const env = {APP_ID: '42', APP_SLUG: 'repair-app', APP_PRIVATE_KEY: pem, WEBHOOK_SECRET: 'test-secret', DB: { + prepare(sql) { return {bind(...values) { return { + async first() {return db.prepare(sql).get(...values);}, + async run() {return {meta: db.prepare(sql).run(...values)};}, + }; }}; }, + }}; + const pr = {number: 3572, state: 'open', body, user: {type: 'Bot'}, head: {ref: 'maintenance/node-security', sha: 'a'.repeat(40), repo: {full_name: 'example/repo'}}, base: {ref: 'main', sha: 'b'.repeat(40), repo: {full_name: 'example/repo'}}}; + const check = {id: 123, name: 'Dependency repair', app: {id: 42}, head_sha: pr.head.sha, external_id: `node-repair:3572:${await digest(pr, selection)}`, status: 'completed'}; + const payload = {installation: {id: 5}, repository: {id: 6, full_name: 'example/repo', default_branch: 'main'}, sender: {login: 'maintainer'}, action: 'requested_action', requested_action: {identifier: 'repair-selected'}, check_run: {...check, pull_requests: [{number: 3572}]}}; + const calls = []; + let permission = 'write', failDispatch = false; + const call = async (_token, path, method = 'GET', data) => { + calls.push({path, method, data}); + if (path.endsWith('/access_tokens')) return {token: 'installation-token'}; + if (path.endsWith('/pulls/3572')) return pr; + if (path.endsWith('/permission')) return {permission}; + if (path.endsWith('/check-runs/123')) { + if (method === 'PATCH') Object.assign(check, data); + return check; + } + if (path.includes('/check-runs?')) return {check_runs: [check]}; + if (path.endsWith('/dispatches')) {if (failDispatch) throw new Error('timeout'); return null;} + if (path.endsWith('/dependency-repair.yml')) return {state: 'active'}; + if (path.endsWith('/actions/runs/456')) return payload.workflow_run; + throw new Error(`Unexpected request ${path}`); + }; + return {env, pr, check, payload, call, calls, db, setPermission: value => permission = value, failDispatch: () => failDispatch = true}; +} + +test('selection syntax matches the generated consent list and rejects broad upgrades', () => { + assert.deepEqual(selections(body), selection); + assert.deepEqual(selections(body.replace('[x]', '[ ]')), []); + for (const text of [body + body, body.replace('2.14.2', '3.0.0'), body.replace('2.14.2', '2.14.0'), body.replace('Allow', 'Install')]) assert.throws(() => selections(text)); +}); + +test('webhook signatures authenticate the exact bytes', async () => { + const bytes = new TextEncoder().encode('{"a":1}'); + const key = await crypto.subtle.importKey('raw', new TextEncoder().encode('secret'), {name: 'HMAC', hash: 'SHA-256'}, false, ['sign']); + const signature = 'sha256=' + Buffer.from(await crypto.subtle.sign('HMAC', key, bytes)).toString('hex'); + assert.equal(await verifySignature('secret', bytes, signature), true); + assert.equal(await verifySignature('wrong', bytes, signature), false); + assert.equal(await verifySignature('secret', new TextEncoder().encode('{}'), signature), false); + assert.equal(await verifySignature('secret', bytes, 'garbage'), false); +}); + +test('a permitted click captures exact versions and revisions and dispatches once', async () => { + const f = await fixture(); + await handle('check_run', f.payload, delivery, f.env, f.call); + await handle('check_run', f.payload, delivery, f.env, f.call); + const sent = f.calls.filter(c => c.path.endsWith('/dispatches')); + assert.equal(sent.length, 1); + assert.deepEqual(sent[0].data, {ref: 'main', inputs: {'request-id': delivery, 'pull-request': '3572', 'expected-head': f.pr.head.sha, 'expected-base': f.pr.base.sha, selections: JSON.stringify(selection)}}); + assert.equal(f.check.status, 'in_progress'); +}); + +for (const reason of ['read-only actor', 'changed selection', 'changed head', 'fork', 'closed', 'wrong branch', 'wrong app']) { + test(`rejects ${reason}`, async () => { + const f = await fixture(); + if (reason === 'read-only actor') f.setPermission('read'); + if (reason === 'changed selection') f.pr.body = body.replace('2.14.2', '2.14.3'); + if (reason === 'changed head') f.pr.head.sha = 'c'.repeat(40); + if (reason === 'fork') f.pr.head.repo.full_name = 'attacker/repo'; + if (reason === 'closed') f.pr.state = 'closed'; + if (reason === 'wrong branch') f.pr.head.ref = 'feature'; + if (reason === 'wrong app') f.payload.check_run.app = {id: 999}; + await handle('check_run', f.payload, delivery, f.env, f.call); + assert.equal(f.calls.some(c => c.path.endsWith('/dispatches')), false); + if (reason === 'changed selection') assert.match(f.check.output.summary, /No repair was dispatched by this click/); + }); +} + +test('PR edits update a read-only summary and do not dispatch a run', async () => { + const f = await fixture(); + await handle('pull_request', {...f.payload, action: 'edited', number: 3572, pull_request: f.pr}, delivery, f.env, f.call); + assert.match(f.check.output.summary, /launch-editor.*2.14.1 → 2.14.2/); + assert.equal(f.check.actions[0].label, 'Run selected fixes'); + assert.equal(f.calls.some(c => c.path.endsWith('/dispatches')), false); +}); + +test('an uncertain dispatch is never replayed automatically', async () => { + const f = await fixture(); f.failDispatch(); + await assert.rejects(handle('check_run', f.payload, delivery, f.env, f.call)); + await handle('check_run', f.payload, delivery, f.env, f.call); + assert.equal(f.calls.filter(c => c.path.endsWith('/dispatches')).length, 1); + assert.match(f.check.output.summary, /may have reached GitHub/); +}); + +test('workflow failure links evidence and requires an explicit next attempt', async () => { + const f = await fixture(); + await handle('check_run', f.payload, delivery, f.env, f.call); + f.payload.action = 'completed'; + f.payload.workflow_run = {id: 456, display_title: `Node repair #3572 · ${delivery}`, path: '.github/workflows/dependency-repair.yml', event: 'workflow_dispatch', actor: {login: 'repair-app[bot]'}, status: 'completed', conclusion: 'failure', html_url: 'https://github.com/example/repo/actions/runs/456'}; + await handle('workflow_run', f.payload, 'other-delivery', f.env, f.call); + assert.equal(f.check.conclusion, 'failure'); + assert.equal(f.check.actions[0].identifier, 'repair-selected'); + assert.match(f.check.output.summary, /Failed validation leaves the PR files unchanged/); + assert.equal(f.calls.filter(c => c.path.endsWith('/dispatches')).length, 1); +}); + +test('unsigned HTTP requests cannot reach GitHub', async () => { + const f = await fixture(); + const result = await worker.fetch(new Request('https://test/webhook', {method: 'POST', body: '{}'}), f.env); + assert.equal(result.status, 401); +}); diff --git a/repair-app/wrangler.jsonc b/repair-app/wrangler.jsonc new file mode 100644 index 0000000..d453288 --- /dev/null +++ b/repair-app/wrangler.jsonc @@ -0,0 +1,14 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "node-dependency-repair", + "main": "worker.mjs", + "compatibility_date": "2026-10-22", + "workers_dev": true, + "vars": { "APP_ID": "REPLACE_WITH_GITHUB_APP_ID", "APP_SLUG": "REPLACE_WITH_GITHUB_APP_SLUG" }, + "d1_databases": [{ + "binding": "DB", + "database_name": "node-dependency-repair", + "database_id": "REPLACE_WITH_CREATED_DATABASE_ID" + }], + "triggers": { "crons": ["0 3 * * *"] } +}