@seekrit/cli 1.9.5 → 2.1.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/dist/index.js CHANGED
@@ -1,5 +1,4 @@
1
1
  #!/usr/bin/env node
2
- import { spawn, spawnSync } from "node:child_process";
3
2
  import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
4
3
  import { z } from "zod";
5
4
  import { Command } from "commander";
@@ -7,9 +6,10 @@ import { arch, homedir, hostname, platform, tmpdir, userInfo } from "node:os";
7
6
  import { basename, dirname, join, parse, resolve } from "node:path";
8
7
  import { createInterface } from "node:readline";
9
8
  import { Writable } from "node:stream";
10
- import { createHash } from "node:crypto";
9
+ import { createHash, randomBytes } from "node:crypto";
11
10
  import { connect } from "node:net";
12
11
  import { fileURLToPath } from "node:url";
12
+ import { spawn, spawnSync } from "node:child_process";
13
13
  import { request } from "node:http";
14
14
  /** Default lifetime of a published bundle (7 days), in seconds. */
15
15
  const POLICY_DEFAULT_TTL_SECONDS = 604800;
@@ -5591,11 +5591,13 @@ function encodeOpensshPrivateKey(seed, pub, comment) {
5591
5591
  //#region ../../packages/crypto/src/token.ts
5592
5592
  /**
5593
5593
  * Service tokens (CI, docker builds, agent proxies, k8s, …) are self-contained
5594
- * principals: the token string itself carries the private key, so the server
5595
- * never holds it. The server stores only the SHA-256 hash of the full token
5596
- * (for authentication) and the public key (for wrapping DEK grants).
5594
+ * principals. One local bundle holds independent authentication and decryption
5595
+ * material. Only serviceTokenAuth(token) may be sent to the API; the private
5596
+ * key stays local. The server stores the auth credential hash and public key.
5597
5597
  *
5598
- * Format: `skt_<token id>_<private key pkcs8, base64url>`
5598
+ * Bundle: `skt_<id>_v2.<32-byte auth secret>.<private key pkcs8, base64url>`
5599
+ * Wire: `skt_<id>_v2.<32-byte auth secret>`
5600
+ * Legacy bundles remain readable offline but must never be transmitted.
5599
5601
  */
5600
5602
  const TOKEN_PREFIX = "skt";
5601
5603
  const CLI_SESSION_PREFIX = "skc";
@@ -5625,11 +5627,11 @@ async function createServiceToken() {
5625
5627
  const { publicKeyJwk, privateKeyJwk } = await generateKeyPair();
5626
5628
  const pkcs8 = await exportPrivateKeyPkcs8(await importPrivateKey(privateKeyJwk));
5627
5629
  const tokenId = randomTokenId();
5628
- const token = `${tokenId}_${toBase64Url(pkcs8)}`;
5630
+ const token = `${tokenId}_v2.${toBase64Url(crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(32)))}.${toBase64Url(pkcs8)}`;
5629
5631
  return {
5630
5632
  token,
5631
5633
  tokenId,
5632
- tokenHash: await hashToken(token),
5634
+ tokenHash: await hashToken(serviceTokenAuth(token)),
5633
5635
  publicKeyJwk
5634
5636
  };
5635
5637
  }
@@ -5642,8 +5644,8 @@ async function createHoneyToken() {
5642
5644
  };
5643
5645
  }
5644
5646
  async function parseServiceToken(token) {
5645
- const match = /^(skt_[0-9A-Za-z]+)_([A-Za-z0-9_-]+)$/.exec(token);
5646
- if (!match) throw new SeekritCryptoError("MALFORMED_TOKEN", "not a valid seekrit service token");
5647
+ const match = /^(skt_[0-9A-Za-z]+)_(?:v2\.[A-Za-z0-9_-]{43}\.)?([A-Za-z0-9_-]+)$/.exec(token);
5648
+ if (!match || match[0] !== token) throw new SeekritCryptoError("MALFORMED_TOKEN", "not a valid seekrit service token");
5647
5649
  const [, tokenId, keyB64] = match;
5648
5650
  try {
5649
5651
  return {
@@ -5654,6 +5656,18 @@ async function parseServiceToken(token) {
5654
5656
  throw new SeekritCryptoError("MALFORMED_TOKEN", "service token private key is corrupted");
5655
5657
  }
5656
5658
  }
5659
+ /** Extract only the independent auth credential. Never fall back to a raw token. */
5660
+ function serviceTokenAuth(token) {
5661
+ const match = /^(skt_[0-9A-Za-z]+_v2\.[A-Za-z0-9_-]{43})(?:\.[A-Za-z0-9_-]+)?$/.exec(token);
5662
+ const auth = match?.[1];
5663
+ if (!match || match[0] !== token || !auth) throw new SeekritCryptoError("MALFORMED_TOKEN", "a v2 service token is required for API access; replace legacy tokens in the dashboard");
5664
+ return auth;
5665
+ }
5666
+ /** Preserve human/session credentials while stripping service-token private keys. */
5667
+ function apiBearerToken(token) {
5668
+ const value = token.trim();
5669
+ return isServiceToken(value) ? serviceTokenAuth(value) : token;
5670
+ }
5657
5671
  function isServiceToken(value) {
5658
5672
  return value.startsWith(`${TOKEN_PREFIX}_`);
5659
5673
  }
@@ -5686,7 +5700,7 @@ async function createAgentTaskToken() {
5686
5700
  }
5687
5701
  //#endregion
5688
5702
  //#region package.json
5689
- var version = "1.9.5";
5703
+ var version = "2.1.0";
5690
5704
  //#endregion
5691
5705
  //#region ../../packages/api-client/src/index.ts
5692
5706
  /**
@@ -5725,13 +5739,15 @@ var SeekritClient = class {
5725
5739
  ...extraHeaders
5726
5740
  };
5727
5741
  if (this.client) headers["x-seekrit-client"] = this.client;
5728
- if (this.auth.type === "bearer") headers.authorization = `Bearer ${this.auth.token}`;
5742
+ if (this.auth.type === "bearer") headers.authorization = `Bearer ${apiBearerToken(this.auth.token)}`;
5729
5743
  else if (this.auth.type === "dynamic") {
5730
5744
  const token = await this.auth.getToken();
5731
5745
  if (!token) throw new SeekritApiError(401, "unauthorized", "session expired");
5732
- headers.authorization = `Bearer ${token}`;
5733
- } else if (this.auth.type === "m2m") headers.authorization = `Basic ${btoa(`${this.auth.clientId}:${this.auth.clientSecret}`)}`;
5734
- else headers["x-seekrit-dev-user"] = this.auth.email;
5746
+ headers.authorization = `Bearer ${apiBearerToken(token)}`;
5747
+ } else if (this.auth.type === "m2m") {
5748
+ if (isServiceToken(this.auth.clientId.trim()) || isServiceToken(this.auth.clientSecret.trim())) throw new Error("service tokens must use bearer authentication, not machine client credentials");
5749
+ headers.authorization = `Basic ${btoa(`${this.auth.clientId}:${this.auth.clientSecret}`)}`;
5750
+ } else headers["x-seekrit-dev-user"] = this.auth.email;
5735
5751
  if (body !== void 0) headers["content-type"] = "application/json";
5736
5752
  const res = await this.fetchImpl(`${this.baseUrl}${path}`, {
5737
5753
  method,
@@ -6205,6 +6221,7 @@ var SeekritClient = class {
6205
6221
  * identity are three different answers.
6206
6222
  */
6207
6223
  introspectAgentTask(token) {
6224
+ if (!token.startsWith("skd_")) throw new Error("expected a dispatched task token");
6208
6225
  return this.request("POST", "/v1/tasks/introspect", { token });
6209
6226
  }
6210
6227
  /** End a run's authority now. Idempotent. */
@@ -9350,7 +9367,7 @@ const OFFLINE_DECRYPTOR_HTML = `<!doctype html>
9350
9367
 
9351
9368
  /** skt_<id>_<pkcs8 base64url>: a service token carries its own private key. */
9352
9369
  async function importToken(token) {
9353
- var match = /^(skt_[0-9A-Za-z]+)_([A-Za-z0-9_-]+)$/.exec(String(token).trim());
9370
+ var match = /^(skt_[0-9A-Za-z]+)_(?:v2\\.[A-Za-z0-9_-]{43}\\.)?([A-Za-z0-9_-]+)$/.exec(String(token).trim());
9354
9371
  if (!match) throw new Error("not a valid seekrit service token");
9355
9372
  var privateKey = await crypto.subtle.importKey(
9356
9373
  "pkcs8",
@@ -12130,8 +12147,15 @@ async function ensureM2mAdminToken(dotenvVars = {}) {
12130
12147
  if (!creds) return null;
12131
12148
  const cached = readCachedM2mToken(creds.clientId);
12132
12149
  if (cached) {
12133
- process.env.SEEKRIT_TOKEN = cached;
12134
- return cached;
12150
+ let usable = false;
12151
+ try {
12152
+ serviceTokenAuth(cached);
12153
+ usable = true;
12154
+ } catch {}
12155
+ if (usable) {
12156
+ process.env.SEEKRIT_TOKEN = cached;
12157
+ return cached;
12158
+ }
12135
12159
  }
12136
12160
  const token = await mintAdminToken(process.env.SEEKRIT_API_URL ?? dotenvVars.SEEKRIT_API_URL ?? readGlobalConfig().apiUrl ?? "https://api.seekrit.dev", creds);
12137
12161
  cacheM2mToken(creds.clientId, token);
@@ -12715,7 +12739,7 @@ function collect$5(value, acc) {
12715
12739
  * release-please-config.json), so the pin follows the crate without anyone
12716
12740
  * remembering to move it.
12717
12741
  */
12718
- const PROXY_VERSION = "0.12.7";
12742
+ const PROXY_VERSION = "0.13.0";
12719
12743
  const BIN = "seekrit-proxy";
12720
12744
  /**
12721
12745
  * Host → Rust target triple.
@@ -12751,7 +12775,7 @@ function versionPrefix(version) {
12751
12775
  return version.startsWith("v") ? version : `v${version}`;
12752
12776
  }
12753
12777
  function resolveVersion(explicit) {
12754
- return explicit ?? process.env.SEEKRIT_PROXY_VERSION ?? "0.12.7";
12778
+ return explicit ?? process.env.SEEKRIT_PROXY_VERSION ?? "0.13.0";
12755
12779
  }
12756
12780
  function resolveBaseUrl(explicit) {
12757
12781
  return (explicit ?? process.env.SEEKRIT_PROXY_BASE_URL ?? "https://proxy.seekrit.dev").replace(/\/+$/, "");
@@ -12909,7 +12933,7 @@ const PLUGIN_BIN = "seekrit-nomad-secrets";
12909
12933
  * entry in release-please-config.json), so the pin follows the crate without
12910
12934
  * anyone remembering to move it — the same arrangement `PROXY_VERSION` has.
12911
12935
  */
12912
- const NOMAD_PLUGIN_VERSION = "0.2.5";
12936
+ const NOMAD_PLUGIN_VERSION = "0.3.0";
12913
12937
  /** Public artifact host. Mirrors GitHub Releases; public, no egress fees. */
12914
12938
  const DEFAULT_BASE_URL = "https://nomad.seekrit.dev";
12915
12939
  /** Where the plugin reads its node config, matching `config::DEFAULT_CONFIG_PATH`. */
@@ -12996,7 +13020,7 @@ async function fetchBytes(url) {
12996
13020
  * let Nomad fingerprint a half-written executable.
12997
13021
  */
12998
13022
  async function installPlugin(pluginDir, options = {}) {
12999
- const version = options.version ?? "0.2.5";
13023
+ const version = options.version ?? "0.3.0";
13000
13024
  const baseUrl = (options.baseUrl ?? "https://nomad.seekrit.dev").replace(/\/+$/, "");
13001
13025
  const { target, exe } = detectTarget();
13002
13026
  if (exe !== "") fail("Nomad secret provider plugins are not supported on Windows clients");
@@ -14701,10 +14725,10 @@ Next:
14701
14725
  });
14702
14726
  console.log(path);
14703
14727
  const { target } = detectTarget();
14704
- process.stderr.write(`seekrit-proxy ${versionPrefix(options.proxyVersion ?? "0.12.7")} (${target})\n`);
14728
+ process.stderr.write(`seekrit-proxy ${versionPrefix(options.proxyVersion ?? "0.13.0")} (${target})\n`);
14705
14729
  });
14706
14730
  proxy.command("where").description("show which binary `seekrit proxy run` would use, without fetching it").option("--proxy-version <version>", `version to report (default: ${PROXY_VERSION})`).option("--json", "machine-readable output").action((options) => {
14707
- const version = options.proxyVersion ?? process.env.SEEKRIT_PROXY_VERSION ?? "0.12.7";
14731
+ const version = options.proxyVersion ?? process.env.SEEKRIT_PROXY_VERSION ?? "0.13.0";
14708
14732
  const override = process.env.SEEKRIT_PROXY_BIN;
14709
14733
  const { target, exe } = detectTarget();
14710
14734
  const path = override ?? proxyBinaryPath(version, target, exe);
@@ -14763,7 +14787,7 @@ Next:
14763
14787
  process.stdout.write(renderComposeSnippet(plan, {
14764
14788
  service: options.service,
14765
14789
  workload: options.workload,
14766
- image: options.image ?? `seekritdev/proxy:0.12.7`,
14790
+ image: options.image ?? `seekritdev/proxy:0.13.0`,
14767
14791
  publish: Boolean(options.publish)
14768
14792
  }));
14769
14793
  });
@@ -15078,6 +15102,303 @@ function registerRotationCommands(program) {
15078
15102
  });
15079
15103
  }
15080
15104
  //#endregion
15105
+ //#region src/run-command.ts
15106
+ /** Keep signal handlers installed from provisioning through asynchronous cleanup. */
15107
+ async function runCommand(cmd, args, prepare, cleanup) {
15108
+ let child;
15109
+ let forwarded;
15110
+ let result = { code: 1 };
15111
+ const stragglers = /* @__PURE__ */ new Set();
15112
+ const collect = () => {
15113
+ if (process.platform === "win32" || !child?.pid) return;
15114
+ for (const pid of teardownTargets(child.pid)) stragglers.add(pid);
15115
+ };
15116
+ const forward = (signal) => {
15117
+ forwarded ??= signal;
15118
+ if (!child || child.exitCode !== null || child.signalCode !== null) return;
15119
+ collect();
15120
+ child.kill(signal);
15121
+ for (const pid of stragglers) try {
15122
+ process.kill(pid, signal);
15123
+ } catch {}
15124
+ };
15125
+ const signals = [
15126
+ "SIGINT",
15127
+ "SIGTERM",
15128
+ "SIGHUP",
15129
+ "SIGQUIT"
15130
+ ];
15131
+ for (const signal of signals) process.on(signal, forward);
15132
+ try {
15133
+ const env = await prepare(() => forwarded !== void 0);
15134
+ if (!forwarded) {
15135
+ const running = spawn(cmd, args, {
15136
+ stdio: "inherit",
15137
+ env
15138
+ });
15139
+ child = running;
15140
+ result = await new Promise((resolve, reject) => {
15141
+ running.once("exit", (code, signal) => resolve({
15142
+ code: code ?? 1,
15143
+ signal: signal ?? void 0
15144
+ }));
15145
+ running.once("error", (err) => reject(/* @__PURE__ */ new Error(`failed to start ${cmd}: ${err.message}`)));
15146
+ });
15147
+ }
15148
+ } finally {
15149
+ try {
15150
+ if (forwarded) {
15151
+ collect();
15152
+ await reapStragglers(stragglers, forwarded);
15153
+ }
15154
+ } finally {
15155
+ try {
15156
+ if (!await cleanup() && result.code === 0) result.code = 1;
15157
+ } finally {
15158
+ for (const signal of signals) process.off(signal, forward);
15159
+ }
15160
+ }
15161
+ }
15162
+ if (!child && forwarded) result.signal = forwarded;
15163
+ return result;
15164
+ }
15165
+ /**
15166
+ * Every live process `seekrit run` is responsible for tearing down, from a
15167
+ * single `ps(1)` snapshot: each descendant of `root`, plus — when we lead our
15168
+ * own process group — every other member of that group.
15169
+ *
15170
+ * Two mechanisms, because each covers the other's blind spot:
15171
+ *
15172
+ * - The parent-chain walk reaches a process that left our group via
15173
+ * setsid(2)/setpgid(2), which no group-wide kill and no tty-generated signal
15174
+ * can touch. But it loses anything whose chain up to `root` has already
15175
+ * broken — teardown kills the middle layers first, and an orphan reparents to
15176
+ * init, out of reach of any walk down from `root`.
15177
+ * - Group membership survives exactly that: a pgid outlives the parent that
15178
+ * passed it down. But it misses the ones that left the group.
15179
+ *
15180
+ * The group half applies only when our pid *is* our pgid, which is the shell-job
15181
+ * case: the shell put this command in a new group, so every other member of it
15182
+ * descends from us. Nested inside a shell script or a CI runner we are not the
15183
+ * leader and the group holds processes we did not start, so there we use the
15184
+ * walk alone.
15185
+ *
15186
+ * Zombies are skipped: they are already dead and waiting to be reaped, so
15187
+ * counting them would stall teardown for the full grace period and then report
15188
+ * a kill that did nothing. Returns nothing if `ps` is unavailable; callers fall
15189
+ * back to signalling the immediate child.
15190
+ */
15191
+ function teardownTargets(root) {
15192
+ const ps = spawnSync("ps", [
15193
+ "-A",
15194
+ "-o",
15195
+ "pid=,ppid=,pgid=,stat="
15196
+ ], { encoding: "utf8" });
15197
+ if (ps.status !== 0 || typeof ps.stdout !== "string") return [];
15198
+ const childrenOf = /* @__PURE__ */ new Map();
15199
+ const groupOf = /* @__PURE__ */ new Map();
15200
+ const isId = (value) => value !== void 0 && Number.isInteger(value);
15201
+ for (const line of ps.stdout.split("\n")) {
15202
+ const [pid, ppid, pgid, stat] = line.trim().split(/\s+/, 4);
15203
+ const [id, parent, group] = [
15204
+ pid,
15205
+ ppid,
15206
+ pgid
15207
+ ].map(Number);
15208
+ if (!isId(id) || !isId(parent) || !isId(group)) continue;
15209
+ if (stat?.startsWith("Z")) continue;
15210
+ const siblings = childrenOf.get(parent);
15211
+ if (siblings) siblings.push(id);
15212
+ else childrenOf.set(parent, [id]);
15213
+ groupOf.set(id, group);
15214
+ }
15215
+ const found = /* @__PURE__ */ new Set();
15216
+ const queue = [root];
15217
+ const seen = new Set(queue);
15218
+ for (let pid = queue.shift(); pid !== void 0; pid = queue.shift()) for (const kid of childrenOf.get(pid) ?? []) {
15219
+ if (seen.has(kid)) continue;
15220
+ seen.add(kid);
15221
+ found.add(kid);
15222
+ queue.push(kid);
15223
+ }
15224
+ if (groupOf.get(process.pid) === process.pid) {
15225
+ for (const [pid, group] of groupOf) if (group === process.pid) found.add(pid);
15226
+ }
15227
+ found.delete(process.pid);
15228
+ found.delete(root);
15229
+ return [...found];
15230
+ }
15231
+ /** Whether `pid` still exists — signal 0 tests reachability, delivers nothing. */
15232
+ function isAlive(pid) {
15233
+ try {
15234
+ process.kill(pid, 0);
15235
+ return true;
15236
+ } catch {
15237
+ return false;
15238
+ }
15239
+ }
15240
+ /** How long a straggler gets to finish shutting down before SIGKILL. */
15241
+ const STRAGGLER_GRACE_MS = 2e3;
15242
+ /**
15243
+ * Once the command has exited, make sure nothing it started outlives us.
15244
+ *
15245
+ * Anything still alive here has ignored the signal we forwarded *and* lost the
15246
+ * parent that launched it, and we are the last process that knows its pid:
15247
+ * the tty won't hang it up (it is no longer in the foreground group, and the
15248
+ * shell has moved on), so it survives until the machine reboots. That is how
15249
+ * `tsx` and the server under it stacked up for days.
15250
+ *
15251
+ * So escalate rather than trust. SIGTERM first — it costs no extra time and is
15252
+ * a rung the SIGINT-only handlers common in dev servers don't catch — then
15253
+ * SIGKILL whatever is left after the grace period. Nothing is killed unless it
15254
+ * was asked to stop first and declined, and this runs only when we forwarded a
15255
+ * termination signal: a command that exits on its own having deliberately left a
15256
+ * daemon behind keeps working.
15257
+ */
15258
+ async function reapStragglers(pids, signal) {
15259
+ let live = [...pids].filter(isAlive);
15260
+ if (live.length === 0) return;
15261
+ for (const pid of live) try {
15262
+ process.kill(pid, "SIGTERM");
15263
+ } catch {}
15264
+ const deadline = Date.now() + STRAGGLER_GRACE_MS;
15265
+ while (live.length > 0 && Date.now() < deadline) {
15266
+ await new Promise((resolve) => setTimeout(resolve, 50));
15267
+ live = live.filter(isAlive);
15268
+ }
15269
+ if (live.length === 0) return;
15270
+ console.error(`seekrit: force-killed ${live.length} leftover process(es) that ignored ${signal} and SIGTERM (${live.join(", ")})`);
15271
+ for (const pid of live) try {
15272
+ process.kill(pid, "SIGKILL");
15273
+ } catch {}
15274
+ }
15275
+ //#endregion
15276
+ //#region src/run-leases.ts
15277
+ /** Validate all flags before contacting the API or creating any database roles. */
15278
+ function parseRunLeaseOptions(bindings = [], ttl) {
15279
+ if (!bindings.length) {
15280
+ if (ttl !== void 0) throw new Error("--lease-ttl requires --lease VAR=target");
15281
+ return;
15282
+ }
15283
+ const names = /* @__PURE__ */ new Set();
15284
+ const parsed = bindings.map((binding) => {
15285
+ const eq = binding.indexOf("=");
15286
+ const variable = binding.slice(0, eq);
15287
+ const target = binding.slice(eq + 1).trim();
15288
+ if (eq < 1 || !secretNameSchema.safeParse(variable).success || !target) throw new Error("--lease requires VAR=target (for example DATABASE_URL=production)");
15289
+ const key = process.platform === "win32" ? variable.toUpperCase() : variable;
15290
+ if (names.has(key)) throw new Error(`duplicate --lease variable ${variable}`);
15291
+ names.add(key);
15292
+ return {
15293
+ variable,
15294
+ target
15295
+ };
15296
+ });
15297
+ const match = /^(\d+)\s*([smhd]?)$/.exec((ttl ?? "30m").trim());
15298
+ const ttlSeconds = match ? Number(match[1]) * ({
15299
+ "": 1,
15300
+ s: 1,
15301
+ m: 60,
15302
+ h: 3600,
15303
+ d: 86400
15304
+ }[match[2] ?? ""] ?? 1) : NaN;
15305
+ if (!Number.isSafeInteger(ttlSeconds) || ttlSeconds < 60 || ttlSeconds > 604800) throw new Error("--lease-ttl must be between 1m and 7d (for example 30m)");
15306
+ return {
15307
+ bindings: parsed,
15308
+ ttlSeconds
15309
+ };
15310
+ }
15311
+ async function prepareRunLeases(options, orgSlug, envFiles = [".env"]) {
15312
+ const dotenvVars = {};
15313
+ overlayEnvFiles(dotenvVars, {}, envFiles);
15314
+ const ctx = tryBuildContext(dotenvVars);
15315
+ if (!ctx) throw new Error("--lease requires authentication; run `seekrit login`");
15316
+ const client = new SeekritClient({
15317
+ baseUrl: ctx.apiUrl,
15318
+ auth: ctx.auth,
15319
+ fetch: (input, init) => fetch(input, {
15320
+ ...init,
15321
+ signal: AbortSignal.timeout(15e3)
15322
+ })
15323
+ });
15324
+ return new RunLeases(client, (await resolveOrg({
15325
+ ...ctx,
15326
+ client
15327
+ }, orgSlug)).id, options);
15328
+ }
15329
+ /** Own every successfully minted lease, including partial setup failures. */
15330
+ var RunLeases = class {
15331
+ client;
15332
+ orgId;
15333
+ options;
15334
+ active = [];
15335
+ constructor(client, orgId, options) {
15336
+ this.client = client;
15337
+ this.orgId = orgId;
15338
+ this.options = options;
15339
+ }
15340
+ async mint(interrupted) {
15341
+ const values = Object.create(null);
15342
+ if (interrupted()) return values;
15343
+ const { targets } = await this.client.listLeaseTargets(this.orgId);
15344
+ const selected = this.options.bindings.map((binding) => {
15345
+ const matches = targets.filter((t) => t.id === binding.target || t.name === binding.target);
15346
+ const target = matches[0];
15347
+ if (matches.length !== 1 || !target) throw new Error(`--lease target "${binding.target}" is missing or ambiguous; use its ID`);
15348
+ if (target.provider !== "postgres") throw new Error(`--lease target "${target.name}" must be Postgres`);
15349
+ return {
15350
+ binding,
15351
+ target
15352
+ };
15353
+ });
15354
+ for (const { binding, target } of selected) {
15355
+ if (interrupted()) break;
15356
+ const username = `tmp_${randomBytes(12).toString("hex")}`;
15357
+ const { password, verifier } = await generatePostgresCredential();
15358
+ if (interrupted()) break;
15359
+ let minted;
15360
+ try {
15361
+ minted = await this.client.mintLease(this.orgId, {
15362
+ provider: "postgres",
15363
+ targetId: target.id,
15364
+ roleName: username,
15365
+ verifier,
15366
+ ttlSeconds: this.options.ttlSeconds
15367
+ });
15368
+ } catch {
15369
+ throw new Error(`could not create temporary user ${username} on target ${target.name}; command not started. If provisioning completed, broker expiry will attempt cleanup after ${this.options.ttlSeconds}s; check the lease ledger`);
15370
+ }
15371
+ const { lease, connection } = minted;
15372
+ this.active.push({
15373
+ id: lease.id,
15374
+ username,
15375
+ expiresAt: connection.expiresAt
15376
+ });
15377
+ console.error(`seekrit: created temporary user ${username} on ${target.name} · lease ${lease.id} · expires ${connection.expiresAt}`);
15378
+ const url = new URL("postgresql://localhost");
15379
+ url.hostname = connection.host.includes(":") && !connection.host.startsWith("[") ? `[${connection.host}]` : connection.host;
15380
+ url.port = String(connection.port);
15381
+ url.username = connection.username;
15382
+ url.password = password;
15383
+ url.pathname = `/${encodeURIComponent(connection.database)}`;
15384
+ values[binding.variable] = url.href;
15385
+ console.error(`seekrit: injected ${binding.variable}`);
15386
+ }
15387
+ return values;
15388
+ }
15389
+ async revoke() {
15390
+ let ok = true;
15391
+ for (const lease of this.active.splice(0).reverse()) try {
15392
+ await this.client.revokeLease(this.orgId, lease.id);
15393
+ console.error(`seekrit: revoked temporary user ${lease.username} · lease ${lease.id}`);
15394
+ } catch {
15395
+ ok = false;
15396
+ console.error(`seekrit: FAILED to revoke temporary user ${lease.username} · lease ${lease.id}. Run seekrit pg revoke ${lease.id} --org ${this.orgId}; broker expiry will retry at/after ${lease.expiresAt}`);
15397
+ }
15398
+ return ok;
15399
+ }
15400
+ };
15401
+ //#endregion
15081
15402
  //#region src/ssh.ts
15082
15403
  /**
15083
15404
  * `seekrit ssh` — temporary SSH access via short-lived certificates (Vault-style
@@ -16444,167 +16765,37 @@ async function materializeForRun(options) {
16444
16765
  };
16445
16766
  }
16446
16767
  }
16447
- /**
16448
- * Every live process `seekrit run` is responsible for tearing down, from a
16449
- * single `ps(1)` snapshot: each descendant of `root`, plus — when we lead our
16450
- * own process group — every other member of that group.
16451
- *
16452
- * Two mechanisms, because each covers the other's blind spot:
16453
- *
16454
- * - The parent-chain walk reaches a process that left our group via
16455
- * setsid(2)/setpgid(2), which no group-wide kill and no tty-generated signal
16456
- * can touch. But it loses anything whose chain up to `root` has already
16457
- * broken — teardown kills the middle layers first, and an orphan reparents to
16458
- * init, out of reach of any walk down from `root`.
16459
- * - Group membership survives exactly that: a pgid outlives the parent that
16460
- * passed it down. But it misses the ones that left the group.
16461
- *
16462
- * The group half applies only when our pid *is* our pgid, which is the shell-job
16463
- * case: the shell put this command in a new group, so every other member of it
16464
- * descends from us. Nested inside a shell script or a CI runner we are not the
16465
- * leader and the group holds processes we did not start, so there we use the
16466
- * walk alone.
16467
- *
16468
- * Zombies are skipped: they are already dead and waiting to be reaped, so
16469
- * counting them would stall teardown for the full grace period and then report
16470
- * a kill that did nothing. Returns nothing if `ps` is unavailable; callers fall
16471
- * back to signalling the immediate child.
16472
- */
16473
- function teardownTargets(root) {
16474
- const ps = spawnSync("ps", [
16475
- "-A",
16476
- "-o",
16477
- "pid=,ppid=,pgid=,stat="
16478
- ], { encoding: "utf8" });
16479
- if (ps.status !== 0 || typeof ps.stdout !== "string") return [];
16480
- const childrenOf = /* @__PURE__ */ new Map();
16481
- const groupOf = /* @__PURE__ */ new Map();
16482
- const isId = (value) => value !== void 0 && Number.isInteger(value);
16483
- for (const line of ps.stdout.split("\n")) {
16484
- const [pid, ppid, pgid, stat] = line.trim().split(/\s+/, 4);
16485
- const [id, parent, group] = [
16486
- pid,
16487
- ppid,
16488
- pgid
16489
- ].map(Number);
16490
- if (!isId(id) || !isId(parent) || !isId(group)) continue;
16491
- if (stat?.startsWith("Z")) continue;
16492
- const siblings = childrenOf.get(parent);
16493
- if (siblings) siblings.push(id);
16494
- else childrenOf.set(parent, [id]);
16495
- groupOf.set(id, group);
16496
- }
16497
- const found = /* @__PURE__ */ new Set();
16498
- const queue = [root];
16499
- const seen = new Set(queue);
16500
- for (let pid = queue.shift(); pid !== void 0; pid = queue.shift()) for (const kid of childrenOf.get(pid) ?? []) {
16501
- if (seen.has(kid)) continue;
16502
- seen.add(kid);
16503
- found.add(kid);
16504
- queue.push(kid);
16505
- }
16506
- if (groupOf.get(process.pid) === process.pid) {
16507
- for (const [pid, group] of groupOf) if (group === process.pid) found.add(pid);
16508
- }
16509
- found.delete(process.pid);
16510
- found.delete(root);
16511
- return [...found];
16512
- }
16513
- /** Whether `pid` still exists — signal 0 tests reachability, delivers nothing. */
16514
- function isAlive(pid) {
16515
- try {
16516
- process.kill(pid, 0);
16517
- return true;
16518
- } catch {
16519
- return false;
16520
- }
16521
- }
16522
- /** How long a straggler gets to finish shutting down before SIGKILL. */
16523
- const STRAGGLER_GRACE_MS = 2e3;
16524
- /**
16525
- * Once the command has exited, make sure nothing it started outlives us.
16526
- *
16527
- * Anything still alive here has ignored the signal we forwarded *and* lost the
16528
- * parent that launched it, and we are the last process that knows its pid:
16529
- * the tty won't hang it up (it is no longer in the foreground group, and the
16530
- * shell has moved on), so it survives until the machine reboots. That is how
16531
- * `tsx` and the server under it stacked up for days.
16532
- *
16533
- * So escalate rather than trust. SIGTERM first — it costs no extra time and is
16534
- * a rung the SIGINT-only handlers common in dev servers don't catch — then
16535
- * SIGKILL whatever is left after the grace period. Nothing is killed unless it
16536
- * was asked to stop first and declined, and this runs only when we forwarded a
16537
- * termination signal: a command that exits on its own having deliberately left a
16538
- * daemon behind keeps working.
16539
- */
16540
- async function reapStragglers(pids, signal) {
16541
- let live = [...pids].filter(isAlive);
16542
- if (live.length === 0) return;
16543
- for (const pid of live) try {
16544
- process.kill(pid, "SIGTERM");
16545
- } catch {}
16546
- const deadline = Date.now() + STRAGGLER_GRACE_MS;
16547
- while (live.length > 0 && Date.now() < deadline) {
16548
- await new Promise((resolve) => setTimeout(resolve, 50));
16549
- live = live.filter(isAlive);
16550
- }
16551
- if (live.length === 0) return;
16552
- console.error(`seekrit: force-killed ${live.length} leftover process(es) that ignored ${signal} and SIGTERM (${live.join(", ")})`);
16553
- for (const pid of live) try {
16554
- process.kill(pid, "SIGKILL");
16555
- } catch {}
16556
- }
16557
- program.command("run").description("run a command with decrypted secrets injected (process env > .env > app > group)").passThroughOptions().option("--org <slug>", "organization slug (defaults to seekrit.json, or your only org)").option("--app <slug>", "application slug (token path infers this)").option("--env <slug>", "environment slug (token path infers this)").option("--branch <slug>", "read a branch of that environment (or $SEEKRIT_BRANCH)").option("--with <group=env>", "override one group’s slice for this run", collectKv).option("--env-file <path>", "a .env file to overlay (repeatable; default .env)", collectList).option("--explain", "print where each variable resolved from (to stderr)").option("--no-interpolate", "leave ${OTHER_SECRET} references as literal text").option("--cache", "keep a last-known-good copy of the encrypted response and fall back to it when the API is unreachable (off by default; SEEKRIT_CACHE=1)").option("--cache-dir <path>", "where to keep it (default: $XDG_CACHE_HOME/seekrit)").option("--cache-max-age <duration>", "how stale a cached copy may be and still be used, e.g. 15m, 24h, 7d (default: 24h)").argument("<command...>", "command to run (prefix with -- to pass flags)").action(async (commandParts, options) => {
16768
+ program.command("run").description("run a command with decrypted secrets injected (process env > .env > app > group)").passThroughOptions().option("--org <slug>", "organization slug (defaults to seekrit.json, or your only org)").option("--app <slug>", "application slug (token path infers this)").option("--env <slug>", "environment slug (token path infers this)").option("--branch <slug>", "read a branch of that environment (or $SEEKRIT_BRANCH)").option("--with <group=env>", "override one group’s slice for this run", collectKv).option("--lease <VAR=target>", "inject a temporary Postgres connection URL (repeatable)", collectList).option("--lease-ttl <duration>", "maximum lease lifetime, 1m–7d (default: 30m)").option("--env-file <path>", "a .env file to overlay (repeatable; default .env)", collectList).option("--explain", "print where each variable resolved from (to stderr)").option("--no-interpolate", "leave ${OTHER_SECRET} references as literal text").option("--cache", "keep a last-known-good copy of the encrypted response and fall back to it when the API is unreachable (off by default; SEEKRIT_CACHE=1)").option("--cache-dir <path>", "where to keep it (default: $XDG_CACHE_HOME/seekrit)").option("--cache-max-age <duration>", "how stale a cached copy may be and still be used, e.g. 15m, 24h, 7d (default: 24h)").argument("<command...>", "command to run (prefix with -- to pass flags)").action(async (commandParts, options) => {
16558
16769
  const [cmd, ...args] = commandParts;
16559
16770
  if (!cmd) fail("no command given");
16771
+ const leaseOptions = parseRunLeaseOptions(options.lease, options.leaseTtl);
16560
16772
  const { values, provenance, interpolated, unresolvedRefs } = await materializeForRun(options);
16561
- if (options.explain) {
16562
- for (const name of Object.keys(values)) if (process.env[name] !== void 0 && process.env[name] !== values[name]) provenance[name] = "env";
16563
- printExplain(provenance, {
16564
- interpolated,
16565
- unresolved: unresolvedRefs
16566
- });
16567
- }
16568
- const posix = process.platform !== "win32";
16569
- const child = spawn(cmd, args, {
16570
- stdio: "inherit",
16571
- env: {
16773
+ const leases = leaseOptions ? await prepareRunLeases(leaseOptions, options.org, options.envFile) : void 0;
16774
+ const result = await runCommand(cmd, args, async (interrupted) => {
16775
+ const leased = await leases?.mint(interrupted) ?? {};
16776
+ if (options.explain) {
16777
+ for (const name of Object.keys(values)) if (process.env[name] !== void 0 && process.env[name] !== values[name]) provenance[name] = "env";
16778
+ for (const binding of leaseOptions?.bindings ?? []) provenance[binding.variable] = `lease:${binding.target}`;
16779
+ printExplain(provenance, {
16780
+ interpolated,
16781
+ unresolved: unresolvedRefs
16782
+ });
16783
+ }
16784
+ const env = {
16572
16785
  ...values,
16573
16786
  ...process.env
16787
+ };
16788
+ if (process.platform === "win32") {
16789
+ const leasedNames = new Set(Object.keys(leased).map((name) => name.toUpperCase()));
16790
+ for (const name of Object.keys(env)) if (leasedNames.has(name.toUpperCase())) delete env[name];
16574
16791
  }
16575
- });
16576
- const signals = [
16577
- "SIGINT",
16578
- "SIGTERM",
16579
- "SIGHUP",
16580
- "SIGQUIT"
16581
- ];
16582
- const stragglers = /* @__PURE__ */ new Set();
16583
- let forwarded;
16584
- const collect = () => {
16585
- if (!posix || !child.pid) return;
16586
- for (const pid of teardownTargets(child.pid)) stragglers.add(pid);
16587
- };
16588
- const forward = (signal) => {
16589
- if (child.exitCode !== null || child.signalCode !== null) return;
16590
- forwarded ??= signal;
16591
- collect();
16592
- child.kill(signal);
16593
- for (const pid of stragglers) try {
16594
- process.kill(pid, signal);
16595
- } catch {}
16596
- };
16597
- for (const signal of signals) process.on(signal, forward);
16598
- child.on("exit", async (code, signal) => {
16599
- if (forwarded) {
16600
- collect();
16601
- await reapStragglers(stragglers, forwarded);
16602
- }
16603
- for (const s of signals) process.off(s, forward);
16604
- if (signal) process.kill(process.pid, signal);
16605
- else process.exit(code ?? 1);
16606
- });
16607
- child.on("error", (err) => fail(`failed to start ${cmd}: ${err.message}`));
16792
+ return {
16793
+ ...env,
16794
+ ...leased
16795
+ };
16796
+ }, () => leases?.revoke() ?? Promise.resolve(true));
16797
+ if (result.signal) process.kill(process.pid, result.signal);
16798
+ else process.exit(result.code);
16608
16799
  });
16609
16800
  program.command("export").description("print decrypted secrets (dotenv, json, or shell)").option("--org <slug>", "organization slug (defaults to seekrit.json, or your only org)").option("--app <slug>", "application slug (token path infers this)").option("--env <slug>", "environment slug (token path infers this)").option("--branch <slug>", "read a branch of that environment (or $SEEKRIT_BRANCH)").option("--with <group=env>", "override one group’s slice", collectKv).option("--env-file <path>", "a .env file to overlay (repeatable; default .env)", collectList).option("--explain", "print where each variable resolved from (to stderr)").option("--no-interpolate", "leave ${OTHER_SECRET} references as literal text").option("--cache", "keep a last-known-good copy of the encrypted response and fall back to it when the API is unreachable (off by default; SEEKRIT_CACHE=1)").option("--cache-dir <path>", "where to keep it (default: $XDG_CACHE_HOME/seekrit)").option("--cache-max-age <duration>", "how stale a cached copy may be and still be used, e.g. 15m, 24h, 7d (default: 24h)").option("--format <format>", "dotenv | json | shell", "dotenv").action(async (options) => {
16610
16801
  if (![
@@ -16622,6 +16813,11 @@ program.command("export").description("print decrypted secrets (dotenv, json, or
16622
16813
  registerAccessCommands(program);
16623
16814
  registerHoneyTokenCommands(program);
16624
16815
  const token = program.command("token").description("manage service tokens (CI, docker, agents)");
16816
+ token.command("auth").description("print only the v2 authentication component for a manual API request").action(() => {
16817
+ const ctx = buildContext();
16818
+ if (ctx.auth.type !== "bearer" || !isServiceToken(ctx.auth.token)) fail("token auth requires a v2 service token in SEEKRIT_TOKEN or your saved configuration");
16819
+ console.log(serviceTokenAuth(ctx.auth.token));
16820
+ });
16625
16821
  token.command("create").description("create a service token (runtime, or --admin for provisioning); prints it once").requiredOption("--name <name>", "display name, e.g. ci-deploy").option("--org <slug>", "organization slug (defaults to seekrit.json, or your only org)").option("--app <slug>", "application to bind the token to (runtime tokens)").option("--env <slug>", "environment to bind the token to (runtime tokens)").option("--admin", "mint an org-scoped admin token that can provision structure (no env binding required)").option("--allow <group=env>", "also grant an alternate group slice (for `run --with`)", collectKv).option("--no-grant", "skip auto-granting the env + composed group keys").action(async (options) => {
16626
16822
  const ctx = buildContext();
16627
16823
  const role = options.admin ? "admin" : "member";
@@ -16719,7 +16915,7 @@ registerKmsCommands(program);
16719
16915
  registerRotationCommands(program);
16720
16916
  registerRecoveryCommands(program);
16721
16917
  program.command("mcp").description("run an MCP server over stdio so AI agents can drive seekrit").action(async () => {
16722
- const { runMcpServer } = await import("./mcp-D7rsPeBQ.js");
16918
+ const { runMcpServer } = await import("./mcp-DZoBFmDF.js");
16723
16919
  await runMcpServer();
16724
16920
  });
16725
16921
  registerAuditCommands(program);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seekrit/cli",
3
- "version": "1.9.5",
3
+ "version": "2.1.0",
4
4
  "description": "End-to-end encrypted secrets manager CLI — inject decrypted secrets into any command.",
5
5
  "homepage": "https://seekrit.dev/docs/guides/cli",
6
6
  "keywords": [
@@ -1,6 +1,6 @@
1
1
  import { A as signatureKeyRef, B as generateDek, C as version, D as importSigningKey, E as generateSigningKeyMaterial, F as generateEncryptKeyMaterial, H as parseBranchTtl, I as kmsBlobKeyRef, L as kmsDecrypt, M as generatePostgresCredential, N as generateMysqlCredential, O as importVerifyingKey, P as generateDataKey, R as kmsEncrypt, S as writeProjectConfig, T as parseServiceToken, V as toBase64, _ as getDek, a as kmsResolveRecipient, b as tryBuildContext, c as fetchDecryptedSecrets, d as resolveApp, f as resolveAppEnv, g as resolveOrg, h as resolveGroup, i as kmsResolveKey, j as verifyMessage, k as signMessage, l as fetchDecryptedVersion, m as resolveEnvTarget, n as kmsCallerIdentity, o as syncGrantsForGroupLink, p as resolveBranch, r as kmsRecoverMaterial, s as encryptAndSetSecret, t as ensureM2mAdminToken, u as materializeEnv, v as isTokenAuth, w as createServiceToken, x as setFailThrows, y as principalPublicKeyJwk, z as wrapDek } from "./index.js";
2
- import { spawn } from "node:child_process";
3
2
  import { z } from "zod";
3
+ import { spawn } from "node:child_process";
4
4
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
5
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
6
  //#region src/mcp.ts