feat(singleton#4): cosign verify (WIP — tests pending)#79
Conversation
…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>
|
Warning Rate limit exceeded
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 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 configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (3)
📝 WalkthroughWalkthroughThis PR adds a complete binary verification feature to pgserve. It introduces a new ChangesBinary Verification with Cosign Keyless OIDC
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
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.
| export function sha256File(filePath) { | ||
| const hash = crypto.createHash('sha256'); | ||
| const buf = fs.readFileSync(filePath); | ||
| hash.update(buf); | ||
| return hash.digest('hex'); | ||
| } |
There was a problem hiding this comment.
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'; |
| 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' }); | ||
| } |
There was a problem hiding this comment.
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;
}| * 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)}`; |
There was a problem hiding this comment.
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.
| 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)}`; |
| else if (a === '--bundle') opts.bundlePath = args[++i]; | ||
| else if (a === '--cosign-bin') opts.cosignBin = args[++i]; |
There was a problem hiding this comment.
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 };
}
}There was a problem hiding this comment.
💡 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".
| if (cache.ok) { | ||
| // Cache hit — bump lastUsedAt and return. | ||
| touchCacheToken(cache.payload, {}); | ||
| emit(opts, { |
There was a problem hiding this comment.
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)}`; |
There was a problem hiding this comment.
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>
There was a problem hiding this comment.
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
📒 Files selected for processing (14)
bin/pgserve-wrapper.cjsknip.jsonsrc/cli-install.cjssrc/commands/verify.jssrc/cosign/cache-token.jssrc/cosign/schema.jssrc/cosign/trust-list.jssrc/cosign/verify-binary.jssrc/upgrade/index.jssrc/upgrade/steps/cosign-meta-migration.jstests/cli/verify.test.jstests/cosign/cache-token.test.jstests/cosign/schema.test.jstests/cosign/verify-binary.test.js
| /** | ||
| * 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)}`; | ||
| } |
There was a problem hiding this comment.
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.
| /** | |
| * 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.
| 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; | ||
| } | ||
| } |
There was a problem hiding this comment.
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.
| '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'), |
There was a problem hiding this comment.
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.
| '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.
| 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; | ||
| } |
There was a problem hiding this comment.
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.
| const proc = spawnSync(cosignBin, args, { | ||
| encoding: 'utf8', | ||
| stdio: ['ignore', 'pipe', 'pipe'], | ||
| }); |
There was a problem hiding this comment.
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.
| test('refuses clients without a query() method', async () => { | ||
| expect(applyVerifiedColumns(null)).rejects.toThrow(/query/); | ||
| expect(applyVerifiedColumns({})).rejects.toThrow(/query/); | ||
| }); |
There was a problem hiding this comment.
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.
| 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>
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 P5src/commands/verify.js(NEW) —pgserve verify <path>entry pointsrc/cli-install.cjs— routing for verify subcommandsrc/upgrade/index.js— pgserve_meta migration runner wiringbin/pgserve-wrapper.cjs— subcommand dispatchknip.json—src/cosign/**added to entry listNOT shipped (work remaining for G4 acceptance)
tests/cli/verify.test.js+tests/cosign/— not yet written--skip-sigstoreflag completenessCohort: singleton G4 of pgserve v2.4. Holding for engineer to finish before merge.
Summary by CodeRabbit
pgserve verifycommand for verifying binary authenticity using keyless OIDC signatures--skip-sigstoreoption for offline verification using a local trust list--jsonoutput format for machine-readable verification results