pgserve 2.3.0 → 2.5.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.
- package/bin/pgserve-wrapper.cjs +9 -4
- package/bin/postgres-server.js +170 -631
- package/config/logrotate.d/pgserve +47 -0
- package/config/pgaudit.conf +31 -0
- package/package.json +3 -2
- package/scripts/audit-redaction-lint.js +349 -0
- package/scripts/test-npx.sh +32 -10
- package/src/audit/audit.js +134 -0
- package/src/cli-install.cjs +340 -100
- package/src/commands/uninstall.js +241 -0
- package/src/commands/verify.js +360 -0
- package/src/cosign/cache-token.js +328 -0
- package/src/cosign/schema.js +97 -0
- package/src/cosign/trust-list.js +81 -0
- package/src/cosign/verify-binary.js +277 -0
- package/src/index.js +11 -44
- package/src/lib/admin-json.js +202 -0
- package/src/lib/pm2-args.js +119 -0
- package/src/lib/runtime-json.js +181 -0
- package/src/lib/socket-dir.js +69 -0
- package/src/postgres.js +64 -5
- package/src/upgrade/index.js +5 -0
- package/src/upgrade/steps/cosign-meta-migration.js +123 -0
- package/src/admin-client.js +0 -223
- package/src/audit.js +0 -168
- package/src/cluster.js +0 -654
- package/src/control-db.js +0 -330
- package/src/daemon-control.js +0 -468
- package/src/daemon-shared.js +0 -18
- package/src/daemon-tcp.js +0 -297
- package/src/daemon.js +0 -709
- package/src/dashboard.js +0 -217
- package/src/fingerprint.js +0 -479
- package/src/gc.js +0 -351
- package/src/pg-wire.js +0 -869
- package/src/protocol.js +0 -389
- package/src/restore.js +0 -574
- package/src/router.js +0 -546
- package/src/sdk.js +0 -137
- package/src/stats-collector.js +0 -453
- package/src/stats-dashboard.js +0 -401
- package/src/sync.js +0 -335
- package/src/tenancy.js +0 -75
- package/src/tokens.js +0 -102
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# pgserve singleton (v2.4) — logrotate config for the pm2 / systemd-user
|
|
2
|
+
# / launchd-supervised postmaster.
|
|
3
|
+
#
|
|
4
|
+
# Postgres' stderr (where pgaudit's audit log lands when the .so is
|
|
5
|
+
# present, and `log_statement=all` lands in the fallback path) is captured
|
|
6
|
+
# by the supervisor and written to:
|
|
7
|
+
#
|
|
8
|
+
# ~/.autopg/logs/autopg-server-out.log
|
|
9
|
+
# ~/.autopg/logs/autopg-server-error.log
|
|
10
|
+
#
|
|
11
|
+
# Symbolic ~/ resolves under each operator's HOME — logrotate runs the
|
|
12
|
+
# rules per-user when invoked with `--state ~/.config/logrotate.state`
|
|
13
|
+
# (see the autopg installer hint). The `su` directive is intentionally
|
|
14
|
+
# absent: this config ships as a USER-context drop-in, not a system one.
|
|
15
|
+
# System-wide rotation under a dedicated `autopg` UNIX user is delivered
|
|
16
|
+
# by the separate `autopg-service-install-system` wish (out of scope for
|
|
17
|
+
# v2.4).
|
|
18
|
+
#
|
|
19
|
+
# Copy or symlink to: /etc/logrotate.d/pgserve (system-wide)
|
|
20
|
+
# Or use per-user: ~/.config/logrotate.d/pgserve
|
|
21
|
+
|
|
22
|
+
~/.autopg/logs/autopg-server-out.log
|
|
23
|
+
~/.autopg/logs/autopg-server-error.log
|
|
24
|
+
~/.pgserve/audit.log
|
|
25
|
+
{
|
|
26
|
+
# Daily rotation keeps audit/forensic events queryable for ~2 weeks
|
|
27
|
+
# without burning disk on a long-running dev host.
|
|
28
|
+
daily
|
|
29
|
+
rotate 14
|
|
30
|
+
# Compress yesterday's log on tomorrow's rotation so today's log stays
|
|
31
|
+
# uncompressed for tail/grep.
|
|
32
|
+
compress
|
|
33
|
+
delaycompress
|
|
34
|
+
# Don't fail when a log file has not been created yet (e.g. fresh
|
|
35
|
+
# `autopg install` before the postmaster has emitted any output).
|
|
36
|
+
missingok
|
|
37
|
+
# Don't fail when a log file is empty.
|
|
38
|
+
notifempty
|
|
39
|
+
# Truncate-in-place so pm2 / systemd / launchd's open file handles
|
|
40
|
+
# keep writing to the rotated file. Avoids the "send SIGHUP and
|
|
41
|
+
# postgres re-opens" dance which we don't want to trigger from a
|
|
42
|
+
# supervisor-agnostic rule.
|
|
43
|
+
copytruncate
|
|
44
|
+
# File mode 0600: audit lines may include literal SQL (parameter
|
|
45
|
+
# values, error contexts) — operator-only on a multi-user host.
|
|
46
|
+
create 0600
|
|
47
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# pgserve singleton (v2.4) — pgaudit GUC defaults.
|
|
2
|
+
#
|
|
3
|
+
# Loaded by `src/postgres.js::_startPostgres` when the embedded postgres
|
|
4
|
+
# bundle ships pgaudit.so. The postmaster passes these as `-c key=value`
|
|
5
|
+
# pairs at boot; settings.json `_extra` overrides apply on top via the
|
|
6
|
+
# normal curated < extra precedence.
|
|
7
|
+
#
|
|
8
|
+
# When pgaudit.so is NOT present in the bundle (the current state of the
|
|
9
|
+
# `@embedded-postgres/<plat>-<arch>` packages), postgres.js falls back to
|
|
10
|
+
# `log_statement=all`. The cohort sibling `autopg-distribution-cutover`
|
|
11
|
+
# owns shipping the pgaudit binary; this file documents the GUCs that
|
|
12
|
+
# light up the moment the .so lands.
|
|
13
|
+
|
|
14
|
+
shared_preload_libraries = 'pgaudit'
|
|
15
|
+
|
|
16
|
+
# `pgaudit.log = 'all'` is the audit-log breadth contract from the wish.
|
|
17
|
+
# Operators who need a tighter classification (e.g. drop READ to cut log
|
|
18
|
+
# volume on hot read paths) override via:
|
|
19
|
+
# ~/.autopg/settings.json -> postgres._extra.pgaudit.log = 'write,role,ddl'
|
|
20
|
+
pgaudit.log = 'all'
|
|
21
|
+
|
|
22
|
+
# Skip pg_catalog reads — they are constant per session and their
|
|
23
|
+
# inclusion floods the audit log without forensic value.
|
|
24
|
+
pgaudit.log_catalog = off
|
|
25
|
+
|
|
26
|
+
# Audit lines emit on the postmaster's stderr, captured by the supervisor
|
|
27
|
+
# (pm2 -> ~/.autopg/logs/autopg-server-error.log; systemd-user -> journal;
|
|
28
|
+
# launchd -> launchd-managed plist log path). The legacy
|
|
29
|
+
# ~/.pgserve/audit.log path stays rotated by `config/logrotate.d/pgserve`
|
|
30
|
+
# so existing tooling parsing the file keeps working through the
|
|
31
|
+
# migration window.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pgserve",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.5.0",
|
|
4
4
|
"description": "Embedded PostgreSQL server with true concurrent connections - zero config, auto-provision databases",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
"files": [
|
|
12
12
|
"bin/",
|
|
13
13
|
"src/",
|
|
14
|
+
"config/",
|
|
14
15
|
"console/dist/",
|
|
15
16
|
"README.md",
|
|
16
17
|
"CHANGELOG.md",
|
|
@@ -19,7 +20,6 @@
|
|
|
19
20
|
"scripts/"
|
|
20
21
|
],
|
|
21
22
|
"scripts": {
|
|
22
|
-
"bench": "bun tests/benchmarks/runner.js",
|
|
23
23
|
"test": "bun test tests/**/*.test.js",
|
|
24
24
|
"test:watch": "bun test --watch tests/**/*.test.js",
|
|
25
25
|
"dev": "bun --watch bin/postgres-server.js",
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
"console:dev": "bun build console/src/main.jsx --target browser --define 'process.env.NODE_ENV=\"development\"' --watch --outfile console/dist/app.js",
|
|
31
31
|
"lint": "eslint src/ bin/",
|
|
32
32
|
"lint:fix": "eslint src/ bin/ --fix",
|
|
33
|
+
"lint:audit": "bun scripts/audit-redaction-lint.js",
|
|
33
34
|
"deadcode": "knip",
|
|
34
35
|
"test:npx": "scripts/test-npx.sh",
|
|
35
36
|
"test:bun-self-heal": "scripts/test-bun-self-heal.sh",
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Audit redaction lint — Group 6, autopg-distribution-cutover.
|
|
4
|
+
*
|
|
5
|
+
* Walks every .js / .cjs source under `src/` (excluding `src/audit/audit.js`
|
|
6
|
+
* itself) and locates every `auditEmit(...)` call. For each call site, the
|
|
7
|
+
* lint asserts:
|
|
8
|
+
*
|
|
9
|
+
* 1. No object-literal key matches /password|secret|token|connection_string|database_url/i.
|
|
10
|
+
* 2. No value is `process.env.*PASSWORD*|*SECRET*|*TOKEN*|*DATABASE_URL*|*CONNECTION_STRING*`.
|
|
11
|
+
* 3. No string-literal value looks like a `postgres://user:pass@host/...` URL.
|
|
12
|
+
*
|
|
13
|
+
* Failure = exit 1 with file:line per offending site. Clean tree = exit 0.
|
|
14
|
+
*
|
|
15
|
+
* The walker is a hand-rolled scanner rather than a full parser: it tracks
|
|
16
|
+
* string state (single, double, backtick), template-literal nesting, line
|
|
17
|
+
* comments, block comments, and balanced brace depth. That's enough to
|
|
18
|
+
* isolate the first object-literal argument of every `auditEmit(...)` call
|
|
19
|
+
* without pulling in a parser dependency. New AST-flavored rules can be
|
|
20
|
+
* added by extending `scanRecord()`.
|
|
21
|
+
*
|
|
22
|
+
* Usage:
|
|
23
|
+
* bun run scripts/audit-redaction-lint.js
|
|
24
|
+
* bun run scripts/audit-redaction-lint.js path/to/file.js
|
|
25
|
+
* bun run scripts/audit-redaction-lint.js --fixture tests/audit/fixtures
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import fs from 'fs';
|
|
29
|
+
import path from 'path';
|
|
30
|
+
|
|
31
|
+
const REPO_ROOT = path.resolve(import.meta.dir, '..');
|
|
32
|
+
const DEFAULT_ROOT = path.join(REPO_ROOT, 'src');
|
|
33
|
+
const SELF_EXCLUDE = path.join('src', 'audit', 'audit.js');
|
|
34
|
+
|
|
35
|
+
const FORBIDDEN_KEY_RE = /^(password|secret|token|connection_string|database_url)$/i;
|
|
36
|
+
const ENV_SECRET_RE =
|
|
37
|
+
/process\.env\.[A-Za-z_][A-Za-z0-9_]*(PASSWORD|SECRET|TOKEN|DATABASE_URL|CONNECTION_STRING)[A-Za-z0-9_]*\b/i;
|
|
38
|
+
const POSTGRES_URL_RE = /\bpostgres(?:ql)?:\/\/[^\s'"]*:[^\s'"@]+@/i;
|
|
39
|
+
|
|
40
|
+
function listSourceFiles(roots) {
|
|
41
|
+
const out = [];
|
|
42
|
+
for (const root of roots) {
|
|
43
|
+
if (!fs.existsSync(root)) continue;
|
|
44
|
+
const stat = fs.statSync(root);
|
|
45
|
+
if (stat.isFile()) {
|
|
46
|
+
if (/\.(js|cjs|mjs)$/.test(root)) out.push(root);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
walk(root, out);
|
|
50
|
+
}
|
|
51
|
+
return out.filter((p) => !p.endsWith(SELF_EXCLUDE));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function walk(dir, out) {
|
|
55
|
+
for (const name of fs.readdirSync(dir)) {
|
|
56
|
+
if (name === 'node_modules' || name.startsWith('.')) continue;
|
|
57
|
+
const full = path.join(dir, name);
|
|
58
|
+
const st = fs.statSync(full);
|
|
59
|
+
if (st.isDirectory()) {
|
|
60
|
+
walk(full, out);
|
|
61
|
+
} else if (/\.(js|cjs|mjs)$/.test(name)) {
|
|
62
|
+
out.push(full);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Find every `auditEmit(...)` call in `src` and yield each one's first
|
|
69
|
+
* argument span (the object literal between `{` and matching `}`), plus
|
|
70
|
+
* the file-relative line number of the `auditEmit` identifier.
|
|
71
|
+
*/
|
|
72
|
+
function* findAuditEmitCalls(src) {
|
|
73
|
+
const len = src.length;
|
|
74
|
+
let i = 0;
|
|
75
|
+
let line = 1;
|
|
76
|
+
|
|
77
|
+
const isIdentChar = (ch) => /[A-Za-z0-9_$]/.test(ch);
|
|
78
|
+
|
|
79
|
+
while (i < len) {
|
|
80
|
+
const ch = src[i];
|
|
81
|
+
|
|
82
|
+
// Track newlines for accurate line reporting.
|
|
83
|
+
if (ch === '\n') { line++; i++; continue; }
|
|
84
|
+
|
|
85
|
+
// Skip line comments.
|
|
86
|
+
if (ch === '/' && src[i + 1] === '/') {
|
|
87
|
+
while (i < len && src[i] !== '\n') i++;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
// Skip block comments.
|
|
91
|
+
if (ch === '/' && src[i + 1] === '*') {
|
|
92
|
+
i += 2;
|
|
93
|
+
while (i < len && !(src[i] === '*' && src[i + 1] === '/')) {
|
|
94
|
+
if (src[i] === '\n') line++;
|
|
95
|
+
i++;
|
|
96
|
+
}
|
|
97
|
+
i += 2;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
// Skip strings.
|
|
101
|
+
if (ch === '"' || ch === "'" || ch === '`') {
|
|
102
|
+
i = skipString(src, i, ch, (n) => { line += n; });
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Match `auditEmit` as a whole identifier (must be word-boundary-prefixed).
|
|
107
|
+
if (ch === 'a' && src.startsWith('auditEmit', i)) {
|
|
108
|
+
const before = src[i - 1];
|
|
109
|
+
const after = src[i + 'auditEmit'.length];
|
|
110
|
+
if ((!before || !isIdentChar(before)) && !isIdentChar(after)) {
|
|
111
|
+
const idLine = line;
|
|
112
|
+
// Advance past identifier, skip whitespace, expect '('.
|
|
113
|
+
let j = i + 'auditEmit'.length;
|
|
114
|
+
while (j < len && /\s/.test(src[j])) {
|
|
115
|
+
if (src[j] === '\n') line++;
|
|
116
|
+
j++;
|
|
117
|
+
}
|
|
118
|
+
if (src[j] === '(') {
|
|
119
|
+
// Now find first `{` (the object-literal arg start) before the
|
|
120
|
+
// matching ')'. Object literals as function args appear directly
|
|
121
|
+
// after `(` (perhaps after whitespace) — but `auditEmit` callers
|
|
122
|
+
// pass an object literal by spec, so this is the path we care
|
|
123
|
+
// about. If we find a `)` first, it's auditEmit() with no
|
|
124
|
+
// object-literal arg → not redaction-relevant; skip.
|
|
125
|
+
let k = j + 1;
|
|
126
|
+
while (k < len) {
|
|
127
|
+
const c = src[k];
|
|
128
|
+
if (c === '\n') { line++; k++; continue; }
|
|
129
|
+
if (/\s/.test(c)) { k++; continue; }
|
|
130
|
+
if (c === '/' && src[k + 1] === '/') {
|
|
131
|
+
while (k < len && src[k] !== '\n') k++;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (c === '/' && src[k + 1] === '*') {
|
|
135
|
+
k += 2;
|
|
136
|
+
while (k < len && !(src[k] === '*' && src[k + 1] === '/')) {
|
|
137
|
+
if (src[k] === '\n') line++;
|
|
138
|
+
k++;
|
|
139
|
+
}
|
|
140
|
+
k += 2;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
if (src[k] === '{') {
|
|
146
|
+
const literalLine = line;
|
|
147
|
+
const end = findBalanced(src, k, '{', '}', (n) => { line += n; });
|
|
148
|
+
if (end !== -1) {
|
|
149
|
+
const literal = src.slice(k, end + 1);
|
|
150
|
+
yield { call: 'auditEmit', idLine, literalLine, literal };
|
|
151
|
+
i = end + 1;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
i++;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function skipString(src, i, quote, onNewline) {
|
|
164
|
+
// Returns the index just past the closing quote.
|
|
165
|
+
const len = src.length;
|
|
166
|
+
i++; // open quote
|
|
167
|
+
while (i < len) {
|
|
168
|
+
const ch = src[i];
|
|
169
|
+
if (ch === '\\') { i += 2; continue; }
|
|
170
|
+
if (ch === '\n') { onNewline(1); i++; continue; }
|
|
171
|
+
if (ch === quote) { return i + 1; }
|
|
172
|
+
if (quote === '`' && ch === '$' && src[i + 1] === '{') {
|
|
173
|
+
// Template literal interpolation — find matching `}`.
|
|
174
|
+
i = findBalanced(src, i + 1, '{', '}', onNewline);
|
|
175
|
+
if (i === -1) return len;
|
|
176
|
+
i++;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
i++;
|
|
180
|
+
}
|
|
181
|
+
return len;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function findBalanced(src, start, open, close, onNewline) {
|
|
185
|
+
const len = src.length;
|
|
186
|
+
let depth = 0;
|
|
187
|
+
let i = start;
|
|
188
|
+
while (i < len) {
|
|
189
|
+
const ch = src[i];
|
|
190
|
+
if (ch === '\n') { onNewline(1); i++; continue; }
|
|
191
|
+
if (ch === '/' && src[i + 1] === '/') {
|
|
192
|
+
while (i < len && src[i] !== '\n') i++;
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
if (ch === '/' && src[i + 1] === '*') {
|
|
196
|
+
i += 2;
|
|
197
|
+
while (i < len && !(src[i] === '*' && src[i + 1] === '/')) {
|
|
198
|
+
if (src[i] === '\n') onNewline(1);
|
|
199
|
+
i++;
|
|
200
|
+
}
|
|
201
|
+
i += 2;
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (ch === '"' || ch === "'" || ch === '`') {
|
|
205
|
+
i = skipString(src, i, ch, onNewline);
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
if (ch === open) depth++;
|
|
209
|
+
else if (ch === close) {
|
|
210
|
+
depth--;
|
|
211
|
+
if (depth === 0) return i;
|
|
212
|
+
}
|
|
213
|
+
i++;
|
|
214
|
+
}
|
|
215
|
+
return -1;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Walk the captured object-literal source and pull out each top-level key.
|
|
220
|
+
* Skips nested objects/arrays so a nested `meta: { secret: 'x' }` is still
|
|
221
|
+
* caught (see scanForNestedSecrets) — but the simple flat-key shape is the
|
|
222
|
+
* primary check.
|
|
223
|
+
*/
|
|
224
|
+
function extractTopLevelKeys(literal) {
|
|
225
|
+
// Drop the outer braces.
|
|
226
|
+
if (literal[0] !== '{' || literal[literal.length - 1] !== '}') return [];
|
|
227
|
+
const inner = literal.slice(1, -1);
|
|
228
|
+
const keys = [];
|
|
229
|
+
let i = 0;
|
|
230
|
+
let line = 0;
|
|
231
|
+
const len = inner.length;
|
|
232
|
+
let depth = 0;
|
|
233
|
+
let atKeyPosition = true;
|
|
234
|
+
|
|
235
|
+
while (i < len) {
|
|
236
|
+
const ch = inner[i];
|
|
237
|
+
if (ch === '\n') { line++; i++; continue; }
|
|
238
|
+
if (ch === '/' && inner[i + 1] === '/') {
|
|
239
|
+
while (i < len && inner[i] !== '\n') i++;
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
if (ch === '/' && inner[i + 1] === '*') {
|
|
243
|
+
i += 2;
|
|
244
|
+
while (i < len && !(inner[i] === '*' && inner[i + 1] === '/')) {
|
|
245
|
+
if (inner[i] === '\n') line++;
|
|
246
|
+
i++;
|
|
247
|
+
}
|
|
248
|
+
i += 2;
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
if (ch === '"' || ch === "'" || ch === '`') {
|
|
252
|
+
const next = skipString(inner, i, ch, (n) => { line += n; });
|
|
253
|
+
if (depth === 0 && atKeyPosition) {
|
|
254
|
+
const raw = inner.slice(i + 1, next - 1);
|
|
255
|
+
keys.push({ name: raw, line });
|
|
256
|
+
atKeyPosition = false;
|
|
257
|
+
}
|
|
258
|
+
i = next;
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
if (ch === '{' || ch === '[' || ch === '(') { depth++; atKeyPosition = false; i++; continue; }
|
|
262
|
+
if (ch === '}' || ch === ']' || ch === ')') { depth--; i++; continue; }
|
|
263
|
+
if (ch === ',' && depth === 0) { atKeyPosition = true; i++; continue; }
|
|
264
|
+
if (ch === ':' && depth === 0) { atKeyPosition = false; i++; continue; }
|
|
265
|
+
if (depth === 0 && atKeyPosition && /[A-Za-z_$]/.test(ch)) {
|
|
266
|
+
const start = i;
|
|
267
|
+
while (i < len && /[A-Za-z0-9_$]/.test(inner[i])) i++;
|
|
268
|
+
const name = inner.slice(start, i);
|
|
269
|
+
keys.push({ name, line });
|
|
270
|
+
atKeyPosition = false;
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
i++;
|
|
274
|
+
}
|
|
275
|
+
return keys;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function lintFile(file) {
|
|
279
|
+
const src = fs.readFileSync(file, 'utf8');
|
|
280
|
+
const errors = [];
|
|
281
|
+
const rel = path.relative(REPO_ROOT, file);
|
|
282
|
+
|
|
283
|
+
for (const call of findAuditEmitCalls(src)) {
|
|
284
|
+
const keys = extractTopLevelKeys(call.literal);
|
|
285
|
+
for (const key of keys) {
|
|
286
|
+
if (FORBIDDEN_KEY_RE.test(key.name)) {
|
|
287
|
+
errors.push({
|
|
288
|
+
file: rel,
|
|
289
|
+
line: call.literalLine + key.line,
|
|
290
|
+
message: `auditEmit field "${key.name}" matches forbidden secret pattern (${FORBIDDEN_KEY_RE})`,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
// Value-side checks run on the whole literal: env-secret references and
|
|
295
|
+
// postgres:// URLs would be caught here even if buried inside nested
|
|
296
|
+
// objects.
|
|
297
|
+
const envMatch = call.literal.match(ENV_SECRET_RE);
|
|
298
|
+
if (envMatch) {
|
|
299
|
+
const offset = envMatch.index || 0;
|
|
300
|
+
const lineOffset = call.literal.slice(0, offset).split('\n').length - 1;
|
|
301
|
+
errors.push({
|
|
302
|
+
file: rel,
|
|
303
|
+
line: call.literalLine + lineOffset,
|
|
304
|
+
message: `auditEmit value sources from secret env var: ${envMatch[0]}`,
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
const urlMatch = call.literal.match(POSTGRES_URL_RE);
|
|
308
|
+
if (urlMatch) {
|
|
309
|
+
const offset = urlMatch.index || 0;
|
|
310
|
+
const lineOffset = call.literal.slice(0, offset).split('\n').length - 1;
|
|
311
|
+
errors.push({
|
|
312
|
+
file: rel,
|
|
313
|
+
line: call.literalLine + lineOffset,
|
|
314
|
+
message: `auditEmit value contains postgres URL with embedded password: ${urlMatch[0]}…`,
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return errors;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function main(argv) {
|
|
322
|
+
const args = argv.slice(2);
|
|
323
|
+
let roots;
|
|
324
|
+
if (args.length === 0) {
|
|
325
|
+
roots = [DEFAULT_ROOT];
|
|
326
|
+
} else {
|
|
327
|
+
roots = args.map((a) => path.resolve(a));
|
|
328
|
+
}
|
|
329
|
+
const files = listSourceFiles(roots);
|
|
330
|
+
const allErrors = [];
|
|
331
|
+
for (const f of files) {
|
|
332
|
+
allErrors.push(...lintFile(f));
|
|
333
|
+
}
|
|
334
|
+
if (allErrors.length === 0) {
|
|
335
|
+
console.log(`audit-redaction-lint: scanned ${files.length} file(s); 0 issues.`);
|
|
336
|
+
return 0;
|
|
337
|
+
}
|
|
338
|
+
for (const err of allErrors) {
|
|
339
|
+
console.error(`${err.file}:${err.line}: ${err.message}`);
|
|
340
|
+
}
|
|
341
|
+
console.error(`audit-redaction-lint: ${allErrors.length} issue(s) across ${files.length} file(s).`);
|
|
342
|
+
return 1;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (import.meta.main) {
|
|
346
|
+
process.exit(main(process.argv));
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export { lintFile, findAuditEmitCalls, extractTopLevelKeys, listSourceFiles };
|
package/scripts/test-npx.sh
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
2
|
# Test that the package works with npx (simulates fresh user install)
|
|
3
|
-
# This catches path resolution issues that static analysis can't detect
|
|
3
|
+
# This catches path resolution issues that static analysis can't detect.
|
|
4
|
+
#
|
|
5
|
+
# v2.4 contract change (2026-05-08): `pgserve` (no args) now prints help and
|
|
6
|
+
# exits cleanly. The long-running entry is `pgserve postmaster` (or its
|
|
7
|
+
# `pgserve serve` alias). This test invokes the postmaster directly to verify
|
|
8
|
+
# npx-installed bits boot a real PG.
|
|
4
9
|
|
|
5
10
|
set -e
|
|
6
11
|
|
|
7
|
-
echo "=== Testing npx compatibility ==="
|
|
12
|
+
echo "=== Testing npx compatibility (v2.4 postmaster entry) ==="
|
|
8
13
|
|
|
9
14
|
# Create temp directory
|
|
10
15
|
TEST_DIR=$(mktemp -d)
|
|
@@ -29,22 +34,39 @@ cd "$TEST_DIR"
|
|
|
29
34
|
echo '{"name":"test-npx-install","private":true}' > package.json
|
|
30
35
|
npm install "./$PACK_FILE" > /dev/null 2>&1
|
|
31
36
|
|
|
32
|
-
#
|
|
33
|
-
|
|
34
|
-
|
|
37
|
+
# Verify the bare invocation prints v2.4 help and exits 0 (regression guard
|
|
38
|
+
# for the breaking-cut: pre-v2.4 auto-started a server here).
|
|
39
|
+
echo "Verifying bare 'npx pgserve' prints v2.4 help and exits cleanly..."
|
|
40
|
+
HELP_OUT=$(npx pgserve 2>&1)
|
|
41
|
+
if ! echo "$HELP_OUT" | grep -q "pgserve postmaster"; then
|
|
42
|
+
echo "✗ Bare 'npx pgserve' output does not match v2.4 help (missing 'pgserve postmaster')"
|
|
43
|
+
echo "Output:"
|
|
44
|
+
echo "$HELP_OUT"
|
|
45
|
+
echo "=== npx test FAILED ==="
|
|
46
|
+
exit 1
|
|
47
|
+
fi
|
|
48
|
+
echo "✓ Bare invocation prints v2.4 help"
|
|
49
|
+
|
|
50
|
+
# Test that the postmaster entry starts (with timeout)
|
|
51
|
+
DATA_DIR="$TEST_DIR/data"
|
|
52
|
+
SOCKET_DIR="$TEST_DIR/sock"
|
|
53
|
+
mkdir -p "$DATA_DIR" "$SOCKET_DIR"
|
|
54
|
+
echo "Testing postmaster startup via npx (port 15432)..."
|
|
55
|
+
timeout 30 npx pgserve postmaster --port 15432 --data "$DATA_DIR" --socket-dir "$SOCKET_DIR" > output.log 2>&1 &
|
|
35
56
|
PID=$!
|
|
36
57
|
|
|
37
|
-
# Wait for ready signal
|
|
58
|
+
# Wait for ready signal — bin/postgres-server.js logs
|
|
59
|
+
# 'pgserve postmaster: ready (Unix socket + TCP)' once both transports are bound.
|
|
38
60
|
for i in {1..60}; do
|
|
39
|
-
if grep -q "
|
|
40
|
-
echo "✓
|
|
61
|
+
if grep -q "pgserve postmaster: ready" output.log 2>/dev/null; then
|
|
62
|
+
echo "✓ Postmaster started successfully via npx"
|
|
41
63
|
kill $PID 2>/dev/null || true
|
|
42
64
|
wait $PID 2>/dev/null || true
|
|
43
65
|
echo "=== npx test PASSED ==="
|
|
44
66
|
exit 0
|
|
45
67
|
fi
|
|
46
68
|
if ! kill -0 $PID 2>/dev/null; then
|
|
47
|
-
echo "✗
|
|
69
|
+
echo "✗ Postmaster exited unexpectedly"
|
|
48
70
|
cat output.log
|
|
49
71
|
echo "=== npx test FAILED ==="
|
|
50
72
|
exit 1
|
|
@@ -54,7 +76,7 @@ done
|
|
|
54
76
|
|
|
55
77
|
# Timeout
|
|
56
78
|
kill $PID 2>/dev/null || true
|
|
57
|
-
echo "✗
|
|
79
|
+
echo "✗ Postmaster did not start within timeout"
|
|
58
80
|
cat output.log
|
|
59
81
|
echo "=== npx test FAILED ==="
|
|
60
82
|
exit 1
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured audit emitter for privilege-changing operations.
|
|
3
|
+
*
|
|
4
|
+
* Group 6 of autopg-distribution-cutover. This is the v1 audit surface
|
|
5
|
+
* consumed by Group 5's `create-app` / `list` / `revoke` / `rotate` and the
|
|
6
|
+
* LOCK 1 manifest verifier. Distinct from the legacy `src/audit.js` event
|
|
7
|
+
* stream (DB lifecycle, connection routing): that stream is `event`-keyed
|
|
8
|
+
* and writes to `~/.autopg/audit.log`; this stream is `op`-keyed and writes
|
|
9
|
+
* to `~/.autopg/logs/audit.log` with `schemaVersion: 1`.
|
|
10
|
+
*
|
|
11
|
+
* Records are JSON Lines. Every emit produces exactly one line. The shape
|
|
12
|
+
* is fixed at v1 to give the redaction lint a stable target — adding a new
|
|
13
|
+
* field is a `schemaVersion: 2` migration, not an in-place addition.
|
|
14
|
+
*
|
|
15
|
+
* Threat model the redaction lint guards:
|
|
16
|
+
* - The audit log will leak. Plan for it.
|
|
17
|
+
* - Therefore: no field name may be a secret category, and no value may
|
|
18
|
+
* be sourced from `process.env.*PASSWORD*` (or matching token/secret
|
|
19
|
+
* patterns). The lint enforces this at every call site.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import fs from 'fs';
|
|
23
|
+
import os from 'os';
|
|
24
|
+
import path from 'path';
|
|
25
|
+
|
|
26
|
+
export const AUDIT_SCHEMA_VERSION = 1;
|
|
27
|
+
|
|
28
|
+
export const AUDIT_OPS = Object.freeze({
|
|
29
|
+
CREATE_APP: 'create-app',
|
|
30
|
+
REVOKE: 'revoke',
|
|
31
|
+
ROTATE: 'rotate',
|
|
32
|
+
MANIFEST_VERIFY: 'manifest-verify',
|
|
33
|
+
MANIFEST_VERIFY_BYPASS: 'manifest-verify-bypass',
|
|
34
|
+
ADOPT_EXISTING_DB: 'adopt-existing-db',
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const VALID_OPS = new Set(Object.values(AUDIT_OPS));
|
|
38
|
+
|
|
39
|
+
const FILE_MODE = 0o600;
|
|
40
|
+
const DIR_MODE = 0o700;
|
|
41
|
+
|
|
42
|
+
function getConfigDir() {
|
|
43
|
+
return (
|
|
44
|
+
process.env.AUTOPG_CONFIG_DIR ||
|
|
45
|
+
process.env.PGSERVE_CONFIG_DIR ||
|
|
46
|
+
path.join(os.homedir(), '.autopg')
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function defaultLogPath() {
|
|
51
|
+
return path.join(getConfigDir(), 'logs', 'audit.log');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let LOG_PATH = defaultLogPath();
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Override the audit log path. Tests use this to redirect into a scratch
|
|
58
|
+
* dir; the daemon may use it if `AUTOPG_CONFIG_DIR` is set after import.
|
|
59
|
+
*
|
|
60
|
+
* Pass no argument to reset to the default (re-resolves env vars).
|
|
61
|
+
*
|
|
62
|
+
* @param {{logFile?: string}} [cfg]
|
|
63
|
+
*/
|
|
64
|
+
export function configureAuditEmit(cfg = {}) {
|
|
65
|
+
if (cfg.logFile) {
|
|
66
|
+
LOG_PATH = cfg.logFile;
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
LOG_PATH = defaultLogPath();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function getAuditLogPath() {
|
|
73
|
+
return LOG_PATH;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Emit a single audit record.
|
|
78
|
+
*
|
|
79
|
+
* Required: `op`, `actor`. Optional: `app`, `role`, `manifestSha256`,
|
|
80
|
+
* `sigVerified`, `incidentId`. Unknown fields are passed through verbatim
|
|
81
|
+
* so call sites stay flexible — but the redaction lint validates that the
|
|
82
|
+
* payload never contains secret-shaped names or env-sourced secret values.
|
|
83
|
+
*
|
|
84
|
+
* Record shape on disk (JSON Lines):
|
|
85
|
+
* {"schemaVersion":1,"ts":"<iso>","op":"create-app",...}
|
|
86
|
+
*
|
|
87
|
+
* Returns the written record (mostly for tests; production callers ignore).
|
|
88
|
+
*
|
|
89
|
+
* @param {object} record
|
|
90
|
+
* @param {string} record.op - one of AUDIT_OPS
|
|
91
|
+
* @param {string} [record.actor] - OS user or admin role performing the op
|
|
92
|
+
* @param {string} [record.app] - target app name
|
|
93
|
+
* @param {string} [record.role] - target postgres role
|
|
94
|
+
* @param {string} [record.manifestSha256] - hex sha256 of the verified manifest
|
|
95
|
+
* @param {boolean} [record.sigVerified] - whether the manifest sig verified
|
|
96
|
+
* @param {string} [record.incidentId] - present only when bypass was used
|
|
97
|
+
* @returns {object}
|
|
98
|
+
*/
|
|
99
|
+
export function auditEmit(record) {
|
|
100
|
+
if (!record || typeof record !== 'object') {
|
|
101
|
+
throw new Error('auditEmit: record must be an object');
|
|
102
|
+
}
|
|
103
|
+
if (typeof record.op !== 'string' || !VALID_OPS.has(record.op)) {
|
|
104
|
+
throw new Error(
|
|
105
|
+
`auditEmit: unknown op "${record.op}". Allowed: ${[...VALID_OPS].join(', ')}`
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const out = {
|
|
110
|
+
schemaVersion: AUDIT_SCHEMA_VERSION,
|
|
111
|
+
ts: new Date().toISOString(),
|
|
112
|
+
...record,
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
writeJsonLine(out, LOG_PATH);
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function writeJsonLine(record, logFile) {
|
|
120
|
+
const dir = path.dirname(logFile);
|
|
121
|
+
if (!fs.existsSync(dir)) {
|
|
122
|
+
fs.mkdirSync(dir, { recursive: true, mode: DIR_MODE });
|
|
123
|
+
}
|
|
124
|
+
const fd = fs.openSync(logFile, 'a', FILE_MODE);
|
|
125
|
+
try {
|
|
126
|
+
fs.writeSync(fd, JSON.stringify(record) + '\n');
|
|
127
|
+
} finally {
|
|
128
|
+
fs.closeSync(fd);
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
fs.chmodSync(logFile, FILE_MODE);
|
|
132
|
+
} catch { /* best-effort tighten */ }
|
|
133
|
+
}
|
|
134
|
+
|