From 0d64ce2c5031eb5363a45c0498c2ffe526b3f3f0 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Sun, 23 Aug 2026 01:31:12 +0200 Subject: [PATCH 1/5] feat(security): enforce verified commit signing Add fail-closed local signing doctor and outgoing-range hooks, GitHub verification pagination, CI aggregation, focused coverage, and contributor recovery guidance. --- .github/workflows/ci.yml | 24 +- AGENTS.md | 17 + CONTRIBUTING.md | 9 + docs/CI.md | 3 +- docs/VERIFIED-SIGNING.md | 69 ++++ package.json | 3 + scripts/check-tauri-import-boundary.mjs | 2 + scripts/hooks/pre-commit.mjs | 3 +- scripts/hooks/pre-push.mjs | 2 + scripts/signing/check-range.mjs | 22 ++ scripts/signing/doctor.mjs | 48 +++ scripts/signing/signing-core.d.mts | 40 ++ scripts/signing/signing-core.mjs | 370 +++++++++++++++++++ scripts/signing/verify-github-signatures.mjs | 62 ++++ scripts/signing/verify-outgoing.mjs | 26 ++ scripts/signing/verify-remote.d.mts | 42 +++ scripts/signing/verify-remote.mjs | 169 +++++++++ tests/unit/signing.test.ts | 163 ++++++++ 18 files changed, 1070 insertions(+), 4 deletions(-) create mode 100644 docs/VERIFIED-SIGNING.md create mode 100644 scripts/signing/check-range.mjs create mode 100644 scripts/signing/doctor.mjs create mode 100644 scripts/signing/signing-core.d.mts create mode 100644 scripts/signing/signing-core.mjs create mode 100644 scripts/signing/verify-github-signatures.mjs create mode 100644 scripts/signing/verify-outgoing.mjs create mode 100644 scripts/signing/verify-remote.d.mts create mode 100644 scripts/signing/verify-remote.mjs create mode 100644 tests/unit/signing.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c8cf3f757..818c72f25 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -129,6 +129,24 @@ jobs: echo "crates=false" >> "$GITHUB_OUTPUT" fi + signatures: + name: ๐Ÿ” Verified Signatures + runs-on: ubuntu-latest + timeout-minutes: 10 + needs: [security] + permissions: + contents: read + pull-requests: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + fetch-depth: 0 + persist-credentials: false + - name: Verify every introduced GitHub commit and release tag + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: node scripts/signing/verify-github-signatures.mjs + # ---------------------------------------------------------- # 1. QUALITY GATE: Lint + Typecheck + Tests (parallel matrix) # ---------------------------------------------------------- @@ -417,20 +435,21 @@ jobs: # ---------------------------------------------------------- # 3. CI SUCCESS: single required-status aggregator - # (security + quality + changes + rust-tauri + core-rust + build + e2e + vrt) + # (security + signatures + quality + changes + rust-tauri + core-rust + build + e2e + vrt) # ---------------------------------------------------------- ci-success: name: โœ… CI Success runs-on: ubuntu-latest timeout-minutes: 5 # QNBS-v3: Every unconditional job is either required here or explicitly advisory at job level. - needs: [security, quality, changes, rust-tauri, core-rust, build, e2e, lighthouse, vrt] + needs: [security, signatures, quality, changes, rust-tauri, core-rust, build, e2e, lighthouse, vrt] if: always() steps: - name: Verify all required jobs succeeded run: | FAIL=0 [ "${{ needs.security.result }}" = "success" ] || FAIL=1 + [ "${{ needs.signatures.result }}" = "success" ] || FAIL=1 [ "${{ needs.quality.result }}" = "success" ] || FAIL=1 [ "${{ needs.changes.result }}" = "success" ] || FAIL=1 if [ "${{ needs.rust-tauri.result }}" != "success" ] && [ "${{ needs.rust-tauri.result }}" != "skipped" ]; then @@ -446,6 +465,7 @@ jobs: if [ "$FAIL" = "1" ]; then echo "One or more required jobs did not succeed:" echo " security: ${{ needs.security.result }}" + echo " signatures: ${{ needs.signatures.result }}" echo " quality: ${{ needs.quality.result }}" echo " changes: ${{ needs.changes.result }}" echo " rust-tauri: ${{ needs.rust-tauri.result }} (skipped = OK, src-tauri untouched)" diff --git a/AGENTS.md b/AGENTS.md index 39f51993f..eb431789d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -271,6 +271,23 @@ and invoke local binaries without pnpm's workspace-state preflight in the hook p The pre-commit hook is not a substitute for the complete pre-push gate; CI remains mandatory when hooks are not installed. +### Verified signing cutover + +`required_signatures` remains enabled on `main`. Before creating or pushing new history, run +`pnpm run signing:doctor` and install the hooks with `pnpm run hooks:install`. The pre-commit hook +fails closed when the effective signing configuration cannot create and Git-verify a signed commit; +the pre-push hook verifies every commit introduced by every ref update and verifies both annotated +release tags and their target commits. CI verifies GitHub's `commit.verification.verified` result +for the complete introduced range and includes that gate in `โœ… CI Success`. + +Local Git verification and GitHub Verified status are distinct: a local `git verify-commit` pass is +necessary but cannot establish GitHub account/key association. Never use `--no-gpg-sign`, `--no-verify`, +unsigned temporary commits, or unsigned release tags as recovery. Squash merges create a new signed +result and do not rewrite or retroactively verify legacy unsigned source commits. Worktree-local Git +configuration overrides repository and global configuration; the doctor reports unsafe environment +overrides. See [`docs/VERIFIED-SIGNING.md`](docs/VERIFIED-SIGNING.md) for the recovery and audit +procedure. + --- ## Testing Instructions diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ab55f31cf..f44c75f49 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -511,3 +511,12 @@ The tool will automatically appear in `WriterView` if added to the tool list in ## License [MIT](LICENSE) +## Signed commits and tags + +New commits, outgoing pushes, and release tags must be signed. Run `pnpm run signing:doctor` +before installing hooks or when a signing operation fails, then install the repository hooks with +`pnpm run hooks:install`. The hooks reject missing or invalid local signatures; CI additionally +requires GitHub's `verification.verified` result for every introduced commit and for both an +annotated tag and its target commit. Do not bypass these checks with `--no-gpg-sign`, `--no-verify`, +or unsigned fallback objects. See [`docs/VERIFIED-SIGNING.md`](docs/VERIFIED-SIGNING.md) for +configuration precedence, recovery, and squash-history semantics. diff --git a/docs/CI.md b/docs/CI.md index eaaee91b9..ab3150548 100644 --- a/docs/CI.md +++ b/docs/CI.md @@ -135,7 +135,8 @@ registry gzip-decoding failure mode, while OSV failures remain blocking. | `lighthouse` | `build` | LHCI (mobile): **accessibility error gate** `minScore: 0.95`; **CLS error** โ‰ค 0.1; performance/SEO warn. Desktop run: `continue-on-error: true` until baselines stabilise. Timeout 25 min. | | `storybook` | `quality` | Cloud-first โ€” Storybook build + test-runner only run in CI (not locally); Playwright browser cache `v5`; `--maxWorkers=2 --junit` (non-blocking, `continue-on-error: true` โ€” see [exit criteria](#non-blocking-gates--exit-criteria-f-13)); artifacts uploaded always. Debug: manual `storybook-debug.yml` workflow. | | `vrt` | `build` | Visual regression against production `dist`; `toHaveScreenshot()` with committed PNG baselines (4 views ร— Chromium); artifacts uploaded always | -| `ci-success` | `security`, `quality`, `changes`, `rust-tauri`, `core-rust`, `build`, `e2e`, `lighthouse`, `vrt` | Required-status **aggregator** โ€” `if: always()`, fails if any required release-safety job does not resolve to `success`; Storybook and deep-E2E are explicitly advisory. Rust jobs are legitimately skipped when their paths are untouched. | +| `signatures` | `security` | Read-only GitHub API verification of every commit in the complete introduced range; pull-request commit pagination; and annotated release-tag plus target-commit verification. | +| `ci-success` | `security`, `signatures`, `quality`, `changes`, `rust-tauri`, `core-rust`, `build`, `e2e`, `lighthouse`, `vrt` | Required-status **aggregator** โ€” `if: always()`, fails if any required release-safety job does not resolve to `success`; signature verification is authoritative; Storybook and deep-E2E are explicitly advisory. Rust jobs are legitimately skipped when their paths are untouched. | | `deploy` | `ci-success` | **Only** `main` push (not PR), and only after the aggregate gate succeeds; the Pages artifact is resolved from the same workflow run. | > **Desktop:** On-demand / tag-driven Tauri bundles live in [`tauri-build.yml`](../.github/workflows/tauri-build.yml); **`v*` tags** additionally publish installers on a **GitHub Release**. See [`docs/TAURI-CI.md`](TAURI-CI.md). Desktop CI does not block the web deploy graph above. diff --git a/docs/VERIFIED-SIGNING.md b/docs/VERIFIED-SIGNING.md new file mode 100644 index 000000000..ee89ba80f --- /dev/null +++ b/docs/VERIFIED-SIGNING.md @@ -0,0 +1,69 @@ +# Verified signing policy + +WorldScript Studio requires signed commit objects at the local hook boundary and requires +GitHub's `commit.verification.verified == true` result at the CI boundary. These are related +but different checks: + +- `git verify-commit` proves that the local Git installation can validate the signature object + against its configured trust/key policy. It does not prove that GitHub will associate the + commit with a verified account. +- GitHub's `verification.verified` result is the release and merge gate. It covers GitHub's + signature parser, key association, and account identity rules. +- Annotated release tags have two objects to verify: the tag object and its target commit. + Lightweight release tags still require their target commit to be verified. + +## Local setup and recovery + +Run `pnpm run signing:doctor` after configuring a signing key. The doctor performs a +plumbing-level `git commit-tree -S` probe in an isolated temporary repository and validates the +result with Git-native verification. It never invokes normal repository hooks, reads private key +material, or prints signatures. `pnpm run hooks:install` installs the fail-closed pre-commit and +pre-push wrappers. + +If a hook rejects a commit or push: + +1. Run the doctor and correct the reported effective configuration, identity, key availability, + or trust/allowed-signers configuration. +2. Re-run the exact failed operation. Never use `--no-gpg-sign`, `--no-verify`, an unsigned + temporary commit, or an unsigned tag as a recovery path. +3. Use `pnpm run signing:check-range -- before..after` to inspect an exact local range. +4. For a pull request, use `pnpm run signing:verify-remote -- / ` when a + GitHub token is available; the CI gate remains authoritative. + +The hook reads effective Git configuration, including repository and worktree configuration. +Repository-local and worktree-local values override global values, and environment-provided Git +configuration can override all of them. The doctor reports unsafe configuration overrides rather +than silently accepting them. Configure only a public signing-key reference where possible; +never copy, print, commit, or place private key material in the repository. + +## History and squash semantics + +The signing cutover applies to every new commit, push, release tag, and GitHub merge result. It +does not rewrite legacy history. A squash merge creates a new signed commit whose tree contains +the reviewed change; it does not make the unsigned source commits in the old branch signed or +erase their historical verification state. A historical audit is evidence for migration planning, +not a release waiver for new unsigned objects. + +CI verifies the complete `before..after` range for branch pushes and the complete paginated PR +commit list for pull requests. Public-fork pull requests use read-only GitHub API access. API +errors, missing pages, missing verification data, invalid signatures, unsigned commits, and +unverified tag objects fail closed. + +## Historical audit snapshot + +The audit below is generated against `main` with GitHub's verification result, not merely local +Git trust. Dates are UTC and the interval is inclusive of commits reachable from `main` whose +committer timestamp is within the stated trailing window. Keep the command output or API response +with the release evidence when refreshing these figures. + +| Window ending 2026-08-23 UTC | Verified | Unverified | Total | +| --- | ---: | ---: | ---: | +| 7 days | 89 | 0 | 89 | +| 14 days | 124 | 0 | 124 | +| 30 days | 178 | 0 | 178 | + +This snapshot intentionally distinguishes the signed squash result from the source history that +preceded it. Open and merged PR source histories should be audited separately when investigating +legacy unsigned commits. The counts were collected on 2026-08-23 with GitHub's REST commits API, +`sha=main`, UTC windows ending `2026-08-23T23:59:59Z`, and the `commit.verification.verified` +boolean; they are not inferred from local Git trust. diff --git a/package.json b/package.json index e271365aa..6d81e390e 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,9 @@ }, "scripts": { "hooks:install": "simple-git-hooks", + "signing:doctor": "node scripts/signing/doctor.mjs", + "signing:check-range": "node scripts/signing/check-range.mjs", + "signing:verify-remote": "node scripts/signing/verify-remote.mjs", "toolchain:check": "node scripts/check-pnpm-toolchain.mjs", "deps:verify": "node scripts/dependency-state.mjs verify", "deps:reconcile": "node scripts/dependency-state.mjs reconcile", diff --git a/scripts/check-tauri-import-boundary.mjs b/scripts/check-tauri-import-boundary.mjs index 636e99f8b..8409d0f0a 100644 --- a/scripts/check-tauri-import-boundary.mjs +++ b/scripts/check-tauri-import-boundary.mjs @@ -32,6 +32,8 @@ const IGNORE_DIRS = new Set([ 'storybook-static', 'coverage', '.git', + // QNBS-v3: linked worktrees are separate checkout artifacts, not application source. + '.worktrees', 'reports', 'graphify-out', '.codegraph', diff --git a/scripts/hooks/pre-commit.mjs b/scripts/hooks/pre-commit.mjs index 8ce3f04e3..9a6ef9eec 100644 --- a/scripts/hooks/pre-commit.mjs +++ b/scripts/hooks/pre-commit.mjs @@ -1,5 +1,6 @@ import process from 'node:process'; -import { ensureDependencyState, runLocalBinary } from './shared.mjs'; +import { ensureDependencyState, runLocalBinary, runNodeScript } from './shared.mjs'; +if (runNodeScript('scripts/signing/doctor.mjs', ['--hook']) !== 0) process.exit(1); if (!ensureDependencyState()) process.exit(1); process.exit(runLocalBinary('lint-staged')); diff --git a/scripts/hooks/pre-push.mjs b/scripts/hooks/pre-push.mjs index 13582888e..5c8f3aa39 100644 --- a/scripts/hooks/pre-push.mjs +++ b/scripts/hooks/pre-push.mjs @@ -1,4 +1,6 @@ import process from 'node:process'; import { runNodeScript } from './shared.mjs'; +if (runNodeScript('scripts/signing/verify-outgoing.mjs', process.argv.slice(2)) !== 0) + process.exit(1); process.exit(runNodeScript('scripts/ci-prepush-lowend.mjs')); diff --git a/scripts/signing/check-range.mjs b/scripts/signing/check-range.mjs new file mode 100644 index 000000000..ffa620613 --- /dev/null +++ b/scripts/signing/check-range.mjs @@ -0,0 +1,22 @@ +#!/usr/bin/env node +import { verifyCommitRange } from './signing-core.mjs'; + +const range = process.argv[2]; +if (!range) { + console.error('usage: pnpm run signing:check-range -- '); + process.exit(2); +} +try { + const reports = verifyCommitRange(range); + for (const report of reports) { + console.log( + `${report.sha.slice(0, 12)} ${report.verification.ok ? 'verified' : 'REJECTED'} ${report.verification.reason} ${report.subject}`, + ); + } + process.exit(reports.every((report) => report.verification.ok) ? 0 : 1); +} catch (error) { + console.error( + `signing range check failed: ${error instanceof Error ? error.message : 'invalid Git range'}`, + ); + process.exit(1); +} diff --git a/scripts/signing/doctor.mjs b/scripts/signing/doctor.mjs new file mode 100644 index 000000000..125d2a153 --- /dev/null +++ b/scripts/signing/doctor.mjs @@ -0,0 +1,48 @@ +#!/usr/bin/env node +import { + getIdentity, + getSigningConfig, + getUnsafeOverrides, + isGitHubCompatibleEmail, + isSigningEnabled, + runSigningProbe, + safeConfigSummary, +} from './signing-core.mjs'; + +const jsonMode = process.argv.includes('--json'); +const cwd = process.cwd(); +const signing = getSigningConfig(cwd); +const identity = getIdentity(cwd); +const unsafeOverrides = getUnsafeOverrides(); +const probe = runSigningProbe(cwd); +const summary = { + ...safeConfigSummary(cwd), + probe: { ok: probe.ok, reason: probe.reason ?? 'signed probe verified' }, +}; + +if (jsonMode) { + process.stdout.write(`${JSON.stringify(summary)}\n`); +} else { + console.log(`signing format: ${signing.format}`); + console.log(`commit.gpgsign: ${isSigningEnabled(signing.config) ? 'enabled' : 'disabled'}`); + console.log(`signing key: ${signing.keyConfigured ? 'configured' : 'missing'}`); + console.log( + `identity: ${identity.name && identity.email ? 'configured' : 'missing'} (${isGitHubCompatibleEmail(identity.email) ? 'GitHub noreply-compatible' : 'GitHub identity requires account verification'})`, + ); + console.log( + `hooks: ${summary.hooks.pathConfigured ? 'custom path configured' : 'default path'} (${summary.hooks.preCommitInstalled ? 'available' : 'not installed'})`, + ); + console.log( + `unsafe config overrides: ${unsafeOverrides.length ? unsafeOverrides.join(', ') : 'none detected'}`, + ); + console.log(`isolated signing probe: ${probe.ok ? 'passed' : `failed โ€” ${probe.reason}`}`); +} + +const hookFailure = + !signing.enabled || + !signing.keyConfigured || + !identity.name || + !identity.email || + unsafeOverrides.length > 0 || + !probe.ok; +process.exit(hookFailure ? 1 : 0); diff --git a/scripts/signing/signing-core.d.mts b/scripts/signing/signing-core.d.mts new file mode 100644 index 000000000..5dc845a09 --- /dev/null +++ b/scripts/signing/signing-core.d.mts @@ -0,0 +1,40 @@ +export interface VerificationResult { + ok: boolean; + reason: string; +} + +export interface RefUpdate { + localRef: string; + localSha: string; + remoteRef: string; +} + +export interface SigningConfig { + format: string; + keyConfigured: boolean; + gpgProgramConfigured: boolean; + allowedSignersConfigured: boolean; + enabled: boolean; + keyDisplay: string; + config: Record; +} + +export function classifyCommitObject(input: { + objectType: string; + contents: string; + verificationStatus: number; +}): VerificationResult; +export function classifyTagVerification(input: { + objectType: string; + targetType: string; + tagVerificationStatus: number; + commitVerification: VerificationResult; +}): VerificationResult; +export function getSigningConfig(cwd?: string): SigningConfig; +export function hasCommitSignature(commitText: string): boolean; +export function isGitHubCompatibleEmail(email: string): boolean; +export function parseRefUpdate(line: string): RefUpdate | null; +export function selectIntroducedCommits(commits: string[], reachableFromBase: string[]): string[]; +export function runSigningProbe(cwd?: string): { ok: boolean; reason?: string; commit?: string }; +export function verifyCommitObject(sha: string, cwd?: string): VerificationResult; +export function verifyTagObject(sha: string, cwd?: string): VerificationResult; diff --git a/scripts/signing/signing-core.mjs b/scripts/signing/signing-core.mjs new file mode 100644 index 000000000..edd50b652 --- /dev/null +++ b/scripts/signing/signing-core.mjs @@ -0,0 +1,370 @@ +import { spawnSync } from 'node:child_process'; +import { existsSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { basename, join, resolve } from 'node:path'; + +const SHA = /^[0-9a-f]{40}$/i; +const ZERO_SHA = /^0{40}$/; +const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'; + +export function runGit(args, { cwd = process.cwd(), input = '' } = {}) { + const result = spawnSync('git', args, { + cwd, + encoding: 'utf8', + env: { + ...process.env, + GIT_TERMINAL_PROMPT: '0', + SSH_ASKPASS: '/bin/false', + SSH_ASKPASS_REQUIRE: 'force', + }, + input, + timeout: 5000, + killSignal: 'SIGTERM', + stdio: ['pipe', 'pipe', 'pipe'], + }); + return { + status: result.status ?? 1, + stdout: result.stdout ?? '', + stderr: result.stderr ?? '', + error: result.error, + }; +} + +export function gitOutput(args, options = {}) { + const result = runGit(args, options); + return result.status === 0 ? result.stdout.trim() : ''; +} + +export function isSha(value) { + return SHA.test(value); +} + +export function isZeroSha(value) { + return ZERO_SHA.test(value); +} + +export function getRepositoryRoot(cwd = process.cwd()) { + const root = gitOutput(['rev-parse', '--show-toplevel'], { cwd }); + return root ? resolve(root) : null; +} + +export function getGitDirectory(cwd = process.cwd()) { + const gitDir = gitOutput(['rev-parse', '--git-dir'], { cwd }); + return gitDir ? resolve(cwd, gitDir) : null; +} + +export function getConfig(cwd = process.cwd()) { + const keys = [ + 'user.email', + 'user.name', + 'user.signingkey', + 'commit.gpgsign', + 'gpg.format', + 'gpg.program', + 'gpg.ssh.allowedSignersFile', + 'core.hooksPath', + ]; + const values = Object.fromEntries( + keys.map((key) => [key, gitOutput(['config', '--get', key], { cwd })]), + ); + return values; +} + +export function getIdentity(cwd = process.cwd()) { + const config = getConfig(cwd); + return { name: config['user.name'], email: config['user.email'] }; +} + +export function isGitHubCompatibleEmail(email) { + return /^\d+\+[^@\s]+@users\.noreply\.github\.com$/i.test(email); +} + +export function getUnsafeOverrides(env = process.env) { + const names = [ + 'GIT_CONFIG_NOSYSTEM', + 'GIT_CONFIG_SYSTEM', + 'GIT_CONFIG_GLOBAL', + 'GIT_CONFIG_COUNT', + 'GIT_CONFIG_PARAMETERS', + ]; + return names.filter((name) => env[name] !== undefined).map((name) => name); +} + +export function isSigningEnabled(config) { + return /^(true|yes|on|1)$/i.test(config['commit.gpgsign'] ?? ''); +} + +export function getSigningConfig(cwd = process.cwd()) { + const config = getConfig(cwd); + return { + format: config['gpg.format'] || 'openpgp', + keyConfigured: Boolean(config['user.signingkey']), + gpgProgramConfigured: Boolean(config['gpg.program']), + allowedSignersConfigured: Boolean(config['gpg.ssh.allowedSignersFile']), + enabled: isSigningEnabled(config), + keyDisplay: config['user.signingkey'] ? basename(config['user.signingkey']) : '', + config, + }; +} + +function configureProbeRepository(repo, signing, identity) { + const settings = [ + ['user.name', identity.name || 'WorldScript signing probe'], + ['user.email', identity.email || 'signing-probe@users.noreply.github.com'], + ['commit.gpgsign', 'true'], + ['core.hooksPath', join(repo, 'hooks-disabled')], + ]; + if (signing.config['gpg.format']) settings.push(['gpg.format', signing.config['gpg.format']]); + if (signing.config['user.signingkey']) + settings.push(['user.signingkey', signing.config['user.signingkey']]); + if (signing.config['gpg.program']) settings.push(['gpg.program', signing.config['gpg.program']]); + for (const [key, value] of settings) { + const result = runGit(['config', key, value], { cwd: repo }); + if (result.status !== 0) return false; + } + return true; +} + +function signingFailureReason(stderr) { + const message = stderr.toLowerCase(); + if (message.includes('passphrase')) return 'signing key requires an unavailable passphrase'; + if (message.includes('no such file') || message.includes('cannot open')) + return 'signing key or signer program is unavailable'; + return 'Git signing command failed'; +} + +export function runSigningProbe(cwd = process.cwd()) { + const signing = getSigningConfig(cwd); + const identity = getIdentity(cwd); + if (!signing.enabled) return { ok: false, reason: 'commit.gpgsign is not enabled' }; + if (!signing.keyConfigured) return { ok: false, reason: 'no signing key is configured' }; + if (!identity.name || !identity.email) + return { ok: false, reason: 'user.name and user.email are required' }; + + let probe; + try { + probe = mkdtempSync(join(tmpdir(), 'worldscript-signing-probe-')); + } catch (error) { + if (error?.code !== 'EROFS' && error?.code !== 'EACCES') throw error; + probe = mkdtempSync(join(cwd, '.worldscript-signing-probe-')); + } + try { + let result = runGit(['init', '--quiet', '--initial-branch=main', probe]); + if (result.status !== 0 || !configureProbeRepository(probe, signing, identity)) { + return { ok: false, reason: 'isolated probe repository could not be configured' }; + } + result = runGit( + ['commit-tree', '-S', '-m', 'WorldScript signing capability probe', EMPTY_TREE], + { + cwd: probe, + }, + ); + if (result.status !== 0 || !isSha(result.stdout.trim())) { + return { + ok: false, + reason: `plumbing-level signed commit could not be created: ${signingFailureReason(result.stderr)}`, + }; + } + const commit = result.stdout.trim(); + const verification = verifyCommitObject(commit, probe); + return verification.ok + ? { ok: true, commit } + : { ok: false, reason: `Git-native verification failed: ${verification.reason}` }; + } finally { + rmSync(probe, { recursive: true, force: true }); + } +} + +export function hasCommitSignature(commitText) { + return /(^|\n)gpgsig /.test(commitText); +} + +export function classifyCommitObject({ objectType, contents, verificationStatus }) { + if (objectType !== 'commit') return { ok: false, reason: 'object is not a commit' }; + if (!hasCommitSignature(contents)) return { ok: false, reason: 'commit has no signature object' }; + if (verificationStatus !== 0) + return { ok: false, reason: 'Git-native signature verification failed' }; + return { ok: true, reason: 'Git-native signature verified' }; +} + +export function verifyCommitObject(sha, cwd = process.cwd()) { + if (!isSha(sha)) return { ok: false, reason: 'invalid commit SHA' }; + const object = runGit(['cat-file', '-t', sha], { cwd }); + const contents = runGit(['cat-file', '-p', sha], { cwd }); + const verified = runGit(['verify-commit', '--raw', sha], { cwd }); + return classifyCommitObject({ + objectType: object.status === 0 ? object.stdout.trim() : '', + contents: contents.status === 0 ? contents.stdout : '', + verificationStatus: verified.status, + }); +} + +export function commitSubject(sha, cwd = process.cwd()) { + return gitOutput(['show', '-s', '--format=%s', sha], { cwd }) || '(subject unavailable)'; +} + +export function commitsInRange(range, cwd = process.cwd()) { + if (!range || /\s/.test(range)) throw new Error('range must be one Git revision range'); + const result = runGit(['rev-list', '--reverse', range], { cwd }); + if (result.status !== 0) throw new Error('requested Git range is not available'); + return result.stdout.split(/\r?\n/).filter(Boolean); +} + +export function verifyCommitRange(range, cwd = process.cwd()) { + return commitsInRange(range, cwd).map((sha) => ({ + sha, + subject: commitSubject(sha, cwd), + verification: verifyCommitObject(sha, cwd), + })); +} + +export function parseRefUpdate(line) { + const fields = line.trim().split(/\s+/); + if (fields.length !== 3 || !fields[0] || !fields[1] || !fields[2]) return null; + return { localRef: fields[0], localSha: fields[1], remoteRef: fields[2] }; +} + +function refSha(ref, cwd) { + const sha = gitOutput(['rev-parse', '--verify', `${ref}^{commit}`], { cwd }); + return isSha(sha) ? sha : null; +} + +export function remoteTrackingBases(remote, remoteRef, cwd = process.cwd()) { + const bases = []; + const add = (ref) => { + const sha = refSha(ref, cwd); + if (sha && !bases.includes(sha)) bases.push(sha); + }; + if (remoteRef.startsWith('refs/heads/')) { + const branch = remoteRef.slice('refs/heads/'.length); + add(`refs/remotes/${remote}/${branch}`); + const symbolic = gitOutput( + ['symbolic-ref', '--quiet', '--short', `refs/remotes/${remote}/HEAD`], + { cwd }, + ); + if (symbolic) add(symbolic); + add(`refs/remotes/${remote}/main`); + add(`refs/remotes/${remote}/master`); + } + return bases; +} + +export function introducedCommits(update, remote, cwd = process.cwd()) { + if (update.remoteRef.startsWith('refs/tags/')) return []; + if (!update.remoteRef.startsWith('refs/heads/')) { + throw new Error(`unsupported outgoing ref ${update.remoteRef}`); + } + const bases = remoteTrackingBases(remote, update.remoteRef, cwd); + const args = ['rev-list', '--reverse', update.localSha, ...bases.map((base) => `^${base}`)]; + const result = runGit(args, { cwd }); + if (result.status !== 0) + throw new Error(`cannot enumerate outgoing commits for ${update.localRef}`); + return result.stdout.split(/\r?\n/).filter(Boolean); +} + +export function selectIntroducedCommits(commits, reachableFromBase) { + const excluded = new Set(reachableFromBase); + return commits.filter((sha) => !excluded.has(sha)); +} + +export function parseAnnotatedTag(sha, cwd = process.cwd()) { + const type = gitOutput(['cat-file', '-t', sha], { cwd }); + if (type === 'commit') return { objectType: 'commit', target: sha }; + if (type !== 'tag') return null; + const body = gitOutput(['cat-file', '-p', sha], { cwd }); + const target = body.match(/^object ([0-9a-f]{40})$/m)?.[1] ?? ''; + const targetType = target ? gitOutput(['cat-file', '-t', target], { cwd }) : ''; + return { objectType: 'tag', target, targetType }; +} + +export function verifyTagObject(sha, cwd = process.cwd()) { + const tag = parseAnnotatedTag(sha, cwd); + if (!tag) return { ok: false, reason: 'tag target is not a commit or annotated tag' }; + if (tag.objectType === 'commit') return verifyCommitObject(tag.target, cwd); + if (!tag.target || tag.targetType !== 'commit') + return { ok: false, reason: 'annotated tag does not target a commit' }; + const verifiedTag = runGit(['verify-tag', '--raw', sha], { cwd }); + if (verifiedTag.status !== 0) + return { ok: false, reason: 'Git-native tag signature verification failed' }; + const verifiedCommit = verifyCommitObject(tag.target, cwd); + return verifiedCommit.ok + ? { ok: true, reason: 'tag and target commit verified' } + : verifiedCommit; +} + +export function classifyTagVerification({ + objectType, + targetType, + tagVerificationStatus, + commitVerification, +}) { + if (objectType === 'commit') return commitVerification; + if (objectType !== 'tag' || targetType !== 'commit') + return { ok: false, reason: 'annotated tag does not target a commit' }; + if (tagVerificationStatus !== 0) + return { ok: false, reason: 'Git-native tag signature verification failed' }; + return commitVerification.ok + ? { ok: true, reason: 'tag and target commit verified' } + : commitVerification; +} + +export function verifyOutgoingUpdates(lines, remote, cwd = process.cwd()) { + const updates = lines.map(parseRefUpdate); + if (updates.some((update) => !update)) + return { ok: false, reason: 'invalid pre-push ref-update input' }; + const reports = []; + for (const update of updates) { + if (isZeroSha(update.localSha)) continue; + if (!isSha(update.localSha)) + return { ok: false, reason: `invalid outgoing SHA for ${update.remoteRef}` }; + if (update.remoteRef.startsWith('refs/tags/')) { + const verification = verifyTagObject(update.localSha, cwd); + reports.push({ sha: update.localSha, subject: update.remoteRef, verification }); + if (!verification.ok) + return { ok: false, reports, reason: `${update.remoteRef}: ${verification.reason}` }; + continue; + } + const commits = introducedCommits(update, remote, cwd); + for (const sha of commits) { + const verification = verifyCommitObject(sha, cwd); + const report = { sha, subject: commitSubject(sha, cwd), verification }; + reports.push(report); + if (!verification.ok) + return { ok: false, reports, reason: `${sha.slice(0, 12)}: ${verification.reason}` }; + } + } + return { ok: true, reports }; +} + +export function safeConfigSummary(cwd = process.cwd()) { + const signing = getSigningConfig(cwd); + const identity = getIdentity(cwd); + const hooks = getGitDirectory(cwd); + const hookDir = signing.config['core.hooksPath'] + ? resolve(cwd, signing.config['core.hooksPath']) + : hooks; + const hookNames = ['pre-commit', 'pre-push']; + return { + signing: { + format: signing.format, + enabled: signing.enabled, + keyConfigured: signing.keyConfigured, + keyDisplay: signing.keyDisplay, + gpgProgramConfigured: signing.gpgProgramConfigured, + allowedSignersConfigured: signing.allowedSignersConfigured, + }, + identity: { + nameConfigured: Boolean(identity.name), + emailConfigured: Boolean(identity.email), + githubCompatible: isGitHubCompatibleEmail(identity.email), + }, + hooks: { + pathConfigured: Boolean(signing.config['core.hooksPath']), + directory: hookDir ? basename(hookDir) : '', + preCommitInstalled: Boolean( + hookDir && hookNames.every((name) => existsSync(join(hookDir, name))), + ), + }, + unsafeOverrides: getUnsafeOverrides(), + }; +} diff --git a/scripts/signing/verify-github-signatures.mjs b/scripts/signing/verify-github-signatures.mjs new file mode 100644 index 000000000..449dd3c51 --- /dev/null +++ b/scripts/signing/verify-github-signatures.mjs @@ -0,0 +1,62 @@ +#!/usr/bin/env node +import { readFileSync } from 'node:fs'; +import process from 'node:process'; +import { commitsInRange } from './signing-core.mjs'; +import { + hasCompleteCommitRange, + verifyRemoteCommitRange, + verifyRemotePullRequest, + verifyRemoteTag, +} from './verify-remote.mjs'; + +const [owner, repo] = String(process.env.GITHUB_REPOSITORY ?? '').split('/', 2); +const token = process.env.GITHUB_TOKEN ?? ''; +const fetchImpl = fetch; +const event = process.env.GITHUB_EVENT_NAME ?? ''; + +function fail(message) { + console.error(`signature gate failed: ${message}`); + process.exit(1); +} + +if (!owner || !repo) fail('GITHUB_REPOSITORY is unavailable'); + +try { + let result; + if (event === 'pull_request') { + const payload = JSON.parse(readFileSync(process.env.GITHUB_EVENT_PATH, 'utf8')); + const number = payload.pull_request?.number; + const before = payload.pull_request?.base?.sha; + const after = payload.pull_request?.head?.sha; + if (!number || !before || !after) + fail('pull request payload is missing its exact before/after SHAs'); + const expected = new Set(commitsInRange(`${before}..${after}`)); + result = await verifyRemotePullRequest({ owner, repo, number, token, fetchImpl }); + if (!hasCompleteCommitRange([...expected], result.reports)) { + fail('GitHub PR pagination did not cover the complete before..after commit range'); + } + } else if (event === 'push' && process.env.GITHUB_REF_TYPE === 'tag') { + result = await verifyRemoteTag({ + owner, + repo, + tag: process.env.GITHUB_REF_NAME, + token, + fetchImpl, + }); + } else { + const before = process.env.GITHUB_EVENT_BEFORE; + const after = process.env.GITHUB_SHA; + if (!after) fail('push event is missing its after SHA'); + const shas = + before && !/^0{40}$/.test(before) ? commitsInRange(`${before}..${after}`) : [after]; + result = await verifyRemoteCommitRange({ owner, repo, shas, token, fetchImpl }); + } + for (const report of result.reports) { + console.log( + `${report.sha} ${report.verified ? 'verified' : 'REJECTED'} ${report.reason} ${report.subject}`, + ); + } + if (!result.ok) fail(result.reason); +} catch (error) { + fail(error instanceof Error ? error.message : 'unexpected verification error'); +} diff --git a/scripts/signing/verify-outgoing.mjs b/scripts/signing/verify-outgoing.mjs new file mode 100644 index 000000000..7b3d52339 --- /dev/null +++ b/scripts/signing/verify-outgoing.mjs @@ -0,0 +1,26 @@ +#!/usr/bin/env node +import process from 'node:process'; +import { verifyOutgoingUpdates } from './signing-core.mjs'; + +const remote = process.argv[2]; +if (!remote) { + console.error('pre-push signing check requires the remote name'); + process.exit(1); +} +let input = ''; +process.stdin.setEncoding('utf8'); +for await (const chunk of process.stdin) input += chunk; +const lines = input.split(/\r?\n/).filter(Boolean); +try { + const result = verifyOutgoingUpdates(lines, remote); + if (!result.ok) { + console.error(`pre-push signing check rejected the update: ${result.reason}`); + process.exit(1); + } + console.log(`pre-push signing check verified ${result.reports.length} outgoing object(s)`); +} catch (error) { + console.error( + `pre-push signing check failed closed: ${error instanceof Error ? error.message : 'unknown error'}`, + ); + process.exit(1); +} diff --git a/scripts/signing/verify-remote.d.mts b/scripts/signing/verify-remote.d.mts new file mode 100644 index 000000000..cc7eb89cc --- /dev/null +++ b/scripts/signing/verify-remote.d.mts @@ -0,0 +1,42 @@ +export interface RemoteCommit { + sha: string; + commit?: { + message?: string; + verification?: { + verified?: boolean; + reason?: string; + }; + }; +} + +export interface RemoteReport { + sha: string; + subject: string; + verified: boolean; + reason: string; +} + +export interface RemoteResult { + ok: boolean; + reports: RemoteReport[]; + reason: string; +} + +export interface RemoteOptions { + owner: string; + repo: string; + number: number | string; + token?: string; + fetchImpl?: typeof fetch; +} + +export function verifyRemoteCommitReports(commits: RemoteCommit[]): RemoteResult; +export function hasCompleteCommitRange(expectedShas: string[], reports: RemoteReport[]): boolean; +export function verifyRemotePullRequest(options: RemoteOptions): Promise; +export function verifyRemoteTag(options: { + owner: string; + repo: string; + tag: string; + token?: string; + fetchImpl?: typeof fetch; +}): Promise; diff --git a/scripts/signing/verify-remote.mjs b/scripts/signing/verify-remote.mjs new file mode 100644 index 000000000..337622f33 --- /dev/null +++ b/scripts/signing/verify-remote.mjs @@ -0,0 +1,169 @@ +#!/usr/bin/env node +import process from 'node:process'; + +const API = 'https://api.github.com'; + +function safeText(value, limit = 200) { + return Array.from(String(value ?? ''), (character) => + character.charCodeAt(0) >= 32 && character.charCodeAt(0) !== 127 ? character : ' ', + ) + .join('') + .slice(0, limit); +} + +function safeSha(value) { + return String(value ?? '').match(/^[0-9a-f]{12}/i)?.[0] ?? 'invalid-sha'; +} + +function safeSubject(message) { + return ( + safeText( + String(message ?? '') + .split(/\r?\n/, 1)[0] + .slice(0, 160), + ) || '(subject unavailable)' + ); +} + +function safeReport(commit) { + return { + sha: safeSha(commit.sha), + subject: safeSubject(commit.commit?.message), + verified: commit.commit?.verification?.verified === true, + reason: safeText(commit.commit?.verification?.reason ?? 'missing verification result'), + }; +} + +export async function fetchJson(fetchImpl, url, token) { + const response = await fetchImpl(url, { + headers: { + Accept: 'application/vnd.github+json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + 'X-GitHub-Api-Version': '2022-11-28', + }, + }); + if (!response.ok) throw new Error(`GitHub API request failed (${response.status})`); + return response.json(); +} + +export async function fetchPullRequestCommits({ + owner, + repo, + number, + token = '', + fetchImpl = fetch, +}) { + const commits = []; + for (let page = 1; ; page += 1) { + const batch = await fetchJson( + fetchImpl, + `${API}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls/${number}/commits?per_page=100&page=${page}`, + token, + ); + if (!Array.isArray(batch)) throw new Error('GitHub PR commits response was not an array'); + commits.push(...batch); + if (batch.length < 100) return commits; + } +} + +export function verifyRemoteCommitReports(commits) { + const reports = commits.map(safeReport); + const invalid = reports.find((report) => !report.verified); + return { ok: !invalid, reports, reason: invalid ? `${invalid.sha}: ${invalid.reason}` : '' }; +} + +export function hasCompleteCommitRange(expectedShas, reports) { + if (expectedShas.length !== reports.length) return false; + return expectedShas.every((sha) => reports.some((report) => sha.startsWith(report.sha))); +} + +export async function verifyRemotePullRequest(options) { + const commits = await fetchPullRequestCommits(options); + return verifyRemoteCommitReports(commits); +} + +export async function verifyRemoteCommitRange({ + owner, + repo, + shas, + token = '', + fetchImpl = fetch, +}) { + const commits = []; + for (const sha of shas) { + commits.push( + await fetchJson( + fetchImpl, + `${API}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/commits/${sha}`, + token, + ), + ); + } + return verifyRemoteCommitReports(commits); +} + +export async function verifyRemoteTag({ owner, repo, tag, token = '', fetchImpl = fetch }) { + const ref = await fetchJson( + fetchImpl, + `${API}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/git/ref/tags/${encodeURIComponent(tag)}`, + token, + ); + const object = ref.object; + if (!object?.sha || !object.type) throw new Error('GitHub tag ref response was incomplete'); + let tagReport = null; + let targetSha = object.sha; + if (object.type === 'tag') { + const tagObject = await fetchJson( + fetchImpl, + `${API}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/git/tags/${encodeURIComponent(object.sha)}`, + token, + ); + tagReport = { + sha: safeSha(object.sha), + subject: safeText(`tag ${tag}`, 160), + verified: tagObject.verification?.verified === true, + reason: safeText(tagObject.verification?.reason ?? 'missing tag verification result'), + }; + targetSha = tagObject.object?.sha; + if (tagObject.object?.type !== 'commit' || !targetSha) + throw new Error('annotated tag does not target a commit'); + } + const commit = await fetchJson( + fetchImpl, + `${API}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/commits/${encodeURIComponent(targetSha)}`, + token, + ); + const commitReport = safeReport(commit); + const reports = tagReport ? [tagReport, commitReport] : [commitReport]; + const invalid = reports.find((report) => !report.verified); + return { ok: !invalid, reports, reason: invalid ? `${invalid.sha}: ${invalid.reason}` : '' }; +} + +export { safeReport }; + +if (process.argv[1]?.endsWith('/verify-remote.mjs')) { + const [repository, number] = process.argv.slice(2); + const [owner, repo] = String(repository ?? process.env.GITHUB_REPOSITORY ?? '').split('/', 2); + if (!owner || !repo || !number) { + console.error('usage: pnpm run signing:verify-remote -- / '); + process.exit(2); + } + try { + const result = await verifyRemotePullRequest({ + owner, + repo, + number, + token: process.env.GITHUB_TOKEN ?? '', + }); + for (const report of result.reports) + console.log( + `${report.sha} ${report.verified ? 'verified' : 'REJECTED'} ${report.reason} ${report.subject}`, + ); + process.exit(result.ok ? 0 : 1); + } catch (error) { + console.error( + `remote signature verification failed: ${error instanceof Error ? error.message : 'unexpected API error'}`, + ); + process.exit(1); + } +} diff --git a/tests/unit/signing.test.ts b/tests/unit/signing.test.ts new file mode 100644 index 000000000..471730563 --- /dev/null +++ b/tests/unit/signing.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it } from 'vitest'; +import { + classifyCommitObject, + classifyTagVerification, + getSigningConfig, + hasCommitSignature, + isGitHubCompatibleEmail, + parseRefUpdate, + selectIntroducedCommits, +} from '../../scripts/signing/signing-core.mjs'; +import { + hasCompleteCommitRange, + verifyRemoteCommitReports, + verifyRemotePullRequest, + verifyRemoteTag, +} from '../../scripts/signing/verify-remote.mjs'; + +describe('local signing controls', () => { + it('parses every pre-push update shape, including deletion', () => { + expect(parseRefUpdate('refs/heads/main abc refs/heads/main')).toEqual({ + localRef: 'refs/heads/main', + localSha: 'abc', + remoteRef: 'refs/heads/main', + }); + expect( + parseRefUpdate( + '0000000000000000000000000000000000000000 0000000000000000000000000000000000000000 refs/heads/deleted', + ), + ).toEqual({ + localRef: '0000000000000000000000000000000000000000', + localSha: '0000000000000000000000000000000000000000', + remoteRef: 'refs/heads/deleted', + }); + expect(parseRefUpdate('not enough')).toBeNull(); + }); + + it('checks only commits introduced beyond the remote tracking base', () => { + const base = 'a'.repeat(40); + const introduced = 'b'.repeat(40); + expect(selectIntroducedCommits([base, introduced], [base])).toEqual([introduced]); + }); + + it('requires both an annotated tag signature and its target commit signature', () => { + const unsignedCommit = { ok: false, reason: 'commit has no signature object' }; + expect( + classifyTagVerification({ + objectType: 'tag', + targetType: 'commit', + tagVerificationStatus: 0, + commitVerification: unsignedCommit, + }), + ).toEqual(unsignedCommit); + expect( + classifyTagVerification({ + objectType: 'tag', + targetType: 'commit', + tagVerificationStatus: 1, + commitVerification: { ok: true, reason: 'verified' }, + }), + ).toMatchObject({ ok: false }); + }); + + it('distinguishes unsigned and malformed signed commit objects', () => { + expect( + classifyCommitObject({ objectType: 'commit', contents: '', verificationStatus: 0 }), + ).toMatchObject({ ok: false, reason: 'commit has no signature object' }); + expect(hasCommitSignature('\ngpgsig -----BEGIN SSH SIGNATURE-----\n')).toBe(true); + expect( + classifyCommitObject({ + objectType: 'commit', + contents: '\ngpgsig malformed\n', + verificationStatus: 1, + }), + ).toMatchObject({ ok: false, reason: 'Git-native signature verification failed' }); + }); + + it('reports configuration and GitHub-compatible identity without a key fallback', () => { + expect(getSigningConfig()).toHaveProperty('enabled'); + expect(isGitHubCompatibleEmail('155236708+qnbs@users.noreply.github.com')).toBe(true); + expect(isGitHubCompatibleEmail('writer@example.com')).toBe(false); + }); +}); + +describe('GitHub signature API controls', () => { + it('rejects any unverified commit while preserving safe report fields', () => { + const result = verifyRemoteCommitReports([ + { + sha: 'a'.repeat(40), + commit: { message: 'signed', verification: { verified: true, reason: 'valid' } }, + }, + { + sha: 'b'.repeat(40), + commit: { + message: 'unsigned\nbody', + verification: { verified: false, reason: 'unsigned' }, + }, + }, + ]); + expect(result.ok).toBe(false); + expect(result.reason).toBe('bbbbbbbbbbbb: unsigned'); + expect(result.reports[1]).toEqual({ + sha: 'bbbbbbbbbbbb', + subject: 'unsigned', + verified: false, + reason: 'unsigned', + }); + expect(hasCompleteCommitRange(['a'.repeat(40), 'b'.repeat(40)], result.reports)).toBe(true); + expect(hasCompleteCommitRange(['a'.repeat(40), 'c'.repeat(40)], result.reports)).toBe(false); + }); + + it('paginates PR commits and verifies annotated tags plus target commits', async () => { + const verifiedCommit = (sha: string) => ({ + sha, + commit: { message: 'commit', verification: { verified: true, reason: 'valid' } }, + }); + const pages = new Map([ + [ + 'page=1', + Array.from({ length: 100 }, (_, index) => verifiedCommit(String(index).padStart(40, '0'))), + ], + ['page=2', [verifiedCommit('f'.repeat(40))]], + ]); + const fetchImpl: typeof fetch = async (input) => + new Response(JSON.stringify(pages.get(String(input).split('&').pop() ?? '') ?? []), { + status: 200, + }); + const pullRequest = await verifyRemotePullRequest({ + owner: 'qnbs', + repo: 'WorldScript-Studio', + number: 1, + fetchImpl, + }); + expect(pullRequest.ok).toBe(true); + expect(pullRequest.reports).toHaveLength(101); + + const tagObjectSha = '1'.repeat(40); + const targetSha = '2'.repeat(40); + const tagFetch: typeof fetch = async (input) => { + const url = String(input); + if (url.includes('/git/ref/tags/')) + return new Response(JSON.stringify({ object: { sha: tagObjectSha, type: 'tag' } }), { + status: 200, + }); + if (url.includes('/git/tags/')) + return new Response( + JSON.stringify({ + object: { sha: targetSha, type: 'commit' }, + verification: { verified: true, reason: 'valid' }, + }), + { status: 200 }, + ); + return new Response(JSON.stringify(verifiedCommit(targetSha)), { status: 200 }); + }; + const tag = await verifyRemoteTag({ + owner: 'qnbs', + repo: 'WorldScript-Studio', + tag: 'v1.0.0', + fetchImpl: tagFetch, + }); + expect(tag.ok).toBe(true); + expect(tag.reports).toHaveLength(2); + }); +}); From 9775493a2df26d03b550e8622781210ef0bbfc36 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Sun, 23 Aug 2026 01:38:06 +0200 Subject: [PATCH 2/5] fix(security): forward pre-push hook arguments Ensure the installed Git hook passes the remote name into the complete outgoing-range signing check. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 6d81e390e..708bb32d9 100644 --- a/package.json +++ b/package.json @@ -206,7 +206,7 @@ }, "simple-git-hooks": { "pre-commit": "node scripts/hooks/pre-commit.mjs", - "pre-push": "node scripts/hooks/pre-push.mjs" + "pre-push": "node scripts/hooks/pre-push.mjs \"$@\"" }, "lint-staged": { "*.{ts,tsx,js,mjs,css,md,json}": "biome check --write --error-on-warnings --no-errors-on-unmatched --files-ignore-unknown=true", From 2f1567d3538d6d5619eb91d5acb9c6ccc681f83e Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Sun, 23 Aug 2026 01:43:10 +0200 Subject: [PATCH 3/5] fix(security): parse complete pre-push updates Validate the four-field Git pre-push protocol so branch, deletion, multi-ref, and tag updates are checked without accepting malformed input. --- README.md | 8 ++++---- scripts/signing/signing-core.d.mts | 1 + scripts/signing/signing-core.mjs | 5 +++-- tests/unit/signing.test.ts | 19 +++++++++++-------- 4 files changed, 19 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 0410afb73..ae053ad67 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ IndexedDB v8 PWA v3.0 i18n 19 locales โ€” 2925 keys - 6937+ tests / 573 files + 6944+ tests / 574 files Codecov Coverage License MIT CI Status @@ -511,7 +511,7 @@ The Settings โ†’ AI panel shows a live GPU status badge with adapter details and | **Document Export** | docx + jszip | Word-compatible `.docx` generation (lazy-loaded) | | **PWA** | Service Worker + Web App Manifest v3 | Offline support, installability, Workbox chunking | | **i18n** | Custom React Context (`I18nContext.tsx`) | 2925 keys ร— 19 locales (de/en/es/fr/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta); EN fallback; `localStorage` persistence | -| **Testing** | Vitest 4.x (6937+ tests / 573 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) | +| **Testing** | Vitest 4.x (6944+ tests / 574 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) | | **Code Quality** | Biome (lint + format) + TypeScript 7 (tsgo) strict | `--error-on-warnings` in CI; zero `any` policy | | **Visualization** | Force-directed graph | Interactive character relationship network | | **Desktop** | Tauri v2 | Cross-platform installer; auto-updater via `latest.json` | @@ -549,7 +549,7 @@ WorldScript-Studio/ โ”‚ โ”œโ”€โ”€ sw.js # PWA Service Worker โ”‚ โ””โ”€โ”€ manifest.json # PWA Web App Manifest v3 โ”œโ”€โ”€ tests/ -โ”‚ โ”œโ”€โ”€ unit/ # Vitest unit tests (6937+ tests, 573 files) โ€” count spans tests/, components/, packages/*/tests/, not just this folder +โ”‚ โ”œโ”€โ”€ unit/ # Vitest unit tests (6944+ tests, 574 files) โ€” count spans tests/, components/, packages/*/tests/, not just this folder โ”‚ โ”‚ โ”œโ”€โ”€ ai/ # aiSmallModules, aiCoreFallbackPaths โ”‚ โ”‚ โ””โ”€โ”€ settings/ # WebLlmPanel, AiSections โ”‚ โ””โ”€โ”€ e2e/ # Playwright specs + helpers.ts @@ -711,7 +711,7 @@ The main pipeline is [`.github/workflows/ci.yml`](.github/workflows/ci.yml). Opt | `scorecard` | weekly + `main` push | OpenSSF Scorecard โ€” SARIF uploaded to GitHub Code Scanning | **Current test metrics (2026-08-21, source-synchronized; CI remains authoritative for pass/fail):** -- **6937+ unit tests** across **573 test files** โ€” CI is authoritative for pass/fail +- **6944+ unit tests** across **574 test files** โ€” CI is authoritative for pass/fail - Coverage thresholds: lines โ‰ฅ 80 ยท branches โ‰ฅ 66 ยท functions โ‰ฅ 72 ยท statements โ‰ฅ 78 โ€” enforced in CI (see Codecov badge for live metrics) - i18n: **2925 keys ร— 19 locales** (en/de/fr/es/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta) diff --git a/scripts/signing/signing-core.d.mts b/scripts/signing/signing-core.d.mts index 5dc845a09..d8a857a59 100644 --- a/scripts/signing/signing-core.d.mts +++ b/scripts/signing/signing-core.d.mts @@ -7,6 +7,7 @@ export interface RefUpdate { localRef: string; localSha: string; remoteRef: string; + remoteSha: string; } export interface SigningConfig { diff --git a/scripts/signing/signing-core.mjs b/scripts/signing/signing-core.mjs index edd50b652..c04e7351f 100644 --- a/scripts/signing/signing-core.mjs +++ b/scripts/signing/signing-core.mjs @@ -218,10 +218,11 @@ export function verifyCommitRange(range, cwd = process.cwd()) { })); } +// QNBS-v3: Git supplies both object IDs so malformed ref updates fail closed. export function parseRefUpdate(line) { const fields = line.trim().split(/\s+/); - if (fields.length !== 3 || !fields[0] || !fields[1] || !fields[2]) return null; - return { localRef: fields[0], localSha: fields[1], remoteRef: fields[2] }; + if (fields.length !== 4 || !fields[0] || !fields[1] || !fields[2] || !fields[3]) return null; + return { localRef: fields[0], localSha: fields[1], remoteRef: fields[2], remoteSha: fields[3] }; } function refSha(ref, cwd) { diff --git a/tests/unit/signing.test.ts b/tests/unit/signing.test.ts index 471730563..41d899de2 100644 --- a/tests/unit/signing.test.ts +++ b/tests/unit/signing.test.ts @@ -17,21 +17,24 @@ import { describe('local signing controls', () => { it('parses every pre-push update shape, including deletion', () => { - expect(parseRefUpdate('refs/heads/main abc refs/heads/main')).toEqual({ + const remoteSha = 'c'.repeat(40); + expect( + parseRefUpdate(`refs/heads/main ${'a'.repeat(40)} refs/heads/main ${remoteSha}`), + ).toEqual({ localRef: 'refs/heads/main', - localSha: 'abc', + localSha: 'a'.repeat(40), remoteRef: 'refs/heads/main', + remoteSha, }); expect( - parseRefUpdate( - '0000000000000000000000000000000000000000 0000000000000000000000000000000000000000 refs/heads/deleted', - ), + parseRefUpdate(`refs/heads/deleted ${'0'.repeat(40)} refs/heads/deleted ${'d'.repeat(40)}`), ).toEqual({ - localRef: '0000000000000000000000000000000000000000', - localSha: '0000000000000000000000000000000000000000', + localRef: 'refs/heads/deleted', + localSha: '0'.repeat(40), remoteRef: 'refs/heads/deleted', + remoteSha: 'd'.repeat(40), }); - expect(parseRefUpdate('not enough')).toBeNull(); + expect(parseRefUpdate('refs/heads/main abc refs/heads/main')).toBeNull(); }); it('checks only commits introduced beyond the remote tracking base', () => { From 5b77930be41d65e03f88fa17a2a190f99f15cad2 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Sun, 23 Aug 2026 02:26:00 +0200 Subject: [PATCH 4/5] fix(security): close signing review gaps Require exact push ranges and annotated release tags, preserve SSH verification configuration in isolated probes, and keep CI signature authority and audit documentation explicit. --- README.md | 8 +- docs/CI.md | 5 +- docs/VERIFIED-SIGNING.md | 6 +- scripts/signing/doctor.mjs | 2 +- scripts/signing/signing-core.d.mts | 77 ++++++++++++ scripts/signing/signing-core.mjs | 56 +++++++-- scripts/signing/verify-github-signatures.mjs | 22 ++-- scripts/signing/verify-outgoing.mjs | 4 +- scripts/signing/verify-remote.d.mts | 13 ++ scripts/signing/verify-remote.mjs | 35 +++--- tests/unit/signing.test.ts | 125 +++++++++++++++++++ tests/unit/workflowPolicy.test.ts | 2 + 12 files changed, 314 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index ae053ad67..8c23e8a8e 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ IndexedDB v8 PWA v3.0 i18n 19 locales โ€” 2925 keys - 6944+ tests / 574 files + 6947+ tests / 574 files Codecov Coverage License MIT CI Status @@ -511,7 +511,7 @@ The Settings โ†’ AI panel shows a live GPU status badge with adapter details and | **Document Export** | docx + jszip | Word-compatible `.docx` generation (lazy-loaded) | | **PWA** | Service Worker + Web App Manifest v3 | Offline support, installability, Workbox chunking | | **i18n** | Custom React Context (`I18nContext.tsx`) | 2925 keys ร— 19 locales (de/en/es/fr/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta); EN fallback; `localStorage` persistence | -| **Testing** | Vitest 4.x (6944+ tests / 574 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) | +| **Testing** | Vitest 4.x (6947+ tests / 574 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) | | **Code Quality** | Biome (lint + format) + TypeScript 7 (tsgo) strict | `--error-on-warnings` in CI; zero `any` policy | | **Visualization** | Force-directed graph | Interactive character relationship network | | **Desktop** | Tauri v2 | Cross-platform installer; auto-updater via `latest.json` | @@ -549,7 +549,7 @@ WorldScript-Studio/ โ”‚ โ”œโ”€โ”€ sw.js # PWA Service Worker โ”‚ โ””โ”€โ”€ manifest.json # PWA Web App Manifest v3 โ”œโ”€โ”€ tests/ -โ”‚ โ”œโ”€โ”€ unit/ # Vitest unit tests (6944+ tests, 574 files) โ€” count spans tests/, components/, packages/*/tests/, not just this folder +โ”‚ โ”œโ”€โ”€ unit/ # Vitest unit tests (6947+ tests, 574 files) โ€” count spans tests/, components/, packages/*/tests/, not just this folder โ”‚ โ”‚ โ”œโ”€โ”€ ai/ # aiSmallModules, aiCoreFallbackPaths โ”‚ โ”‚ โ””โ”€โ”€ settings/ # WebLlmPanel, AiSections โ”‚ โ””โ”€โ”€ e2e/ # Playwright specs + helpers.ts @@ -711,7 +711,7 @@ The main pipeline is [`.github/workflows/ci.yml`](.github/workflows/ci.yml). Opt | `scorecard` | weekly + `main` push | OpenSSF Scorecard โ€” SARIF uploaded to GitHub Code Scanning | **Current test metrics (2026-08-21, source-synchronized; CI remains authoritative for pass/fail):** -- **6944+ unit tests** across **574 test files** โ€” CI is authoritative for pass/fail +- **6947+ unit tests** across **574 test files** โ€” CI is authoritative for pass/fail - Coverage thresholds: lines โ‰ฅ 80 ยท branches โ‰ฅ 66 ยท functions โ‰ฅ 72 ยท statements โ‰ฅ 78 โ€” enforced in CI (see Codecov badge for live metrics) - i18n: **2925 keys ร— 19 locales** (en/de/fr/es/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta) diff --git a/docs/CI.md b/docs/CI.md index ab3150548..b65d740bb 100644 --- a/docs/CI.md +++ b/docs/CI.md @@ -37,8 +37,8 @@ CI runs for the affected test path before removing a temporary quarantine. ### Gate authority -`โœ… CI Success` is the required branch-protection status and aggregates `security`, `quality`, -`changes`, `rust-tauri`, `core-rust`, `build`, `e2e`, `lighthouse`, and `vrt`. `e2e-deep` and +`โœ… CI Success` is the required branch-protection status and aggregates `security`, `signatures`, +`quality`, `changes`, `rust-tauri`, `core-rust`, `build`, `e2e`, `lighthouse`, and `vrt`. `e2e-deep` and `storybook` are explicitly advisory at job level while their stability criteria are measured. The `deploy` job depends only on that aggregate and remains main-push-only. @@ -104,6 +104,7 @@ security โ”€โ”€โ–บ quality โ”€โ”€โ”ฌโ”€โ”€โ–บ build โ”€โ”€โ”ฌโ”€โ”€โ–บ lighthous โ””โ”€โ”€โ–บ storybook (advisory) security โ”€โ”ฌ +signatures โ”€โ”ค quality โ”€โ”€โ”ผโ”€โ”€โ–บ ci-success (required-status aggregator) changes โ”€โ”€โ”ค rust โ”€โ”€โ”€โ”€โ”ค diff --git a/docs/VERIFIED-SIGNING.md b/docs/VERIFIED-SIGNING.md index ee89ba80f..527f95995 100644 --- a/docs/VERIFIED-SIGNING.md +++ b/docs/VERIFIED-SIGNING.md @@ -10,7 +10,8 @@ but different checks: - GitHub's `verification.verified` result is the release and merge gate. It covers GitHub's signature parser, key association, and account identity rules. - Annotated release tags have two objects to verify: the tag object and its target commit. - Lightweight release tags still require their target commit to be verified. + Lightweight release tags are rejected because they have no independently verifiable tag + object; a verified target commit alone is insufficient for a release tag. ## Local setup and recovery @@ -65,5 +66,6 @@ with the release evidence when refreshing these figures. This snapshot intentionally distinguishes the signed squash result from the source history that preceded it. Open and merged PR source histories should be audited separately when investigating legacy unsigned commits. The counts were collected on 2026-08-23 with GitHub's REST commits API, -`sha=main`, UTC windows ending `2026-08-23T23:59:59Z`, and the `commit.verification.verified` +`sha=main`, at collection time `2026-08-23T00:15:24Z`, with cutoff +`2026-08-23T00:15:24Z` and trailing UTC windows, using the `commit.verification.verified` boolean; they are not inferred from local Git trust. diff --git a/scripts/signing/doctor.mjs b/scripts/signing/doctor.mjs index 125d2a153..c2ce4d5f5 100644 --- a/scripts/signing/doctor.mjs +++ b/scripts/signing/doctor.mjs @@ -30,7 +30,7 @@ if (jsonMode) { `identity: ${identity.name && identity.email ? 'configured' : 'missing'} (${isGitHubCompatibleEmail(identity.email) ? 'GitHub noreply-compatible' : 'GitHub identity requires account verification'})`, ); console.log( - `hooks: ${summary.hooks.pathConfigured ? 'custom path configured' : 'default path'} (${summary.hooks.preCommitInstalled ? 'available' : 'not installed'})`, + `hooks: ${summary.hooks.pathConfigured ? 'custom path configured' : 'default path'} (${summary.hooks.hooksInstalled ? 'available' : 'not installed'})`, ); console.log( `unsafe config overrides: ${unsafeOverrides.length ? unsafeOverrides.join(', ') : 'none detected'}`, diff --git a/scripts/signing/signing-core.d.mts b/scripts/signing/signing-core.d.mts index d8a857a59..ac961e747 100644 --- a/scripts/signing/signing-core.d.mts +++ b/scripts/signing/signing-core.d.mts @@ -3,6 +3,13 @@ export interface VerificationResult { reason: string; } +export interface GitResult { + status: number; + stdout: string; + stderr: string; + error?: Error; +} + export interface RefUpdate { localRef: string; localSha: string; @@ -17,9 +24,38 @@ export interface SigningConfig { allowedSignersConfigured: boolean; enabled: boolean; keyDisplay: string; + allowedSignersFile: string; config: Record; } +export interface GitOptions { + cwd?: string; + input?: string; +} + +export interface OutgoingReport { + sha: string; + subject: string; + verification: VerificationResult; +} + +export interface OutgoingResult { + ok: boolean; + reports?: OutgoingReport[]; + reason: string; +} + +export function runGit(args: string[], options?: GitOptions): GitResult; +export function gitOutput(args: string[], options?: GitOptions): string; +export function isSha(value: unknown): boolean; +export function isZeroSha(value: unknown): boolean; +export function getRepositoryRoot(cwd?: string): string | null; +export function getGitDirectory(cwd?: string): string | null; +export function getConfig(cwd?: string): Record; +export function getIdentity(cwd?: string): { name: string; email: string }; +export function getUnsafeOverrides(env?: Record): string[]; +export function isSigningEnabled(config: Record): boolean; + export function classifyCommitObject(input: { objectType: string; contents: string; @@ -38,4 +74,45 @@ export function parseRefUpdate(line: string): RefUpdate | null; export function selectIntroducedCommits(commits: string[], reachableFromBase: string[]): string[]; export function runSigningProbe(cwd?: string): { ok: boolean; reason?: string; commit?: string }; export function verifyCommitObject(sha: string, cwd?: string): VerificationResult; +export function commitSubject(sha: string, cwd?: string): string; +export function commitsInRange(range: string, cwd?: string): string[]; +export function verifyCommitRange( + range: string, + cwd?: string, +): Array<{ sha: string; subject: string; verification: VerificationResult }>; +export function pushEventRange(payload: { before?: string; after?: string }): { + before: string; + after: string; +}; +export function pushCommitShas( + payload: { before?: string; after?: string }, + cwd?: string, + rangeResolver?: (range: string, cwd?: string) => string[], +): string[]; export function verifyTagObject(sha: string, cwd?: string): VerificationResult; +export function remoteTrackingBases(remote: string, remoteRef: string, cwd?: string): string[]; +export function outgoingBaseShas(update: RefUpdate, fallbackBases: string[]): string[]; +export function introducedCommits(update: RefUpdate, remote: string, cwd?: string): string[]; +export function safeConfigSummary(cwd?: string): { + signing: { + format: string; + enabled: boolean; + keyConfigured: boolean; + keyDisplay: string; + gpgProgramConfigured: boolean; + allowedSignersConfigured: boolean; + }; + identity: { nameConfigured: boolean; emailConfigured: boolean; githubCompatible: boolean }; + hooks: { pathConfigured: boolean; directory: string; hooksInstalled: boolean }; + unsafeOverrides: string[]; +}; +export function verifyOutgoingUpdates( + lines: string[], + remote: string, + cwd?: string, + dependencies?: { + verifyCommitObject?: (sha: string) => VerificationResult; + verifyTagObject?: (sha: string) => VerificationResult; + introducedCommits?: (update: RefUpdate) => string[]; + }, +): OutgoingResult; diff --git a/scripts/signing/signing-core.mjs b/scripts/signing/signing-core.mjs index c04e7351f..64b28b5b8 100644 --- a/scripts/signing/signing-core.mjs +++ b/scripts/signing/signing-core.mjs @@ -96,6 +96,9 @@ export function isSigningEnabled(config) { export function getSigningConfig(cwd = process.cwd()) { const config = getConfig(cwd); + const allowedSignersFile = config['gpg.ssh.allowedSignersFile'] + ? gitOutput(['config', '--path', '--get', 'gpg.ssh.allowedSignersFile'], { cwd }) + : ''; return { format: config['gpg.format'] || 'openpgp', keyConfigured: Boolean(config['user.signingkey']), @@ -103,6 +106,7 @@ export function getSigningConfig(cwd = process.cwd()) { allowedSignersConfigured: Boolean(config['gpg.ssh.allowedSignersFile']), enabled: isSigningEnabled(config), keyDisplay: config['user.signingkey'] ? basename(config['user.signingkey']) : '', + allowedSignersFile, config, }; } @@ -118,6 +122,8 @@ function configureProbeRepository(repo, signing, identity) { if (signing.config['user.signingkey']) settings.push(['user.signingkey', signing.config['user.signingkey']]); if (signing.config['gpg.program']) settings.push(['gpg.program', signing.config['gpg.program']]); + if (signing.allowedSignersFile) + settings.push(['gpg.ssh.allowedSignersFile', signing.allowedSignersFile]); for (const [key, value] of settings) { const result = runGit(['config', key, value], { cwd: repo }); if (result.status !== 0) return false; @@ -218,6 +224,20 @@ export function verifyCommitRange(range, cwd = process.cwd()) { })); } +export function pushEventRange(payload) { + const before = payload?.before; + const after = payload?.after; + if (!isSha(before)) throw new Error('push event is missing an exact before SHA'); + if (!isSha(after) || isZeroSha(after)) + throw new Error('push event is missing an exact after SHA'); + return { before, after }; +} + +export function pushCommitShas(payload, cwd = process.cwd(), rangeResolver = commitsInRange) { + const { before, after } = pushEventRange(payload); + return isZeroSha(before) ? [after] : rangeResolver(`${before}..${after}`, cwd); +} + // QNBS-v3: Git supplies both object IDs so malformed ref updates fail closed. export function parseRefUpdate(line) { const fields = line.trim().split(/\s+/); @@ -250,12 +270,20 @@ export function remoteTrackingBases(remote, remoteRef, cwd = process.cwd()) { return bases; } +export function outgoingBaseShas(update, fallbackBases) { + if (!isZeroSha(update.remoteSha)) { + if (!isSha(update.remoteSha)) throw new Error('invalid remote SHA in pre-push update'); + return [update.remoteSha]; + } + return fallbackBases; +} + export function introducedCommits(update, remote, cwd = process.cwd()) { if (update.remoteRef.startsWith('refs/tags/')) return []; if (!update.remoteRef.startsWith('refs/heads/')) { throw new Error(`unsupported outgoing ref ${update.remoteRef}`); } - const bases = remoteTrackingBases(remote, update.remoteRef, cwd); + const bases = outgoingBaseShas(update, remoteTrackingBases(remote, update.remoteRef, cwd)); const args = ['rev-list', '--reverse', update.localSha, ...bases.map((base) => `^${base}`)]; const result = runGit(args, { cwd }); if (result.status !== 0) @@ -281,7 +309,8 @@ export function parseAnnotatedTag(sha, cwd = process.cwd()) { export function verifyTagObject(sha, cwd = process.cwd()) { const tag = parseAnnotatedTag(sha, cwd); if (!tag) return { ok: false, reason: 'tag target is not a commit or annotated tag' }; - if (tag.objectType === 'commit') return verifyCommitObject(tag.target, cwd); + if (tag.objectType === 'commit') + return { ok: false, reason: 'release tag is not an annotated tag object' }; if (!tag.target || tag.targetType !== 'commit') return { ok: false, reason: 'annotated tag does not target a commit' }; const verifiedTag = runGit(['verify-tag', '--raw', sha], { cwd }); @@ -299,7 +328,8 @@ export function classifyTagVerification({ tagVerificationStatus, commitVerification, }) { - if (objectType === 'commit') return commitVerification; + if (objectType === 'commit') + return { ok: false, reason: 'release tag is not an annotated tag object' }; if (objectType !== 'tag' || targetType !== 'commit') return { ok: false, reason: 'annotated tag does not target a commit' }; if (tagVerificationStatus !== 0) @@ -309,7 +339,11 @@ export function classifyTagVerification({ : commitVerification; } -export function verifyOutgoingUpdates(lines, remote, cwd = process.cwd()) { +export function verifyOutgoingUpdates(lines, remote, cwd = process.cwd(), dependencies = {}) { + const verifyCommit = dependencies.verifyCommitObject ?? ((sha) => verifyCommitObject(sha, cwd)); + const verifyTag = dependencies.verifyTagObject ?? ((sha) => verifyTagObject(sha, cwd)); + const getIntroducedCommits = + dependencies.introducedCommits ?? ((update) => introducedCommits(update, remote, cwd)); const updates = lines.map(parseRefUpdate); if (updates.some((update) => !update)) return { ok: false, reason: 'invalid pre-push ref-update input' }; @@ -319,15 +353,15 @@ export function verifyOutgoingUpdates(lines, remote, cwd = process.cwd()) { if (!isSha(update.localSha)) return { ok: false, reason: `invalid outgoing SHA for ${update.remoteRef}` }; if (update.remoteRef.startsWith('refs/tags/')) { - const verification = verifyTagObject(update.localSha, cwd); + const verification = verifyTag(update.localSha); reports.push({ sha: update.localSha, subject: update.remoteRef, verification }); if (!verification.ok) return { ok: false, reports, reason: `${update.remoteRef}: ${verification.reason}` }; continue; } - const commits = introducedCommits(update, remote, cwd); + const commits = getIntroducedCommits(update); for (const sha of commits) { - const verification = verifyCommitObject(sha, cwd); + const verification = verifyCommit(sha); const report = { sha, subject: commitSubject(sha, cwd), verification }; reports.push(report); if (!verification.ok) @@ -340,10 +374,12 @@ export function verifyOutgoingUpdates(lines, remote, cwd = process.cwd()) { export function safeConfigSummary(cwd = process.cwd()) { const signing = getSigningConfig(cwd); const identity = getIdentity(cwd); - const hooks = getGitDirectory(cwd); + const gitDir = getGitDirectory(cwd); const hookDir = signing.config['core.hooksPath'] ? resolve(cwd, signing.config['core.hooksPath']) - : hooks; + : gitDir + ? join(gitDir, 'hooks') + : null; const hookNames = ['pre-commit', 'pre-push']; return { signing: { @@ -362,7 +398,7 @@ export function safeConfigSummary(cwd = process.cwd()) { hooks: { pathConfigured: Boolean(signing.config['core.hooksPath']), directory: hookDir ? basename(hookDir) : '', - preCommitInstalled: Boolean( + hooksInstalled: Boolean( hookDir && hookNames.every((name) => existsSync(join(hookDir, name))), ), }, diff --git a/scripts/signing/verify-github-signatures.mjs b/scripts/signing/verify-github-signatures.mjs index 449dd3c51..019bfdf52 100644 --- a/scripts/signing/verify-github-signatures.mjs +++ b/scripts/signing/verify-github-signatures.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node import { readFileSync } from 'node:fs'; import process from 'node:process'; -import { commitsInRange } from './signing-core.mjs'; +import { commitsInRange, pushCommitShas } from './signing-core.mjs'; import { hasCompleteCommitRange, verifyRemoteCommitRange, @@ -19,12 +19,24 @@ function fail(message) { process.exit(1); } +function readEventPayload() { + const eventPath = process.env.GITHUB_EVENT_PATH; + if (!eventPath) fail('GITHUB_EVENT_PATH is unavailable'); + try { + return JSON.parse(readFileSync(eventPath, 'utf8')); + } catch (error) { + fail( + `cannot read GitHub event payload: ${error instanceof Error ? error.message : 'invalid JSON'}`, + ); + } +} + if (!owner || !repo) fail('GITHUB_REPOSITORY is unavailable'); try { let result; if (event === 'pull_request') { - const payload = JSON.parse(readFileSync(process.env.GITHUB_EVENT_PATH, 'utf8')); + const payload = readEventPayload(); const number = payload.pull_request?.number; const before = payload.pull_request?.base?.sha; const after = payload.pull_request?.head?.sha; @@ -44,11 +56,7 @@ try { fetchImpl, }); } else { - const before = process.env.GITHUB_EVENT_BEFORE; - const after = process.env.GITHUB_SHA; - if (!after) fail('push event is missing its after SHA'); - const shas = - before && !/^0{40}$/.test(before) ? commitsInRange(`${before}..${after}`) : [after]; + const shas = pushCommitShas(readEventPayload()); result = await verifyRemoteCommitRange({ owner, repo, shas, token, fetchImpl }); } for (const report of result.reports) { diff --git a/scripts/signing/verify-outgoing.mjs b/scripts/signing/verify-outgoing.mjs index 7b3d52339..a499b9eca 100644 --- a/scripts/signing/verify-outgoing.mjs +++ b/scripts/signing/verify-outgoing.mjs @@ -4,7 +4,9 @@ import { verifyOutgoingUpdates } from './signing-core.mjs'; const remote = process.argv[2]; if (!remote) { - console.error('pre-push signing check requires the remote name'); + console.error( + 'pre-push signing check requires the remote name. Run "pnpm run hooks:install" to refresh the installed hook.', + ); process.exit(1); } let input = ''; diff --git a/scripts/signing/verify-remote.d.mts b/scripts/signing/verify-remote.d.mts index cc7eb89cc..9e7b00fbe 100644 --- a/scripts/signing/verify-remote.d.mts +++ b/scripts/signing/verify-remote.d.mts @@ -30,9 +30,22 @@ export interface RemoteOptions { fetchImpl?: typeof fetch; } +export interface RemoteCommitRangeOptions { + owner: string; + repo: string; + shas: string[]; + token?: string; + fetchImpl?: typeof fetch; +} + +export function fetchJson(fetchImpl: typeof fetch, url: string, token: string): Promise; +export function fetchPullRequestCommits(options: RemoteOptions): Promise; +export function safeReport(commit: RemoteCommit): RemoteReport; + export function verifyRemoteCommitReports(commits: RemoteCommit[]): RemoteResult; export function hasCompleteCommitRange(expectedShas: string[], reports: RemoteReport[]): boolean; export function verifyRemotePullRequest(options: RemoteOptions): Promise; +export function verifyRemoteCommitRange(options: RemoteCommitRangeOptions): Promise; export function verifyRemoteTag(options: { owner: string; repo: string; diff --git a/scripts/signing/verify-remote.mjs b/scripts/signing/verify-remote.mjs index 337622f33..236620a78 100644 --- a/scripts/signing/verify-remote.mjs +++ b/scripts/signing/verify-remote.mjs @@ -110,24 +110,31 @@ export async function verifyRemoteTag({ owner, repo, tag, token = '', fetchImpl ); const object = ref.object; if (!object?.sha || !object.type) throw new Error('GitHub tag ref response was incomplete'); - let tagReport = null; - let targetSha = object.sha; - if (object.type === 'tag') { - const tagObject = await fetchJson( - fetchImpl, - `${API}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/git/tags/${encodeURIComponent(object.sha)}`, - token, - ); - tagReport = { + if (object.type !== 'tag') { + const report = { sha: safeSha(object.sha), subject: safeText(`tag ${tag}`, 160), - verified: tagObject.verification?.verified === true, - reason: safeText(tagObject.verification?.reason ?? 'missing tag verification result'), + verified: false, + reason: 'release tag is not an annotated tag object', }; - targetSha = tagObject.object?.sha; - if (tagObject.object?.type !== 'commit' || !targetSha) - throw new Error('annotated tag does not target a commit'); + return { ok: false, reports: [report], reason: `${report.sha}: ${report.reason}` }; } + let tagReport = null; + let targetSha = object.sha; + const tagObject = await fetchJson( + fetchImpl, + `${API}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/git/tags/${encodeURIComponent(object.sha)}`, + token, + ); + tagReport = { + sha: safeSha(object.sha), + subject: safeText(`tag ${tag}`, 160), + verified: tagObject.verification?.verified === true, + reason: safeText(tagObject.verification?.reason ?? 'missing tag verification result'), + }; + targetSha = tagObject.object?.sha; + if (tagObject.object?.type !== 'commit' || !targetSha) + throw new Error('annotated tag does not target a commit'); const commit = await fetchJson( fetchImpl, `${API}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/commits/${encodeURIComponent(targetSha)}`, diff --git a/tests/unit/signing.test.ts b/tests/unit/signing.test.ts index 41d899de2..c14f30c7c 100644 --- a/tests/unit/signing.test.ts +++ b/tests/unit/signing.test.ts @@ -5,8 +5,12 @@ import { getSigningConfig, hasCommitSignature, isGitHubCompatibleEmail, + outgoingBaseShas, parseRefUpdate, + pushCommitShas, + pushEventRange, selectIntroducedCommits, + verifyOutgoingUpdates, } from '../../scripts/signing/signing-core.mjs'; import { hasCompleteCommitRange, @@ -43,6 +47,107 @@ describe('local signing controls', () => { expect(selectIntroducedCommits([base, introduced], [base])).toEqual([introduced]); }); + it('uses the Git-advertised remote SHA before new-branch fallbacks', () => { + const remoteSha = 'c'.repeat(40); + const fallback = ['d'.repeat(40)]; + expect( + outgoingBaseShas( + { + localRef: 'refs/heads/main', + localSha: 'a'.repeat(40), + remoteRef: 'refs/heads/main', + remoteSha, + }, + fallback, + ), + ).toEqual([remoteSha]); + expect( + outgoingBaseShas( + { + localRef: 'refs/heads/new', + localSha: 'a'.repeat(40), + remoteRef: 'refs/heads/new', + remoteSha: '0'.repeat(40), + }, + fallback, + ), + ).toEqual(fallback); + }); + + it('covers deletion, malformed, tag, and branch pre-push routing', () => { + const zero = '0'.repeat(40); + const commit = 'a'.repeat(40); + const remote = 'b'.repeat(40); + expect( + verifyOutgoingUpdates( + [`refs/heads/deleted ${zero} refs/heads/deleted ${remote}`], + 'origin', + process.cwd(), + { + introducedCommits: () => { + throw new Error('deletions must be skipped'); + }, + }, + ), + ).toMatchObject({ ok: true, reports: [] }); + expect(verifyOutgoingUpdates(['malformed'], 'origin')).toMatchObject({ + ok: false, + reason: 'invalid pre-push ref-update input', + }); + expect( + verifyOutgoingUpdates( + [`refs/tags/v1.0.0 ${commit} refs/tags/v1.0.0 ${zero}`], + 'origin', + process.cwd(), + { + verifyTagObject: () => ({ ok: true, reason: 'tag and target commit verified' }), + introducedCommits: () => { + throw new Error('tags must not enumerate branch commits'); + }, + }, + ), + ).toMatchObject({ ok: true, reports: [{ sha: commit, subject: 'refs/tags/v1.0.0' }] }); + expect( + verifyOutgoingUpdates( + [`refs/heads/main ${commit} refs/heads/main ${remote}`], + 'origin', + process.cwd(), + { + introducedCommits: (update) => { + expect(update.remoteSha).toBe(remote); + return [commit]; + }, + verifyCommitObject: () => ({ ok: true, reason: 'Git-native signature verified' }), + }, + ), + ).toMatchObject({ ok: true, reports: [{ sha: commit }] }); + }); + + it('derives an exact push range and rejects an unverified earlier commit', () => { + const before = '0'.repeat(40); + const after = 'f'.repeat(40); + expect(pushEventRange({ before, after })).toEqual({ before, after }); + expect( + pushCommitShas({ before: '1'.repeat(40), after }, process.cwd(), () => [ + '1'.repeat(40), + 'e'.repeat(40), + after, + ]), + ).toEqual(['1'.repeat(40), 'e'.repeat(40), after]); + const result = verifyRemoteCommitReports([ + { + sha: '1'.repeat(40), + commit: { message: 'earlier', verification: { verified: false, reason: 'unsigned' } }, + }, + { + sha: after, + commit: { message: 'head', verification: { verified: true, reason: 'valid' } }, + }, + ]); + expect(result.ok).toBe(false); + expect(result.reason).toBe('111111111111: unsigned'); + }); + it('requires both an annotated tag signature and its target commit signature', () => { const unsignedCommit = { ok: false, reason: 'commit has no signature object' }; expect( @@ -61,6 +166,14 @@ describe('local signing controls', () => { commitVerification: { ok: true, reason: 'verified' }, }), ).toMatchObject({ ok: false }); + expect( + classifyTagVerification({ + objectType: 'commit', + targetType: 'commit', + tagVerificationStatus: 0, + commitVerification: { ok: true, reason: 'verified' }, + }), + ).toMatchObject({ ok: false, reason: 'release tag is not an annotated tag object' }); }); it('distinguishes unsigned and malformed signed commit objects', () => { @@ -162,5 +275,17 @@ describe('GitHub signature API controls', () => { }); expect(tag.ok).toBe(true); expect(tag.reports).toHaveLength(2); + + const lightweight = await verifyRemoteTag({ + owner: 'qnbs', + repo: 'WorldScript-Studio', + tag: 'v1.0.0-lightweight', + fetchImpl: async () => + new Response(JSON.stringify({ object: { sha: targetSha, type: 'commit' } }), { + status: 200, + }), + }); + expect(lightweight.ok).toBe(false); + expect(lightweight.reason).toContain('not an annotated tag object'); }); }); diff --git a/tests/unit/workflowPolicy.test.ts b/tests/unit/workflowPolicy.test.ts index 01bdb2010..4c7d9298b 100644 --- a/tests/unit/workflowPolicy.test.ts +++ b/tests/unit/workflowPolicy.test.ts @@ -131,6 +131,7 @@ describe('CI workflow policy', () => { ); expect(extractNeeds(workflowSource, 'ci-success')).toEqual([ 'security', + 'signatures', 'quality', 'changes', 'rust-tauri', @@ -140,6 +141,7 @@ describe('CI workflow policy', () => { 'lighthouse', 'vrt', ]); + expect(ciSuccessBlock).toMatch(/\$\{\{\s*needs\.signatures\.result\s*\}\}/); expect(ciSuccessBlock).toMatch(/\$\{\{\s*needs\.lighthouse\.result\s*\}\}/); for (const jobName of ['e2e-deep', 'storybook']) { From c9eb3ad5891ba00e7bde49f95c1f992027413e12 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Sun, 23 Aug 2026 02:34:22 +0200 Subject: [PATCH 5/5] fix(security): complete signing declarations Declare the exported annotated-tag parser so the signing module runtime and TypeScript contract remain aligned. --- scripts/signing/signing-core.d.mts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/scripts/signing/signing-core.d.mts b/scripts/signing/signing-core.d.mts index ac961e747..715a9d42c 100644 --- a/scripts/signing/signing-core.d.mts +++ b/scripts/signing/signing-core.d.mts @@ -89,6 +89,10 @@ export function pushCommitShas( cwd?: string, rangeResolver?: (range: string, cwd?: string) => string[], ): string[]; +export function parseAnnotatedTag( + sha: string, + cwd?: string, +): { objectType: 'commit'; target: string } | { objectType: 'tag'; target: string; targetType: string } | null; export function verifyTagObject(sha: string, cwd?: string): VerificationResult; export function remoteTrackingBases(remote: string, remoteRef: string, cwd?: string): string[]; export function outgoingBaseShas(update: RefUpdate, fallbackBases: string[]): string[];