diff --git a/.github/workflows/security-scan-codex.yml b/.github/workflows/security-scan-codex.yml index 0f0473c6..e5833f82 100644 --- a/.github/workflows/security-scan-codex.yml +++ b/.github/workflows/security-scan-codex.yml @@ -1,6 +1,8 @@ name: Security Scan Codex Worker on: + repository_dispatch: + types: [clawhub-security-scan] workflow_dispatch: inputs: limit: @@ -39,9 +41,9 @@ jobs: shard: [0, 1, 2, 3] env: CONVEX_URL: ${{ vars.CONVEX_URL || vars.VITE_CONVEX_URL || 'https://wry-manatee-359.convex.cloud' }} - CODEX_SECURITY_SCAN_LIMIT: ${{ inputs.limit || inputs['batch-limit'] || '4' }} - CODEX_SECURITY_SCAN_MAX_JOBS: ${{ inputs['max-jobs'] || '' }} - CODEX_SECURITY_SCAN_MAX_RUNTIME_MINUTES: ${{ inputs['max-runtime-minutes'] || '8' }} + CODEX_SECURITY_SCAN_LIMIT: ${{ github.event.client_payload.batch_limit || inputs.limit || inputs['batch-limit'] || '4' }} + CODEX_SECURITY_SCAN_MAX_JOBS: ${{ github.event.client_payload.max_jobs || inputs['max-jobs'] || '' }} + CODEX_SECURITY_SCAN_MAX_RUNTIME_MINUTES: ${{ github.event.client_payload.max_runtime_minutes || inputs['max-runtime-minutes'] || '8' }} CODEX_SECURITY_SCAN_TIMEOUT_MS: ${{ vars.CODEX_SECURITY_SCAN_TIMEOUT_MS || '240000' }} CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN: "1" CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN_SANDBOX: ${{ vars.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN_SANDBOX || 'docker' }} diff --git a/convex/securityScanDispatch.test.ts b/convex/securityScanDispatch.test.ts index 9db6ed11..47290150 100644 --- a/convex/securityScanDispatch.test.ts +++ b/convex/securityScanDispatch.test.ts @@ -3,6 +3,7 @@ import { beginSecurityScanDispatchInternal, dispatchSecurityScanWorkflow, finishSecurityScanDispatchInternal, + getGitHubRepositoryDispatchPermission, requestSecurityScanDispatchInternal, } from "./securityScanDispatch"; @@ -352,26 +353,19 @@ describe("securityScanDispatch", () => { ); }); - it("refuses to dispatch when the GitHub App lacks Actions write permission", async () => { - const fetchImpl = vi.fn(); - - await expect( - dispatchSecurityScanWorkflow( - { - token: "installation-token", - permissions: { actions: "read", contents: "read" }, - }, - fetchImpl, - ), - ).resolves.toEqual({ - ok: false, - reason: "actions-write-required", + it("reports whether the GitHub App can create repository dispatches", () => { + expect(getGitHubRepositoryDispatchPermission({ contents: "read" })).toEqual({ + contentsPermission: "read", + canDispatch: false, + }); + expect(getGitHubRepositoryDispatchPermission({ contents: "write" })).toEqual({ + contentsPermission: "write", + canDispatch: true, }); - expect(fetchImpl).not.toHaveBeenCalled(); }); - it("dispatches the production workflow on main with bounded worker inputs", async () => { - const fetchImpl = vi.fn(async () => new Response(null, { status: 204 })); + it("refuses to dispatch when the GitHub App lacks Contents write permission", async () => { + const fetchImpl = vi.fn(); await expect( dispatchSecurityScanWorkflow( @@ -381,20 +375,38 @@ describe("securityScanDispatch", () => { }, fetchImpl, ), + ).resolves.toEqual({ + ok: false, + reason: "contents-write-required", + }); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("dispatches the production workflow through a narrowly typed repository event", async () => { + const fetchImpl = vi.fn(async () => new Response(null, { status: 204 })); + + await expect( + dispatchSecurityScanWorkflow( + { + token: "installation-token", + permissions: { contents: "write" }, + }, + fetchImpl, + ), ).resolves.toEqual({ ok: true }); expect(fetchImpl).toHaveBeenCalledWith( - "https://api.github.com/repos/openclaw/clawhub/actions/workflows/security-scan-codex.yml/dispatches", + "https://api.github.com/repos/openclaw/clawhub/dispatches", expect.objectContaining({ method: "POST", headers: expect.objectContaining({ Authorization: "Bearer installation-token", }), body: JSON.stringify({ - ref: "main", - inputs: { - "batch-limit": "4", - "max-runtime-minutes": "8", + event_type: "clawhub-security-scan", + client_payload: { + batch_limit: "4", + max_runtime_minutes: "8", }, }), }), diff --git a/convex/securityScanDispatch.ts b/convex/securityScanDispatch.ts index 7feb547d..0029bc3c 100644 --- a/convex/securityScanDispatch.ts +++ b/convex/securityScanDispatch.ts @@ -7,8 +7,7 @@ import { createGitHubAppInstallationToken, isGitHubAppConfigured } from "./lib/g const DISPATCH_STATE_KEY = "codex-worker"; const DISPATCH_LEASE_MS = 5 * 60 * 1000; const SCHEDULE_STALE_MS = 60 * 1000; -const GITHUB_WORKFLOW_DISPATCH_URL = - "https://api.github.com/repos/openclaw/clawhub/actions/workflows/security-scan-codex.yml/dispatches"; +const GITHUB_REPOSITORY_DISPATCH_URL = "https://api.github.com/repos/openclaw/clawhub/dispatches"; const internalRefs = internal as unknown as { securityScanDispatch: { @@ -38,6 +37,14 @@ export function isSecurityScanEventDispatchEnabled(env: NodeJS.ProcessEnv = proc ); } +export function getGitHubRepositoryDispatchPermission(permissions: Record) { + const contentsPermission = permissions.contents ?? "none"; + return { + contentsPermission, + canDispatch: contentsPermission === "write", + }; +} + export async function dispatchSecurityScanWorkflow( installationToken: { token: string; @@ -45,11 +52,11 @@ export async function dispatchSecurityScanWorkflow( }, fetchImpl: typeof fetch = fetch, ) { - if (installationToken.permissions.actions !== "write") { - return { ok: false as const, reason: "actions-write-required" as const }; + if (!getGitHubRepositoryDispatchPermission(installationToken.permissions).canDispatch) { + return { ok: false as const, reason: "contents-write-required" as const }; } - const response = await fetchImpl(GITHUB_WORKFLOW_DISPATCH_URL, { + const response = await fetchImpl(GITHUB_REPOSITORY_DISPATCH_URL, { method: "POST", headers: { Accept: "application/vnd.github+json", @@ -59,10 +66,10 @@ export async function dispatchSecurityScanWorkflow( "X-GitHub-Api-Version": "2022-11-28", }, body: JSON.stringify({ - ref: "main", - inputs: { - "batch-limit": "4", - "max-runtime-minutes": "8", + event_type: "clawhub-security-scan", + client_payload: { + batch_limit: "4", + max_runtime_minutes: "8", }, }), }); @@ -210,24 +217,23 @@ export const finishSecurityScanDispatchInternal = internalMutation({ }, }); -export const checkGitHubActionsPermissionInternal = internalAction({ +export const checkGitHubRepositoryDispatchPermissionInternal = internalAction({ args: {}, handler: async () => { if (!isGitHubAppConfigured()) { return { configured: false as const, - actionsPermission: null, + contentsPermission: null, canDispatch: false, }; } const installationToken = await createGitHubAppInstallationToken({ userAgent: "clawhub/security-scan-dispatch-preflight", }); - const actionsPermission = installationToken.permissions.actions ?? "none"; + const permission = getGitHubRepositoryDispatchPermission(installationToken.permissions); return { configured: true as const, - actionsPermission, - canDispatch: actionsPermission === "write", + ...permission, }; }, }); @@ -264,9 +270,9 @@ export const dispatchSecurityScanWorkerInternal = internalAction({ } const error = - result.reason === "actions-write-required" - ? "GitHub App Actions write permission is required" - : `GitHub workflow dispatch rejected with HTTP ${result.status}`; + result.reason === "contents-write-required" + ? "GitHub App Contents write permission is required" + : `GitHub repository dispatch rejected with HTTP ${result.status}`; await runMutationRef( ctx, internalRefs.securityScanDispatch.finishSecurityScanDispatchInternal, diff --git a/scripts/security/security-scan-worker-workflow.test.ts b/scripts/security/security-scan-worker-workflow.test.ts index 9c29bb4b..2b3910c6 100644 --- a/scripts/security/security-scan-worker-workflow.test.ts +++ b/scripts/security/security-scan-worker-workflow.test.ts @@ -41,6 +41,7 @@ describe("security-scan-codex workflow", () => { }; }; on?: { + repository_dispatch?: { types?: string[] }; schedule?: Array<{ cron?: string }>; workflow_dispatch?: unknown; }; @@ -69,11 +70,18 @@ describe("security-scan-codex workflow", () => { expect(uploadStep?.with?.path).toBe("${{ env.CODEX_SECURITY_SCAN_DIAGNOSTICS_DIR }}"); expect(workflow.jobs["codex-security-scan"]["timeout-minutes"]).toBe(20); expect(workflow.on?.workflow_dispatch).toBeDefined(); + expect(workflow.on?.repository_dispatch?.types).toEqual(["clawhub-security-scan"]); expect(workflow.on?.schedule).toBeUndefined(); expect(workflow.jobs["codex-security-scan"].strategy?.["max-parallel"]).toBe(4); expect(workflow.jobs["codex-security-scan"].strategy?.matrix?.shard).toEqual([0, 1, 2, 3]); + expect(jobEnv.CODEX_SECURITY_SCAN_LIMIT).toBe( + "${{ github.event.client_payload.batch_limit || inputs.limit || inputs['batch-limit'] || '4' }}", + ); + expect(jobEnv.CODEX_SECURITY_SCAN_MAX_JOBS).toBe( + "${{ github.event.client_payload.max_jobs || inputs['max-jobs'] || '' }}", + ); expect(jobEnv.CODEX_SECURITY_SCAN_MAX_RUNTIME_MINUTES).toBe( - "${{ inputs['max-runtime-minutes'] || '8' }}", + "${{ github.event.client_payload.max_runtime_minutes || inputs['max-runtime-minutes'] || '8' }}", ); expect(jobEnv.CODEX_SECURITY_SCAN_TIMEOUT_MS).toBe( "${{ vars.CODEX_SECURITY_SCAN_TIMEOUT_MS || '240000' }}", diff --git a/specs/security-moderation.md b/specs/security-moderation.md index 9f543ece..ad2a7666 100644 --- a/specs/security-moderation.md +++ b/specs/security-moderation.md @@ -247,8 +247,10 @@ See also: [acceptable-usage.md](./acceptable-usage.md) for the marketplace polic - Claimable queue work edge-triggers a coalesced GitHub Actions worker dispatch. Successful completion requests another dispatch while queued work remains; a five-minute Convex cron is only a recovery watchdog for lost dispatch signals. - Event-driven dispatch stays disabled until the production GitHub App is - verified to have Actions write permission. + Convex emits the narrowly typed `clawhub-security-scan` + `repository_dispatch` event using the production GitHub App. Event-driven + dispatch stays disabled until that installation is verified to have Contents + write permission. - Queue source priority is `manual`, `backfill`, `publish`, `vt-update`, then `bulk-rescan`. A later VirusTotal update may make a waiting publish job claimable immediately, but it must not demote that job from publish priority.