pgserve 2.6.1 → 2.6.5
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.
- package/CHANGELOG.md +205 -0
- package/README.md +20 -0
- package/bin/pgserve-wrapper.cjs +27 -2
- package/console/dist/app.js +7 -7
- package/package.json +1 -1
- package/scripts/aggregate-manifest.sh +42 -26
- package/scripts/fetch-postgres-bins.sh +9 -2
- package/scripts/verify-published-artifacts.sh +85 -32
- package/src/admin/admin-bootstrap.js +296 -0
- package/src/cli-config.cjs +37 -0
- package/src/cli-install.cjs +24 -13
- package/src/commands/create-app.js +387 -0
- package/src/commands/doctor.js +65 -0
- package/src/commands/gc.js +16 -1
- package/src/commands/verify.js +94 -4
- package/src/cosign/locked-roots.js +141 -0
- package/src/cosign/trust-list.js +29 -6
- package/src/cosign/verify-binary.js +162 -12
- package/src/gc/audit-log.js +92 -0
- package/src/postgres.js +16 -1
- package/src/schema/autopg-meta.js +120 -0
package/src/commands/verify.js
CHANGED
|
@@ -45,6 +45,8 @@ import {
|
|
|
45
45
|
writeCacheToken,
|
|
46
46
|
} from '../cosign/cache-token.js';
|
|
47
47
|
import { sha256File, verifyBinary } from '../cosign/verify-binary.js';
|
|
48
|
+
import { loadLockedRoots as loadLockedRootsImpl } from '../cosign/locked-roots.js';
|
|
49
|
+
import { readAdminJson } from '../lib/admin-json.js';
|
|
48
50
|
|
|
49
51
|
const EXIT_OK = 0;
|
|
50
52
|
const EXIT_VERIFY_FAILED = 2;
|
|
@@ -110,6 +112,8 @@ function parseArgs(args) {
|
|
|
110
112
|
cosignBin: null,
|
|
111
113
|
allowFetch: false,
|
|
112
114
|
noCache: false,
|
|
115
|
+
slug: null,
|
|
116
|
+
port: null,
|
|
113
117
|
};
|
|
114
118
|
for (let i = 0; i < args.length; i++) {
|
|
115
119
|
const a = args[i];
|
|
@@ -119,6 +123,15 @@ function parseArgs(args) {
|
|
|
119
123
|
else if (a === '--no-cache') opts.noCache = true;
|
|
120
124
|
else if (a === '--bundle') opts.bundlePath = args[++i];
|
|
121
125
|
else if (a === '--cosign-bin') opts.cosignBin = args[++i];
|
|
126
|
+
else if (a === '--slug') opts.slug = args[++i];
|
|
127
|
+
else if (a === '--port' || a === '-p') {
|
|
128
|
+
const v = Number(args[++i]);
|
|
129
|
+
if (!Number.isInteger(v) || v <= 0 || v > 65535) {
|
|
130
|
+
process.stderr.write('pgserve verify: --port requires an integer in [1, 65535]\n');
|
|
131
|
+
return { exit: EXIT_INVOCATION };
|
|
132
|
+
}
|
|
133
|
+
opts.port = v;
|
|
134
|
+
}
|
|
122
135
|
else if (a === '--help' || a === '-h') {
|
|
123
136
|
printHelp(process.stdout);
|
|
124
137
|
return { exit: EXIT_OK };
|
|
@@ -136,6 +149,10 @@ function parseArgs(args) {
|
|
|
136
149
|
printHelp(process.stderr);
|
|
137
150
|
return { exit: EXIT_INVOCATION };
|
|
138
151
|
}
|
|
152
|
+
if (opts.slug !== null && (typeof opts.slug !== 'string' || opts.slug.trim().length === 0)) {
|
|
153
|
+
process.stderr.write('pgserve verify: --slug requires a non-empty value\n');
|
|
154
|
+
return { exit: EXIT_INVOCATION };
|
|
155
|
+
}
|
|
139
156
|
return { opts };
|
|
140
157
|
}
|
|
141
158
|
|
|
@@ -155,12 +172,19 @@ Options:
|
|
|
155
172
|
--cosign-bin <path> Override the cosign executable
|
|
156
173
|
--allow-fetch Allow downloading cosign if missing
|
|
157
174
|
--no-cache Never read or write the verified-cache token
|
|
175
|
+
--slug <slug> Verify against the locked_roots snapshot for
|
|
176
|
+
the named consumer slug from public.autopg_meta
|
|
177
|
+
(frozen at \`pgserve create-app\` time). When
|
|
178
|
+
omitted, the live TRUSTED_IDENTITIES list is used.
|
|
179
|
+
--port, -p <N> Override the postgres port for --slug lookups
|
|
180
|
+
(default: read ~/.autopg/admin.json or 5432)
|
|
158
181
|
--help, -h Show this help
|
|
159
182
|
|
|
160
183
|
Exit codes:
|
|
161
184
|
0 Verified (fresh or cache hit)
|
|
162
|
-
2 Verification failed
|
|
163
|
-
3 Invocation problem (missing binary/bundle/cosign/pretrusted key
|
|
185
|
+
2 Verification failed (binary's identity not in trust list / locked roots)
|
|
186
|
+
3 Invocation problem (missing binary/bundle/cosign/pretrusted key,
|
|
187
|
+
unknown --slug, autopg_meta not bootstrapped)
|
|
164
188
|
`);
|
|
165
189
|
}
|
|
166
190
|
|
|
@@ -183,11 +207,30 @@ function emit({ json }, payload) {
|
|
|
183
207
|
}
|
|
184
208
|
}
|
|
185
209
|
|
|
210
|
+
function resolveVerifyPort(opts) {
|
|
211
|
+
if (typeof opts.port === 'number') return opts.port;
|
|
212
|
+
try {
|
|
213
|
+
const admin = readAdminJson();
|
|
214
|
+
if (admin && Number.isInteger(admin.port) && admin.port > 0) return admin.port;
|
|
215
|
+
} catch {
|
|
216
|
+
/* admin.json absent — fall through */
|
|
217
|
+
}
|
|
218
|
+
return 5432;
|
|
219
|
+
}
|
|
220
|
+
|
|
186
221
|
/**
|
|
187
222
|
* Run the verify command. `argv` is the bare argument list AFTER the
|
|
188
|
-
* `verify` token.
|
|
223
|
+
* `verify` token.
|
|
224
|
+
*
|
|
225
|
+
* @param {string[]} argv
|
|
226
|
+
* @param {object} [deps] dependency injection seam
|
|
227
|
+
* @param {Function} [deps.loadLockedRoots] test stub for the
|
|
228
|
+
* autopg_meta loader; defaults
|
|
229
|
+
* to the real psql shellout.
|
|
230
|
+
* @returns {number} exit code
|
|
189
231
|
*/
|
|
190
|
-
export function runVerify(argv) {
|
|
232
|
+
export function runVerify(argv, deps = {}) {
|
|
233
|
+
const loadLockedRoots = deps.loadLockedRoots || loadLockedRootsImpl;
|
|
191
234
|
const parsed = parseArgs(argv);
|
|
192
235
|
if (parsed.exit !== undefined) return parsed.exit;
|
|
193
236
|
const opts = parsed.opts;
|
|
@@ -296,10 +339,55 @@ export function runVerify(argv) {
|
|
|
296
339
|
}
|
|
297
340
|
|
|
298
341
|
// ── Cosign path ──────────────────────────────────────────────────────
|
|
342
|
+
// --slug: load the frozen locked_roots from autopg_meta and pass them
|
|
343
|
+
// through as options.trustList so cosign verification runs against the
|
|
344
|
+
// create-app-time snapshot, not the live TRUSTED_IDENTITIES (BRIEF v5
|
|
345
|
+
// deliverable D4b — the manifest LOCK 1 invariant).
|
|
346
|
+
let slugTrustList;
|
|
347
|
+
let slugLockedAt;
|
|
348
|
+
if (opts.slug) {
|
|
349
|
+
try {
|
|
350
|
+
const port = resolveVerifyPort(opts);
|
|
351
|
+
const loaded = loadLockedRoots({ slug: opts.slug, port });
|
|
352
|
+
slugTrustList = loaded.lockedRoots;
|
|
353
|
+
slugLockedAt = loaded.createdAt;
|
|
354
|
+
} catch (err) {
|
|
355
|
+
// Map structured loader errors → invocation exit code (3). These
|
|
356
|
+
// are operator/setup problems, not "this binary is wrong" failures
|
|
357
|
+
// (which are exit code 2 — handled below by verifyBinary's
|
|
358
|
+
// empty-trust-list / no-matching-identity reasons).
|
|
359
|
+
if (
|
|
360
|
+
err.code === 'EAUTOPGMETAMISSING'
|
|
361
|
+
|| err.code === 'EAUTOPGSLUGUNKNOWN'
|
|
362
|
+
|| err.code === 'EAUTOPGLOCKEDPARSE'
|
|
363
|
+
) {
|
|
364
|
+
emit(opts, {
|
|
365
|
+
ok: false,
|
|
366
|
+
reason: 'slug-lookup-failed',
|
|
367
|
+
detail: err.message,
|
|
368
|
+
slug: opts.slug,
|
|
369
|
+
loaderCode: err.code,
|
|
370
|
+
});
|
|
371
|
+
return EXIT_INVOCATION;
|
|
372
|
+
}
|
|
373
|
+
// Anything else (postgres unreachable, psql crash, etc.) is also
|
|
374
|
+
// an invocation problem — operator hasn't connected pgserve verify
|
|
375
|
+
// to a reachable postmaster.
|
|
376
|
+
emit(opts, {
|
|
377
|
+
ok: false,
|
|
378
|
+
reason: 'slug-lookup-failed',
|
|
379
|
+
detail: err.message,
|
|
380
|
+
slug: opts.slug,
|
|
381
|
+
});
|
|
382
|
+
return EXIT_INVOCATION;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
299
386
|
const result = verifyBinary(binaryPath, {
|
|
300
387
|
cosignBin: opts.cosignBin || process.env.PGSERVE_COSIGN_BIN || undefined,
|
|
301
388
|
bundlePath: opts.bundlePath || undefined,
|
|
302
389
|
allowFetch: opts.allowFetch === true,
|
|
390
|
+
trustList: slugTrustList,
|
|
303
391
|
});
|
|
304
392
|
|
|
305
393
|
if (!result.ok) {
|
|
@@ -348,6 +436,8 @@ export function runVerify(argv) {
|
|
|
348
436
|
cacheFile,
|
|
349
437
|
bundle: result.bundle,
|
|
350
438
|
cosignBin: result.cosignBin,
|
|
439
|
+
slug: opts.slug || undefined,
|
|
440
|
+
slugLockedAt: slugLockedAt || undefined,
|
|
351
441
|
});
|
|
352
442
|
return EXIT_OK;
|
|
353
443
|
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Locked-roots loader — reads `autopg_meta.locked_roots` for a slug.
|
|
3
|
+
*
|
|
4
|
+
* pgserve singleton (v2.6) — `autopg-distribution-cutover-finalize`
|
|
5
|
+
* wish, Group 3 (deliverable D4a). Companion to `pgserve verify --slug
|
|
6
|
+
* <slug>` (D4b): the verify verb passes the loaded `lockedRoots` as
|
|
7
|
+
* `options.trustList` to `verifyBinary()` so verification runs against
|
|
8
|
+
* the frozen-at-create-app snapshot, not the live `TRUSTED_IDENTITIES`.
|
|
9
|
+
*
|
|
10
|
+
* Why this is its own module:
|
|
11
|
+
* - keeps create-app + verify decoupled (verify must not import
|
|
12
|
+
* create-app's full verb code);
|
|
13
|
+
* - lets tests stub the loader via dependency injection without
|
|
14
|
+
* spinning up postgres;
|
|
15
|
+
* - mirrors the cohort-canonical pattern of one psql-shellout file
|
|
16
|
+
* per concern (provision/queries.js, gc/queries.js, etc.).
|
|
17
|
+
*
|
|
18
|
+
* Structured errors with stable `code` values let `pgserve verify`'s
|
|
19
|
+
* error-mapping layer pick the correct exit code:
|
|
20
|
+
*
|
|
21
|
+
* EAUTOPGMETAMISSING table doesn't exist (schema not bootstrapped on
|
|
22
|
+
* this postmaster — operator hasn't run any
|
|
23
|
+
* `pgserve create-app` yet) → invocation problem
|
|
24
|
+
* EAUTOPGSLUGUNKNOWN table exists but no row for slug → invocation
|
|
25
|
+
* problem; actionable message includes the
|
|
26
|
+
* remediation `pgserve create-app <slug>`
|
|
27
|
+
* EAUTOPGLOCKEDPARSE row exists but locked_roots JSONB is malformed
|
|
28
|
+
* (file rot / direct-mutation accident) →
|
|
29
|
+
* invocation problem
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { pgQuery, quoteLiteral } from '../lib/pg-query.js';
|
|
33
|
+
import { sanitizeSlug } from '../provision/db-naming.js';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Load the locked-roots snapshot for a slug.
|
|
37
|
+
*
|
|
38
|
+
* @param {object} args
|
|
39
|
+
* @param {string} args.slug raw or sanitized slug; sanitizeSlug runs
|
|
40
|
+
* inside so callers can pass either form.
|
|
41
|
+
* @param {number} [args.port=5432]
|
|
42
|
+
* @returns {{
|
|
43
|
+
* slug: string,
|
|
44
|
+
* lockedRoots: Array<object>,
|
|
45
|
+
* createdAt: string,
|
|
46
|
+
* lastUpdated: string,
|
|
47
|
+
* manifestPath: string,
|
|
48
|
+
* }}
|
|
49
|
+
* @throws Error with `.code` set to one of EAUTOPGMETAMISSING /
|
|
50
|
+
* EAUTOPGSLUGUNKNOWN / EAUTOPGLOCKEDPARSE on the structured
|
|
51
|
+
* failure modes above; the underlying psql error otherwise.
|
|
52
|
+
*/
|
|
53
|
+
export function loadLockedRoots({ slug, port = 5432 } = {}) {
|
|
54
|
+
if (typeof slug !== 'string' || slug.trim().length === 0) {
|
|
55
|
+
throw new TypeError('loadLockedRoots: slug must be a non-empty string');
|
|
56
|
+
}
|
|
57
|
+
const sanitized = sanitizeSlug(slug);
|
|
58
|
+
if (sanitized.length === 0) {
|
|
59
|
+
const err = new Error(
|
|
60
|
+
`loadLockedRoots: slug "${slug}" sanitizes to empty; pick a slug `
|
|
61
|
+
+ 'with at least one alphanumeric character',
|
|
62
|
+
);
|
|
63
|
+
err.code = 'EAUTOPGSLUGUNKNOWN';
|
|
64
|
+
throw err;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Probe the table existence in the same query so we can disambiguate
|
|
68
|
+
// "table missing" from "row missing". `to_regclass` returns NULL when
|
|
69
|
+
// the relation is absent in the active search_path; we wrap the
|
|
70
|
+
// SELECT in a CTE that conditionally returns 'NO_TABLE' / 'NO_ROW' /
|
|
71
|
+
// the tab-separated payload. Plain text comes back via psql -At -F\t.
|
|
72
|
+
const sentinel = '__autopg_loaded__';
|
|
73
|
+
const sql = [
|
|
74
|
+
"WITH t AS (SELECT to_regclass('public.autopg_meta') AS rel)",
|
|
75
|
+
'SELECT',
|
|
76
|
+
" CASE WHEN t.rel IS NULL THEN 'NO_TABLE' ELSE",
|
|
77
|
+
` COALESCE((SELECT '${sentinel}\t' || locked_roots::text || '\t' ||`,
|
|
78
|
+
" to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"') || '\t' ||",
|
|
79
|
+
" to_char(last_updated AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"') || '\t' ||",
|
|
80
|
+
' manifest_path',
|
|
81
|
+
' FROM public.autopg_meta',
|
|
82
|
+
` WHERE slug = ${quoteLiteral(sanitized)} LIMIT 1), 'NO_ROW')`,
|
|
83
|
+
' END AS payload',
|
|
84
|
+
'FROM t',
|
|
85
|
+
].join('\n');
|
|
86
|
+
|
|
87
|
+
const out = pgQuery({ sql, port, captureStdout: true });
|
|
88
|
+
|
|
89
|
+
if (out === 'NO_TABLE') {
|
|
90
|
+
const err = new Error(
|
|
91
|
+
`loadLockedRoots: public.autopg_meta does not exist on this postmaster `
|
|
92
|
+
+ '(no apps registered yet — run `pgserve create-app <slug>` to bootstrap)',
|
|
93
|
+
);
|
|
94
|
+
err.code = 'EAUTOPGMETAMISSING';
|
|
95
|
+
throw err;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (out === 'NO_ROW' || !out.startsWith(`${sentinel}\t`)) {
|
|
99
|
+
const err = new Error(
|
|
100
|
+
`loadLockedRoots: no autopg_meta row for slug "${sanitized}" `
|
|
101
|
+
+ `— run \`pgserve create-app ${sanitized}\` first`,
|
|
102
|
+
);
|
|
103
|
+
err.code = 'EAUTOPGSLUGUNKNOWN';
|
|
104
|
+
err.slug = sanitized;
|
|
105
|
+
throw err;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Strip the sentinel + parse the four tab-separated fields.
|
|
109
|
+
const payload = out.slice(sentinel.length + 1);
|
|
110
|
+
const [lockedRootsJson, createdAt, lastUpdated, manifestPath] = payload.split('\t');
|
|
111
|
+
|
|
112
|
+
let lockedRoots;
|
|
113
|
+
try {
|
|
114
|
+
lockedRoots = JSON.parse(lockedRootsJson);
|
|
115
|
+
} catch (err) {
|
|
116
|
+
const wrap = new Error(
|
|
117
|
+
`loadLockedRoots: locked_roots JSONB for slug "${sanitized}" is malformed: ${err.message}`,
|
|
118
|
+
);
|
|
119
|
+
wrap.code = 'EAUTOPGLOCKEDPARSE';
|
|
120
|
+
wrap.slug = sanitized;
|
|
121
|
+
wrap.cause = err;
|
|
122
|
+
throw wrap;
|
|
123
|
+
}
|
|
124
|
+
if (!Array.isArray(lockedRoots)) {
|
|
125
|
+
const err = new Error(
|
|
126
|
+
`loadLockedRoots: locked_roots for slug "${sanitized}" is not a JSON array `
|
|
127
|
+
+ `(got ${typeof lockedRoots})`,
|
|
128
|
+
);
|
|
129
|
+
err.code = 'EAUTOPGLOCKEDPARSE';
|
|
130
|
+
err.slug = sanitized;
|
|
131
|
+
throw err;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
slug: sanitized,
|
|
136
|
+
lockedRoots,
|
|
137
|
+
createdAt,
|
|
138
|
+
lastUpdated,
|
|
139
|
+
manifestPath,
|
|
140
|
+
};
|
|
141
|
+
}
|
package/src/cosign/trust-list.js
CHANGED
|
@@ -37,22 +37,45 @@ export const TRUSTED_IDENTITIES = Object.freeze([
|
|
|
37
37
|
id: 'automagik-genie-release',
|
|
38
38
|
publisher: '@automagik/genie',
|
|
39
39
|
issuer: SIGSTORE_GITHUB_ACTIONS_ISSUER,
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
// genie's release.yml is an orchestrator that workflow_call's into
|
|
41
|
+
// sign-attest.yml — the Fulcio SAN URI therefore binds to
|
|
42
|
+
// sign-attest.yml@<ref>, not release.yml@<ref>. Verified against
|
|
43
|
+
// both v4.260511.1 (released from main) and v4.260511.2 (released
|
|
44
|
+
// from wish/genie-distribution-cutover-g1) bundle certificates on
|
|
45
|
+
// 2026-05-11: both cert subjects are
|
|
46
|
+
// `automagik-dev/genie/.github/workflows/sign-attest.yml@refs/tags/v4.260511.x`.
|
|
47
|
+
// Same shape as pgserve's own entry below.
|
|
48
|
+
identityRegexp: '^https://github.com/automagik-dev/genie/.github/workflows/sign-attest.yml@refs/tags/v.*$',
|
|
49
|
+
description: 'Namastex automagik genie sign-attest workflow (GitHub Actions OIDC)',
|
|
42
50
|
}),
|
|
43
51
|
Object.freeze({
|
|
44
52
|
id: 'automagik-omni-release',
|
|
45
53
|
publisher: '@automagik/omni',
|
|
46
54
|
issuer: SIGSTORE_GITHUB_ACTIONS_ISSUER,
|
|
47
|
-
|
|
48
|
-
|
|
55
|
+
// Omni signing pipeline mirrors genie's orchestrator+workflow_call
|
|
56
|
+
// pattern (Felipe directive 2026-05-11, decision 2.α for
|
|
57
|
+
// `v3-prerelease-trust-loop` G2): release.yml orchestrates, the
|
|
58
|
+
// cosign keyless sign-blob runs inside a reusable sign-attest.yml,
|
|
59
|
+
// and the Fulcio SAN URI binds to sign-attest.yml@<ref>. Anchoring
|
|
60
|
+
// here pre-emptively so omni's first signed tag (post-G2 merge)
|
|
61
|
+
// verifies without a follow-up trust-list flip. Re-validate against
|
|
62
|
+
// the first signed omni bundle during G4 smoke.
|
|
63
|
+
identityRegexp: '^https://github.com/automagik-dev/omni/.github/workflows/sign-attest.yml@refs/tags/v.*$',
|
|
64
|
+
description: 'Namastex automagik omni sign-attest workflow (GitHub Actions OIDC)',
|
|
49
65
|
}),
|
|
50
66
|
Object.freeze({
|
|
51
67
|
id: 'automagik-pgserve-release',
|
|
52
68
|
publisher: 'pgserve',
|
|
53
69
|
issuer: SIGSTORE_GITHUB_ACTIONS_ISSUER,
|
|
54
|
-
|
|
55
|
-
|
|
70
|
+
// Wave A (v2.6.x): pgserve's signing happens in `sign-attest.yml`,
|
|
71
|
+
// not `release.yml` (which is the unrelated npm-publish workflow
|
|
72
|
+
// with zero cosign content). Renaming sign-attest.yml → release.yml
|
|
73
|
+
// would clobber that workflow, so we anchor the trust regex on
|
|
74
|
+
// sign-attest.yml instead. Mirror image of genie PR #1725 (binding
|
|
75
|
+
// release.yml@refs/tags/v* — same Sigstore identity discipline,
|
|
76
|
+
// different workflow filename).
|
|
77
|
+
identityRegexp: '^https://github.com/namastexlabs/pgserve/.github/workflows/sign-attest.yml@refs/tags/v.*$',
|
|
78
|
+
description: 'Namastex automagik pgserve sign-attest workflow (GitHub Actions OIDC)',
|
|
56
79
|
}),
|
|
57
80
|
]);
|
|
58
81
|
|
|
@@ -54,13 +54,141 @@ export function sha256File(filePath) {
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
/**
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
57
|
+
* Candidate sidecar bundle paths for a given binary path, in priority
|
|
58
|
+
* order. First existing file wins. When none exist, the first candidate
|
|
59
|
+
* (`<binary>.bundle`) is returned so the caller's existing
|
|
60
|
+
* `bundle-missing` error path keeps the same operator-facing message
|
|
61
|
+
* shape.
|
|
62
|
+
*
|
|
63
|
+
* Three conventions are accepted:
|
|
64
|
+
*
|
|
65
|
+
* 1. `<binary>.bundle` — `cosign sign-blob --bundle` default;
|
|
66
|
+
* pgserve's own producer convention.
|
|
67
|
+
* 2. `<binary-stem>.intoto.jsonl` — slsa-github-generator
|
|
68
|
+
* per-artifact provenance (writes `<artifact>.intoto.jsonl` when
|
|
69
|
+
* provenance-name interpolates the artifact stem). The stem is
|
|
70
|
+
* computed by stripping `.tgz` (the only archive ext used in the
|
|
71
|
+
* automagik-cohort cosign trust loop today).
|
|
72
|
+
* 3. `<dirname>/provenance.intoto.jsonl` — slsa-github-generator
|
|
73
|
+
* generic-provenance default (single sibling per release upload).
|
|
74
|
+
* This is what `automagik-dev/genie` ships today (verified via
|
|
75
|
+
* `gh release view` against v4.260508.3+).
|
|
76
|
+
*
|
|
77
|
+
* Why fall-through, not single-shape: CV-VERIFY-BUNDLE-NAMING (qa
|
|
78
|
+
* Wave-B-live finding 2026-05-09). pgserve's prior single-shape
|
|
79
|
+
* resolver returned literal `<binary>.bundle` and failed every
|
|
80
|
+
* `pgserve verify` against producer-side artifacts that ship per the
|
|
81
|
+
* standards-compliant `intoto.jsonl` conventions. Consumer-side
|
|
82
|
+
* fall-through preserves the producer's standards conformance instead
|
|
83
|
+
* of forcing every producer-side wave (A/B/C) into a non-standard
|
|
84
|
+
* `.bundle` rename.
|
|
85
|
+
*/
|
|
86
|
+
export function resolveBundleCandidates(binaryPath) {
|
|
87
|
+
const stem = binaryPath.replace(/\.tgz$/, '');
|
|
88
|
+
return [
|
|
89
|
+
`${binaryPath}.bundle`,
|
|
90
|
+
`${stem}.intoto.jsonl`,
|
|
91
|
+
path.join(path.dirname(binaryPath), 'provenance.intoto.jsonl'),
|
|
92
|
+
];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Resolve the sidecar bundle path for a given binary path. Returns the
|
|
97
|
+
* first existing candidate; when none exist, returns the first
|
|
98
|
+
* candidate (`<binary>.bundle`) so the existing `bundle-missing` error
|
|
99
|
+
* path retains its operator-facing message shape.
|
|
100
|
+
*
|
|
101
|
+
* Callers that need the full candidate list (e.g. for diagnostics) can
|
|
102
|
+
* use `resolveBundleCandidates` directly.
|
|
61
103
|
*/
|
|
62
104
|
export function resolveBundlePath(binaryPath) {
|
|
63
|
-
|
|
105
|
+
const candidates = resolveBundleCandidates(binaryPath);
|
|
106
|
+
return candidates.find((p) => fs.existsSync(p)) ?? candidates[0];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Detect detached cosign artifacts (`<binary>.sig` + `<binary>.cert`)
|
|
111
|
+
* for a binary. Returns `{ signature, certificate }` when both
|
|
112
|
+
* siblings exist; null otherwise.
|
|
113
|
+
*
|
|
114
|
+
* WAVE-B-BUNDLE-FORMAT (v2.6.3): cosign supports two output shapes for
|
|
115
|
+
* `cosign sign-blob`:
|
|
116
|
+
*
|
|
117
|
+
* 1. Bundled — `--bundle <path>` writes a JSON envelope containing
|
|
118
|
+
* sig + cert + tlog + bundle metadata. Verified via
|
|
119
|
+
* `cosign verify-blob --bundle <path>`.
|
|
120
|
+
* 2. Detached — `--output-signature <sig> --output-certificate <cert>`
|
|
121
|
+
* writes two sidecar files. Verified via
|
|
122
|
+
* `cosign verify-blob --signature <sig> --certificate <cert>`.
|
|
123
|
+
*
|
|
124
|
+
* `automagik-dev/genie` (and many slsa-github-generator-driven release
|
|
125
|
+
* pipelines) emit the detached pair as their release-identity-bearing
|
|
126
|
+
* signature, alongside a separate `provenance.intoto.jsonl` carrying
|
|
127
|
+
* the SLSA generator's identity. Pre-fix pgserve only consumed bundled
|
|
128
|
+
* formats and silently failed `pgserve verify` against detached
|
|
129
|
+
* producers because `provenance.intoto.jsonl`'s cert subject doesn't
|
|
130
|
+
* match the consumer-side trust regex. This helper closes that gap on
|
|
131
|
+
* the consumer side per the asymmetric-cohort principle (Postel's Law:
|
|
132
|
+
* pgserve adapts to producer-side standards-compliant emit shapes).
|
|
133
|
+
*
|
|
134
|
+
* Priority semantics (used in verifyBinary):
|
|
135
|
+
* - When a caller passes `options.bundlePath` explicitly, that wins.
|
|
136
|
+
* - Otherwise, detached takes priority over bundle resolution when
|
|
137
|
+
* both shapes' siblings exist for the same binary. Reasoning:
|
|
138
|
+
* producer-explicit publishing of `<binary>.sig` + `<binary>.cert`
|
|
139
|
+
* is the established cosign convention; preferring bundles
|
|
140
|
+
* silently could mask producer-side regressions in a dual-emit
|
|
141
|
+
* setup. Operators who specifically want bundle resolution can
|
|
142
|
+
* pass `--bundle <path>` to override.
|
|
143
|
+
*/
|
|
144
|
+
export function resolveDetachedCosign(binaryPath) {
|
|
145
|
+
const signature = `${binaryPath}.sig`;
|
|
146
|
+
const certificate = `${binaryPath}.cert`;
|
|
147
|
+
if (fs.existsSync(signature) && fs.existsSync(certificate)) {
|
|
148
|
+
return { signature, certificate };
|
|
149
|
+
}
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Compute the verification material pgserve will hand to cosign for a
|
|
155
|
+
* given binary, applying the priority rules documented on
|
|
156
|
+
* `resolveDetachedCosign`. Returned shapes:
|
|
157
|
+
*
|
|
158
|
+
* { kind: 'bundle', bundlePath, exists, probed: string[] }
|
|
159
|
+
* { kind: 'detached', signature, certificate, exists, probed: string[] }
|
|
160
|
+
*
|
|
161
|
+
* `exists` is true iff every required sibling file is on disk. `probed`
|
|
162
|
+
* lists the paths inspected so the bundle-missing diagnostic can echo
|
|
163
|
+
* them back to operators.
|
|
164
|
+
*/
|
|
165
|
+
export function resolveVerificationMaterial(binaryPath, { bundlePath } = {}) {
|
|
166
|
+
if (bundlePath) {
|
|
167
|
+
return {
|
|
168
|
+
kind: 'bundle',
|
|
169
|
+
bundlePath,
|
|
170
|
+
exists: fs.existsSync(bundlePath),
|
|
171
|
+
probed: [bundlePath],
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
const detached = resolveDetachedCosign(binaryPath);
|
|
175
|
+
if (detached) {
|
|
176
|
+
return {
|
|
177
|
+
kind: 'detached',
|
|
178
|
+
signature: detached.signature,
|
|
179
|
+
certificate: detached.certificate,
|
|
180
|
+
exists: true,
|
|
181
|
+
probed: [detached.signature, detached.certificate],
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
const candidates = resolveBundleCandidates(binaryPath);
|
|
185
|
+
const found = candidates.find((p) => fs.existsSync(p));
|
|
186
|
+
return {
|
|
187
|
+
kind: 'bundle',
|
|
188
|
+
bundlePath: found ?? candidates[0],
|
|
189
|
+
exists: !!found,
|
|
190
|
+
probed: [...candidates, `${binaryPath}.sig`, `${binaryPath}.cert`],
|
|
191
|
+
};
|
|
64
192
|
}
|
|
65
193
|
|
|
66
194
|
/**
|
|
@@ -187,12 +315,23 @@ export function verifyBinary(binaryPath, options = {}) {
|
|
|
187
315
|
return { ok: false, reason: 'binary-not-a-file', detail: binaryPath };
|
|
188
316
|
}
|
|
189
317
|
|
|
190
|
-
const
|
|
191
|
-
if (!
|
|
318
|
+
const material = resolveVerificationMaterial(binaryPath, { bundlePath: options.bundlePath });
|
|
319
|
+
if (!material.exists) {
|
|
320
|
+
// CV-VERIFY-BUNDLE-NAMING (v2.6.3) + WAVE-B-BUNDLE-FORMAT (v2.6.3):
|
|
321
|
+
// when neither bundled siblings nor the detached `.sig`+`.cert` pair
|
|
322
|
+
// exist, surface the full probed list in the error detail so
|
|
323
|
+
// operators see exactly which paths pgserve inspected. The
|
|
324
|
+
// bundle-missing reason code is unchanged so any log-grep wired
|
|
325
|
+
// against it keeps working; an explicit override via
|
|
326
|
+
// options.bundlePath narrows the probe list to just that path.
|
|
192
327
|
return {
|
|
193
328
|
ok: false,
|
|
194
329
|
reason: 'bundle-missing',
|
|
195
|
-
detail:
|
|
330
|
+
detail:
|
|
331
|
+
`expected cosign verification material for ${binaryPath} — probed: ${material.probed.join(', ')}. `
|
|
332
|
+
+ `Pass --bundle <path> to override, or run `
|
|
333
|
+
+ `\`cosign sign-blob --bundle ${binaryPath}.bundle ${binaryPath}\` to attest `
|
|
334
|
+
+ `(or use detached \`--output-signature ${binaryPath}.sig --output-certificate ${binaryPath}.cert\`).`,
|
|
196
335
|
};
|
|
197
336
|
}
|
|
198
337
|
|
|
@@ -225,7 +364,7 @@ export function verifyBinary(binaryPath, options = {}) {
|
|
|
225
364
|
}
|
|
226
365
|
const result = invokeCosign({
|
|
227
366
|
cosignBin,
|
|
228
|
-
|
|
367
|
+
material,
|
|
229
368
|
binaryPath,
|
|
230
369
|
identity,
|
|
231
370
|
});
|
|
@@ -238,7 +377,10 @@ export function verifyBinary(binaryPath, options = {}) {
|
|
|
238
377
|
tier: COSIGN_TIER,
|
|
239
378
|
sha256,
|
|
240
379
|
cosignBin,
|
|
241
|
-
|
|
380
|
+
// Back-compat: callers reading `.bundle` still get a value
|
|
381
|
+
// (null when detached, the path when bundled).
|
|
382
|
+
bundle: material.kind === 'bundle' ? material.bundlePath : null,
|
|
383
|
+
material,
|
|
242
384
|
identityChain,
|
|
243
385
|
};
|
|
244
386
|
}
|
|
@@ -254,10 +396,18 @@ export function verifyBinary(binaryPath, options = {}) {
|
|
|
254
396
|
};
|
|
255
397
|
}
|
|
256
398
|
|
|
257
|
-
function invokeCosign({ cosignBin,
|
|
399
|
+
function invokeCosign({ cosignBin, material, binaryPath, identity }) {
|
|
400
|
+
// WAVE-B-BUNDLE-FORMAT (v2.6.3): dispatch on material.kind so cosign
|
|
401
|
+
// gets the matching argv shape — `--bundle <path>` for bundled
|
|
402
|
+
// sigstore JSON envelopes; `--signature <sig> --certificate <cert>`
|
|
403
|
+
// for detached cosign sign-blob output (genie's release-identity-
|
|
404
|
+
// bearing artifacts ship in this shape).
|
|
405
|
+
const materialArgs = material.kind === 'detached'
|
|
406
|
+
? ['--signature', material.signature, '--certificate', material.certificate]
|
|
407
|
+
: ['--bundle', material.bundlePath];
|
|
258
408
|
const args = [
|
|
259
409
|
'verify-blob',
|
|
260
|
-
|
|
410
|
+
...materialArgs,
|
|
261
411
|
'--certificate-identity-regexp', identity.identityRegexp,
|
|
262
412
|
'--certificate-oidc-issuer', identity.issuer,
|
|
263
413
|
binaryPath,
|
package/src/gc/audit-log.js
CHANGED
|
@@ -29,6 +29,8 @@ export const AUDIT_DIR_NAME = 'audit';
|
|
|
29
29
|
export const AUDIT_FILE_PREFIX = 'gc-';
|
|
30
30
|
export const AUDIT_FILE_MODE = 0o600;
|
|
31
31
|
export const AUDIT_DIR_MODE = 0o700;
|
|
32
|
+
export const AUDIT_DEFAULT_RETENTION_DAYS = 90;
|
|
33
|
+
const AUDIT_FILENAME_RE = /^gc-(\d{4})-(\d{2})-(\d{2})\.log$/;
|
|
32
34
|
|
|
33
35
|
/**
|
|
34
36
|
* @typedef {Object} GcAuditEvent
|
|
@@ -147,4 +149,94 @@ export function readGcAuditDay({ homeDir = os.homedir(), date = new Date() } = {
|
|
|
147
149
|
return out;
|
|
148
150
|
}
|
|
149
151
|
|
|
152
|
+
/**
|
|
153
|
+
* Rotate audit logs by deleting `gc-<YYYY-MM-DD>.log` files older than
|
|
154
|
+
* `retentionDays` (default 90). Each deletion writes a `rotate` audit event
|
|
155
|
+
* to today's log so the rotation itself is auditable.
|
|
156
|
+
*
|
|
157
|
+
* Boundary guard: NEVER deletes the current day's log file, even if the
|
|
158
|
+
* retention math would otherwise include it (clock skew, manual mtime edits,
|
|
159
|
+
* timezone-confusion regressions). The current day is determined from
|
|
160
|
+
* `today` (UTC) — the same date the next `writeGcAudit` call would use.
|
|
161
|
+
*
|
|
162
|
+
* Files whose names do not match `gc-<YYYY-MM-DD>.log` are skipped — the
|
|
163
|
+
* rotator only touches its own log files.
|
|
164
|
+
*
|
|
165
|
+
* Returns `{ deleted: string[], kept: string[], errors: Array<{file,error}> }`
|
|
166
|
+
* so callers can surface a summary line without re-walking the directory.
|
|
167
|
+
*/
|
|
168
|
+
export function rotateGcAuditLogs({
|
|
169
|
+
homeDir = os.homedir(),
|
|
170
|
+
retentionDays = AUDIT_DEFAULT_RETENTION_DAYS,
|
|
171
|
+
today = new Date(),
|
|
172
|
+
} = {}) {
|
|
173
|
+
if (!Number.isFinite(retentionDays) || retentionDays < 0) {
|
|
174
|
+
throw new TypeError('rotateGcAuditLogs: retentionDays must be a non-negative number');
|
|
175
|
+
}
|
|
176
|
+
if (!(today instanceof Date) || Number.isNaN(today.getTime())) {
|
|
177
|
+
throw new TypeError('rotateGcAuditLogs: today must be a valid Date');
|
|
178
|
+
}
|
|
179
|
+
const dir = getAuditDir({ homeDir });
|
|
180
|
+
const result = { deleted: [], kept: [], errors: [] };
|
|
181
|
+
let entries;
|
|
182
|
+
try {
|
|
183
|
+
entries = fs.readdirSync(dir);
|
|
184
|
+
} catch (err) {
|
|
185
|
+
if (err.code === 'ENOENT') return result;
|
|
186
|
+
throw err;
|
|
187
|
+
}
|
|
188
|
+
const todayStr = formatUtcDate(today);
|
|
189
|
+
const cutoffMs = Date.UTC(
|
|
190
|
+
today.getUTCFullYear(),
|
|
191
|
+
today.getUTCMonth(),
|
|
192
|
+
today.getUTCDate(),
|
|
193
|
+
) - retentionDays * 24 * 60 * 60 * 1000;
|
|
194
|
+
for (const name of entries) {
|
|
195
|
+
const match = AUDIT_FILENAME_RE.exec(name);
|
|
196
|
+
if (!match) continue;
|
|
197
|
+
const [, yyyy, mm, dd] = match;
|
|
198
|
+
const dateStr = `${yyyy}-${mm}-${dd}`;
|
|
199
|
+
if (dateStr === todayStr) {
|
|
200
|
+
// Boundary guard — the current day's log is always retained.
|
|
201
|
+
result.kept.push(name);
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
const fileMs = Date.UTC(Number(yyyy), Number(mm) - 1, Number(dd));
|
|
205
|
+
if (fileMs >= cutoffMs) {
|
|
206
|
+
result.kept.push(name);
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
const full = path.join(dir, name);
|
|
210
|
+
try {
|
|
211
|
+
fs.unlinkSync(full);
|
|
212
|
+
result.deleted.push(name);
|
|
213
|
+
writeGcAudit(
|
|
214
|
+
{
|
|
215
|
+
action: 'rotate',
|
|
216
|
+
detail: `deleted ${name} (>${retentionDays} days old)`,
|
|
217
|
+
},
|
|
218
|
+
{ homeDir, date: today },
|
|
219
|
+
);
|
|
220
|
+
} catch (err) {
|
|
221
|
+
result.errors.push({ file: name, error: err.message });
|
|
222
|
+
// Record the per-file failure in today's audit log so an operator
|
|
223
|
+
// investigating "why is gc-2026-01-01.log still here?" can find the
|
|
224
|
+
// exact reason (permission denied / file in use / etc.) instead of
|
|
225
|
+
// only seeing the rotate-summary count.
|
|
226
|
+
try {
|
|
227
|
+
writeGcAudit(
|
|
228
|
+
{
|
|
229
|
+
action: 'rotate-error',
|
|
230
|
+
detail: `failed to delete ${name}: ${err.message}`,
|
|
231
|
+
},
|
|
232
|
+
{ homeDir, date: today },
|
|
233
|
+
);
|
|
234
|
+
} catch {
|
|
235
|
+
/* best-effort — never let audit-write failure abort the rotation pass */
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return result;
|
|
240
|
+
}
|
|
241
|
+
|
|
150
242
|
export const __testInternals = Object.freeze({ formatUtcDate });
|