pgserve 1.1.10 → 2.0.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/.genie/brainstorms/pgserve-v2/DESIGN.md +174 -0
- package/.genie/wishes/pgserve-v2/BRIEF-from-genie-pgserve.md +99 -0
- package/.genie/wishes/pgserve-v2/WISH.md +442 -0
- package/.genie/wishes/release-system-genie-pattern/WISH.md +268 -0
- package/.genie/wishes/release-system-genie-pattern/validation.md +205 -0
- package/.github/workflows/ci.yml +8 -4
- package/.github/workflows/release.yml +233 -111
- package/.github/workflows/{build-all-platforms.yml → version.yml} +32 -8
- package/AGENTS.md +10 -8
- package/CHANGELOG.md +150 -0
- package/Makefile +18 -41
- package/README.md +186 -1
- package/SECURITY.md +109 -0
- package/bin/pglite-server.js +253 -1
- package/eslint.config.js +2 -0
- package/package.json +1 -1
- package/src/admin-client.js +171 -0
- package/src/audit.js +168 -0
- package/src/control-db.js +313 -0
- package/src/daemon-control.js +408 -0
- package/src/daemon-shared.js +18 -0
- package/src/daemon-tcp.js +296 -0
- package/src/daemon.js +629 -0
- package/src/fingerprint.js +453 -0
- package/src/gc.js +351 -0
- package/src/index.js +11 -0
- package/src/postgres.js +54 -0
- package/src/protocol.js +131 -0
- package/src/router.js +78 -5
- package/src/tenancy.js +75 -0
- package/src/tokens.js +102 -0
- package/tests/audit.test.js +189 -0
- package/tests/control-db.test.js +285 -0
- package/tests/daemon-fingerprint-integration.test.js +109 -0
- package/tests/daemon-pr24-regression.test.js +201 -0
- package/tests/fingerprint.test.js +249 -0
- package/tests/fixtures/240-orphan-seed.sql +30 -0
- package/tests/multi-tenant.test.js +164 -0
- package/tests/orphan-cleanup.test.js +390 -0
- package/tests/tcp-listen.test.js +368 -0
- package/tests/tenancy.test.js +403 -0
- package/.github/release.yml +0 -30
- package/scripts/release.cjs +0 -198
package/Makefile
CHANGED
|
@@ -28,15 +28,13 @@ help: ## Show this help
|
|
|
28
28
|
@echo "$(CYAN)Embedded PostgreSQL server with multi-tenant support$(RESET)"
|
|
29
29
|
@echo ""
|
|
30
30
|
@echo "$(BOLD)Quick Commands:$(RESET)"
|
|
31
|
-
@echo " $(PURPLE)release-rc$(RESET) Create RC release locally"
|
|
32
|
-
@echo " $(PURPLE)release-stable$(RESET) Promote RC to stable"
|
|
33
31
|
@echo " $(PURPLE)test-local$(RESET) Test server locally"
|
|
34
32
|
@echo " $(PURPLE)pm2-start$(RESET) Start server with PM2"
|
|
35
33
|
@echo ""
|
|
36
|
-
@echo "$(BOLD)
|
|
37
|
-
@echo "
|
|
38
|
-
@echo "
|
|
39
|
-
@echo "
|
|
34
|
+
@echo "$(BOLD)Releasing:$(RESET)"
|
|
35
|
+
@echo " Manual: bump locally with 'npm version patch|minor|major', PR to main."
|
|
36
|
+
@echo " Bot: 'gh workflow run release.yml -f bump=patch' (or minor/major)."
|
|
37
|
+
@echo " Skip: any commit message starting with [skip ci] is ignored."
|
|
40
38
|
@echo ""
|
|
41
39
|
@echo "$(BOLD)Build Executables:$(RESET)"
|
|
42
40
|
@echo " $(PURPLE)build$(RESET) Build for current platform"
|
|
@@ -211,35 +209,19 @@ clean-dist: ## Clean build artifacts
|
|
|
211
209
|
@echo "$(GREEN)✅ Dist cleaned!$(RESET)"
|
|
212
210
|
|
|
213
211
|
# ==========================================
|
|
214
|
-
# 🚀
|
|
212
|
+
# 🚀 Releasing
|
|
215
213
|
# ==========================================
|
|
216
|
-
# Releases are
|
|
217
|
-
# - 'rc' label → Creates RC release (1.0.8 → 1.0.9-rc.1)
|
|
218
|
-
# - 'stable' label → Promotes RC to stable (1.0.9-rc.1 → 1.0.9)
|
|
214
|
+
# Releases are driven by .github/workflows/release.yml on push to main.
|
|
219
215
|
#
|
|
220
|
-
#
|
|
216
|
+
# Manual: bump locally with `npm version patch|minor|major`, commit, PR
|
|
217
|
+
# to main. Merge -> release fires automatically.
|
|
218
|
+
# Bot: `gh workflow run release.yml -f bump=patch` (or minor/major).
|
|
219
|
+
# The bot bumps, tags, builds binaries, publishes to npm via OIDC.
|
|
220
|
+
# Skip: any commit message starting with [skip ci] is ignored.
|
|
221
|
+
#
|
|
222
|
+
# There are no Make targets for releases — versioning is intentionally
|
|
223
|
+
# centralized in CI to keep the local-vs-prod workflow paths identical.
|
|
221
224
|
# ==========================================
|
|
222
|
-
.PHONY: release-rc release-stable release-dry
|
|
223
|
-
|
|
224
|
-
release-rc: ## Create RC release locally (for testing)
|
|
225
|
-
@echo "$(CYAN)🔢 Creating RC release...$(RESET)"
|
|
226
|
-
@node scripts/release.cjs --action bump-rc
|
|
227
|
-
@echo ""
|
|
228
|
-
@echo "$(GREEN)✅ RC release created!$(RESET)"
|
|
229
|
-
@echo "$(YELLOW)Push with: git push && git push --tags$(RESET)"
|
|
230
|
-
|
|
231
|
-
release-stable: ## Promote RC to stable locally (for testing)
|
|
232
|
-
@echo "$(CYAN)🎉 Promoting to stable...$(RESET)"
|
|
233
|
-
@node scripts/release.cjs --action promote
|
|
234
|
-
@echo ""
|
|
235
|
-
@echo "$(GREEN)✅ Stable release created!$(RESET)"
|
|
236
|
-
@echo "$(YELLOW)Push with: git push && git push --tags$(RESET)"
|
|
237
|
-
|
|
238
|
-
release-dry: ## Dry-run release (no changes)
|
|
239
|
-
@echo "$(CYAN)🔍 Dry-run release...$(RESET)"
|
|
240
|
-
@node scripts/release.cjs --action bump-rc --dry-run
|
|
241
|
-
@echo ""
|
|
242
|
-
@echo "$(GREEN)✅ Dry-run complete (no changes made)$(RESET)"
|
|
243
225
|
|
|
244
226
|
# ==========================================
|
|
245
227
|
# 📦 Manual Publish (Deprecated)
|
|
@@ -254,19 +236,14 @@ publish-dry: pre-publish ## Dry-run publish (test without actually publishing)
|
|
|
254
236
|
@echo "$(GREEN)✅ Dry-run successful!$(RESET)"
|
|
255
237
|
@echo "$(YELLOW)To actually publish, run: make publish$(RESET)"
|
|
256
238
|
|
|
257
|
-
publish: ## ⚠️ [DEPRECATED]
|
|
239
|
+
publish: ## ⚠️ [DEPRECATED] Releases run from CI on push to main
|
|
258
240
|
@echo ""
|
|
259
241
|
@echo "$(YELLOW)$(BOLD)╔═══════════════════════════════════════════════════════════════╗$(RESET)"
|
|
260
242
|
@echo "$(YELLOW)$(BOLD)║ ⚠️ Manual publish is DEPRECATED ║$(RESET)"
|
|
261
243
|
@echo "$(YELLOW)$(BOLD)║ ║$(RESET)"
|
|
262
|
-
@echo "$(YELLOW)$(BOLD)║
|
|
263
|
-
@echo "$(YELLOW)$(BOLD)║
|
|
264
|
-
@echo "$(YELLOW)$(BOLD)║
|
|
265
|
-
@echo "$(YELLOW)$(BOLD)║ ║$(RESET)"
|
|
266
|
-
@echo "$(YELLOW)$(BOLD)║ Local testing: ║$(RESET)"
|
|
267
|
-
@echo "$(YELLOW)$(BOLD)║ make release-rc Create RC locally ║$(RESET)"
|
|
268
|
-
@echo "$(YELLOW)$(BOLD)║ make release-stable Promote locally ║$(RESET)"
|
|
269
|
-
@echo "$(YELLOW)$(BOLD)║ make release-dry Dry-run (no changes) ║$(RESET)"
|
|
244
|
+
@echo "$(YELLOW)$(BOLD)║ Releases are driven by .github/workflows/release.yml: ║$(RESET)"
|
|
245
|
+
@echo "$(YELLOW)$(BOLD)║ Manual: npm version patch|minor|major, commit, PR to main ║$(RESET)"
|
|
246
|
+
@echo "$(YELLOW)$(BOLD)║ Bot: gh workflow run release.yml -f bump=patch ║$(RESET)"
|
|
270
247
|
@echo "$(YELLOW)$(BOLD)╚═══════════════════════════════════════════════════════════════╝$(RESET)"
|
|
271
248
|
@echo ""
|
|
272
249
|
|
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
<a href="https://discord.gg/xcW8c7fF3R"><img src="https://img.shields.io/discord/1095114867012292758?style=flat-square&color=00D9FF&label=discord" alt="Discord"></a>
|
|
11
11
|
</p>
|
|
12
12
|
|
|
13
|
-
<p><em>Zero config, auto-provision databases, unlimited concurrent connections
|
|
13
|
+
<p><em>npx pgserve and it just works, no credentials needed. Zero config, auto-provision databases, unlimited concurrent connections.</em></p>
|
|
14
14
|
|
|
15
15
|
<p>
|
|
16
16
|
<a href="#-quick-start">Quick Start</a> •
|
|
@@ -35,6 +35,8 @@ Connect from any PostgreSQL client — databases auto-create on first connection
|
|
|
35
35
|
psql postgresql://localhost:8432/myapp
|
|
36
36
|
```
|
|
37
37
|
|
|
38
|
+
> Note: v2 default is the Unix socket — see [Daemon mode](#daemon-mode). The TCP form above is the v1 compat path.
|
|
39
|
+
|
|
38
40
|
<br>
|
|
39
41
|
|
|
40
42
|
## Features
|
|
@@ -168,6 +170,189 @@ pgserve --sync-to "postgresql://user:pass@db.example.com:5432/prod"
|
|
|
168
170
|
|
|
169
171
|
<br>
|
|
170
172
|
|
|
173
|
+
## Daemon mode
|
|
174
|
+
|
|
175
|
+
`pgserve@2` ships a singleton daemon that binds a Unix control socket
|
|
176
|
+
inside `$XDG_RUNTIME_DIR/pgserve` (fallback `/tmp/pgserve`). One daemon
|
|
177
|
+
per host serves every consumer on the box — no port conflicts, no
|
|
178
|
+
credentials, kernel-rooted identity. Run it under PM2 or systemd so it
|
|
179
|
+
restarts automatically.
|
|
180
|
+
|
|
181
|
+
```bash
|
|
182
|
+
# Foreground (for debugging)
|
|
183
|
+
pgserve daemon
|
|
184
|
+
|
|
185
|
+
# Stop a running daemon
|
|
186
|
+
pgserve daemon stop
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
A second `pgserve daemon` invocation while the first is running exits with
|
|
190
|
+
`already running, pid N`. A daemon killed with `kill -9` leaves an orphan
|
|
191
|
+
PID file + socket; the next `pgserve daemon` boot detects the dead pid and
|
|
192
|
+
cleans both up automatically.
|
|
193
|
+
|
|
194
|
+
Connect from any libpq client (no host/port/user/password required —
|
|
195
|
+
the daemon authenticates via SO_PEERCRED on accept):
|
|
196
|
+
|
|
197
|
+
```bash
|
|
198
|
+
psql -h "${XDG_RUNTIME_DIR:-/tmp}/pgserve" -d myapp
|
|
199
|
+
# or via connection URI
|
|
200
|
+
psql "postgresql:///myapp?host=${XDG_RUNTIME_DIR:-/tmp}/pgserve"
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
### Supervised by PM2
|
|
204
|
+
|
|
205
|
+
`ecosystem.config.cjs` snippet:
|
|
206
|
+
|
|
207
|
+
```javascript
|
|
208
|
+
module.exports = {
|
|
209
|
+
apps: [{
|
|
210
|
+
name: 'pgserve',
|
|
211
|
+
script: 'pgserve',
|
|
212
|
+
args: 'daemon',
|
|
213
|
+
autorestart: true,
|
|
214
|
+
max_memory_restart: '1G',
|
|
215
|
+
env: { XDG_RUNTIME_DIR: '/run/user/1000' },
|
|
216
|
+
}],
|
|
217
|
+
};
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
```bash
|
|
221
|
+
pm2 start ecosystem.config.cjs && pm2 save
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
### Supervised by systemd
|
|
225
|
+
|
|
226
|
+
`/etc/systemd/user/pgserve.service`:
|
|
227
|
+
|
|
228
|
+
```ini
|
|
229
|
+
[Unit]
|
|
230
|
+
Description=pgserve daemon
|
|
231
|
+
After=default.target
|
|
232
|
+
|
|
233
|
+
[Service]
|
|
234
|
+
Type=simple
|
|
235
|
+
ExecStart=/usr/bin/env npx pgserve daemon
|
|
236
|
+
Restart=on-failure
|
|
237
|
+
RestartSec=5
|
|
238
|
+
|
|
239
|
+
[Install]
|
|
240
|
+
WantedBy=default.target
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
Enable for the current user:
|
|
244
|
+
|
|
245
|
+
```bash
|
|
246
|
+
systemctl --user enable --now pgserve
|
|
247
|
+
journalctl --user -u pgserve -f
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
The systemd user unit inherits `XDG_RUNTIME_DIR` automatically; the daemon
|
|
251
|
+
binds `${XDG_RUNTIME_DIR}/pgserve/control.sock` (mode 0600, dir mode 0700)
|
|
252
|
+
plus a `.s.PGSQL.5432` symlink so off-the-shelf PostgreSQL clients connect
|
|
253
|
+
without further configuration.
|
|
254
|
+
|
|
255
|
+
<br>
|
|
256
|
+
|
|
257
|
+
## Fingerprint isolation
|
|
258
|
+
|
|
259
|
+
Each consumer is identified by a **kernel-rooted fingerprint** derived from
|
|
260
|
+
the peer's `SO_PEERCRED` plus the resolved `package.json` `name`, collapsed
|
|
261
|
+
to 12 hex chars. The daemon auto-creates one database per fingerprint —
|
|
262
|
+
`app_<sanitized-name>_<12hex>` — and refuses to route a peer into any other
|
|
263
|
+
database with SQLSTATE `28P01 invalid_authorization — database fingerprint
|
|
264
|
+
mismatch`.
|
|
265
|
+
|
|
266
|
+
```bash
|
|
267
|
+
# What `psql -l` shows on a host with three consumers:
|
|
268
|
+
$ psql -h "${XDG_RUNTIME_DIR:-/tmp}/pgserve" -l
|
|
269
|
+
Name | Owner | ...
|
|
270
|
+
-----------------------+----------+----
|
|
271
|
+
app_genie_a1b2c3d4e5f6 | postgres | ...
|
|
272
|
+
app_brain_4f3e2d1c0b9a | postgres | ...
|
|
273
|
+
app_omni_9876543210ab | postgres | ...
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
**Monorepo rule:** the **root** `package.json` `name` wins. Every workspace
|
|
277
|
+
under it shares one fingerprint and one database — sub-packages do **not**
|
|
278
|
+
get their own. If you need separate isolation, run them from separate
|
|
279
|
+
checkouts.
|
|
280
|
+
|
|
281
|
+
**Sanitization:** non-`[a-z0-9]` runs collapse to `_`, lowercased, truncated
|
|
282
|
+
to 30 chars so the final DB name stays within PostgreSQL's 63-char limit.
|
|
283
|
+
A name like `@scope/foo bar` becomes `_scope_foo_bar`.
|
|
284
|
+
|
|
285
|
+
**Emergency kill switch:** `PGSERVE_DISABLE_FINGERPRINT_ENFORCEMENT=1`
|
|
286
|
+
disables enforcement for the daemon process. Use it as a debugging tool
|
|
287
|
+
only — every bypassed connection emits an `enforcement_kill_switch_used`
|
|
288
|
+
audit event and the daemon logs a deprecation warning at boot.
|
|
289
|
+
|
|
290
|
+
<br>
|
|
291
|
+
|
|
292
|
+
## Long-running apps: `pgserve.persist`
|
|
293
|
+
|
|
294
|
+
Default lifecycle is **ephemeral**: a database whose `liveness_pid` is dead
|
|
295
|
+
AND whose `last_connection_at` is older than 24h is dropped on the next GC
|
|
296
|
+
sweep (boot, hourly, sampled on-connect). Reaped DBs emit
|
|
297
|
+
`db_reaped_ttl` or `db_reaped_liveness` audit events.
|
|
298
|
+
|
|
299
|
+
If your app holds state worth keeping past 24h of idle — genie's wish/agent
|
|
300
|
+
store, internal dashboards, anything you'd be unhappy to lose — declare
|
|
301
|
+
persistence in `package.json`:
|
|
302
|
+
|
|
303
|
+
```jsonc
|
|
304
|
+
{
|
|
305
|
+
"name": "my-long-lived-app",
|
|
306
|
+
"pgserve": { "persist": true }
|
|
307
|
+
}
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
Persisted databases are **never** reaped, regardless of liveness or TTL.
|
|
311
|
+
Dev workloads with long debug cycles do not normally need this — any new
|
|
312
|
+
connection slides the TTL window forward. Reach for `pgserve.persist` when
|
|
313
|
+
the app is genuinely long-lived (production daemon, dashboard, durable
|
|
314
|
+
agent state), not just for convenience.
|
|
315
|
+
|
|
316
|
+
<br>
|
|
317
|
+
|
|
318
|
+
## Compat TCP via `--listen`
|
|
319
|
+
|
|
320
|
+
TCP is **off by default** in v2. Bring it back only when you need it
|
|
321
|
+
(Kubernetes pods, remote sync, legacy clients that cannot speak Unix
|
|
322
|
+
sockets) by opting in:
|
|
323
|
+
|
|
324
|
+
```bash
|
|
325
|
+
pgserve daemon --listen :5432
|
|
326
|
+
# Repeatable for multiple binds:
|
|
327
|
+
pgserve daemon --listen :5432 --listen 0.0.0.0:5433
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
TCP peers cannot use `SO_PEERCRED`, so they **must** authenticate at
|
|
331
|
+
connect time. Issue a bearer token bound to a known fingerprint:
|
|
332
|
+
|
|
333
|
+
```bash
|
|
334
|
+
# Prints the token ONCE; the daemon stores only its hash.
|
|
335
|
+
pgserve daemon issue-token --fingerprint a1b2c3d4e5f6
|
|
336
|
+
|
|
337
|
+
# TCP client passes it via libpq application_name:
|
|
338
|
+
# ?fingerprint=a1b2c3d4e5f6&token=<bearer>
|
|
339
|
+
|
|
340
|
+
# Revoke when done:
|
|
341
|
+
pgserve daemon revoke-token <token-id>
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
Audit events: `tcp_token_issued`, `tcp_token_used`, `tcp_token_denied`.
|
|
345
|
+
Tokens are verified with constant-time compare. Without a valid token a
|
|
346
|
+
TCP connection is refused — there is no anonymous TCP path.
|
|
347
|
+
|
|
348
|
+
Verify no port is bound when `--listen` is **not** set:
|
|
349
|
+
|
|
350
|
+
```bash
|
|
351
|
+
ss -tlnp | grep pgserve # no rows expected
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
<br>
|
|
355
|
+
|
|
171
356
|
## API
|
|
172
357
|
|
|
173
358
|
```javascript
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# Security Policy
|
|
2
|
+
|
|
3
|
+
`pgserve` is maintained by [Automagik](https://automagik.dev). We take the security of this package seriously and appreciate responsible disclosure from the community.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Reporting a Vulnerability
|
|
8
|
+
|
|
9
|
+
**Please do not open public issues for security reports.**
|
|
10
|
+
|
|
11
|
+
Send private reports to one of the following channels:
|
|
12
|
+
|
|
13
|
+
| Channel | Address | Best for |
|
|
14
|
+
|---------|---------|----------|
|
|
15
|
+
| Security email | `privacidade@namastex.ai` | Anything security-related, including coordinated disclosure |
|
|
16
|
+
| DPO (privacy + security officer) | `dpo@khal.ai` | Privacy, LGPD, data protection concerns |
|
|
17
|
+
| Private GitHub advisory | [Report via GitHub](https://github.com/namastexlabs/pgserve/security/advisories/new) | Preferred for CVE assignment and coordinated release |
|
|
18
|
+
|
|
19
|
+
**PGP** available on request.
|
|
20
|
+
|
|
21
|
+
### Response SLA
|
|
22
|
+
|
|
23
|
+
- Acknowledgement: **within 2 business hours** (UTC-3).
|
|
24
|
+
- Initial triage and severity assessment: **within 24 hours**.
|
|
25
|
+
- Fix or mitigation plan: **within 7 days** for critical/high severity.
|
|
26
|
+
- Public disclosure: coordinated with reporter, typically within 30 days of fix.
|
|
27
|
+
|
|
28
|
+
We will credit reporters publicly (with their permission) in the released advisory.
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## Supported Versions
|
|
33
|
+
|
|
34
|
+
| Version line | Status |
|
|
35
|
+
|--------------|--------|
|
|
36
|
+
| `1.1.10` and later clean releases | ✅ Supported — current |
|
|
37
|
+
| `1.1.11` – `1.1.14` | ❌ **COMPROMISED — do not use** |
|
|
38
|
+
| `1.1.0` – `1.1.9` | ⚠️ Legacy — security patches only |
|
|
39
|
+
| `1.0.x` and earlier | ❌ End of life |
|
|
40
|
+
|
|
41
|
+
Always install from the current stable line. Pin explicit versions in your `package.json` and avoid `latest` for supply-chain sensitive packages.
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## Past Incidents
|
|
46
|
+
|
|
47
|
+
### 2026-04 — CanisterWorm supply-chain compromise
|
|
48
|
+
|
|
49
|
+
Between 2026-04-21 (~22:14 UTC) and 2026-04-22 (~14:00 UTC), versions `1.1.11`, `1.1.12`, `1.1.13`, and `1.1.14` were published to npm by a threat actor after a developer GitHub OAuth token was exfiltrated. The malicious versions contained a `TeamPCP` payload in `scripts/check-env.js` that executed via `postinstall` to harvest local credentials.
|
|
50
|
+
|
|
51
|
+
- **Exposure window:** ~16 hours
|
|
52
|
+
- **Detection-to-containment:** under 20 hours
|
|
53
|
+
- **Current status:** malicious versions `npm unpublish`-ed and no longer installable
|
|
54
|
+
|
|
55
|
+
**If you installed versions `1.1.11` – `1.1.14` between April 21–22, 2026, assume your machine is compromised.** Follow the remediation guide linked below.
|
|
56
|
+
|
|
57
|
+
**Resources:**
|
|
58
|
+
- 📖 [Full incident response manual](https://github.com/namastexlabs/genie-dpo/blob/main/knowledge/canisterworm-incident-response.md)
|
|
59
|
+
- 🌐 [Public advisory (English)](https://automagik.dev/security)
|
|
60
|
+
- 🌐 [Aviso público (Português)](https://automagik.dev/seguranca)
|
|
61
|
+
- 🛡️ [GitHub Security Advisories](https://github.com/namastexlabs/pgserve/security/advisories) for this repository
|
|
62
|
+
|
|
63
|
+
A full public post-mortem will be published within 30 days of containment.
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
## Acknowledgments
|
|
68
|
+
|
|
69
|
+
We thank the researchers and organizations that identified and tracked this incident:
|
|
70
|
+
|
|
71
|
+
- [**Socket Research Team**](https://socket.dev/blog/namastex-npm-packages-compromised-canisterworm) — primary discovery and continued tracking at [socket.dev/supply-chain-attacks/canistersprawl](https://socket.dev/supply-chain-attacks/canistersprawl).
|
|
72
|
+
- **Endor Labs**, **Kodem Security**, **BleepingComputer**, **The Register**, **CSO Online**, **GBHackers**, **Cybersecurity News** — for coverage, analysis, and technical breakdowns that helped defenders respond quickly.
|
|
73
|
+
|
|
74
|
+
We also thank the Automagik team that ran the end-to-end response during the incident window, and the broader open-source community whose scrutiny, tools, and unfiltered feedback keep this ecosystem healthy. We will keep earning it.
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## Our Commitments
|
|
79
|
+
|
|
80
|
+
Effective 2026-04-23, all `pgserve` releases are governed by:
|
|
81
|
+
|
|
82
|
+
- **Provenance attestation** — every publication is signed with `npm --provenance` and verifiable via Sigstore.
|
|
83
|
+
- **OIDC trusted publishing** — migrating to GitHub Actions OIDC publish, eliminating long-lived npm tokens. (in progress)
|
|
84
|
+
- **Mandatory 2FA** on every maintainer account with publish rights.
|
|
85
|
+
- **Environment protection** — production publishes require manual approval from a second maintainer.
|
|
86
|
+
- **Quarterly token audit** — scope and permission review.
|
|
87
|
+
- **External pentest** — scheduled ahead of the original roadmap.
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## Hardening Recommendations for Consumers
|
|
92
|
+
|
|
93
|
+
- Pin explicit versions, not `latest`: `"pgserve": "1.1.10"`.
|
|
94
|
+
- Use `npm ci` in CI. It enforces lockfile-based installs by default.
|
|
95
|
+
- Evaluate `--ignore-scripts` per-package for untrusted dependencies. The current `pgserve` release does not require any lifecycle script to function.
|
|
96
|
+
- Verify package provenance: `npm view pgserve --json | jq '.dist.attestations'`.
|
|
97
|
+
- Monitor advisories: subscribe to GitHub security alerts for this repository.
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
|
|
101
|
+
## Contact
|
|
102
|
+
|
|
103
|
+
- **Security & incidents:** `privacidade@namastex.ai`
|
|
104
|
+
- **Data Protection Officer (DPO):** Cezar Vasconcelos — `dpo@khal.ai`
|
|
105
|
+
- **Security disclosure page:** [automagik.dev/security](https://automagik.dev/security)
|
|
106
|
+
|
|
107
|
+
Namastex Labs Serviços em Tecnologia Ltda · CNPJ 46.156.854/0001-62
|
|
108
|
+
|
|
109
|
+
*Last updated: 2026-04-23 · v1.0*
|
package/bin/pglite-server.js
CHANGED
|
@@ -12,6 +12,20 @@ import path from 'path';
|
|
|
12
12
|
import os from 'os';
|
|
13
13
|
import { startMultiTenantServer } from '../src/index.js';
|
|
14
14
|
import { startClusterServer } from '../src/cluster.js';
|
|
15
|
+
import {
|
|
16
|
+
PgserveDaemon,
|
|
17
|
+
stopDaemon,
|
|
18
|
+
resolveControlSocketDir,
|
|
19
|
+
resolveControlSocketPath,
|
|
20
|
+
} from '../src/daemon.js';
|
|
21
|
+
import { createAdminClient, readAdminDiscovery } from '../src/admin-client.js';
|
|
22
|
+
import {
|
|
23
|
+
ensureMetaSchema,
|
|
24
|
+
addAllowedToken,
|
|
25
|
+
revokeAllowedToken,
|
|
26
|
+
} from '../src/control-db.js';
|
|
27
|
+
import { mintToken } from '../src/tokens.js';
|
|
28
|
+
import { audit, AUDIT_EVENTS } from '../src/audit.js';
|
|
15
29
|
|
|
16
30
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
17
31
|
|
|
@@ -25,9 +39,247 @@ process.on('uncaughtException', (error) => {
|
|
|
25
39
|
process.exit(1);
|
|
26
40
|
});
|
|
27
41
|
|
|
28
|
-
// Parse CLI arguments
|
|
42
|
+
// Parse CLI arguments — `pgserve daemon [stop]` is dispatched before the
|
|
43
|
+
// classic `pgserve [options]` parser so daemon-mode flags do not collide
|
|
44
|
+
// with router flags.
|
|
29
45
|
const args = process.argv.slice(2);
|
|
30
46
|
|
|
47
|
+
if (args[0] === 'daemon') {
|
|
48
|
+
await runDaemonSubcommand(args.slice(1));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function runDaemonSubcommand(daemonArgs) {
|
|
52
|
+
if (daemonArgs[0] === 'stop') {
|
|
53
|
+
const result = stopDaemon();
|
|
54
|
+
if (result.stopped) {
|
|
55
|
+
console.log(`pgserve daemon stopped (pid ${result.pid})`);
|
|
56
|
+
process.exit(0);
|
|
57
|
+
}
|
|
58
|
+
if (result.reason === 'no-pid-file') {
|
|
59
|
+
console.error('pgserve daemon: no PID file found — is the daemon running?');
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
if (result.reason === 'stale-pid' || result.reason === 'invalid-pid-file') {
|
|
63
|
+
console.log(`pgserve daemon: cleaned up stale lock (pid ${result.pid ?? '?'})`);
|
|
64
|
+
process.exit(0);
|
|
65
|
+
}
|
|
66
|
+
if (result.reason === 'timeout') {
|
|
67
|
+
console.error(`pgserve daemon: pid ${result.pid} did not exit within timeout`);
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
console.error(`pgserve daemon stop: ${result.reason}${result.error ? ` (${result.error})` : ''}`);
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (daemonArgs[0] === 'issue-token') {
|
|
75
|
+
await runIssueTokenSubcommand(daemonArgs.slice(1));
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (daemonArgs[0] === 'revoke-token') {
|
|
79
|
+
await runRevokeTokenSubcommand(daemonArgs.slice(1));
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// `pgserve daemon` (long-running)
|
|
84
|
+
const opts = parseDaemonArgs(daemonArgs);
|
|
85
|
+
const daemon = new PgserveDaemon(opts);
|
|
86
|
+
try {
|
|
87
|
+
await daemon.start();
|
|
88
|
+
} catch (err) {
|
|
89
|
+
if (err.code === 'EALREADYRUNNING') {
|
|
90
|
+
console.error(`pgserve daemon: already running, pid ${err.pid}`);
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
93
|
+
console.error('pgserve daemon: failed to start:', err.message);
|
|
94
|
+
process.exit(1);
|
|
95
|
+
}
|
|
96
|
+
const dir = resolveControlSocketDir();
|
|
97
|
+
console.log(`
|
|
98
|
+
pgserve daemon — singleton mode
|
|
99
|
+
|
|
100
|
+
Control socket: ${resolveControlSocketPath(dir)}
|
|
101
|
+
PID lock: ${path.join(dir, 'pgserve.pid')}
|
|
102
|
+
PG socket: ${daemon.pgManager.getSocketPath() || '(TCP fallback)'}
|
|
103
|
+
|
|
104
|
+
Connect: psql 'host=${dir} dbname=mydb'
|
|
105
|
+
|
|
106
|
+
Press Ctrl+C or send SIGTERM to stop.
|
|
107
|
+
`);
|
|
108
|
+
|
|
109
|
+
// Daemon installs its own SIGTERM/SIGINT handlers; just wait forever.
|
|
110
|
+
await new Promise(() => {});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function parseDaemonArgs(daemonArgs) {
|
|
114
|
+
const opts = {
|
|
115
|
+
baseDir: null,
|
|
116
|
+
useRam: false,
|
|
117
|
+
logLevel: 'info',
|
|
118
|
+
autoProvision: true,
|
|
119
|
+
tcpListens: [],
|
|
120
|
+
};
|
|
121
|
+
for (let i = 0; i < daemonArgs.length; i++) {
|
|
122
|
+
const arg = daemonArgs[i];
|
|
123
|
+
switch (arg) {
|
|
124
|
+
case '--data':
|
|
125
|
+
case '-d':
|
|
126
|
+
opts.baseDir = daemonArgs[++i];
|
|
127
|
+
break;
|
|
128
|
+
case '--ram':
|
|
129
|
+
opts.useRam = true;
|
|
130
|
+
break;
|
|
131
|
+
case '--log':
|
|
132
|
+
case '-l':
|
|
133
|
+
opts.logLevel = daemonArgs[++i];
|
|
134
|
+
break;
|
|
135
|
+
case '--no-provision':
|
|
136
|
+
opts.autoProvision = false;
|
|
137
|
+
break;
|
|
138
|
+
case '--listen':
|
|
139
|
+
opts.tcpListens.push(daemonArgs[++i]);
|
|
140
|
+
break;
|
|
141
|
+
case '--help':
|
|
142
|
+
console.log(`
|
|
143
|
+
pgserve daemon — singleton control-socket mode
|
|
144
|
+
|
|
145
|
+
USAGE:
|
|
146
|
+
pgserve daemon [options]
|
|
147
|
+
pgserve daemon stop
|
|
148
|
+
pgserve daemon issue-token --fingerprint <hex>
|
|
149
|
+
pgserve daemon revoke-token <id>
|
|
150
|
+
|
|
151
|
+
OPTIONS:
|
|
152
|
+
--data <path> Persistent data directory (default: in-memory)
|
|
153
|
+
--ram Use /dev/shm storage (Linux only)
|
|
154
|
+
--log <level> Log level: error|warn|info|debug (default: info)
|
|
155
|
+
--no-provision Disable auto-provisioning of databases
|
|
156
|
+
--listen [host:]port Bind opt-in TCP listener (repeatable)
|
|
157
|
+
--help Show this help
|
|
158
|
+
|
|
159
|
+
The daemon binds $XDG_RUNTIME_DIR/pgserve/control.sock (fallback /tmp/pgserve/control.sock).
|
|
160
|
+
A second invocation while the first is running exits with "already running".
|
|
161
|
+
|
|
162
|
+
TCP peers (--listen) MUST authenticate via libpq application_name shaped
|
|
163
|
+
"?fingerprint=<12hex>&token=<bearer>". Issue tokens with
|
|
164
|
+
"pgserve daemon issue-token --fingerprint <hex>". Revoke with
|
|
165
|
+
"pgserve daemon revoke-token <id>".
|
|
166
|
+
`);
|
|
167
|
+
process.exit(0);
|
|
168
|
+
// falls through (unreachable)
|
|
169
|
+
default:
|
|
170
|
+
if (arg.startsWith('-')) {
|
|
171
|
+
console.error(`Unknown daemon option: ${arg}`);
|
|
172
|
+
process.exit(1);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return opts;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function runIssueTokenSubcommand(args) {
|
|
180
|
+
let fingerprint = null;
|
|
181
|
+
for (let i = 0; i < args.length; i++) {
|
|
182
|
+
const arg = args[i];
|
|
183
|
+
if (arg === '--fingerprint') fingerprint = args[++i];
|
|
184
|
+
else if (arg === '--help') {
|
|
185
|
+
console.log(`
|
|
186
|
+
pgserve daemon issue-token --fingerprint <12hex>
|
|
187
|
+
|
|
188
|
+
Issues a fresh bearer token for an existing fingerprint. Prints the token
|
|
189
|
+
to stdout exactly once; only the sha256 hash is persisted. Use the printed
|
|
190
|
+
value in libpq application_name shaped "?fingerprint=<hex>&token=<bearer>".
|
|
191
|
+
`);
|
|
192
|
+
process.exit(0);
|
|
193
|
+
} else {
|
|
194
|
+
console.error(`Unknown option: ${arg}`);
|
|
195
|
+
process.exit(1);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (!fingerprint || !/^[0-9a-f]{12}$/.test(fingerprint)) {
|
|
199
|
+
console.error('issue-token: --fingerprint <12hex> required');
|
|
200
|
+
process.exit(1);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
let admin;
|
|
204
|
+
try {
|
|
205
|
+
const dir = resolveControlSocketDir();
|
|
206
|
+
const disc = readAdminDiscovery(dir);
|
|
207
|
+
admin = await createAdminClient({ socketDir: disc.socketDir, port: disc.port });
|
|
208
|
+
} catch (err) {
|
|
209
|
+
console.error('issue-token: cannot reach running daemon admin socket:', err.message);
|
|
210
|
+
console.error('Hint: start the daemon first with `pgserve daemon`.');
|
|
211
|
+
process.exit(1);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
try {
|
|
215
|
+
await ensureMetaSchema(admin);
|
|
216
|
+
const { id, cleartext, hash } = mintToken();
|
|
217
|
+
const result = await addAllowedToken(admin, {
|
|
218
|
+
fingerprint,
|
|
219
|
+
tokenId: id,
|
|
220
|
+
tokenHash: hash,
|
|
221
|
+
});
|
|
222
|
+
audit(AUDIT_EVENTS.TCP_TOKEN_ISSUED, {
|
|
223
|
+
fingerprint,
|
|
224
|
+
token_id: id,
|
|
225
|
+
database: result.databaseName,
|
|
226
|
+
});
|
|
227
|
+
console.log('Token issued. Save the bearer value below — it will not be shown again:');
|
|
228
|
+
console.log('');
|
|
229
|
+
console.log(` id: ${id}`);
|
|
230
|
+
console.log(` fingerprint: ${fingerprint}`);
|
|
231
|
+
console.log(` database: ${result.databaseName}`);
|
|
232
|
+
console.log(` token: ${cleartext}`);
|
|
233
|
+
console.log('');
|
|
234
|
+
console.log('Use as libpq application_name:');
|
|
235
|
+
console.log(` application_name='?fingerprint=${fingerprint}&token=${cleartext}'`);
|
|
236
|
+
process.exit(0);
|
|
237
|
+
} catch (err) {
|
|
238
|
+
if (err.code === 'EUNKNOWNFINGERPRINT') {
|
|
239
|
+
console.error(`issue-token: fingerprint ${fingerprint} not provisioned yet.`);
|
|
240
|
+
console.error('Connect once via Unix socket so pgserve creates the database first.');
|
|
241
|
+
process.exit(2);
|
|
242
|
+
}
|
|
243
|
+
console.error('issue-token failed:', err.message);
|
|
244
|
+
process.exit(1);
|
|
245
|
+
} finally {
|
|
246
|
+
try { await admin.end(); } catch { /* swallow */ }
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function runRevokeTokenSubcommand(args) {
|
|
251
|
+
if (args.length === 0 || args[0] === '--help') {
|
|
252
|
+
console.log('Usage: pgserve daemon revoke-token <id>');
|
|
253
|
+
process.exit(args.length === 0 ? 1 : 0);
|
|
254
|
+
}
|
|
255
|
+
const tokenId = args[0];
|
|
256
|
+
|
|
257
|
+
let admin;
|
|
258
|
+
try {
|
|
259
|
+
const dir = resolveControlSocketDir();
|
|
260
|
+
const disc = readAdminDiscovery(dir);
|
|
261
|
+
admin = await createAdminClient({ socketDir: disc.socketDir, port: disc.port });
|
|
262
|
+
} catch (err) {
|
|
263
|
+
console.error('revoke-token: cannot reach running daemon admin socket:', err.message);
|
|
264
|
+
process.exit(1);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
try {
|
|
268
|
+
const affected = await revokeAllowedToken(admin, tokenId);
|
|
269
|
+
if (affected === 0) {
|
|
270
|
+
console.error(`revoke-token: no token with id ${tokenId} found`);
|
|
271
|
+
process.exit(2);
|
|
272
|
+
}
|
|
273
|
+
console.log(`Token ${tokenId} revoked (affected ${affected} row${affected === 1 ? '' : 's'})`);
|
|
274
|
+
process.exit(0);
|
|
275
|
+
} catch (err) {
|
|
276
|
+
console.error('revoke-token failed:', err.message);
|
|
277
|
+
process.exit(1);
|
|
278
|
+
} finally {
|
|
279
|
+
try { await admin.end(); } catch { /* swallow */ }
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
31
283
|
/**
|
|
32
284
|
* Print usage help
|
|
33
285
|
*/
|
package/eslint.config.js
CHANGED