Skip to content

feat(singleton#4): cosign verify (WIP — tests pending)#79

Merged
namastex888 merged 4 commits into
mainfrom
dream-pgserve-v2-4
May 8, 2026
Merged

feat(singleton#4): cosign verify (WIP — tests pending)#79
namastex888 merged 4 commits into
mainfrom
dream-pgserve-v2-4

Conversation

@namastex888

@namastex888 namastex888 commented May 8, 2026

Copy link
Copy Markdown
Contributor

Mid-work checkpoint. Engineer disbanded before tests landed. Preserves 11 files (+1686 lines) for future engineer to finish + validate.

What landed

  • src/cosign/cache-token.js, schema.js, trust-list.js — skeletons (parked from prior round)
  • src/cosign/verify-binary.js (NEW) — cosign CLI shellout per Decision P5
  • src/commands/verify.js (NEW) — pgserve verify <path> entry point
  • src/cli-install.cjs — routing for verify subcommand
  • src/upgrade/index.js — pgserve_meta migration runner wiring
  • bin/pgserve-wrapper.cjs — subcommand dispatch
  • knip.jsonsrc/cosign/** added to entry list

NOT shipped (work remaining for G4 acceptance)

  • tests/cli/verify.test.js + tests/cosign/ — not yet written
  • --skip-sigstore flag completeness
  • Cache-token mtime invalidation testing
  • pgserve_meta delta idempotency proof

Cohort: singleton G4 of pgserve v2.4. Holding for engineer to finish before merge.

Summary by CodeRabbit

  • New Features
    • Added pgserve verify command for verifying binary authenticity using keyless OIDC signatures
    • Implemented automatic verification caching with configurable sliding expiration windows
    • Added --skip-sigstore option for offline verification using a local trust list
    • Added --json output format for machine-readable verification results
    • Extended database schema to store verification metadata

namastex888 and others added 2 commits May 8, 2026 14:36
…ust-list)

Partial work from engineer-g4@dream-pgserve-v2-4 before disband.
Preserves 3 untracked files (16.9K total):
- src/cosign/cache-token.js (HMAC-signed token format)
- src/cosign/schema.js (pgserve_meta additive delta)
- src/cosign/trust-list.js (TRUSTED_IDENTITIES constant)

NOT shipped — verifyBinary(), pgserve verify CLI, --skip-sigstore flag,
and tests still missing. Future engineer can pick up from this checkpoint.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…k checkpoint)

Resumed from parked skeletons (a5c09c9). Engineer disbanded mid-work; this
commit preserves the in-progress state for future review/continuation.

What landed:
- src/cosign/verify-binary.js (NEW): cosign CLI shellout per Decision P5
- src/commands/verify.js (NEW): pgserve verify <path> entry point
- src/cli-install.cjs: routing for verify subcommand
- src/upgrade/index.js: pgserve_meta migration runner wiring
- bin/pgserve-wrapper.cjs: subcommand dispatch update
- knip.json: src/cosign/** added to entry list

NOT shipped — tests at tests/cli/verify.test.js + tests/cosign/ not yet
written; --skip-sigstore flag may be incomplete; cache-token mtime
invalidation untested. Future engineer must verify against G4 acceptance
criteria before declaring G4 done.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented May 8, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Rate limit exceeded

@namastex888 has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 46 minutes and 37 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 179a6cac-c96f-413d-b9c9-2ff3d150855c

📥 Commits

Reviewing files that changed from the base of the PR and between af35ed1 and b2b8be5.

📒 Files selected for processing (3)
  • src/commands/verify.js
  • src/upgrade/steps/cosign-meta-migration.js
  • tests/upgrade/postinstall.test.js
📝 Walkthrough

Walkthrough

This PR adds a complete binary verification feature to pgserve. It introduces a new pgserve verify <binary-path> CLI command that validates binary signatures using cosign keyless OIDC with hardcoded Sigstore trust roots. The implementation includes HMAC-signed cache tokens with sliding expiry, database schema migrations for verification metadata, offline trust file support via --skip-sigstore, and comprehensive test coverage across all layers.

Changes

Binary Verification with Cosign Keyless OIDC

Layer / File(s) Summary
Database Schema
src/cosign/schema.js
Adds idempotent DDL to create verification columns (verified_at, verified_identity, verified_tier) and CHECK constraint validating tier enum values.
Cache Token Contract
src/cosign/cache-token.js
Implements HMAC-signed, version 1 cache tokens with sliding idle (1h) and max (7d) expiry, atomic writes, HMAC validation, and binary attestation checks.
Trust Identities
src/cosign/trust-list.js
Defines pinned GitHub Actions OIDC issuer and frozen array of hardcoded trusted identities with lookup by id/publisher and serialization for trust-list UI.
Verification Engine
src/cosign/verify-binary.js
Implements cosign-based verification: SHA-256 hashing, bundle sidecar resolution, cosign executable discovery with optional static binary fetch, and per-identity verification iteration with detailed failure diagnostics.
Verify Command
src/commands/verify.js
Implements full pgserve verify command with cache lookup/bypass, offline trust support (--skip-sigstore), argument parsing, JSON/human output, and exit code semantics (0=verified, 2=failure, 3=invocation error).
CLI Routing
bin/pgserve-wrapper.cjs, src/cli-install.cjs
Adds verify subcommand to install dispatcher, skipping bun probe for pure-node execution.
Upgrade Integration
src/upgrade/index.js, src/upgrade/steps/cosign-meta-migration.js
Integrates schema migration into upgrade pipeline; step enumerates non-system DBs, probes for pgserve_meta, and applies migration statements idempotently.
Tooling
knip.json
Adds src/cosign/** to entry scan scope.
Schema Migrations Tests
tests/cosign/schema.test.js
Validates tier enum, migration statement structure (4 statements: 3 ADD COLUMN, 1 CHECK), concatenation, and client-based application.
Cache Token Tests
tests/cosign/cache-token.test.js
Tests state directory, HMAC key creation/regeneration, token round-trips, HMAC/payload tampering detection, binary attestation invalidation, sliding expiry (idle and max), touch/delete operations.
Verification Engine Tests
tests/cosign/verify-binary.test.js
Tests happy path, cosign invocation flags, rejection scenarios (tampered binary, missing bundle/binary/cosign), and input validation.
CLI Acceptance Tests
tests/cli/verify.test.js
End-to-end tests for legitimate verification with caching, tampered rejection, offline trust (--skip-sigstore) with/without pretrusted key, cache invalidation on mtime change, and invocation error cases.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐰 A cosign'd realm now greets the day,
with HMAC tokens held at bay,
trust roots hardened, offline and near,
binaries verified, crystal clear!
Cache slides along its gentle time,
migrations dance in SQL rhyme. 🔐

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 40.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(singleton#4): cosign verify (WIP — tests pending)' directly describes the main changes in the PR: implementation of a new cosign verify feature, marked as work-in-progress with incomplete tests.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch dream-pgserve-v2-4

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements a binary verification system using Cosign keyless OIDC, featuring a new verify command, an HMAC-signed caching layer, and additive database schema migrations. Review feedback highlights several critical improvements: adopting a streaming approach for file hashing to prevent memory issues with large binaries, replacing execSync with spawnSync in migration steps to avoid shell expansion vulnerabilities (particularly with PostgreSQL DO blocks), validating CLI flag arguments, and correcting a discrepancy in the cache fingerprint length.

Comment on lines +49 to +54
export function sha256File(filePath) {
const hash = crypto.createHash('sha256');
const buf = fs.readFileSync(filePath);
hash.update(buf);
return hash.digest('hex');
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Reading the entire binary into memory with fs.readFileSync can lead to high memory consumption or crashes if the binary is large (e.g., a full Postgres distribution). It is safer to use a streaming approach to compute the hash, even when using synchronous I/O.

export function sha256File(filePath) {
  const hash = crypto.createHash('sha256');
  const fd = fs.openSync(filePath, 'r');
  try {
    const buffer = Buffer.alloc(65536);
    let bytesRead;
    while ((bytesRead = fs.readSync(fd, buffer, 0, buffer.length)) !== 0) {
      hash.update(buffer.subarray(0, bytesRead));
    }
  } finally {
    fs.closeSync(fd);
  }
  return hash.digest('hex');
}

* provisioned anything) the step is a SKIP.
*/

import { execSync } from 'node:child_process';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Include spawnSync in the imports to support a safer way of invoking psql that avoids shell expansion issues.

Suggested change
import { execSync } from 'node:child_process';
import { execSync, spawnSync } from 'node:child_process';

Comment on lines +25 to +31
function pgQuery({ db, sql, captureStdout = false, port = CANONICAL_PORT }) {
const env = { ...process.env, PGPASSWORD: process.env.PGPASSWORD || 'postgres' };
const cmd = `psql -h 127.0.0.1 -p ${port} -U postgres -d ${db} -At -c ${JSON.stringify(sql)}`;
return captureStdout
? execSync(cmd, { env, stdio: ['ignore', 'pipe', 'pipe'] }).toString().trim()
: execSync(cmd, { env, stdio: 'pipe' });
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Using execSync with a template string and JSON.stringify(sql) is vulnerable to shell expansion. Specifically, the DO $$ blocks in the migration SQL will have $$ expanded by the shell (e.g., bash) to the current PID before reaching psql, causing a syntax error in Postgres. Using spawnSync with an argument array bypasses the shell and is much safer.

function pgQuery({ db, sql, captureStdout = false, port = CANONICAL_PORT }) {
  const env = { ...process.env, PGPASSWORD: process.env.PGPASSWORD || 'postgres' };
  const args = ['-h', '127.0.0.1', '-p', String(port), '-U', 'postgres', '-d', db, '-At', '-c', sql];
  const result = spawnSync('psql', args, { env, stdio: ['ignore', 'pipe', 'pipe'] });
  if (result.status !== 0) {
    const err = result.stderr?.toString() || result.error?.message || 'unknown error';
    throw new Error(`psql failed: ${err}`);
  }
  return captureStdout
    ? result.stdout.toString().trim()
    : result.stdout;
}

Comment thread src/commands/verify.js
* filename short enough to be readable in `ls`.
*/
export function computeFingerprint(binaryRealpath, sha256) {
return `${path.basename(binaryRealpath).replace(/[^A-Za-z0-9._-]/g, '_')}.${sha256.slice(0, 16)}`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

There is a discrepancy between the documentation (line 55) which specifies using the first 32 characters of the SHA256, and the implementation which only uses 16. Increasing this to 32 provides better collision resistance for the cache fingerprint and aligns with the design documentation.

Suggested change
return `${path.basename(binaryRealpath).replace(/[^A-Za-z0-9._-]/g, '_')}.${sha256.slice(0, 16)}`;
return `${path.basename(binaryRealpath).replace(/[^A-Za-z0-9._-]/g, '_')}.${sha256.slice(0, 32)}`;

Comment thread src/commands/verify.js
Comment on lines +120 to +121
else if (a === '--bundle') opts.bundlePath = args[++i];
else if (a === '--cosign-bin') opts.cosignBin = args[++i];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The argument parser does not validate that a value follows the --bundle or --cosign-bin flags. If these are the last arguments provided, args[++i] will be undefined, which could lead to unexpected behavior or confusing error messages.

    else if (a === '--bundle') {
      opts.bundlePath = args[++i];
      if (opts.bundlePath === undefined) {
        process.stderr.write('pgserve verify: --bundle requires a path\n');
        return { exit: EXIT_INVOCATION };
      }
    } else if (a === '--cosign-bin') {
      opts.cosignBin = args[++i];
      if (opts.cosignBin === undefined) {
        process.stderr.write('pgserve verify: --cosign-bin requires a path\n');
        return { exit: EXIT_INVOCATION };
      }
    }

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: af35ed1d5f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/commands/verify.js Outdated
Comment on lines +214 to +217
if (cache.ok) {
// Cache hit — bump lastUsedAt and return.
touchCacheToken(cache.payload, {});
emit(opts, {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Prevent skip-sigstore cache hits from bypassing cosign checks

This early return accepts any valid cache token before evaluating --skip-sigstore, so a token created in skip mode (tier: self_signed) is later treated as a successful default verification. In practice, running pgserve verify <bin> --skip-sigstore once allows subsequent plain pgserve verify <bin> calls to exit 0 from cache without requiring bundle/cosign verification until the token expires, which weakens the default trust model.

Useful? React with 👍 / 👎.


function pgQuery({ db, sql, captureStdout = false, port = CANONICAL_PORT }) {
const env = { ...process.env, PGPASSWORD: process.env.PGPASSWORD || 'postgres' };
const cmd = `psql -h 127.0.0.1 -p ${port} -U postgres -d ${db} -At -c ${JSON.stringify(sql)}`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Execute psql without shell interpolation for migration SQL

The migration SQL is injected into a shell command string, but the schema step includes a DO $$ ... $$ block; when passed through the shell, $$ is expanded to the shell PID (and escaped newlines are also altered), corrupting the SQL sent to psql. This causes the cosign metadata migration statement to fail at runtime on real upgrades, so the step can silently skip DBs instead of reliably applying the intended schema delta.

Useful? React with 👍 / 👎.

…ration added)

CI on PR #79 was red because tests/upgrade/postinstall.test.js still asserted
STEPS.length === 6, but src/upgrade/index.js now exports 7 steps after the G4
cosign-meta-migration step was added.

Updates the test file to match the new STEPS array (verified via bun test
locally: 3 pass / 0 fail).

Engineer left this change uncommitted in the worktree; this commit captures
+ pushes so CI can pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/commands/verify.js`:
- Around line 53-61: The JSDoc for computeFingerprint claims it uses the sha256
first 32 chars but the function actually uses sha256.slice(0, 16); update the
comment to match the implementation (i.e. state "sha256 first 16 chars" or
"first 16 hex chars / 64 bits"), or if you intended 32 chars change the
implementation to use sha256.slice(0, 32); ensure the symbols referenced are
computeFingerprint, binaryRealpath and sha256.slice so readers and future edits
remain consistent.
- Around line 261-324: The code currently turns writeCacheToken exceptions into
a verification failure (emit ok:false + return EXIT_VERIFY_FAILED); instead,
treat cache-write failures as non-fatal: in both the skip-sigstore branch (where
the token is built earlier) and the cosign-success branch (after verifyBinary
returns ok and you call buildTokenPayload/writeCacheToken), catch errors from
writeCacheToken, call emit to record a warning (preserve ok:true and
verification details or include a clear warning field/message and err.message),
and return EXIT_OK (do not return EXIT_VERIFY_FAILED); update the error-handling
blocks that reference writeCacheToken, emit, EXIT_VERIFY_FAILED so they instead
log a warning and exit success to match touchCacheToken’s soft-fail behavior.

In `@src/cosign/schema.js`:
- Around line 46-58: The current IF NOT EXISTS check only filters pg_constraint
by conname (VERIFIED_TIER_CHECK_NAME) which can match constraints on other
tables and cause skipping; update the WHERE clause to also restrict conrelid to
the pgserve_meta table (e.g. add "AND conrelid = 'pgserve_meta'::regclass") so
the check only detects an existing constraint on pgserve_meta before skipping
the ALTER TABLE that adds VERIFIED_TIER_CHECK_NAME to pgserve_meta.

In `@src/cosign/verify-binary.js`:
- Around line 123-136: fetchCosignBin currently writes a downloaded cosign
binary to COSIGN_BIN_FILE without integrity checking; add a hardcoded map of
expected SHA-256 checksums keyed by release version + asset name (use
COSIGN_RELEASE_VERSION and the asset name from pickCosignAssetName) and after
downloadToFileSync(tmp) compute the file's SHA-256, compare it to the expected
value, and if it does not match, remove the tmp file and throw/log an error;
only call fs.chmodSync(tmp, 0o755) and fs.renameSync(tmp, targetFile) when the
checksum matches. Ensure errors include the expected vs actual checksum and
reference fetchCosignBin, pickCosignAssetName, COSIGN_RELEASE_VERSION,
downloadToFileSync, and COSIGN_BIN_FILE so reviewers can locate the change.
- Around line 265-268: spawnSync(cosignBin, args, ...) is called without a
timeout which can hang the process during network stalls; update this by passing
a timeout option to spawnSync and handle the timeout in invokeCosign: detect
result.error?.code === 'ETIMEDOUT' (or equivalent) and return a distinct error
shape, then have verifyBinary map that case to an error with reason:
'cosign-timeout' so callers can distinguish network timeouts from signature
failures; touch the spawnSync call site (cosignBin, args), the invokeCosign
error handling, and verifyBinary's returned error mapping to implement this.

In `@src/upgrade/steps/cosign-meta-migration.js`:
- Around line 17-31: The pgQuery function currently builds a single shell
command with execSync and JSON.stringify(sql), which causes $$ to be expanded
and allows shell injection; replace execSync-based invocation with
child_process.spawnSync and pass the psql executable plus args as an array (e.g.
['-h','127.0.0.1','-p',String(port),'-U','postgres','-d',db,'-At','-c',sql]) so
the shell is not used and the SQL is passed verbatim; preserve the env
construction (including PGPASSWORD default) and mirror the stdio behavior used
now (use 'pipe' for captureStdout true to return stdout string trimmed,
otherwise use 'pipe' or inherit as appropriate), and remove JSON.stringify(sql)
to avoid $$ expansion and close the command-injection vector.

In `@tests/cosign/schema.test.js`:
- Around line 91-94: The two assertions in the test for applyVerifiedColumns use
.rejects but are missing await, so the test exits before the rejection is
evaluated; update the test (the case referencing applyVerifiedColumns) to await
the expect(...).rejects.toThrow(...) calls (or return the promises) for both
null and {} inputs so the rejection matchers are actually executed and the test
fails/fails correctly when applyVerifiedColumns does not reject.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 26fc0bdf-5641-4d96-baab-c23c60cfeef1

📥 Commits

Reviewing files that changed from the base of the PR and between d8cad08 and af35ed1.

📒 Files selected for processing (14)
  • bin/pgserve-wrapper.cjs
  • knip.json
  • src/cli-install.cjs
  • src/commands/verify.js
  • src/cosign/cache-token.js
  • src/cosign/schema.js
  • src/cosign/trust-list.js
  • src/cosign/verify-binary.js
  • src/upgrade/index.js
  • src/upgrade/steps/cosign-meta-migration.js
  • tests/cli/verify.test.js
  • tests/cosign/cache-token.test.js
  • tests/cosign/schema.test.js
  • tests/cosign/verify-binary.test.js

Comment thread src/commands/verify.js
Comment on lines +53 to +61
/**
* Compute the cache fingerprint for a binary. We use the realpath +
* sha256 first 32 chars so two distinct binaries get distinct cache
* entries even if they share a directory layout, while keeping the
* filename short enough to be readable in `ls`.
*/
export function computeFingerprint(binaryRealpath, sha256) {
return `${path.basename(binaryRealpath).replace(/[^A-Za-z0-9._-]/g, '_')}.${sha256.slice(0, 16)}`;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Doc / code mismatch on fingerprint length.

The JSDoc says "we use the realpath + sha256 first 32 chars" but the implementation uses sha256.slice(0, 16) (16 hex chars / 64 bits). Either is fine for cache-key disambiguation, but the comment should match the code so future readers don't reach for the wrong slice when adjusting collision tolerance.

📝 Proposed doc fix
 /**
  * Compute the cache fingerprint for a binary. We use the realpath +
- * sha256 first 32 chars so two distinct binaries get distinct cache
+ * sha256 first 16 chars so two distinct binaries get distinct cache
  * entries even if they share a directory layout, while keeping the
  * filename short enough to be readable in `ls`.
  */
 export function computeFingerprint(binaryRealpath, sha256) {
   return `${path.basename(binaryRealpath).replace(/[^A-Za-z0-9._-]/g, '_')}.${sha256.slice(0, 16)}`;
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/**
* Compute the cache fingerprint for a binary. We use the realpath +
* sha256 first 32 chars so two distinct binaries get distinct cache
* entries even if they share a directory layout, while keeping the
* filename short enough to be readable in `ls`.
*/
export function computeFingerprint(binaryRealpath, sha256) {
return `${path.basename(binaryRealpath).replace(/[^A-Za-z0-9._-]/g, '_')}.${sha256.slice(0, 16)}`;
}
/**
* Compute the cache fingerprint for a binary. We use the realpath +
* sha256 first 16 chars so two distinct binaries get distinct cache
* entries even if they share a directory layout, while keeping the
* filename short enough to be readable in `ls`.
*/
export function computeFingerprint(binaryRealpath, sha256) {
return `${path.basename(binaryRealpath).replace(/[^A-Za-z0-9._-]/g, '_')}.${sha256.slice(0, 16)}`;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/commands/verify.js` around lines 53 - 61, The JSDoc for
computeFingerprint claims it uses the sha256 first 32 chars but the function
actually uses sha256.slice(0, 16); update the comment to match the
implementation (i.e. state "sha256 first 16 chars" or "first 16 hex chars / 64
bits"), or if you intended 32 chars change the implementation to use
sha256.slice(0, 32); ensure the symbols referenced are computeFingerprint,
binaryRealpath and sha256.slice so readers and future edits remain consistent.

Comment thread src/commands/verify.js
Comment on lines +261 to +324
let cacheFile = null;
if (!opts.noCache) {
try {
cacheFile = writeCacheToken(payload, {});
} catch (err) {
emit(opts, { ok: false, reason: 'cache-write-failed', detail: err.message });
return EXIT_VERIFY_FAILED;
}
}
emit(opts, {
ok: true,
cached: false,
binary: binaryPath,
identity,
tier: 'self_signed',
sha256,
cacheFile,
skipSigstore: true,
});
return EXIT_OK;
}

// ── Cosign path ──────────────────────────────────────────────────────
const result = verifyBinary(binaryPath, {
cosignBin: opts.cosignBin || process.env.PGSERVE_COSIGN_BIN || undefined,
bundlePath: opts.bundlePath || undefined,
allowFetch: opts.allowFetch === true,
});

if (!result.ok) {
emit(opts, {
ok: false,
reason: result.reason,
detail: result.detail,
identityChain: result.identityChain,
});
if (result.reason === 'binary-missing'
|| result.reason === 'binary-unreadable'
|| result.reason === 'binary-not-a-file'
|| result.reason === 'bundle-missing'
|| result.reason === 'cosign-missing'
|| result.reason === 'empty-trust-list'
|| result.reason === 'invalid-args') {
return EXIT_INVOCATION;
}
return EXIT_VERIFY_FAILED;
}

let cacheFile = null;
if (!opts.noCache) {
try {
const payload = buildTokenPayload({
fingerprint,
binary: attestation,
identity: result.identity,
tier: result.tier,
sha256: result.sha256,
});
cacheFile = writeCacheToken(payload, {});
} catch (err) {
emit(opts, { ok: false, reason: 'cache-write-failed', detail: err.message });
return EXIT_VERIFY_FAILED;
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Cache-write failures should not be reported as verification failures.

Both the --skip-sigstore branch (Lines 262-268) and the cosign branch (Lines 309-324) translate a writeCacheToken exception into { ok: false, reason: 'cache-write-failed' } + EXIT_VERIFY_FAILED (2). At that point verification has already succeeded — cosign returned ok, or the offline trust file matched. A transient ENOSPC / EACCES / quota error on ~/.local/state/pgserve/verified/<fp>.token then flips a successful verify into a "FAILED" outcome to operators and CI, and the documented exit-code semantics ("2 = verification failed") are violated.

The cache is already designed as a soft optimization (touchCacheToken swallows rewrite failures and returns null). Persistence should follow the same model: emit a warning and exit 0, or at the very least map to EXIT_INVOCATION/a distinct code so callers don't conflate it with a tampered binary.

🛡️ Suggested fix — degrade gracefully on cache write failure
   if (opts.skipSigstore) {
     ...
-    let cacheFile = null;
-    if (!opts.noCache) {
-      try {
-        cacheFile = writeCacheToken(payload, {});
-      } catch (err) {
-        emit(opts, { ok: false, reason: 'cache-write-failed', detail: err.message });
-        return EXIT_VERIFY_FAILED;
-      }
-    }
+    let cacheFile = null;
+    let cacheWriteError = null;
+    if (!opts.noCache) {
+      try {
+        cacheFile = writeCacheToken(payload, {});
+      } catch (err) {
+        cacheWriteError = err.message;
+        process.stderr.write(`pgserve verify: cache token write failed (${err.message}); continuing without cache\n`);
+      }
+    }
     emit(opts, {
       ok: true,
       cached: false,
       binary: binaryPath,
       identity,
       tier: 'self_signed',
       sha256,
       cacheFile,
+      cacheWriteError,
       skipSigstore: true,
     });
     return EXIT_OK;
   }

Apply the same shape to the cosign-success branch at Lines 309-324.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/commands/verify.js` around lines 261 - 324, The code currently turns
writeCacheToken exceptions into a verification failure (emit ok:false + return
EXIT_VERIFY_FAILED); instead, treat cache-write failures as non-fatal: in both
the skip-sigstore branch (where the token is built earlier) and the
cosign-success branch (after verifyBinary returns ok and you call
buildTokenPayload/writeCacheToken), catch errors from writeCacheToken, call emit
to record a warning (preserve ok:true and verification details or include a
clear warning field/message and err.message), and return EXIT_OK (do not return
EXIT_VERIFY_FAILED); update the error-handling blocks that reference
writeCacheToken, emit, EXIT_VERIFY_FAILED so they instead log a warning and exit
success to match touchCacheToken’s soft-fail behavior.

Comment thread src/cosign/schema.js
Comment on lines +46 to +58
'DO $$',
'BEGIN',
' IF NOT EXISTS (',
' SELECT 1 FROM pg_constraint',
` WHERE conname = '${VERIFIED_TIER_CHECK_NAME}'`,
' ) THEN',
' EXECUTE $check$',
` ALTER TABLE pgserve_meta ADD CONSTRAINT ${VERIFIED_TIER_CHECK_NAME}`,
` CHECK (verified_tier IS NULL OR verified_tier IN (${TIER_LIST_SQL}))`,
' $check$;',
' END IF;',
'END$$',
].join('\n'),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

pg_constraint probe is missing a conrelid filter — false positive skips the constraint.

The IF NOT EXISTS guard only matches on conname. Any other table in the same database that coincidentally has a constraint named pgserve_meta_verified_tier_check will cause the DO block to skip applying the CHECK to pgserve_meta, leaving the tier column unconstrained with no warning.

🛡️ Proposed fix
-  '    SELECT 1 FROM pg_constraint',
-  `    WHERE conname = '${VERIFIED_TIER_CHECK_NAME}'`,
+  '    SELECT 1 FROM pg_constraint',
+  `    WHERE conname = '${VERIFIED_TIER_CHECK_NAME}'`,
+  `    AND conrelid = 'public.pgserve_meta'::regclass`,
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
'DO $$',
'BEGIN',
' IF NOT EXISTS (',
' SELECT 1 FROM pg_constraint',
` WHERE conname = '${VERIFIED_TIER_CHECK_NAME}'`,
' ) THEN',
' EXECUTE $check$',
` ALTER TABLE pgserve_meta ADD CONSTRAINT ${VERIFIED_TIER_CHECK_NAME}`,
` CHECK (verified_tier IS NULL OR verified_tier IN (${TIER_LIST_SQL}))`,
' $check$;',
' END IF;',
'END$$',
].join('\n'),
'DO $$',
'BEGIN',
' IF NOT EXISTS (',
' SELECT 1 FROM pg_constraint',
` WHERE conname = '${VERIFIED_TIER_CHECK_NAME}'`,
` AND conrelid = 'public.pgserve_meta'::regclass`,
' ) THEN',
' EXECUTE $check$',
` ALTER TABLE pgserve_meta ADD CONSTRAINT ${VERIFIED_TIER_CHECK_NAME}`,
` CHECK (verified_tier IS NULL OR verified_tier IN (${TIER_LIST_SQL}))`,
' $check$;',
' END IF;',
'END$$',
].join('\n'),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/cosign/schema.js` around lines 46 - 58, The current IF NOT EXISTS check
only filters pg_constraint by conname (VERIFIED_TIER_CHECK_NAME) which can match
constraints on other tables and cause skipping; update the WHERE clause to also
restrict conrelid to the pgserve_meta table (e.g. add "AND conrelid =
'pgserve_meta'::regclass") so the check only detects an existing constraint on
pgserve_meta before skipping the ALTER TABLE that adds VERIFIED_TIER_CHECK_NAME
to pgserve_meta.

Comment on lines +123 to +136
export function fetchCosignBin({
releaseBase = COSIGN_RELEASE_BASE,
targetFile = COSIGN_BIN_FILE,
targetDir = COSIGN_BIN_DIR,
} = {}) {
fs.mkdirSync(targetDir, { recursive: true, mode: 0o755 });
const assetName = pickCosignAssetName();
const url = `${releaseBase}/${assetName}`;
const tmp = `${targetFile}.tmp.${process.pid}`;
downloadToFileSync(url, tmp);
fs.chmodSync(tmp, 0o755);
fs.renameSync(tmp, targetFile);
return targetFile;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

fetchCosignBin installs an unverified binary — undermines the entire verification chain.

The downloaded cosign binary is written to ~/.pgserve/bin/cosign with no checksum or signature check. A MITM attack or a compromised GitHub CDN response could install a malicious cosign that always returns exit 0, silently making every verifyBinary call return ok: true. This is a trust-bootstrapping hole: the tool used to verify binaries is itself unverified.

Sigstore publishes per-release SHA-256 checksums alongside each release artifact. At minimum, the expected checksum for each platform/arch combination should be hardcoded next to COSIGN_RELEASE_VERSION and validated before fs.renameSync commits the binary.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/cosign/verify-binary.js` around lines 123 - 136, fetchCosignBin currently
writes a downloaded cosign binary to COSIGN_BIN_FILE without integrity checking;
add a hardcoded map of expected SHA-256 checksums keyed by release version +
asset name (use COSIGN_RELEASE_VERSION and the asset name from
pickCosignAssetName) and after downloadToFileSync(tmp) compute the file's
SHA-256, compare it to the expected value, and if it does not match, remove the
tmp file and throw/log an error; only call fs.chmodSync(tmp, 0o755) and
fs.renameSync(tmp, targetFile) when the checksum matches. Ensure errors include
the expected vs actual checksum and reference fetchCosignBin,
pickCosignAssetName, COSIGN_RELEASE_VERSION, downloadToFileSync, and
COSIGN_BIN_FILE so reviewers can locate the change.

Comment on lines +265 to +268
const proc = spawnSync(cosignBin, args, {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

spawnSync for cosign has no timeout — can block the process indefinitely.

Even with --bundle, cosign verifies the proof of transparency log inclusion artifact and its certificate, which can involve network calls to TUF or Rekor endpoints. Without a timeout, any network stall hangs the entire Node/Bun process with no recovery path.

⏱️ Proposed fix
   const proc = spawnSync(cosignBin, args, {
     encoding: 'utf8',
     stdio: ['ignore', 'pipe', 'pipe'],
+    timeout: 30_000, // ms — cosign may reach TUF/Rekor; surface ETIMEDOUT rather than hanging
   });

Add a corresponding result.error?.code === 'ETIMEDOUT' check in invokeCosign and surface a distinct reason: 'cosign-timeout' from verifyBinary so callers can distinguish network hangs from signature failures.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/cosign/verify-binary.js` around lines 265 - 268, spawnSync(cosignBin,
args, ...) is called without a timeout which can hang the process during network
stalls; update this by passing a timeout option to spawnSync and handle the
timeout in invokeCosign: detect result.error?.code === 'ETIMEDOUT' (or
equivalent) and return a distinct error shape, then have verifyBinary map that
case to an error with reason: 'cosign-timeout' so callers can distinguish
network timeouts from signature failures; touch the spawnSync call site
(cosignBin, args), the invokeCosign error handling, and verifyBinary's returned
error mapping to implement this.

Comment thread src/upgrade/steps/cosign-meta-migration.js Outdated
Comment on lines +91 to +94
test('refuses clients without a query() method', async () => {
expect(applyVerifiedColumns(null)).rejects.toThrow(/query/);
expect(applyVerifiedColumns({})).rejects.toThrow(/query/);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Missing await — both .rejects assertions are never evaluated.

Without await, the test function resolves before the rejection Promises settle. Both assertions always pass regardless of what applyVerifiedColumns actually does.

🐛 Proposed fix
-    expect(applyVerifiedColumns(null)).rejects.toThrow(/query/);
-    expect(applyVerifiedColumns({})).rejects.toThrow(/query/);
+    await expect(applyVerifiedColumns(null)).rejects.toThrow(/query/);
+    await expect(applyVerifiedColumns({})).rejects.toThrow(/query/);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
test('refuses clients without a query() method', async () => {
expect(applyVerifiedColumns(null)).rejects.toThrow(/query/);
expect(applyVerifiedColumns({})).rejects.toThrow(/query/);
});
test('refuses clients without a query() method', async () => {
await expect(applyVerifiedColumns(null)).rejects.toThrow(/query/);
await expect(applyVerifiedColumns({})).rejects.toThrow(/query/);
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/cosign/schema.test.js` around lines 91 - 94, The two assertions in the
test for applyVerifiedColumns use .rejects but are missing await, so the test
exits before the rejection is evaluated; update the test (the case referencing
applyVerifiedColumns) to await the expect(...).rejects.toThrow(...) calls (or
return the promises) for both null and {} inputs so the rejection matchers are
actually executed and the test fails/fails correctly when applyVerifiedColumns
does not reject.

Two real bugs surfaced by gemini-code-assist + chatgpt-codex bot review on PR #79:

[HIGH] Shell injection in cosign-meta-migration.js
  Previous implementation: execSync with template-string + JSON.stringify(sql).
  Migration SQL contains DO $$ ... $$ blocks; bash expands $$ to its PID
  before psql sees it, corrupting the SQL. The migration would silently apply
  garbage instead of the intended schema delta.
  Fix: spawnSync with array args + shell:false + sql via stdin (-f -). No shell
  parsing, no expansion, no injection surface. Adds proper status-code +
  stderr propagation on psql failure.

[P1 security] Skip-sigstore cache bypass in verify.js
  Previous: cache lookup fired BEFORE --skip-sigstore check. A token written
  under --skip-sigstore (tier:self_signed) would be accepted on a subsequent
  run WITHOUT --skip-sigstore, letting operators bypass cosign verification.
  Fix: tier-strict cache gate. Default invocation requires tier:cosign_signed
  cache hits; --skip-sigstore invocation requires tier:self_signed. Mismatched
  tier falls through to re-verify under the requested tier (does NOT delete
  the cached token — it remains valid for its own tier).

Validation: bun run lint clean, bun run deadcode clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@namastex888 namastex888 merged commit 5e8e2ef into main May 8, 2026
9 checks passed
@namastex888 namastex888 deleted the dream-pgserve-v2-4 branch May 10, 2026 02:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant