@seekrit/cli 2.0.0 → 2.2.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 +487 -190
- package/dist/{mcp-D7rsPeBQ.js → mcp-Dgj-7S9y.js} +17 -26
- package/package.json +3 -3
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;
|
|
@@ -2086,6 +2086,8 @@ const AUDIT_ACTIONS = [
|
|
|
2086
2086
|
"org.created",
|
|
2087
2087
|
"org.updated",
|
|
2088
2088
|
"org.member_added",
|
|
2089
|
+
"org.member_role_changed",
|
|
2090
|
+
"app.access_granted",
|
|
2089
2091
|
"org.member_removed",
|
|
2090
2092
|
"org.member_invited",
|
|
2091
2093
|
"org.invite_revoked",
|
|
@@ -2308,7 +2310,7 @@ const nameSchema = z.string().trim().min(1).max(128);
|
|
|
2308
2310
|
const emailSchema = z.string().trim().toLowerCase().pipe(z.email().max(320));
|
|
2309
2311
|
/** Env-var style secret name: FOO, DATABASE_URL, apiKey2 … */
|
|
2310
2312
|
const secretNameSchema = z.string().min(1).max(256).regex(/^[A-Za-z_][A-Za-z0-9_]*$/, "must be a valid environment variable name");
|
|
2311
|
-
z.enum([
|
|
2313
|
+
const orgRoleSchema = z.enum([
|
|
2312
2314
|
"owner",
|
|
2313
2315
|
"admin",
|
|
2314
2316
|
"member"
|
|
@@ -2347,7 +2349,20 @@ z.object({
|
|
|
2347
2349
|
name: nameSchema,
|
|
2348
2350
|
slug: slugSchema
|
|
2349
2351
|
});
|
|
2352
|
+
/** Compose a group into an application environment (matched by env slug). */
|
|
2353
|
+
const appAccessKeySchema = z.object({
|
|
2354
|
+
environmentId: z.string().min(1),
|
|
2355
|
+
userId: z.string().min(1),
|
|
2356
|
+
wrappedDek: z.string().min(1).max(8192)
|
|
2357
|
+
});
|
|
2358
|
+
const appAccessGrantsSchema = z.array(appAccessKeySchema).max(5e3).optional();
|
|
2359
|
+
z.object({
|
|
2360
|
+
userId: z.string().min(1),
|
|
2361
|
+
grants: z.array(appAccessKeySchema).max(5e3)
|
|
2362
|
+
});
|
|
2363
|
+
z.object({ role: orgRoleSchema });
|
|
2350
2364
|
z.object({
|
|
2365
|
+
appAccessGrants: appAccessGrantsSchema,
|
|
2351
2366
|
groupId: z.string().min(1),
|
|
2352
2367
|
/** Precedence among an env's groups (higher wins). Appended if omitted. */
|
|
2353
2368
|
position: z.number().int().min(0).optional(),
|
|
@@ -2358,6 +2373,11 @@ z.object({
|
|
|
2358
2373
|
})).optional()
|
|
2359
2374
|
});
|
|
2360
2375
|
z.object({
|
|
2376
|
+
/** Wrap the new DEK to all persistent app recipients. */
|
|
2377
|
+
userGrants: z.array(z.object({
|
|
2378
|
+
userId: z.string().min(1),
|
|
2379
|
+
wrappedDek: z.string().min(1).max(8192)
|
|
2380
|
+
})).max(5e3).optional(),
|
|
2361
2381
|
name: nameSchema,
|
|
2362
2382
|
slug: slugSchema,
|
|
2363
2383
|
/** Environment DEK wrapped to the creator's public key — created client-side. */
|
|
@@ -2419,7 +2439,23 @@ z.object({
|
|
|
2419
2439
|
/** The same DEK wrapped to each of the parent's existing grant-holders. */
|
|
2420
2440
|
grants: z.array(grantEnvironmentKeySchema).max(500).default([])
|
|
2421
2441
|
});
|
|
2442
|
+
/**
|
|
2443
|
+
* Token creation is client-driven so the server never sees the token secret:
|
|
2444
|
+
* the client generates the keypair + token string and submits only the hash
|
|
2445
|
+
* and public key.
|
|
2446
|
+
*/
|
|
2447
|
+
const environmentGrantBatchSchema = z.array(z.object({
|
|
2448
|
+
environmentId: z.string().min(1),
|
|
2449
|
+
wrappedDek: z.string().min(1).max(8192)
|
|
2450
|
+
})).max(5e3);
|
|
2451
|
+
z.object({
|
|
2452
|
+
principalType: principalTypeSchema,
|
|
2453
|
+
principalId: z.string().min(1),
|
|
2454
|
+
grants: environmentGrantBatchSchema
|
|
2455
|
+
});
|
|
2422
2456
|
z.object({
|
|
2457
|
+
/** Commit the token and all default resolve keys together, when supplied. */
|
|
2458
|
+
initialGrants: environmentGrantBatchSchema.optional(),
|
|
2423
2459
|
name: nameSchema,
|
|
2424
2460
|
tokenId: z.string().regex(/^skt_[0-9A-Za-z]+$/),
|
|
2425
2461
|
/** SHA-256 hash (base64url) of the full token string. */
|
|
@@ -5700,7 +5736,7 @@ async function createAgentTaskToken() {
|
|
|
5700
5736
|
}
|
|
5701
5737
|
//#endregion
|
|
5702
5738
|
//#region package.json
|
|
5703
|
-
var version = "2.
|
|
5739
|
+
var version = "2.2.0";
|
|
5704
5740
|
//#endregion
|
|
5705
5741
|
//#region ../../packages/api-client/src/index.ts
|
|
5706
5742
|
/**
|
|
@@ -5837,6 +5873,56 @@ var SeekritClient = class {
|
|
|
5837
5873
|
listMembers(orgId) {
|
|
5838
5874
|
return this.request("GET", `/v1/orgs/${orgId}/members`);
|
|
5839
5875
|
}
|
|
5876
|
+
updateMemberRole(orgId, userId, role) {
|
|
5877
|
+
return this.request("PATCH", `/v1/orgs/${orgId}/members/${userId}`, { role });
|
|
5878
|
+
}
|
|
5879
|
+
getAppAccess(orgId, appId) {
|
|
5880
|
+
return this.request("GET", `/v1/orgs/${orgId}/apps/${appId}/access`);
|
|
5881
|
+
}
|
|
5882
|
+
provisionAppAccess(orgId, appId, userId, grants) {
|
|
5883
|
+
return this.request("POST", `/v1/orgs/${orgId}/apps/${appId}/access`, {
|
|
5884
|
+
userId,
|
|
5885
|
+
grants
|
|
5886
|
+
});
|
|
5887
|
+
}
|
|
5888
|
+
accessRecipients(orgId, target) {
|
|
5889
|
+
const path = "appId" in target ? `apps/${target.appId}` : `groups/${target.groupId}`;
|
|
5890
|
+
return this.request("GET", `/v1/orgs/${orgId}/${path}/access-recipients`);
|
|
5891
|
+
}
|
|
5892
|
+
/** The caller owns the new DEK; only public keys and wrapped blobs cross the API. */
|
|
5893
|
+
async wrapNewEnvironmentAccess(orgId, target, dek) {
|
|
5894
|
+
const { recipients } = await this.accessRecipients(orgId, target);
|
|
5895
|
+
return Promise.all(recipients.map(async (r) => {
|
|
5896
|
+
if (!r.publicKeyJwk) throw new Error(`${r.email} must finish key setup before creating this environment`);
|
|
5897
|
+
return {
|
|
5898
|
+
userId: r.userId,
|
|
5899
|
+
wrappedDek: await wrapDek(dek, r.publicKeyJwk)
|
|
5900
|
+
};
|
|
5901
|
+
}));
|
|
5902
|
+
}
|
|
5903
|
+
async wrapGroupAppAccess(orgId, envId, groupId, getDek) {
|
|
5904
|
+
const { environment } = await this.getEnv(orgId, envId);
|
|
5905
|
+
if (!environment.applicationId) throw new Error("only app environments can compose groups");
|
|
5906
|
+
const { recipients } = await this.accessRecipients(orgId, { appId: environment.applicationId });
|
|
5907
|
+
if (!recipients.length) return [];
|
|
5908
|
+
const { environments } = await this.listGroupEnvs(orgId, groupId);
|
|
5909
|
+
const result = [];
|
|
5910
|
+
for (const env of environments) {
|
|
5911
|
+
const { grants } = await this.listEnvKeys(orgId, env.id);
|
|
5912
|
+
const missing = recipients.filter((r) => !grants.some((g) => g.principalType === "user" && g.principalId === r.userId));
|
|
5913
|
+
if (!missing.length) continue;
|
|
5914
|
+
const dek = await getDek(env.id);
|
|
5915
|
+
for (const recipient of missing) {
|
|
5916
|
+
if (!recipient.publicKeyJwk) throw new Error(`${recipient.email} must finish key setup first`);
|
|
5917
|
+
result.push({
|
|
5918
|
+
environmentId: env.id,
|
|
5919
|
+
userId: recipient.userId,
|
|
5920
|
+
wrappedDek: await wrapDek(dek, recipient.publicKeyJwk)
|
|
5921
|
+
});
|
|
5922
|
+
}
|
|
5923
|
+
}
|
|
5924
|
+
return result;
|
|
5925
|
+
}
|
|
5840
5926
|
/**
|
|
5841
5927
|
* The org-wide "require a second factor for all members" policy. `configured`
|
|
5842
5928
|
* is false when the identity provider isn't wired up (local dev), in which
|
|
@@ -6021,6 +6107,46 @@ var SeekritClient = class {
|
|
|
6021
6107
|
return this.request("POST", `/v1/orgs/${orgId}/change-requests/${id}/withdraw`);
|
|
6022
6108
|
}
|
|
6023
6109
|
/** The calling principal's wrapped DEK for this environment. */
|
|
6110
|
+
getEnvironmentKeyScope(orgId, envId) {
|
|
6111
|
+
return this.request("GET", `/v1/orgs/${orgId}/envs/${envId}/key-scope`);
|
|
6112
|
+
}
|
|
6113
|
+
async prepareComposedKeyGrants(orgId, envId, principal, getDek) {
|
|
6114
|
+
const scope = await this.getEnvironmentKeyScope(orgId, envId);
|
|
6115
|
+
const grants = [];
|
|
6116
|
+
for (const layer of scope.environments) {
|
|
6117
|
+
if (scope.grants.some((g) => g.environmentId === layer.environmentId && g.principalType === principal.principalType && g.principalId === principal.principalId)) continue;
|
|
6118
|
+
try {
|
|
6119
|
+
const dek = await getDek(layer.environmentId);
|
|
6120
|
+
grants.push({
|
|
6121
|
+
environmentId: layer.environmentId,
|
|
6122
|
+
wrappedDek: await wrapDek(dek, principal.publicKeyJwk)
|
|
6123
|
+
});
|
|
6124
|
+
} catch (error) {
|
|
6125
|
+
if (error instanceof Error && error.message === "unlock cancelled") throw error;
|
|
6126
|
+
throw new Error(`Could not prepare access to ${layer.label}: ${error instanceof Error ? error.message : "key unavailable"}. No keys were granted.`);
|
|
6127
|
+
}
|
|
6128
|
+
}
|
|
6129
|
+
return grants;
|
|
6130
|
+
}
|
|
6131
|
+
grantComposedKeys(orgId, envId, input) {
|
|
6132
|
+
return this.request("POST", `/v1/orgs/${orgId}/envs/${envId}/keys/composed`, input);
|
|
6133
|
+
}
|
|
6134
|
+
/** Sharing an application environment includes the exact layers resolve reads. */
|
|
6135
|
+
async grantEnvironmentAccess(orgId, envId, principal, getDek) {
|
|
6136
|
+
const { environment } = await this.getEnv(orgId, envId);
|
|
6137
|
+
if (environment.applicationId) {
|
|
6138
|
+
const grants = await this.prepareComposedKeyGrants(orgId, envId, principal, getDek);
|
|
6139
|
+
await this.grantComposedKeys(orgId, envId, {
|
|
6140
|
+
principalType: principal.principalType,
|
|
6141
|
+
principalId: principal.principalId,
|
|
6142
|
+
grants
|
|
6143
|
+
});
|
|
6144
|
+
} else await this.grantEnvKey(orgId, envId, {
|
|
6145
|
+
principalType: principal.principalType,
|
|
6146
|
+
principalId: principal.principalId,
|
|
6147
|
+
wrappedDek: await wrapDek(await getDek(envId), principal.publicKeyJwk)
|
|
6148
|
+
});
|
|
6149
|
+
}
|
|
6024
6150
|
getMyEnvKey(orgId, envId) {
|
|
6025
6151
|
return this.request("GET", `/v1/orgs/${orgId}/envs/${envId}/key`);
|
|
6026
6152
|
}
|
|
@@ -7154,12 +7280,11 @@ function registerAccessCommands(program) {
|
|
|
7154
7280
|
const ctx = buildContext();
|
|
7155
7281
|
const { orgId, envId, label } = await resolveEnvTarget(ctx, options);
|
|
7156
7282
|
const principal = await resolvePrincipal(ctx, orgId, options);
|
|
7157
|
-
|
|
7158
|
-
await ctx.client.grantEnvKey(orgId, envId, {
|
|
7283
|
+
await ctx.client.grantEnvironmentAccess(orgId, envId, {
|
|
7159
7284
|
principalType: principal.type,
|
|
7160
7285
|
principalId: principal.id,
|
|
7161
|
-
|
|
7162
|
-
});
|
|
7286
|
+
publicKeyJwk: principal.publicKeyJwk
|
|
7287
|
+
}, (id) => getDek(ctx, orgId, id));
|
|
7163
7288
|
console.error(`granted ${label} access to ${principal.id}`);
|
|
7164
7289
|
});
|
|
7165
7290
|
withEnvTarget(grant.command("list").alias("ls").description("list who holds a key for an environment").option("--json", "print the raw API response")).action(async (options) => {
|
|
@@ -9013,6 +9138,7 @@ function registerAppCommands(program) {
|
|
|
9013
9138
|
const wrappedDek = await wrapDek(dek, await principalPublicKeyJwk(ctx));
|
|
9014
9139
|
const recoveryWrappedDek = await recoveryWrapForNewEnv(ctx, orgRef.id, dek);
|
|
9015
9140
|
const created = await ctx.client.createEnv(orgRef.id, appRow.id, {
|
|
9141
|
+
userGrants: await ctx.client.wrapNewEnvironmentAccess(orgRef.id, { appId: appRow.id }, dek),
|
|
9016
9142
|
name: options.name,
|
|
9017
9143
|
slug: options.slug,
|
|
9018
9144
|
wrappedDek,
|
|
@@ -9038,6 +9164,7 @@ function registerAppCommands(program) {
|
|
|
9038
9164
|
});
|
|
9039
9165
|
const syncGrants = await syncGrantsForGroupLink(ctx, target.orgId, target.envId, target.envSlug, group.id);
|
|
9040
9166
|
await ctx.client.linkEnvGroup(target.orgId, target.envId, {
|
|
9167
|
+
appAccessGrants: await ctx.client.wrapGroupAppAccess(target.orgId, target.envId, group.id, (id) => getDek(ctx, target.orgId, id)),
|
|
9041
9168
|
groupId: group.id,
|
|
9042
9169
|
position: options.position === void 0 ? void 0 : Number.parseInt(options.position, 10),
|
|
9043
9170
|
syncGrants
|
|
@@ -11807,6 +11934,7 @@ function registerGroupCommands(program) {
|
|
|
11807
11934
|
const wrappedDek = await wrapDek(dek, user.publicKeyJwk);
|
|
11808
11935
|
const recoveryWrappedDek = await recoveryWrapForNewEnv(ctx, groupRef.orgId, dek);
|
|
11809
11936
|
const created = await ctx.client.createGroupEnv(groupRef.orgId, groupRef.id, {
|
|
11937
|
+
userGrants: await ctx.client.wrapNewEnvironmentAccess(groupRef.orgId, { groupId: groupRef.id }, dek),
|
|
11810
11938
|
name: options.name,
|
|
11811
11939
|
slug: options.slug,
|
|
11812
11940
|
wrappedDek,
|
|
@@ -12270,6 +12398,7 @@ async function runInit(options) {
|
|
|
12270
12398
|
const wrappedDek = await wrapDek(dek, await principalPublicKeyJwk(ctx));
|
|
12271
12399
|
const recoveryWrappedDek = await recoveryWrapForNewEnv(ctx, orgRef.id, dek);
|
|
12272
12400
|
({environment: env} = await ctx.client.createEnv(orgRef.id, app.id, {
|
|
12401
|
+
userGrants: await ctx.client.wrapNewEnvironmentAccess(orgRef.id, { appId: app.id }, dek),
|
|
12273
12402
|
name: titleCase(envSlug),
|
|
12274
12403
|
slug: envSlug,
|
|
12275
12404
|
wrappedDek,
|
|
@@ -15102,6 +15231,303 @@ function registerRotationCommands(program) {
|
|
|
15102
15231
|
});
|
|
15103
15232
|
}
|
|
15104
15233
|
//#endregion
|
|
15234
|
+
//#region src/run-command.ts
|
|
15235
|
+
/** Keep signal handlers installed from provisioning through asynchronous cleanup. */
|
|
15236
|
+
async function runCommand(cmd, args, prepare, cleanup) {
|
|
15237
|
+
let child;
|
|
15238
|
+
let forwarded;
|
|
15239
|
+
let result = { code: 1 };
|
|
15240
|
+
const stragglers = /* @__PURE__ */ new Set();
|
|
15241
|
+
const collect = () => {
|
|
15242
|
+
if (process.platform === "win32" || !child?.pid) return;
|
|
15243
|
+
for (const pid of teardownTargets(child.pid)) stragglers.add(pid);
|
|
15244
|
+
};
|
|
15245
|
+
const forward = (signal) => {
|
|
15246
|
+
forwarded ??= signal;
|
|
15247
|
+
if (!child || child.exitCode !== null || child.signalCode !== null) return;
|
|
15248
|
+
collect();
|
|
15249
|
+
child.kill(signal);
|
|
15250
|
+
for (const pid of stragglers) try {
|
|
15251
|
+
process.kill(pid, signal);
|
|
15252
|
+
} catch {}
|
|
15253
|
+
};
|
|
15254
|
+
const signals = [
|
|
15255
|
+
"SIGINT",
|
|
15256
|
+
"SIGTERM",
|
|
15257
|
+
"SIGHUP",
|
|
15258
|
+
"SIGQUIT"
|
|
15259
|
+
];
|
|
15260
|
+
for (const signal of signals) process.on(signal, forward);
|
|
15261
|
+
try {
|
|
15262
|
+
const env = await prepare(() => forwarded !== void 0);
|
|
15263
|
+
if (!forwarded) {
|
|
15264
|
+
const running = spawn(cmd, args, {
|
|
15265
|
+
stdio: "inherit",
|
|
15266
|
+
env
|
|
15267
|
+
});
|
|
15268
|
+
child = running;
|
|
15269
|
+
result = await new Promise((resolve, reject) => {
|
|
15270
|
+
running.once("exit", (code, signal) => resolve({
|
|
15271
|
+
code: code ?? 1,
|
|
15272
|
+
signal: signal ?? void 0
|
|
15273
|
+
}));
|
|
15274
|
+
running.once("error", (err) => reject(/* @__PURE__ */ new Error(`failed to start ${cmd}: ${err.message}`)));
|
|
15275
|
+
});
|
|
15276
|
+
}
|
|
15277
|
+
} finally {
|
|
15278
|
+
try {
|
|
15279
|
+
if (forwarded) {
|
|
15280
|
+
collect();
|
|
15281
|
+
await reapStragglers(stragglers, forwarded);
|
|
15282
|
+
}
|
|
15283
|
+
} finally {
|
|
15284
|
+
try {
|
|
15285
|
+
if (!await cleanup() && result.code === 0) result.code = 1;
|
|
15286
|
+
} finally {
|
|
15287
|
+
for (const signal of signals) process.off(signal, forward);
|
|
15288
|
+
}
|
|
15289
|
+
}
|
|
15290
|
+
}
|
|
15291
|
+
if (!child && forwarded) result.signal = forwarded;
|
|
15292
|
+
return result;
|
|
15293
|
+
}
|
|
15294
|
+
/**
|
|
15295
|
+
* Every live process `seekrit run` is responsible for tearing down, from a
|
|
15296
|
+
* single `ps(1)` snapshot: each descendant of `root`, plus — when we lead our
|
|
15297
|
+
* own process group — every other member of that group.
|
|
15298
|
+
*
|
|
15299
|
+
* Two mechanisms, because each covers the other's blind spot:
|
|
15300
|
+
*
|
|
15301
|
+
* - The parent-chain walk reaches a process that left our group via
|
|
15302
|
+
* setsid(2)/setpgid(2), which no group-wide kill and no tty-generated signal
|
|
15303
|
+
* can touch. But it loses anything whose chain up to `root` has already
|
|
15304
|
+
* broken — teardown kills the middle layers first, and an orphan reparents to
|
|
15305
|
+
* init, out of reach of any walk down from `root`.
|
|
15306
|
+
* - Group membership survives exactly that: a pgid outlives the parent that
|
|
15307
|
+
* passed it down. But it misses the ones that left the group.
|
|
15308
|
+
*
|
|
15309
|
+
* The group half applies only when our pid *is* our pgid, which is the shell-job
|
|
15310
|
+
* case: the shell put this command in a new group, so every other member of it
|
|
15311
|
+
* descends from us. Nested inside a shell script or a CI runner we are not the
|
|
15312
|
+
* leader and the group holds processes we did not start, so there we use the
|
|
15313
|
+
* walk alone.
|
|
15314
|
+
*
|
|
15315
|
+
* Zombies are skipped: they are already dead and waiting to be reaped, so
|
|
15316
|
+
* counting them would stall teardown for the full grace period and then report
|
|
15317
|
+
* a kill that did nothing. Returns nothing if `ps` is unavailable; callers fall
|
|
15318
|
+
* back to signalling the immediate child.
|
|
15319
|
+
*/
|
|
15320
|
+
function teardownTargets(root) {
|
|
15321
|
+
const ps = spawnSync("ps", [
|
|
15322
|
+
"-A",
|
|
15323
|
+
"-o",
|
|
15324
|
+
"pid=,ppid=,pgid=,stat="
|
|
15325
|
+
], { encoding: "utf8" });
|
|
15326
|
+
if (ps.status !== 0 || typeof ps.stdout !== "string") return [];
|
|
15327
|
+
const childrenOf = /* @__PURE__ */ new Map();
|
|
15328
|
+
const groupOf = /* @__PURE__ */ new Map();
|
|
15329
|
+
const isId = (value) => value !== void 0 && Number.isInteger(value);
|
|
15330
|
+
for (const line of ps.stdout.split("\n")) {
|
|
15331
|
+
const [pid, ppid, pgid, stat] = line.trim().split(/\s+/, 4);
|
|
15332
|
+
const [id, parent, group] = [
|
|
15333
|
+
pid,
|
|
15334
|
+
ppid,
|
|
15335
|
+
pgid
|
|
15336
|
+
].map(Number);
|
|
15337
|
+
if (!isId(id) || !isId(parent) || !isId(group)) continue;
|
|
15338
|
+
if (stat?.startsWith("Z")) continue;
|
|
15339
|
+
const siblings = childrenOf.get(parent);
|
|
15340
|
+
if (siblings) siblings.push(id);
|
|
15341
|
+
else childrenOf.set(parent, [id]);
|
|
15342
|
+
groupOf.set(id, group);
|
|
15343
|
+
}
|
|
15344
|
+
const found = /* @__PURE__ */ new Set();
|
|
15345
|
+
const queue = [root];
|
|
15346
|
+
const seen = new Set(queue);
|
|
15347
|
+
for (let pid = queue.shift(); pid !== void 0; pid = queue.shift()) for (const kid of childrenOf.get(pid) ?? []) {
|
|
15348
|
+
if (seen.has(kid)) continue;
|
|
15349
|
+
seen.add(kid);
|
|
15350
|
+
found.add(kid);
|
|
15351
|
+
queue.push(kid);
|
|
15352
|
+
}
|
|
15353
|
+
if (groupOf.get(process.pid) === process.pid) {
|
|
15354
|
+
for (const [pid, group] of groupOf) if (group === process.pid) found.add(pid);
|
|
15355
|
+
}
|
|
15356
|
+
found.delete(process.pid);
|
|
15357
|
+
found.delete(root);
|
|
15358
|
+
return [...found];
|
|
15359
|
+
}
|
|
15360
|
+
/** Whether `pid` still exists — signal 0 tests reachability, delivers nothing. */
|
|
15361
|
+
function isAlive(pid) {
|
|
15362
|
+
try {
|
|
15363
|
+
process.kill(pid, 0);
|
|
15364
|
+
return true;
|
|
15365
|
+
} catch {
|
|
15366
|
+
return false;
|
|
15367
|
+
}
|
|
15368
|
+
}
|
|
15369
|
+
/** How long a straggler gets to finish shutting down before SIGKILL. */
|
|
15370
|
+
const STRAGGLER_GRACE_MS = 2e3;
|
|
15371
|
+
/**
|
|
15372
|
+
* Once the command has exited, make sure nothing it started outlives us.
|
|
15373
|
+
*
|
|
15374
|
+
* Anything still alive here has ignored the signal we forwarded *and* lost the
|
|
15375
|
+
* parent that launched it, and we are the last process that knows its pid:
|
|
15376
|
+
* the tty won't hang it up (it is no longer in the foreground group, and the
|
|
15377
|
+
* shell has moved on), so it survives until the machine reboots. That is how
|
|
15378
|
+
* `tsx` and the server under it stacked up for days.
|
|
15379
|
+
*
|
|
15380
|
+
* So escalate rather than trust. SIGTERM first — it costs no extra time and is
|
|
15381
|
+
* a rung the SIGINT-only handlers common in dev servers don't catch — then
|
|
15382
|
+
* SIGKILL whatever is left after the grace period. Nothing is killed unless it
|
|
15383
|
+
* was asked to stop first and declined, and this runs only when we forwarded a
|
|
15384
|
+
* termination signal: a command that exits on its own having deliberately left a
|
|
15385
|
+
* daemon behind keeps working.
|
|
15386
|
+
*/
|
|
15387
|
+
async function reapStragglers(pids, signal) {
|
|
15388
|
+
let live = [...pids].filter(isAlive);
|
|
15389
|
+
if (live.length === 0) return;
|
|
15390
|
+
for (const pid of live) try {
|
|
15391
|
+
process.kill(pid, "SIGTERM");
|
|
15392
|
+
} catch {}
|
|
15393
|
+
const deadline = Date.now() + STRAGGLER_GRACE_MS;
|
|
15394
|
+
while (live.length > 0 && Date.now() < deadline) {
|
|
15395
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
15396
|
+
live = live.filter(isAlive);
|
|
15397
|
+
}
|
|
15398
|
+
if (live.length === 0) return;
|
|
15399
|
+
console.error(`seekrit: force-killed ${live.length} leftover process(es) that ignored ${signal} and SIGTERM (${live.join(", ")})`);
|
|
15400
|
+
for (const pid of live) try {
|
|
15401
|
+
process.kill(pid, "SIGKILL");
|
|
15402
|
+
} catch {}
|
|
15403
|
+
}
|
|
15404
|
+
//#endregion
|
|
15405
|
+
//#region src/run-leases.ts
|
|
15406
|
+
/** Validate all flags before contacting the API or creating any database roles. */
|
|
15407
|
+
function parseRunLeaseOptions(bindings = [], ttl) {
|
|
15408
|
+
if (!bindings.length) {
|
|
15409
|
+
if (ttl !== void 0) throw new Error("--lease-ttl requires --lease VAR=target");
|
|
15410
|
+
return;
|
|
15411
|
+
}
|
|
15412
|
+
const names = /* @__PURE__ */ new Set();
|
|
15413
|
+
const parsed = bindings.map((binding) => {
|
|
15414
|
+
const eq = binding.indexOf("=");
|
|
15415
|
+
const variable = binding.slice(0, eq);
|
|
15416
|
+
const target = binding.slice(eq + 1).trim();
|
|
15417
|
+
if (eq < 1 || !secretNameSchema.safeParse(variable).success || !target) throw new Error("--lease requires VAR=target (for example DATABASE_URL=production)");
|
|
15418
|
+
const key = process.platform === "win32" ? variable.toUpperCase() : variable;
|
|
15419
|
+
if (names.has(key)) throw new Error(`duplicate --lease variable ${variable}`);
|
|
15420
|
+
names.add(key);
|
|
15421
|
+
return {
|
|
15422
|
+
variable,
|
|
15423
|
+
target
|
|
15424
|
+
};
|
|
15425
|
+
});
|
|
15426
|
+
const match = /^(\d+)\s*([smhd]?)$/.exec((ttl ?? "30m").trim());
|
|
15427
|
+
const ttlSeconds = match ? Number(match[1]) * ({
|
|
15428
|
+
"": 1,
|
|
15429
|
+
s: 1,
|
|
15430
|
+
m: 60,
|
|
15431
|
+
h: 3600,
|
|
15432
|
+
d: 86400
|
|
15433
|
+
}[match[2] ?? ""] ?? 1) : NaN;
|
|
15434
|
+
if (!Number.isSafeInteger(ttlSeconds) || ttlSeconds < 60 || ttlSeconds > 604800) throw new Error("--lease-ttl must be between 1m and 7d (for example 30m)");
|
|
15435
|
+
return {
|
|
15436
|
+
bindings: parsed,
|
|
15437
|
+
ttlSeconds
|
|
15438
|
+
};
|
|
15439
|
+
}
|
|
15440
|
+
async function prepareRunLeases(options, orgSlug, envFiles = [".env"]) {
|
|
15441
|
+
const dotenvVars = {};
|
|
15442
|
+
overlayEnvFiles(dotenvVars, {}, envFiles);
|
|
15443
|
+
const ctx = tryBuildContext(dotenvVars);
|
|
15444
|
+
if (!ctx) throw new Error("--lease requires authentication; run `seekrit login`");
|
|
15445
|
+
const client = new SeekritClient({
|
|
15446
|
+
baseUrl: ctx.apiUrl,
|
|
15447
|
+
auth: ctx.auth,
|
|
15448
|
+
fetch: (input, init) => fetch(input, {
|
|
15449
|
+
...init,
|
|
15450
|
+
signal: AbortSignal.timeout(15e3)
|
|
15451
|
+
})
|
|
15452
|
+
});
|
|
15453
|
+
return new RunLeases(client, (await resolveOrg({
|
|
15454
|
+
...ctx,
|
|
15455
|
+
client
|
|
15456
|
+
}, orgSlug)).id, options);
|
|
15457
|
+
}
|
|
15458
|
+
/** Own every successfully minted lease, including partial setup failures. */
|
|
15459
|
+
var RunLeases = class {
|
|
15460
|
+
client;
|
|
15461
|
+
orgId;
|
|
15462
|
+
options;
|
|
15463
|
+
active = [];
|
|
15464
|
+
constructor(client, orgId, options) {
|
|
15465
|
+
this.client = client;
|
|
15466
|
+
this.orgId = orgId;
|
|
15467
|
+
this.options = options;
|
|
15468
|
+
}
|
|
15469
|
+
async mint(interrupted) {
|
|
15470
|
+
const values = Object.create(null);
|
|
15471
|
+
if (interrupted()) return values;
|
|
15472
|
+
const { targets } = await this.client.listLeaseTargets(this.orgId);
|
|
15473
|
+
const selected = this.options.bindings.map((binding) => {
|
|
15474
|
+
const matches = targets.filter((t) => t.id === binding.target || t.name === binding.target);
|
|
15475
|
+
const target = matches[0];
|
|
15476
|
+
if (matches.length !== 1 || !target) throw new Error(`--lease target "${binding.target}" is missing or ambiguous; use its ID`);
|
|
15477
|
+
if (target.provider !== "postgres") throw new Error(`--lease target "${target.name}" must be Postgres`);
|
|
15478
|
+
return {
|
|
15479
|
+
binding,
|
|
15480
|
+
target
|
|
15481
|
+
};
|
|
15482
|
+
});
|
|
15483
|
+
for (const { binding, target } of selected) {
|
|
15484
|
+
if (interrupted()) break;
|
|
15485
|
+
const username = `tmp_${randomBytes(12).toString("hex")}`;
|
|
15486
|
+
const { password, verifier } = await generatePostgresCredential();
|
|
15487
|
+
if (interrupted()) break;
|
|
15488
|
+
let minted;
|
|
15489
|
+
try {
|
|
15490
|
+
minted = await this.client.mintLease(this.orgId, {
|
|
15491
|
+
provider: "postgres",
|
|
15492
|
+
targetId: target.id,
|
|
15493
|
+
roleName: username,
|
|
15494
|
+
verifier,
|
|
15495
|
+
ttlSeconds: this.options.ttlSeconds
|
|
15496
|
+
});
|
|
15497
|
+
} catch {
|
|
15498
|
+
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`);
|
|
15499
|
+
}
|
|
15500
|
+
const { lease, connection } = minted;
|
|
15501
|
+
this.active.push({
|
|
15502
|
+
id: lease.id,
|
|
15503
|
+
username,
|
|
15504
|
+
expiresAt: connection.expiresAt
|
|
15505
|
+
});
|
|
15506
|
+
console.error(`seekrit: created temporary user ${username} on ${target.name} · lease ${lease.id} · expires ${connection.expiresAt}`);
|
|
15507
|
+
const url = new URL("postgresql://localhost");
|
|
15508
|
+
url.hostname = connection.host.includes(":") && !connection.host.startsWith("[") ? `[${connection.host}]` : connection.host;
|
|
15509
|
+
url.port = String(connection.port);
|
|
15510
|
+
url.username = connection.username;
|
|
15511
|
+
url.password = password;
|
|
15512
|
+
url.pathname = `/${encodeURIComponent(connection.database)}`;
|
|
15513
|
+
values[binding.variable] = url.href;
|
|
15514
|
+
console.error(`seekrit: injected ${binding.variable}`);
|
|
15515
|
+
}
|
|
15516
|
+
return values;
|
|
15517
|
+
}
|
|
15518
|
+
async revoke() {
|
|
15519
|
+
let ok = true;
|
|
15520
|
+
for (const lease of this.active.splice(0).reverse()) try {
|
|
15521
|
+
await this.client.revokeLease(this.orgId, lease.id);
|
|
15522
|
+
console.error(`seekrit: revoked temporary user ${lease.username} · lease ${lease.id}`);
|
|
15523
|
+
} catch {
|
|
15524
|
+
ok = false;
|
|
15525
|
+
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}`);
|
|
15526
|
+
}
|
|
15527
|
+
return ok;
|
|
15528
|
+
}
|
|
15529
|
+
};
|
|
15530
|
+
//#endregion
|
|
15105
15531
|
//#region src/ssh.ts
|
|
15106
15532
|
/**
|
|
15107
15533
|
* `seekrit ssh` — temporary SSH access via short-lived certificates (Vault-style
|
|
@@ -16468,167 +16894,37 @@ async function materializeForRun(options) {
|
|
|
16468
16894
|
};
|
|
16469
16895
|
}
|
|
16470
16896
|
}
|
|
16471
|
-
|
|
16472
|
-
* Every live process `seekrit run` is responsible for tearing down, from a
|
|
16473
|
-
* single `ps(1)` snapshot: each descendant of `root`, plus — when we lead our
|
|
16474
|
-
* own process group — every other member of that group.
|
|
16475
|
-
*
|
|
16476
|
-
* Two mechanisms, because each covers the other's blind spot:
|
|
16477
|
-
*
|
|
16478
|
-
* - The parent-chain walk reaches a process that left our group via
|
|
16479
|
-
* setsid(2)/setpgid(2), which no group-wide kill and no tty-generated signal
|
|
16480
|
-
* can touch. But it loses anything whose chain up to `root` has already
|
|
16481
|
-
* broken — teardown kills the middle layers first, and an orphan reparents to
|
|
16482
|
-
* init, out of reach of any walk down from `root`.
|
|
16483
|
-
* - Group membership survives exactly that: a pgid outlives the parent that
|
|
16484
|
-
* passed it down. But it misses the ones that left the group.
|
|
16485
|
-
*
|
|
16486
|
-
* The group half applies only when our pid *is* our pgid, which is the shell-job
|
|
16487
|
-
* case: the shell put this command in a new group, so every other member of it
|
|
16488
|
-
* descends from us. Nested inside a shell script or a CI runner we are not the
|
|
16489
|
-
* leader and the group holds processes we did not start, so there we use the
|
|
16490
|
-
* walk alone.
|
|
16491
|
-
*
|
|
16492
|
-
* Zombies are skipped: they are already dead and waiting to be reaped, so
|
|
16493
|
-
* counting them would stall teardown for the full grace period and then report
|
|
16494
|
-
* a kill that did nothing. Returns nothing if `ps` is unavailable; callers fall
|
|
16495
|
-
* back to signalling the immediate child.
|
|
16496
|
-
*/
|
|
16497
|
-
function teardownTargets(root) {
|
|
16498
|
-
const ps = spawnSync("ps", [
|
|
16499
|
-
"-A",
|
|
16500
|
-
"-o",
|
|
16501
|
-
"pid=,ppid=,pgid=,stat="
|
|
16502
|
-
], { encoding: "utf8" });
|
|
16503
|
-
if (ps.status !== 0 || typeof ps.stdout !== "string") return [];
|
|
16504
|
-
const childrenOf = /* @__PURE__ */ new Map();
|
|
16505
|
-
const groupOf = /* @__PURE__ */ new Map();
|
|
16506
|
-
const isId = (value) => value !== void 0 && Number.isInteger(value);
|
|
16507
|
-
for (const line of ps.stdout.split("\n")) {
|
|
16508
|
-
const [pid, ppid, pgid, stat] = line.trim().split(/\s+/, 4);
|
|
16509
|
-
const [id, parent, group] = [
|
|
16510
|
-
pid,
|
|
16511
|
-
ppid,
|
|
16512
|
-
pgid
|
|
16513
|
-
].map(Number);
|
|
16514
|
-
if (!isId(id) || !isId(parent) || !isId(group)) continue;
|
|
16515
|
-
if (stat?.startsWith("Z")) continue;
|
|
16516
|
-
const siblings = childrenOf.get(parent);
|
|
16517
|
-
if (siblings) siblings.push(id);
|
|
16518
|
-
else childrenOf.set(parent, [id]);
|
|
16519
|
-
groupOf.set(id, group);
|
|
16520
|
-
}
|
|
16521
|
-
const found = /* @__PURE__ */ new Set();
|
|
16522
|
-
const queue = [root];
|
|
16523
|
-
const seen = new Set(queue);
|
|
16524
|
-
for (let pid = queue.shift(); pid !== void 0; pid = queue.shift()) for (const kid of childrenOf.get(pid) ?? []) {
|
|
16525
|
-
if (seen.has(kid)) continue;
|
|
16526
|
-
seen.add(kid);
|
|
16527
|
-
found.add(kid);
|
|
16528
|
-
queue.push(kid);
|
|
16529
|
-
}
|
|
16530
|
-
if (groupOf.get(process.pid) === process.pid) {
|
|
16531
|
-
for (const [pid, group] of groupOf) if (group === process.pid) found.add(pid);
|
|
16532
|
-
}
|
|
16533
|
-
found.delete(process.pid);
|
|
16534
|
-
found.delete(root);
|
|
16535
|
-
return [...found];
|
|
16536
|
-
}
|
|
16537
|
-
/** Whether `pid` still exists — signal 0 tests reachability, delivers nothing. */
|
|
16538
|
-
function isAlive(pid) {
|
|
16539
|
-
try {
|
|
16540
|
-
process.kill(pid, 0);
|
|
16541
|
-
return true;
|
|
16542
|
-
} catch {
|
|
16543
|
-
return false;
|
|
16544
|
-
}
|
|
16545
|
-
}
|
|
16546
|
-
/** How long a straggler gets to finish shutting down before SIGKILL. */
|
|
16547
|
-
const STRAGGLER_GRACE_MS = 2e3;
|
|
16548
|
-
/**
|
|
16549
|
-
* Once the command has exited, make sure nothing it started outlives us.
|
|
16550
|
-
*
|
|
16551
|
-
* Anything still alive here has ignored the signal we forwarded *and* lost the
|
|
16552
|
-
* parent that launched it, and we are the last process that knows its pid:
|
|
16553
|
-
* the tty won't hang it up (it is no longer in the foreground group, and the
|
|
16554
|
-
* shell has moved on), so it survives until the machine reboots. That is how
|
|
16555
|
-
* `tsx` and the server under it stacked up for days.
|
|
16556
|
-
*
|
|
16557
|
-
* So escalate rather than trust. SIGTERM first — it costs no extra time and is
|
|
16558
|
-
* a rung the SIGINT-only handlers common in dev servers don't catch — then
|
|
16559
|
-
* SIGKILL whatever is left after the grace period. Nothing is killed unless it
|
|
16560
|
-
* was asked to stop first and declined, and this runs only when we forwarded a
|
|
16561
|
-
* termination signal: a command that exits on its own having deliberately left a
|
|
16562
|
-
* daemon behind keeps working.
|
|
16563
|
-
*/
|
|
16564
|
-
async function reapStragglers(pids, signal) {
|
|
16565
|
-
let live = [...pids].filter(isAlive);
|
|
16566
|
-
if (live.length === 0) return;
|
|
16567
|
-
for (const pid of live) try {
|
|
16568
|
-
process.kill(pid, "SIGTERM");
|
|
16569
|
-
} catch {}
|
|
16570
|
-
const deadline = Date.now() + STRAGGLER_GRACE_MS;
|
|
16571
|
-
while (live.length > 0 && Date.now() < deadline) {
|
|
16572
|
-
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
16573
|
-
live = live.filter(isAlive);
|
|
16574
|
-
}
|
|
16575
|
-
if (live.length === 0) return;
|
|
16576
|
-
console.error(`seekrit: force-killed ${live.length} leftover process(es) that ignored ${signal} and SIGTERM (${live.join(", ")})`);
|
|
16577
|
-
for (const pid of live) try {
|
|
16578
|
-
process.kill(pid, "SIGKILL");
|
|
16579
|
-
} catch {}
|
|
16580
|
-
}
|
|
16581
|
-
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) => {
|
|
16897
|
+
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) => {
|
|
16582
16898
|
const [cmd, ...args] = commandParts;
|
|
16583
16899
|
if (!cmd) fail("no command given");
|
|
16900
|
+
const leaseOptions = parseRunLeaseOptions(options.lease, options.leaseTtl);
|
|
16584
16901
|
const { values, provenance, interpolated, unresolvedRefs } = await materializeForRun(options);
|
|
16585
|
-
|
|
16586
|
-
|
|
16587
|
-
|
|
16588
|
-
|
|
16589
|
-
|
|
16590
|
-
|
|
16591
|
-
|
|
16592
|
-
|
|
16593
|
-
|
|
16594
|
-
|
|
16595
|
-
|
|
16902
|
+
const leases = leaseOptions ? await prepareRunLeases(leaseOptions, options.org, options.envFile) : void 0;
|
|
16903
|
+
const result = await runCommand(cmd, args, async (interrupted) => {
|
|
16904
|
+
const leased = await leases?.mint(interrupted) ?? {};
|
|
16905
|
+
if (options.explain) {
|
|
16906
|
+
for (const name of Object.keys(values)) if (process.env[name] !== void 0 && process.env[name] !== values[name]) provenance[name] = "env";
|
|
16907
|
+
for (const binding of leaseOptions?.bindings ?? []) provenance[binding.variable] = `lease:${binding.target}`;
|
|
16908
|
+
printExplain(provenance, {
|
|
16909
|
+
interpolated,
|
|
16910
|
+
unresolved: unresolvedRefs
|
|
16911
|
+
});
|
|
16912
|
+
}
|
|
16913
|
+
const env = {
|
|
16596
16914
|
...values,
|
|
16597
16915
|
...process.env
|
|
16916
|
+
};
|
|
16917
|
+
if (process.platform === "win32") {
|
|
16918
|
+
const leasedNames = new Set(Object.keys(leased).map((name) => name.toUpperCase()));
|
|
16919
|
+
for (const name of Object.keys(env)) if (leasedNames.has(name.toUpperCase())) delete env[name];
|
|
16598
16920
|
}
|
|
16599
|
-
|
|
16600
|
-
|
|
16601
|
-
|
|
16602
|
-
|
|
16603
|
-
|
|
16604
|
-
|
|
16605
|
-
|
|
16606
|
-
const stragglers = /* @__PURE__ */ new Set();
|
|
16607
|
-
let forwarded;
|
|
16608
|
-
const collect = () => {
|
|
16609
|
-
if (!posix || !child.pid) return;
|
|
16610
|
-
for (const pid of teardownTargets(child.pid)) stragglers.add(pid);
|
|
16611
|
-
};
|
|
16612
|
-
const forward = (signal) => {
|
|
16613
|
-
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
16614
|
-
forwarded ??= signal;
|
|
16615
|
-
collect();
|
|
16616
|
-
child.kill(signal);
|
|
16617
|
-
for (const pid of stragglers) try {
|
|
16618
|
-
process.kill(pid, signal);
|
|
16619
|
-
} catch {}
|
|
16620
|
-
};
|
|
16621
|
-
for (const signal of signals) process.on(signal, forward);
|
|
16622
|
-
child.on("exit", async (code, signal) => {
|
|
16623
|
-
if (forwarded) {
|
|
16624
|
-
collect();
|
|
16625
|
-
await reapStragglers(stragglers, forwarded);
|
|
16626
|
-
}
|
|
16627
|
-
for (const s of signals) process.off(s, forward);
|
|
16628
|
-
if (signal) process.kill(process.pid, signal);
|
|
16629
|
-
else process.exit(code ?? 1);
|
|
16630
|
-
});
|
|
16631
|
-
child.on("error", (err) => fail(`failed to start ${cmd}: ${err.message}`));
|
|
16921
|
+
return {
|
|
16922
|
+
...env,
|
|
16923
|
+
...leased
|
|
16924
|
+
};
|
|
16925
|
+
}, () => leases?.revoke() ?? Promise.resolve(true));
|
|
16926
|
+
if (result.signal) process.kill(process.pid, result.signal);
|
|
16927
|
+
else process.exit(result.code);
|
|
16632
16928
|
});
|
|
16633
16929
|
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) => {
|
|
16634
16930
|
if (![
|
|
@@ -16659,42 +16955,43 @@ token.command("create").description("create a service token (runtime, or --admin
|
|
|
16659
16955
|
if (boundToEnv && !(options.app && options.env)) fail("pass both --app and --env to bind a token to an environment");
|
|
16660
16956
|
const target = boundToEnv ? await resolveAppEnv(ctx, options) : { orgId: (await resolveOrg(ctx, options.org)).id };
|
|
16661
16957
|
const created = await createServiceToken();
|
|
16958
|
+
const granted = boundToEnv && options.grant !== false;
|
|
16959
|
+
const initialGrants = granted && "envId" in target ? await ctx.client.prepareComposedKeyGrants(target.orgId, target.envId, {
|
|
16960
|
+
principalType: "service_token",
|
|
16961
|
+
principalId: created.tokenId,
|
|
16962
|
+
publicKeyJwk: created.publicKeyJwk
|
|
16963
|
+
}, (id) => getDek(ctx, target.orgId, id)) : void 0;
|
|
16964
|
+
const extraGrants = [];
|
|
16965
|
+
if (granted) for (const [groupSlug, envSlug] of Object.entries(options.allow ?? {})) {
|
|
16966
|
+
const groupRef = await resolveGroup(ctx, {
|
|
16967
|
+
org: options.org,
|
|
16968
|
+
group: groupSlug
|
|
16969
|
+
});
|
|
16970
|
+
const { environments } = await ctx.client.listGroupEnvs(target.orgId, groupRef.id);
|
|
16971
|
+
const slice = environments.find((e) => e.slug === envSlug);
|
|
16972
|
+
if (!slice) fail(`group "${groupSlug}" has no "${envSlug}" environment`);
|
|
16973
|
+
extraGrants.push({
|
|
16974
|
+
environmentId: slice.id,
|
|
16975
|
+
wrappedDek: await wrapDek(await getDek(ctx, target.orgId, slice.id), created.publicKeyJwk)
|
|
16976
|
+
});
|
|
16977
|
+
}
|
|
16662
16978
|
await ctx.client.createToken(target.orgId, {
|
|
16663
16979
|
name: options.name,
|
|
16664
16980
|
tokenId: created.tokenId,
|
|
16665
16981
|
tokenHash: created.tokenHash,
|
|
16666
16982
|
publicKeyJwk: created.publicKeyJwk,
|
|
16667
16983
|
role,
|
|
16668
|
-
environmentId: "envId" in target ? target.envId : null
|
|
16984
|
+
environmentId: "envId" in target ? target.envId : null,
|
|
16985
|
+
initialGrants
|
|
16669
16986
|
});
|
|
16670
|
-
const
|
|
16671
|
-
|
|
16672
|
-
await ctx.client.grantEnvKey(target.orgId, envId, {
|
|
16987
|
+
for (const extra of extraGrants) try {
|
|
16988
|
+
await ctx.client.grantEnvKey(target.orgId, extra.environmentId, {
|
|
16673
16989
|
principalType: "service_token",
|
|
16674
16990
|
principalId: created.tokenId,
|
|
16675
|
-
wrappedDek:
|
|
16991
|
+
wrappedDek: extra.wrappedDek
|
|
16676
16992
|
});
|
|
16677
|
-
}
|
|
16678
|
-
|
|
16679
|
-
if (granted && "envId" in target) {
|
|
16680
|
-
await grantEnv(target.envId);
|
|
16681
|
-
const { groups } = await ctx.client.listEnvGroups(target.orgId, target.envId);
|
|
16682
|
-
for (const g of groups) {
|
|
16683
|
-
const { environments } = await ctx.client.listGroupEnvs(target.orgId, g.groupId);
|
|
16684
|
-
const slice = environments.find((e) => e.slug === target.envSlug);
|
|
16685
|
-
if (!slice) fail(`group "${g.slug}" has no "${target.envSlug}" environment — create it before granting`);
|
|
16686
|
-
await grantEnv(slice.id);
|
|
16687
|
-
}
|
|
16688
|
-
for (const [groupSlug, envSlug] of Object.entries(options.allow ?? {})) {
|
|
16689
|
-
const groupRef = await resolveGroup(ctx, {
|
|
16690
|
-
org: options.org,
|
|
16691
|
-
group: groupSlug
|
|
16692
|
-
});
|
|
16693
|
-
const { environments } = await ctx.client.listGroupEnvs(target.orgId, groupRef.id);
|
|
16694
|
-
const slice = environments.find((e) => e.slug === envSlug);
|
|
16695
|
-
if (!slice) fail(`group "${groupSlug}" has no "${envSlug}" environment`);
|
|
16696
|
-
await grantEnv(slice.id);
|
|
16697
|
-
}
|
|
16993
|
+
} catch (error) {
|
|
16994
|
+
console.error(`Token created with its default environment keys, but extra grant ${extra.environmentId} failed: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
16698
16995
|
}
|
|
16699
16996
|
const scope = "appSlug" in target ? `${target.appSlug}/${target.envSlug}` : "org-scoped (admin)";
|
|
16700
16997
|
console.error(`${role} token created${granted ? " and granted" : ""} for ${scope} — save it now, it is not stored:`);
|
|
@@ -16748,7 +17045,7 @@ registerKmsCommands(program);
|
|
|
16748
17045
|
registerRotationCommands(program);
|
|
16749
17046
|
registerRecoveryCommands(program);
|
|
16750
17047
|
program.command("mcp").description("run an MCP server over stdio so AI agents can drive seekrit").action(async () => {
|
|
16751
|
-
const { runMcpServer } = await import("./mcp-
|
|
17048
|
+
const { runMcpServer } = await import("./mcp-Dgj-7S9y.js");
|
|
16752
17049
|
await runMcpServer();
|
|
16753
17050
|
});
|
|
16754
17051
|
registerAuditCommands(program);
|
|
@@ -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
|
|
@@ -527,7 +527,8 @@ async function runMcpServer(options = {}) {
|
|
|
527
527
|
return (await ctx.client.createEnv(orgRef.id, appRow.id, {
|
|
528
528
|
name,
|
|
529
529
|
slug,
|
|
530
|
-
wrappedDek
|
|
530
|
+
wrappedDek,
|
|
531
|
+
userGrants: await ctx.client.wrapNewEnvironmentAccess(orgRef.id, { appId: appRow.id }, dek)
|
|
531
532
|
})).environment;
|
|
532
533
|
});
|
|
533
534
|
tool("create_branch", "Fork an environment into an ephemeral branch (a per-PR / preview config). The branch inherits the parent's secrets by layering at read time — nothing is copied or re-encrypted — and holds only the values you override on it. Generates the branch's data key locally, grants it to the caller, and shares it with the parent's other readers.", { idempotent: false }, {
|
|
@@ -595,7 +596,8 @@ async function runMcpServer(options = {}) {
|
|
|
595
596
|
return (await ctx.client.createGroupEnv(g.orgId, g.id, {
|
|
596
597
|
name,
|
|
597
598
|
slug,
|
|
598
|
-
wrappedDek
|
|
599
|
+
wrappedDek,
|
|
600
|
+
userGrants: await ctx.client.wrapNewEnvironmentAccess(g.orgId, { groupId: g.id }, dek)
|
|
599
601
|
})).environment;
|
|
600
602
|
});
|
|
601
603
|
tool("compose_group", "Compose a group into an application environment (higher position wins on name clashes).", { idempotent: true }, {
|
|
@@ -617,6 +619,7 @@ async function runMcpServer(options = {}) {
|
|
|
617
619
|
});
|
|
618
620
|
const syncGrants = await syncGrantsForGroupLink(ctx, target.orgId, target.envId, target.envSlug, g.id);
|
|
619
621
|
return (await ctx.client.linkEnvGroup(target.orgId, target.envId, {
|
|
622
|
+
appAccessGrants: await ctx.client.wrapGroupAppAccess(target.orgId, target.envId, g.id, (id) => getDek(ctx, target.orgId, id)),
|
|
620
623
|
groupId: g.id,
|
|
621
624
|
position,
|
|
622
625
|
syncGrants
|
|
@@ -819,32 +822,21 @@ async function runMcpServer(options = {}) {
|
|
|
819
822
|
env
|
|
820
823
|
}) : { orgId: (await resolveOrg(ctx, org)).id };
|
|
821
824
|
const created = await createServiceToken();
|
|
825
|
+
if (boundToEnv) ensureDecryptable(ctx);
|
|
826
|
+
const initialGrants = "envId" in target ? await ctx.client.prepareComposedKeyGrants(target.orgId, target.envId, {
|
|
827
|
+
principalType: "service_token",
|
|
828
|
+
principalId: created.tokenId,
|
|
829
|
+
publicKeyJwk: created.publicKeyJwk
|
|
830
|
+
}, (id) => getDek(ctx, target.orgId, id)) : void 0;
|
|
822
831
|
await ctx.client.createToken(target.orgId, {
|
|
823
832
|
name,
|
|
824
833
|
tokenId: created.tokenId,
|
|
825
834
|
tokenHash: created.tokenHash,
|
|
826
835
|
publicKeyJwk: created.publicKeyJwk,
|
|
827
836
|
role,
|
|
828
|
-
environmentId: "envId" in target ? target.envId : null
|
|
837
|
+
environmentId: "envId" in target ? target.envId : null,
|
|
838
|
+
initialGrants
|
|
829
839
|
});
|
|
830
|
-
if (boundToEnv && "envId" in target) {
|
|
831
|
-
ensureDecryptable(ctx);
|
|
832
|
-
const grantEnv = async (envId) => {
|
|
833
|
-
const dek = await getDek(ctx, target.orgId, envId);
|
|
834
|
-
await ctx.client.grantEnvKey(target.orgId, envId, {
|
|
835
|
-
principalType: "service_token",
|
|
836
|
-
principalId: created.tokenId,
|
|
837
|
-
wrappedDek: await wrapDek(dek, created.publicKeyJwk)
|
|
838
|
-
});
|
|
839
|
-
};
|
|
840
|
-
await grantEnv(target.envId);
|
|
841
|
-
const { groups } = await ctx.client.listEnvGroups(target.orgId, target.envId);
|
|
842
|
-
for (const g of groups) {
|
|
843
|
-
const { environments } = await ctx.client.listGroupEnvs(target.orgId, g.groupId);
|
|
844
|
-
const slice = environments.find((e) => e.slug === target.envSlug);
|
|
845
|
-
if (slice) await grantEnv(slice.id);
|
|
846
|
-
}
|
|
847
|
-
}
|
|
848
840
|
return {
|
|
849
841
|
token: created.token,
|
|
850
842
|
tokenId: created.tokenId,
|
|
@@ -873,7 +865,6 @@ async function runMcpServer(options = {}) {
|
|
|
873
865
|
const ctx = getCtx();
|
|
874
866
|
ensureDecryptable(ctx);
|
|
875
867
|
const { orgId, envId, label } = await resolveEnvTarget(ctx, o);
|
|
876
|
-
const dek = await getDek(ctx, orgId, envId);
|
|
877
868
|
let principalType;
|
|
878
869
|
let principalId;
|
|
879
870
|
let publicKeyJwk;
|
|
@@ -897,11 +888,11 @@ async function runMcpServer(options = {}) {
|
|
|
897
888
|
t.publicKeyJwk
|
|
898
889
|
];
|
|
899
890
|
}
|
|
900
|
-
await ctx.client.
|
|
891
|
+
await ctx.client.grantEnvironmentAccess(orgId, envId, {
|
|
901
892
|
principalType,
|
|
902
893
|
principalId,
|
|
903
|
-
|
|
904
|
-
});
|
|
894
|
+
publicKeyJwk
|
|
895
|
+
}, (id) => getDek(ctx, orgId, id));
|
|
905
896
|
return {
|
|
906
897
|
ok: true,
|
|
907
898
|
env: label,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@seekrit/cli",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.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": [
|
|
@@ -46,9 +46,9 @@
|
|
|
46
46
|
"@types/node": "^26.6.5",
|
|
47
47
|
"tsdown": "^0.23.1",
|
|
48
48
|
"vitest": "^5.0.5",
|
|
49
|
-
"@seekrit/crypto": "0.0.1",
|
|
50
49
|
"@seekrit/api-client": "0.0.1",
|
|
51
|
-
"@seekrit/core": "0.0.1"
|
|
50
|
+
"@seekrit/core": "0.0.1",
|
|
51
|
+
"@seekrit/crypto": "0.0.1"
|
|
52
52
|
},
|
|
53
53
|
"scripts": {
|
|
54
54
|
"build": "tsdown",
|