pgserve 2.4.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +5 -8
  2. package/bin/pgserve-wrapper.cjs +23 -0
  3. package/bin/postgres-server.js +28 -0
  4. package/package.json +2 -1
  5. package/scripts/aggregate-manifest.sh +184 -0
  6. package/scripts/assemble-tarball.sh +191 -0
  7. package/scripts/audit-redaction-lint.js +349 -0
  8. package/scripts/build-binary.sh +213 -0
  9. package/scripts/fetch-postgres-bins.sh +234 -0
  10. package/scripts/postinstall.cjs +102 -18
  11. package/scripts/verify-published-artifacts.sh +211 -0
  12. package/src/audit/audit.js +134 -0
  13. package/src/cli-install.cjs +258 -26
  14. package/src/commands/doctor.js +465 -0
  15. package/src/commands/gc.js +276 -0
  16. package/src/commands/provision.js +396 -0
  17. package/src/commands/trust.js +187 -0
  18. package/src/commands/verify.js +360 -0
  19. package/src/cosign/cache-token.js +328 -0
  20. package/src/cosign/schema.js +97 -0
  21. package/src/cosign/trust-list.js +81 -0
  22. package/src/cosign/trust-store.js +250 -0
  23. package/src/cosign/verify-binary.js +277 -0
  24. package/src/gc/audit-log.js +150 -0
  25. package/src/gc/orphan-detection.js +190 -0
  26. package/src/gc/queries.js +193 -0
  27. package/src/lib/pg-query.js +145 -0
  28. package/src/lib/runtime-json.js +181 -0
  29. package/src/provision/advisory-lock.js +91 -0
  30. package/src/provision/db-naming.js +130 -0
  31. package/src/provision/fingerprint.js +144 -0
  32. package/src/schema/pgserve-meta.js +120 -0
  33. package/src/security/blocked-versions.js +103 -0
  34. package/src/upgrade/index.js +5 -0
  35. package/src/upgrade/steps/binary-cache-flush.js +2 -2
  36. package/src/upgrade/steps/cosign-meta-migration.js +123 -0
@@ -0,0 +1,250 @@
1
+ /**
2
+ * User-extensible cosign trust store at `~/.pgserve/trust/identities.json`.
3
+ *
4
+ * pgserve singleton (v2.4) — `pgserve-singleton-no-proxy` wish, Group 3
5
+ * (the `pgserve trust add/list/remove` CLI surface).
6
+ *
7
+ * Hardcoded trust roots live in `src/cosign/trust-list.js` and ship in the
8
+ * binary; operators cannot remove or override them. This module owns the
9
+ * separate, mutable layer where operators add their own publishers (e.g.
10
+ * a private fork of pgserve, an internal release workflow).
11
+ *
12
+ * File format (v1):
13
+ * {
14
+ * "schemaVersion": 1,
15
+ * "entries": [
16
+ * {
17
+ * "id": "<short-stable-id>",
18
+ * "publisher": "<package-json-pgserve-publisher>",
19
+ * "issuer": "<oidc-issuer-url>",
20
+ * "identityRegexp": "<sigstore-cert-identity-regexp>",
21
+ * "description": "<human-readable, optional>",
22
+ * "addedAt": "<iso-8601>"
23
+ * }
24
+ * ]
25
+ * }
26
+ *
27
+ * Write semantics: atomic via tmp-file + rename, file mode 0600.
28
+ * Read semantics: missing file or empty contents → `{ schemaVersion: 1, entries: [] }`.
29
+ */
30
+
31
+ import fs from 'node:fs';
32
+ import os from 'node:os';
33
+ import path from 'node:path';
34
+
35
+ import { TRUSTED_IDENTITIES, listHardcodedTrust } from './trust-list.js';
36
+
37
+ const SCHEMA_VERSION = 1;
38
+ const FILE_MODE = 0o600;
39
+ const DIR_MODE = 0o700;
40
+
41
+ const TRUST_FILE_NAME = 'identities.json';
42
+
43
+ export function getTrustDir(homeDir = os.homedir()) {
44
+ return path.join(homeDir, '.pgserve', 'trust');
45
+ }
46
+
47
+ export function getTrustFilePath(homeDir = os.homedir()) {
48
+ return path.join(getTrustDir(homeDir), TRUST_FILE_NAME);
49
+ }
50
+
51
+ function emptyStore() {
52
+ return { schemaVersion: SCHEMA_VERSION, entries: [] };
53
+ }
54
+
55
+ /**
56
+ * Read the user trust store. Returns the parsed object on success, or an
57
+ * empty store if the file is missing. Throws on parse failure / bad shape.
58
+ */
59
+ export function readTrustStore({ homeDir = os.homedir() } = {}) {
60
+ const file = getTrustFilePath(homeDir);
61
+ let raw;
62
+ try {
63
+ raw = fs.readFileSync(file, 'utf8');
64
+ } catch (err) {
65
+ if (err.code === 'ENOENT') return emptyStore();
66
+ throw err;
67
+ }
68
+ if (!raw.trim()) return emptyStore();
69
+ let parsed;
70
+ try {
71
+ parsed = JSON.parse(raw);
72
+ } catch (err) {
73
+ const e = new Error(`pgserve trust store at ${file} is not valid JSON: ${err.message}`);
74
+ e.code = 'ETRUSTSTORE';
75
+ throw e;
76
+ }
77
+ if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.entries)) {
78
+ const e = new Error(`pgserve trust store at ${file} is missing the entries array`);
79
+ e.code = 'ETRUSTSTORE';
80
+ throw e;
81
+ }
82
+ if (parsed.schemaVersion !== SCHEMA_VERSION) {
83
+ const e = new Error(
84
+ `pgserve trust store schemaVersion ${parsed.schemaVersion} unsupported (expected ${SCHEMA_VERSION})`,
85
+ );
86
+ e.code = 'ETRUSTSTORE';
87
+ throw e;
88
+ }
89
+ // Per-entry shape check. Without this, a manually-edited
90
+ // identities.json containing `{"entries":[{}]}` would slip past the
91
+ // store-level guard and crash the formatter in `pgserve trust list`
92
+ // with a generic TypeError on first field access — losing the
93
+ // documented exit-2 ("malformed store") path. Fail fast here with
94
+ // the same ETRUSTSTORE code so downstream callers (the CLI command,
95
+ // `pgserve verify`, future provisioner) can branch on it uniformly.
96
+ for (let i = 0; i < parsed.entries.length; i++) {
97
+ const e = parsed.entries[i];
98
+ if (!e || typeof e !== 'object'
99
+ || typeof e.id !== 'string' || e.id.length === 0
100
+ || typeof e.issuer !== 'string' || e.issuer.length === 0
101
+ || typeof e.identityRegexp !== 'string' || e.identityRegexp.length === 0) {
102
+ const err = new Error(
103
+ `pgserve trust store at ${file}: entries[${i}] is missing required fields ` +
104
+ `(id, issuer, identityRegexp)`,
105
+ );
106
+ err.code = 'ETRUSTSTORE';
107
+ throw err;
108
+ }
109
+ }
110
+ return parsed;
111
+ }
112
+
113
+ /**
114
+ * Atomically write the trust store. Creates the directory if absent.
115
+ */
116
+ export function writeTrustStore(store, { homeDir = os.homedir() } = {}) {
117
+ if (!store || typeof store !== 'object' || !Array.isArray(store.entries)) {
118
+ throw new Error('writeTrustStore: store must be { schemaVersion, entries }');
119
+ }
120
+ const dir = getTrustDir(homeDir);
121
+ const file = getTrustFilePath(homeDir);
122
+ fs.mkdirSync(dir, { recursive: true, mode: DIR_MODE });
123
+ const tmp = `${file}.tmp.${process.pid}`;
124
+ const payload = JSON.stringify({ schemaVersion: SCHEMA_VERSION, entries: store.entries }, null, 2) + '\n';
125
+ fs.writeFileSync(tmp, payload, { mode: FILE_MODE });
126
+ fs.renameSync(tmp, file);
127
+ try {
128
+ fs.chmodSync(file, FILE_MODE);
129
+ } catch {
130
+ /* best-effort on platforms that ignore mode */
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Validate a single user entry candidate. Throws on bad shape.
136
+ * Returns the normalized entry (trimmed strings, computed addedAt).
137
+ */
138
+ export function validateEntry(candidate) {
139
+ if (!candidate || typeof candidate !== 'object') {
140
+ throw new Error('trust entry must be an object');
141
+ }
142
+ const required = ['id', 'issuer', 'identityRegexp'];
143
+ for (const key of required) {
144
+ const v = candidate[key];
145
+ if (typeof v !== 'string' || v.length === 0) {
146
+ throw new Error(`trust entry field "${key}" must be a non-empty string`);
147
+ }
148
+ }
149
+ if (!/^[a-z0-9][a-z0-9._-]{0,63}$/i.test(candidate.id)) {
150
+ throw new Error(
151
+ `trust entry id "${candidate.id}" must match /^[a-z0-9][a-z0-9._-]{0,63}$/i`,
152
+ );
153
+ }
154
+ // Normalize id to lowercase. The regex accepts upper-case (/i flag) so
155
+ // operators can paste pretty identifiers, but storage + lookup are
156
+ // case-insensitive — otherwise an entry typed "Foo" could shadow a
157
+ // hardcoded "foo" silently. Normalizing once on write keeps the
158
+ // hardcoded-shadow check simple and makes `trust remove FOO` idempotent
159
+ // with `trust add foo`.
160
+ const normalizedId = candidate.id.toLowerCase();
161
+ // Validate the identityRegexp parses as JS regex (cosign uses a similar
162
+ // RE2-ish dialect; this catches the obvious garbage while letting valid
163
+ // sigstore patterns through).
164
+ try {
165
+ new RegExp(candidate.identityRegexp);
166
+ } catch (err) {
167
+ throw new Error(`trust entry identityRegexp is not a valid regex: ${err.message}`);
168
+ }
169
+ return {
170
+ id: normalizedId,
171
+ publisher: typeof candidate.publisher === 'string' ? candidate.publisher : '',
172
+ issuer: candidate.issuer,
173
+ identityRegexp: candidate.identityRegexp,
174
+ description: typeof candidate.description === 'string' ? candidate.description : '',
175
+ addedAt: typeof candidate.addedAt === 'string' && candidate.addedAt
176
+ ? candidate.addedAt
177
+ : new Date().toISOString(),
178
+ };
179
+ }
180
+
181
+ function isHardcodedId(id) {
182
+ // Compare lowercase-against-lowercase. validateEntry normalizes new
183
+ // user entries to lowercase; hardcoded ids in TRUSTED_IDENTITIES
184
+ // already use lowercase by convention, but we lowercase both sides to
185
+ // make the predicate symmetric and immune to a typo in the hardcoded
186
+ // table from leaking through.
187
+ const needle = id.toLowerCase();
188
+ return TRUSTED_IDENTITIES.some((e) => e.id.toLowerCase() === needle);
189
+ }
190
+
191
+ /**
192
+ * Add a user trust entry. Refuses to shadow a hardcoded id.
193
+ * Returns the normalized entry that was written.
194
+ */
195
+ export function addUserTrust(candidate, opts = {}) {
196
+ const entry = validateEntry(candidate);
197
+ if (isHardcodedId(entry.id)) {
198
+ const e = new Error(
199
+ `cannot add "${entry.id}" — id collides with a hardcoded trust root and would shadow it`,
200
+ );
201
+ e.code = 'ETRUSTSHADOW';
202
+ throw e;
203
+ }
204
+ const store = readTrustStore(opts);
205
+ const existing = store.entries.findIndex((x) => x.id === entry.id);
206
+ if (existing >= 0) {
207
+ store.entries[existing] = entry;
208
+ } else {
209
+ store.entries.push(entry);
210
+ }
211
+ writeTrustStore(store, opts);
212
+ return entry;
213
+ }
214
+
215
+ /**
216
+ * Remove a user trust entry by id. Refuses to remove hardcoded entries.
217
+ * Returns true on success, false if the id was not in the user store.
218
+ */
219
+ export function removeUserTrust(id, opts = {}) {
220
+ if (typeof id !== 'string' || id.length === 0) {
221
+ throw new Error('removeUserTrust: id must be a non-empty string');
222
+ }
223
+ // Lowercase normalization mirrors validateEntry — `trust remove FOO`
224
+ // must find the entry that `trust add foo` (or `trust add Foo`) wrote.
225
+ const normalizedId = id.toLowerCase();
226
+ if (isHardcodedId(normalizedId)) {
227
+ const e = new Error(`cannot remove "${normalizedId}" — hardcoded trust roots are not removable`);
228
+ e.code = 'ETRUSTHARDCODED';
229
+ throw e;
230
+ }
231
+ const store = readTrustStore(opts);
232
+ const before = store.entries.length;
233
+ store.entries = store.entries.filter((x) => x.id !== normalizedId);
234
+ if (store.entries.length === before) return false;
235
+ writeTrustStore(store, opts);
236
+ return true;
237
+ }
238
+
239
+ /**
240
+ * Combined view: hardcoded entries followed by user entries, each tagged
241
+ * with `source` and `removable`. Used by `pgserve trust list`.
242
+ */
243
+ export function listAllTrust(opts = {}) {
244
+ const hardcoded = listHardcodedTrust();
245
+ const store = readTrustStore(opts);
246
+ const user = store.entries.map((entry) => ({ ...entry, source: 'user', removable: true }));
247
+ return [...hardcoded, ...user];
248
+ }
249
+
250
+ export const __testInternals = Object.freeze({ SCHEMA_VERSION, FILE_MODE, DIR_MODE });
@@ -0,0 +1,277 @@
1
+ /**
2
+ * Cosign keyless OIDC binary verifier.
3
+ *
4
+ * pgserve singleton (v2.4) — `pgserve-singleton-no-proxy` wish, Group 4.
5
+ *
6
+ * Per Decision P5 (locked), we shell out to the `cosign` CLI rather than
7
+ * vendoring sigstore-rs. Verification model:
8
+ *
9
+ * - Each binary distributed by an automagik release ships with a
10
+ * paired Sigstore bundle file at `<binary>.bundle` (modern keyless
11
+ * OIDC attestation, JSON-encoded).
12
+ * - We invoke `cosign verify-blob --bundle <bundle>
13
+ * --certificate-identity-regexp <re> --certificate-oidc-issuer <iss>
14
+ * <binary>` — once per entry in the trust list.
15
+ * - First entry that exits 0 wins. We return its identity + tier.
16
+ * - If every entry fails, we surface the most-specific cosign diagnostic
17
+ * (the last exit) so the operator knows which trust root we tried.
18
+ *
19
+ * Resolution of the cosign executable:
20
+ * 1. Caller-supplied `cosignBin` (used by tests + `--cosign-bin` flag)
21
+ * 2. `cosign` on `$PATH`
22
+ * 3. Cached static binary at `~/.pgserve/bin/cosign`
23
+ * 4. Download official static binary from sigstore release (offline by
24
+ * default — only triggered when `allowFetch: true` is passed)
25
+ *
26
+ * Returns a tagged union:
27
+ * { ok: true, identity, tier, sha256, cosignBin, bundle }
28
+ * { ok: false, reason, detail?, identityChain? }
29
+ */
30
+
31
+ import { spawnSync } from 'node:child_process';
32
+ import crypto from 'node:crypto';
33
+ import fs from 'node:fs';
34
+ import os from 'node:os';
35
+ import path from 'node:path';
36
+
37
+ import { TRUSTED_IDENTITIES } from './trust-list.js';
38
+
39
+ export const COSIGN_TIER = 'cosign_signed';
40
+ export const COSIGN_BIN_DIR = path.join(os.homedir(), '.pgserve', 'bin');
41
+ export const COSIGN_BIN_FILE = path.join(COSIGN_BIN_DIR, process.platform === 'win32' ? 'cosign.exe' : 'cosign');
42
+
43
+ const COSIGN_RELEASE_VERSION = 'v2.2.4';
44
+ const COSIGN_RELEASE_BASE = `https://github.com/sigstore/cosign/releases/download/${COSIGN_RELEASE_VERSION}`;
45
+
46
+ /**
47
+ * Compute sha256 of a file. Returns lowercase hex.
48
+ */
49
+ export function sha256File(filePath) {
50
+ const hash = crypto.createHash('sha256');
51
+ const buf = fs.readFileSync(filePath);
52
+ hash.update(buf);
53
+ return hash.digest('hex');
54
+ }
55
+
56
+ /**
57
+ * Resolve the sidecar bundle path for a given binary path. Convention:
58
+ * `<binary>.bundle`. Operators that publish detached `.sig` + `.cert` can
59
+ * regenerate a bundle with `cosign sign-blob --bundle <path>.bundle`; we
60
+ * intentionally only support the bundle form to keep the surface narrow.
61
+ */
62
+ export function resolveBundlePath(binaryPath) {
63
+ return `${binaryPath}.bundle`;
64
+ }
65
+
66
+ /**
67
+ * Resolve which `cosign` executable to use. Returns a string path or null
68
+ * if no cosign is available and `allowFetch: false`.
69
+ *
70
+ * PATH probing is implemented in-process (rather than shelling out to
71
+ * `which` / `where`) so the resolver works inside test harnesses that
72
+ * scrub PATH down to a single stub directory.
73
+ */
74
+ export function resolveCosignBin({ cosignBin, allowFetch = false } = {}) {
75
+ if (cosignBin && fs.existsSync(cosignBin)) return cosignBin;
76
+
77
+ const fromPath = lookupOnPath(process.platform === 'win32' ? 'cosign.exe' : 'cosign');
78
+ if (fromPath) return fromPath;
79
+
80
+ // Cached static binary.
81
+ if (fs.existsSync(COSIGN_BIN_FILE)) return COSIGN_BIN_FILE;
82
+
83
+ if (!allowFetch) return null;
84
+
85
+ try {
86
+ return fetchCosignBin();
87
+ } catch {
88
+ return null;
89
+ }
90
+ }
91
+
92
+ function lookupOnPath(name) {
93
+ const PATH = process.env.PATH || '';
94
+ if (!PATH) return null;
95
+ const sep = process.platform === 'win32' ? ';' : ':';
96
+ const dirs = PATH.split(sep).filter(Boolean);
97
+ for (const dir of dirs) {
98
+ const candidate = path.join(dir, name);
99
+ try {
100
+ const stat = fs.statSync(candidate);
101
+ if (stat.isFile()) {
102
+ // On POSIX, ensure it's executable; on Windows, file existence is enough.
103
+ if (process.platform === 'win32') return candidate;
104
+ if ((stat.mode & 0o111) !== 0) return candidate;
105
+ }
106
+ } catch {
107
+ // missing or stat error — try next dir
108
+ }
109
+ }
110
+ return null;
111
+ }
112
+
113
+ /**
114
+ * Download the official cosign static binary into `~/.pgserve/bin/cosign`.
115
+ * Synchronous — used by `pgserve verify` when no cosign is on PATH and the
116
+ * operator opts into the fetch (see Decision P5: "if cosign not on PATH,
117
+ * pgserve install shells out to a downloader to fetch the official static
118
+ * binary into ~/.pgserve/bin/cosign").
119
+ *
120
+ * Network-dependent. Throws on failure. Tests stub via `cosignBin` instead
121
+ * of this code path.
122
+ */
123
+ export function fetchCosignBin({
124
+ releaseBase = COSIGN_RELEASE_BASE,
125
+ targetFile = COSIGN_BIN_FILE,
126
+ targetDir = COSIGN_BIN_DIR,
127
+ } = {}) {
128
+ fs.mkdirSync(targetDir, { recursive: true, mode: 0o755 });
129
+ const assetName = pickCosignAssetName();
130
+ const url = `${releaseBase}/${assetName}`;
131
+ const tmp = `${targetFile}.tmp.${process.pid}`;
132
+ downloadToFileSync(url, tmp);
133
+ fs.chmodSync(tmp, 0o755);
134
+ fs.renameSync(tmp, targetFile);
135
+ return targetFile;
136
+ }
137
+
138
+ function pickCosignAssetName() {
139
+ const arch = process.arch === 'x64' ? 'amd64' : process.arch === 'arm64' ? 'arm64' : process.arch;
140
+ if (process.platform === 'darwin') return `cosign-darwin-${arch}`;
141
+ if (process.platform === 'win32') return `cosign-windows-${arch}.exe`;
142
+ return `cosign-linux-${arch}`;
143
+ }
144
+
145
+ function downloadToFileSync(url, destPath) {
146
+ // Node has no built-in synchronous HTTP. We fall back to spawning curl
147
+ // since this only runs once per host (cached afterward) and curl is
148
+ // ubiquitous on the supported platforms.
149
+ const curl = spawnSync('curl', ['-fsSL', '-o', destPath, url], {
150
+ stdio: ['ignore', 'pipe', 'pipe'],
151
+ });
152
+ if (curl.status === 0) return;
153
+ // Fallback: try wget.
154
+ const wget = spawnSync('wget', ['-qO', destPath, url], { stdio: ['ignore', 'pipe', 'pipe'] });
155
+ if (wget.status === 0) return;
156
+ throw new Error(
157
+ `cosign-verify: failed to download cosign from ${url} (curl exit ${curl.status}, wget exit ${wget?.status ?? 'n/a'})`,
158
+ );
159
+ }
160
+
161
+ /**
162
+ * Verify a binary with cosign.
163
+ *
164
+ * @param {string} binaryPath
165
+ * @param {object} [options]
166
+ * @param {string} [options.cosignBin] override cosign executable
167
+ * @param {string} [options.bundlePath] override bundle sidecar path
168
+ * @param {Array} [options.trustList] override hardcoded TRUSTED_IDENTITIES
169
+ * @param {boolean}[options.allowFetch] allow fetching the cosign binary if missing
170
+ * @returns {{ ok: true, identity, tier, sha256, cosignBin, bundle, identityChain }
171
+ * | { ok: false, reason, detail?, identityChain? }}
172
+ */
173
+ export function verifyBinary(binaryPath, options = {}) {
174
+ if (typeof binaryPath !== 'string' || binaryPath.length === 0) {
175
+ return { ok: false, reason: 'invalid-args', detail: 'binaryPath required' };
176
+ }
177
+ if (!fs.existsSync(binaryPath)) {
178
+ return { ok: false, reason: 'binary-missing', detail: binaryPath };
179
+ }
180
+ let stat;
181
+ try {
182
+ stat = fs.statSync(binaryPath);
183
+ } catch (err) {
184
+ return { ok: false, reason: 'binary-unreadable', detail: err.message };
185
+ }
186
+ if (!stat.isFile()) {
187
+ return { ok: false, reason: 'binary-not-a-file', detail: binaryPath };
188
+ }
189
+
190
+ const bundlePath = options.bundlePath || resolveBundlePath(binaryPath);
191
+ if (!fs.existsSync(bundlePath)) {
192
+ return {
193
+ ok: false,
194
+ reason: 'bundle-missing',
195
+ detail: `expected sigstore bundle at ${bundlePath} (run \`cosign sign-blob --bundle ${bundlePath} ${binaryPath}\` to attest)`,
196
+ };
197
+ }
198
+
199
+ const trustList = options.trustList || TRUSTED_IDENTITIES;
200
+ if (!Array.isArray(trustList) || trustList.length === 0) {
201
+ return { ok: false, reason: 'empty-trust-list' };
202
+ }
203
+
204
+ const cosignBin = resolveCosignBin({
205
+ cosignBin: options.cosignBin,
206
+ allowFetch: options.allowFetch === true,
207
+ });
208
+ if (!cosignBin) {
209
+ return {
210
+ ok: false,
211
+ reason: 'cosign-missing',
212
+ detail:
213
+ 'no `cosign` binary on $PATH or in ~/.pgserve/bin/cosign — install cosign or rerun with --allow-fetch',
214
+ };
215
+ }
216
+
217
+ const sha256 = sha256File(binaryPath);
218
+ const identityChain = [];
219
+ let lastFailure = null;
220
+
221
+ for (const identity of trustList) {
222
+ if (!identity || !identity.id || !identity.issuer || !identity.identityRegexp) {
223
+ identityChain.push({ id: identity?.id || '<malformed>', status: 'skipped' });
224
+ continue;
225
+ }
226
+ const result = invokeCosign({
227
+ cosignBin,
228
+ bundlePath,
229
+ binaryPath,
230
+ identity,
231
+ });
232
+ if (result.ok) {
233
+ identityChain.push({ id: identity.id, status: 'matched' });
234
+ return {
235
+ ok: true,
236
+ identity: identity.id,
237
+ publisher: identity.publisher,
238
+ tier: COSIGN_TIER,
239
+ sha256,
240
+ cosignBin,
241
+ bundle: bundlePath,
242
+ identityChain,
243
+ };
244
+ }
245
+ identityChain.push({ id: identity.id, status: 'rejected', exitCode: result.exitCode });
246
+ lastFailure = result;
247
+ }
248
+
249
+ return {
250
+ ok: false,
251
+ reason: 'no-trust-match',
252
+ detail: lastFailure?.stderr || 'cosign rejected the binary against every trust root',
253
+ identityChain,
254
+ };
255
+ }
256
+
257
+ function invokeCosign({ cosignBin, bundlePath, binaryPath, identity }) {
258
+ const args = [
259
+ 'verify-blob',
260
+ '--bundle', bundlePath,
261
+ '--certificate-identity-regexp', identity.identityRegexp,
262
+ '--certificate-oidc-issuer', identity.issuer,
263
+ binaryPath,
264
+ ];
265
+ const proc = spawnSync(cosignBin, args, {
266
+ encoding: 'utf8',
267
+ stdio: ['ignore', 'pipe', 'pipe'],
268
+ });
269
+ if (proc.status === 0) {
270
+ return { ok: true, stdout: proc.stdout || '' };
271
+ }
272
+ return {
273
+ ok: false,
274
+ exitCode: typeof proc.status === 'number' ? proc.status : -1,
275
+ stderr: (proc.stderr || proc.stdout || '').trim().slice(0, 4096),
276
+ };
277
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Append-only audit log writer for `pgserve gc`.
3
+ *
4
+ * pgserve singleton (v2.4) — `pgserve-singleton-no-proxy` wish, Group 3.
5
+ *
6
+ * The wish acceptance criteria require gc to "audit-log every drop" so
7
+ * that an operator can answer "why did my database disappear?" days
8
+ * later. We keep the format intentionally boring: one JSON object per
9
+ * line at `~/.pgserve/audit/gc-<YYYY-MM-DD>.log`, opened with O_APPEND
10
+ * so multiple gc runs on the same day interleave cleanly.
11
+ *
12
+ * One JSON object per event so logs are streamable through tools that
13
+ * expect JSON-lines (jq, fluent-bit, vector, etc.) without a second
14
+ * parser. UTC date in the filename so log rotation across timezones is
15
+ * deterministic.
16
+ *
17
+ * Permissions: dir 0700, file 0600 — same posture as the cosign cache
18
+ * tokens. Audit logs may name databases that contain sensitive tenant
19
+ * identifiers; tightening file mode is cheap insurance.
20
+ *
21
+ * Pure-ish: filesystem I/O is the side effect. No postgres, no network.
22
+ */
23
+
24
+ import fs from 'node:fs';
25
+ import os from 'node:os';
26
+ import path from 'node:path';
27
+
28
+ export const AUDIT_DIR_NAME = 'audit';
29
+ export const AUDIT_FILE_PREFIX = 'gc-';
30
+ export const AUDIT_FILE_MODE = 0o600;
31
+ export const AUDIT_DIR_MODE = 0o700;
32
+
33
+ /**
34
+ * @typedef {Object} GcAuditEvent
35
+ * @property {string} ts ISO 8601 timestamp with ms.
36
+ * @property {'drop'|'skip'|'error'|'start'|'finish'} action
37
+ * @property {string=} fingerprint pgserve_meta.fingerprint
38
+ * @property {string=} database database name acted on
39
+ * @property {string=} role role name acted on
40
+ * @property {string=} reason finding.reason from orphan detection
41
+ * ('missing_db' | 'missing_path' | …)
42
+ * or a free-form skip / error reason.
43
+ * @property {string=} detail operator-facing detail line.
44
+ * @property {string=} dryRun present when --dry-run; the audit
45
+ * line then records what *would* have
46
+ * happened.
47
+ */
48
+
49
+ export function getAuditDir({ homeDir = os.homedir() } = {}) {
50
+ return path.join(homeDir, '.pgserve', AUDIT_DIR_NAME);
51
+ }
52
+
53
+ /**
54
+ * Build the audit-file path for a given UTC date. Defaults to "today".
55
+ */
56
+ export function getAuditFilePath({ homeDir = os.homedir(), date = new Date() } = {}) {
57
+ const yyyyMmDd = formatUtcDate(date);
58
+ return path.join(getAuditDir({ homeDir }), `${AUDIT_FILE_PREFIX}${yyyyMmDd}.log`);
59
+ }
60
+
61
+ function formatUtcDate(date) {
62
+ if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
63
+ throw new TypeError('formatUtcDate: date must be a valid Date');
64
+ }
65
+ const yyyy = String(date.getUTCFullYear()).padStart(4, '0');
66
+ const mm = String(date.getUTCMonth() + 1).padStart(2, '0');
67
+ const dd = String(date.getUTCDate()).padStart(2, '0');
68
+ return `${yyyy}-${mm}-${dd}`;
69
+ }
70
+
71
+ /**
72
+ * Append a single gc audit event. Returns the line that was written
73
+ * (without the trailing newline) so callers can mirror it to stdout.
74
+ *
75
+ * @param {GcAuditEvent} event
76
+ * @param {object} [opts]
77
+ * @param {string} [opts.homeDir] override for tests
78
+ * @param {Date} [opts.date] override for tests; defaults to now
79
+ */
80
+ export function writeGcAudit(event, opts = {}) {
81
+ if (!event || typeof event !== 'object') {
82
+ throw new TypeError('writeGcAudit: event must be an object');
83
+ }
84
+ if (typeof event.action !== 'string' || event.action.length === 0) {
85
+ throw new TypeError('writeGcAudit: event.action is required');
86
+ }
87
+ const dir = getAuditDir(opts);
88
+ const file = getAuditFilePath(opts);
89
+ fs.mkdirSync(dir, { recursive: true, mode: AUDIT_DIR_MODE });
90
+ // mkdirSync's `mode` only applies on creation. If the audit dir was
91
+ // previously created with a looser umask (older gc versions, manual
92
+ // mkdir -p, restored backup) it stays at whatever mode it had —
93
+ // tighten it to 0700 to match the file-side belt-and-suspenders.
94
+ try {
95
+ fs.chmodSync(dir, AUDIT_DIR_MODE);
96
+ } catch {
97
+ /* best-effort on platforms that ignore chmod */
98
+ }
99
+ // ts must be the canonical ISO 8601 string unless the caller supplied
100
+ // a non-empty string (correlation-id use case). The spread MUST come
101
+ // first so a stray `ts: undefined` / `ts: 0` / `ts: new Date()` from
102
+ // the caller cannot silently overwrite our generated value — JS
103
+ // object-spread precedence means later keys win. (Earlier shape had
104
+ // this inverted with a wrong comment claiming the spread "doesn't
105
+ // overwrite" — it does.)
106
+ const enriched = {
107
+ ...event,
108
+ ts: typeof event.ts === 'string' && event.ts ? event.ts : new Date().toISOString(),
109
+ };
110
+ const line = JSON.stringify(enriched);
111
+ fs.appendFileSync(file, line + '\n', { mode: AUDIT_FILE_MODE });
112
+ // appendFileSync's `mode` only applies on file creation; chmod the
113
+ // existing file to be safe in case it was previously created with a
114
+ // looser umask (older gc versions, manual touches, etc.).
115
+ try {
116
+ fs.chmodSync(file, AUDIT_FILE_MODE);
117
+ } catch {
118
+ /* best-effort on platforms that ignore chmod */
119
+ }
120
+ return line;
121
+ }
122
+
123
+ /**
124
+ * Read all events for a single UTC date. Returns parsed objects; lines
125
+ * that fail to parse are returned as `{ malformed: true, raw: <line> }`
126
+ * so a corrupt earlier write doesn't make the rest of the file
127
+ * unreadable. Missing file → empty array.
128
+ */
129
+ export function readGcAuditDay({ homeDir = os.homedir(), date = new Date() } = {}) {
130
+ const file = getAuditFilePath({ homeDir, date });
131
+ let raw;
132
+ try {
133
+ raw = fs.readFileSync(file, 'utf8');
134
+ } catch (err) {
135
+ if (err.code === 'ENOENT') return [];
136
+ throw err;
137
+ }
138
+ const out = [];
139
+ for (const line of raw.split('\n')) {
140
+ if (line.length === 0) continue;
141
+ try {
142
+ out.push(JSON.parse(line));
143
+ } catch {
144
+ out.push({ malformed: true, raw: line });
145
+ }
146
+ }
147
+ return out;
148
+ }
149
+
150
+ export const __testInternals = Object.freeze({ formatUtcDate });