@seekrit/cli 0.25.0 → 0.26.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 +636 -421
- package/dist/{mcp-COWsshZZ.js → mcp-DLplPOvz.js} +71 -1
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -51,6 +51,12 @@ const ENTITLEMENT_KEYS = Object.keys({
|
|
|
51
51
|
description: "Maximum environments under a single application.",
|
|
52
52
|
default: null
|
|
53
53
|
},
|
|
54
|
+
"branches.per_app.max": {
|
|
55
|
+
kind: "limit",
|
|
56
|
+
label: "Branch configs per application",
|
|
57
|
+
description: "Maximum ephemeral branch environments under a single application.",
|
|
58
|
+
default: null
|
|
59
|
+
},
|
|
54
60
|
"secrets.per_env.max": {
|
|
55
61
|
kind: "limit",
|
|
56
62
|
label: "Secrets per environment",
|
|
@@ -144,6 +150,51 @@ const SUBSCRIPTION_STATUSES = [
|
|
|
144
150
|
"paused"
|
|
145
151
|
];
|
|
146
152
|
//#endregion
|
|
153
|
+
//#region ../../packages/core/src/branches.ts
|
|
154
|
+
/**
|
|
155
|
+
* Branch (ephemeral) environments — a per-PR/preview overlay on an existing
|
|
156
|
+
* application environment.
|
|
157
|
+
*
|
|
158
|
+
* A branch is an ordinary environment row with a parent and a TTL. It is an
|
|
159
|
+
* **overlay, not a copy**: resolve returns the parent's layers and then the
|
|
160
|
+
* branch's own on top, so a branch holds only the values that differ and
|
|
161
|
+
* tracks the parent live. Nothing is re-encrypted at creation — a secret's
|
|
162
|
+
* ciphertext is bound to `(environmentId, name)` as AAD, so copying blobs into
|
|
163
|
+
* a new environment could not decrypt anyway, and a snapshot would immediately
|
|
164
|
+
* drift from its base.
|
|
165
|
+
*
|
|
166
|
+
* Two rules keep the read path cheap and predictable, enforced here:
|
|
167
|
+
*
|
|
168
|
+
* - **Depth one.** A branch's parent must not itself be a branch, so resolve
|
|
169
|
+
* never recurses on the hot path.
|
|
170
|
+
* - **Application environments only.** Group environments are pulled in by
|
|
171
|
+
* composition (matched by slug) and have no single parent to overlay.
|
|
172
|
+
*/
|
|
173
|
+
/** Longest life a branch may be given. Bounds sprawl even if nobody cleans up. */
|
|
174
|
+
const MAX_BRANCH_TTL_SECONDS = 720 * 60 * 60;
|
|
175
|
+
/**
|
|
176
|
+
* Parse a human TTL — `30m`, `12h`, `7d`, `2w`, or bare seconds — into seconds.
|
|
177
|
+
* Returns null for anything unparseable, so callers can report the input back.
|
|
178
|
+
* `never` / `none` mean "no expiry" and yield `Infinity`, which
|
|
179
|
+
* {@link planBranchCreate} rejects unless passed as an explicit `null`.
|
|
180
|
+
*/
|
|
181
|
+
function parseBranchTtl(input) {
|
|
182
|
+
const raw = input.trim().toLowerCase();
|
|
183
|
+
if (raw === "never" || raw === "none") return Number.POSITIVE_INFINITY;
|
|
184
|
+
const match = /^(\d+)\s*(s|m|h|d|w)?$/.exec(raw);
|
|
185
|
+
if (!match) return null;
|
|
186
|
+
const value = Number(match[1]);
|
|
187
|
+
const multiplier = {
|
|
188
|
+
s: 1,
|
|
189
|
+
m: 60,
|
|
190
|
+
h: 3600,
|
|
191
|
+
d: 86400,
|
|
192
|
+
w: 604800
|
|
193
|
+
}[match[2] ?? "s"];
|
|
194
|
+
if (multiplier === void 0) return null;
|
|
195
|
+
return value * multiplier;
|
|
196
|
+
}
|
|
197
|
+
//#endregion
|
|
147
198
|
//#region ../../packages/core/src/interpolate.ts
|
|
148
199
|
/**
|
|
149
200
|
* Secret references: `${OTHER_SECRET}` inside a secret value.
|
|
@@ -864,11 +915,22 @@ z.object({
|
|
|
864
915
|
*/
|
|
865
916
|
encryptedPrivateKey: z.string().min(1)
|
|
866
917
|
});
|
|
867
|
-
z.object({
|
|
918
|
+
const grantEnvironmentKeySchema = z.object({
|
|
868
919
|
principalType: principalTypeSchema,
|
|
869
920
|
principalId: z.string().min(1),
|
|
870
921
|
wrappedDek: z.string().min(1)
|
|
871
922
|
});
|
|
923
|
+
z.object({
|
|
924
|
+
slug: slugSchema,
|
|
925
|
+
/** Display name; defaults to the slug. */
|
|
926
|
+
name: nameSchema.optional(),
|
|
927
|
+
ttlSeconds: z.number().int().min(60).max(MAX_BRANCH_TTL_SECONDS).nullish(),
|
|
928
|
+
/** The branch's own DEK, wrapped to the creator — generated client-side. */
|
|
929
|
+
wrappedDek: z.string().min(1),
|
|
930
|
+
recoveryWrappedDek: z.string().min(1).nullish(),
|
|
931
|
+
/** The same DEK wrapped to each of the parent's existing grant-holders. */
|
|
932
|
+
grants: z.array(grantEnvironmentKeySchema).max(500).default([])
|
|
933
|
+
});
|
|
872
934
|
z.object({
|
|
873
935
|
name: nameSchema,
|
|
874
936
|
tokenId: z.string().regex(/^skt_[0-9A-Za-z]+$/),
|
|
@@ -2124,7 +2186,7 @@ function isServiceToken(value) {
|
|
|
2124
2186
|
}
|
|
2125
2187
|
//#endregion
|
|
2126
2188
|
//#region package.json
|
|
2127
|
-
var version = "0.
|
|
2189
|
+
var version = "0.26.0";
|
|
2128
2190
|
//#endregion
|
|
2129
2191
|
//#region ../../packages/api-client/src/index.ts
|
|
2130
2192
|
var SeekritApiError = class extends Error {
|
|
@@ -2253,6 +2315,28 @@ var SeekritClient = class {
|
|
|
2253
2315
|
deleteEnv(orgId, envId) {
|
|
2254
2316
|
return this.request("DELETE", `/v1/orgs/${orgId}/envs/${envId}`);
|
|
2255
2317
|
}
|
|
2318
|
+
/**
|
|
2319
|
+
* The public keys of an environment's grant-holders, so a client can wrap a
|
|
2320
|
+
* new DEK to each of them (see `createBranch`). No key material is returned.
|
|
2321
|
+
*/
|
|
2322
|
+
listGrantees(orgId, envId) {
|
|
2323
|
+
return this.request("GET", `/v1/orgs/${orgId}/envs/${envId}/grantees`);
|
|
2324
|
+
}
|
|
2325
|
+
listBranches(orgId, envId) {
|
|
2326
|
+
return this.request("GET", `/v1/orgs/${orgId}/envs/${envId}/branches`);
|
|
2327
|
+
}
|
|
2328
|
+
/** Every branch in an application, across all its environments. */
|
|
2329
|
+
listAppBranches(orgId, appId) {
|
|
2330
|
+
return this.request("GET", `/v1/orgs/${orgId}/apps/${appId}/branches`);
|
|
2331
|
+
}
|
|
2332
|
+
/** Fork `envId` into an ephemeral branch. `envId` is the parent, not the branch. */
|
|
2333
|
+
createBranch(orgId, envId, input) {
|
|
2334
|
+
return this.request("POST", `/v1/orgs/${orgId}/envs/${envId}/branches`, input);
|
|
2335
|
+
}
|
|
2336
|
+
/** Branches are environments, so tearing one down is `deleteEnv`. */
|
|
2337
|
+
deleteBranch(orgId, branchId) {
|
|
2338
|
+
return this.request("DELETE", `/v1/orgs/${orgId}/envs/${branchId}`);
|
|
2339
|
+
}
|
|
2256
2340
|
listGroups(orgId) {
|
|
2257
2341
|
return this.request("GET", `/v1/orgs/${orgId}/groups`);
|
|
2258
2342
|
}
|
|
@@ -2288,6 +2372,7 @@ var SeekritClient = class {
|
|
|
2288
2372
|
resolve(query = {}) {
|
|
2289
2373
|
const params = new URLSearchParams();
|
|
2290
2374
|
if (query.env) params.set("env", query.env);
|
|
2375
|
+
if (query.branch) params.set("branch", query.branch);
|
|
2291
2376
|
for (const [group, slug] of Object.entries(query.with ?? {})) params.append("with", `${group}:${slug}`);
|
|
2292
2377
|
const qs = params.size > 0 ? `?${params}` : "";
|
|
2293
2378
|
return this.request("GET", `/v1/resolve${qs}`);
|
|
@@ -2681,12 +2766,14 @@ async function resolveOrg(ctx, orgSlug) {
|
|
|
2681
2766
|
}
|
|
2682
2767
|
/**
|
|
2683
2768
|
* Resolve an environment to operate on — an application env (`--app --env`,
|
|
2684
|
-
* or the config's app + `--env`)
|
|
2769
|
+
* or the config's app + `--env`), a branch of one (`--branch`), or a group env
|
|
2770
|
+
* (`--group --env`).
|
|
2685
2771
|
*/
|
|
2686
2772
|
async function resolveEnvTarget(ctx, opts) {
|
|
2687
2773
|
const org = await resolveOrg(ctx, opts.org);
|
|
2688
2774
|
if (!opts.env) fail("specify --env");
|
|
2689
2775
|
if (opts.group) {
|
|
2776
|
+
if (opts.branch) fail("--branch applies to application environments, not groups");
|
|
2690
2777
|
const { groups } = await ctx.client.listGroups(org.id);
|
|
2691
2778
|
const group = groups.find((g) => g.slug === opts.group || g.id === opts.group);
|
|
2692
2779
|
if (!group) fail(`no group "${opts.group}" in ${org.slug}`);
|
|
@@ -2699,34 +2786,59 @@ async function resolveEnvTarget(ctx, opts) {
|
|
|
2699
2786
|
label: `${group.slug}@${env.slug}`
|
|
2700
2787
|
};
|
|
2701
2788
|
}
|
|
2702
|
-
const
|
|
2703
|
-
if (!appSlug) fail("specify --app or --group (or run `seekrit init`)");
|
|
2704
|
-
const { apps } = await ctx.client.listApps(org.id);
|
|
2705
|
-
const app = apps.find((a) => a.slug === appSlug || a.id === appSlug);
|
|
2706
|
-
if (!app) fail(`no app "${appSlug}" in ${org.slug}`);
|
|
2789
|
+
const app = await resolveApp(ctx, opts);
|
|
2707
2790
|
const { environments } = await ctx.client.listEnvs(org.id, app.id);
|
|
2708
2791
|
const env = environments.find((e) => e.slug === opts.env || e.id === opts.env);
|
|
2709
2792
|
if (!env) fail(`no environment "${opts.env}" in ${app.slug}`);
|
|
2793
|
+
if (opts.branch) {
|
|
2794
|
+
const branch = await resolveBranch(ctx, app, opts.branch);
|
|
2795
|
+
return {
|
|
2796
|
+
orgId: org.id,
|
|
2797
|
+
envId: branch.id,
|
|
2798
|
+
label: `${app.slug}/${env.slug}#${branch.slug}`
|
|
2799
|
+
};
|
|
2800
|
+
}
|
|
2710
2801
|
return {
|
|
2711
2802
|
orgId: org.id,
|
|
2712
2803
|
envId: env.id,
|
|
2713
2804
|
label: `${app.slug}/${env.slug}`
|
|
2714
2805
|
};
|
|
2715
2806
|
}
|
|
2716
|
-
/** Resolve
|
|
2717
|
-
async function
|
|
2807
|
+
/** Resolve the target application from a flag or the committed config. */
|
|
2808
|
+
async function resolveApp(ctx, opts) {
|
|
2718
2809
|
const org = await resolveOrg(ctx, opts.org);
|
|
2719
2810
|
const appSlug = opts.app ?? findProjectConfig()?.app;
|
|
2720
2811
|
if (!appSlug) fail("specify --app (or run `seekrit init`)");
|
|
2721
|
-
if (!opts.env) fail("specify --env");
|
|
2722
2812
|
const { apps } = await ctx.client.listApps(org.id);
|
|
2723
2813
|
const app = apps.find((a) => a.slug === appSlug || a.id === appSlug);
|
|
2724
2814
|
if (!app) fail(`no app "${appSlug}" in ${org.slug}`);
|
|
2725
|
-
|
|
2815
|
+
return {
|
|
2816
|
+
orgId: org.id,
|
|
2817
|
+
orgSlug: org.slug,
|
|
2818
|
+
id: app.id,
|
|
2819
|
+
slug: app.slug
|
|
2820
|
+
};
|
|
2821
|
+
}
|
|
2822
|
+
/**
|
|
2823
|
+
* Find a branch by slug (or id) anywhere in an application. Branch slugs share
|
|
2824
|
+
* the application's environment namespace, so one lookup is unambiguous — no
|
|
2825
|
+
* need to name the parent environment.
|
|
2826
|
+
*/
|
|
2827
|
+
async function resolveBranch(ctx, app, branchRef) {
|
|
2828
|
+
const { branches } = await ctx.client.listAppBranches(app.orgId, app.id);
|
|
2829
|
+
const branch = branches.find((b) => b.slug === branchRef || b.id === branchRef);
|
|
2830
|
+
if (!branch) fail(`no branch "${branchRef}" in ${app.slug}`);
|
|
2831
|
+
return branch;
|
|
2832
|
+
}
|
|
2833
|
+
/** Resolve an application environment, keeping ids + slugs (for token binding). */
|
|
2834
|
+
async function resolveAppEnv(ctx, opts) {
|
|
2835
|
+
if (!opts.env) fail("specify --env");
|
|
2836
|
+
const app = await resolveApp(ctx, opts);
|
|
2837
|
+
const { environments } = await ctx.client.listEnvs(app.orgId, app.id);
|
|
2726
2838
|
const env = environments.find((e) => e.slug === opts.env || e.id === opts.env);
|
|
2727
2839
|
if (!env) fail(`no environment "${opts.env}" in ${app.slug}`);
|
|
2728
2840
|
return {
|
|
2729
|
-
orgId:
|
|
2841
|
+
orgId: app.orgId,
|
|
2730
2842
|
appId: app.id,
|
|
2731
2843
|
appSlug: app.slug,
|
|
2732
2844
|
envId: env.id,
|
|
@@ -2888,198 +3000,6 @@ function registerAwsCommands(program) {
|
|
|
2888
3000
|
});
|
|
2889
3001
|
}
|
|
2890
3002
|
//#endregion
|
|
2891
|
-
//#region src/dotenv.ts
|
|
2892
|
-
/**
|
|
2893
|
-
* Minimal `.env` parser: `KEY=VALUE`, `#` comments, an optional `export`
|
|
2894
|
-
* prefix, and single/double-quoted values (double quotes honor `\n \t \r \" \\`
|
|
2895
|
-
* escapes; unquoted values drop trailing ` # comments`). Multiline values are
|
|
2896
|
-
* not supported — keep those in seekrit itself.
|
|
2897
|
-
*/
|
|
2898
|
-
function parseDotenv(content) {
|
|
2899
|
-
const out = {};
|
|
2900
|
-
for (const raw of content.split(/\r?\n/)) {
|
|
2901
|
-
let line = raw.trim();
|
|
2902
|
-
if (!line || line.startsWith("#")) continue;
|
|
2903
|
-
if (line.startsWith("export ")) line = line.slice(7).trimStart();
|
|
2904
|
-
const eq = line.indexOf("=");
|
|
2905
|
-
if (eq === -1) continue;
|
|
2906
|
-
const key = line.slice(0, eq).trim();
|
|
2907
|
-
if (!key) continue;
|
|
2908
|
-
let value = line.slice(eq + 1).trim();
|
|
2909
|
-
const quote = value[0];
|
|
2910
|
-
if (value.length >= 2 && (quote === "\"" || quote === "'") && value.at(-1) === quote) {
|
|
2911
|
-
value = value.slice(1, -1);
|
|
2912
|
-
if (quote === "\"") value = value.replace(/\\n/g, "\n").replace(/\\r/g, "\r").replace(/\\t/g, " ").replace(/\\"/g, "\"").replace(/\\\\/g, "\\");
|
|
2913
|
-
} else {
|
|
2914
|
-
const comment = value.indexOf(" #");
|
|
2915
|
-
if (comment !== -1) value = value.slice(0, comment).trim();
|
|
2916
|
-
}
|
|
2917
|
-
out[key] = value;
|
|
2918
|
-
}
|
|
2919
|
-
return out;
|
|
2920
|
-
}
|
|
2921
|
-
//#endregion
|
|
2922
|
-
//#region src/format.ts
|
|
2923
|
-
function needsQuoting(value) {
|
|
2924
|
-
return /[\s"'`$\\#]/.test(value) || value === "";
|
|
2925
|
-
}
|
|
2926
|
-
function dotenvQuote(value) {
|
|
2927
|
-
if (!needsQuoting(value)) return value;
|
|
2928
|
-
return `"${value.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"").replaceAll("\n", "\\n")}"`;
|
|
2929
|
-
}
|
|
2930
|
-
function shellQuote(value) {
|
|
2931
|
-
return `'${value.replaceAll("'", `'\\''`)}'`;
|
|
2932
|
-
}
|
|
2933
|
-
function formatSecrets(values, format) {
|
|
2934
|
-
const names = Object.keys(values).sort();
|
|
2935
|
-
switch (format) {
|
|
2936
|
-
case "json": return JSON.stringify(values, names, 2);
|
|
2937
|
-
case "shell": return names.map((name) => `export ${name}=${shellQuote(values[name] ?? "")}`).join("\n");
|
|
2938
|
-
case "dotenv": return names.map((name) => `${name}=${dotenvQuote(values[name] ?? "")}`).join("\n");
|
|
2939
|
-
}
|
|
2940
|
-
}
|
|
2941
|
-
//#endregion
|
|
2942
|
-
//#region src/gcp.ts
|
|
2943
|
-
/**
|
|
2944
|
-
* `seekrit gcp` — temporary GCP credentials via IAM Credentials
|
|
2945
|
-
* `generateAccessToken` (Vault-style dynamic secrets, the tier-2 sibling of
|
|
2946
|
-
* `seekrit aws`).
|
|
2947
|
-
*
|
|
2948
|
-
* Zero-knowledge for the leased credential: minting generates an ephemeral P-256
|
|
2949
|
-
* keypair on THIS machine and sends only the public key; GCP mints the token and
|
|
2950
|
-
* the broker returns it wrapped to that key, so the control plane only ever
|
|
2951
|
-
* relays ciphertext and only this machine can unwrap it. Registering a target
|
|
2952
|
-
* wraps the service-account key JSON to the broker's public key locally, so the
|
|
2953
|
-
* control plane never sees it either — the source service account needs only
|
|
2954
|
-
* `roles/iam.serviceAccountTokenCreator` on the target.
|
|
2955
|
-
*/
|
|
2956
|
-
/** Parse a duration like `30m`, `1h`, or a bare seconds count. */
|
|
2957
|
-
function parseTtlSeconds$5(input) {
|
|
2958
|
-
const m = /^(\d+)\s*([smhd]?)$/.exec(input.trim());
|
|
2959
|
-
if (!m) fail(`invalid --ttl "${input}" (try 30m, 1h, 12h)`);
|
|
2960
|
-
return Number(m[1]) * ({
|
|
2961
|
-
s: 1,
|
|
2962
|
-
m: 60,
|
|
2963
|
-
h: 3600,
|
|
2964
|
-
d: 86400
|
|
2965
|
-
}[m[2] || "s"] ?? 1);
|
|
2966
|
-
}
|
|
2967
|
-
/** Collect a repeatable flag (e.g. --scope) into a list. */
|
|
2968
|
-
function collectList$1(value, acc = []) {
|
|
2969
|
-
acc.push(value);
|
|
2970
|
-
return acc;
|
|
2971
|
-
}
|
|
2972
|
-
/**
|
|
2973
|
-
* The service-account key JSON the broker impersonates with. From --key-file or
|
|
2974
|
-
* GOOGLE_APPLICATION_CREDENTIALS. Never leaves this machine unwrapped — it is
|
|
2975
|
-
* wrapped to the broker key before upload.
|
|
2976
|
-
*/
|
|
2977
|
-
function resolveServiceAccountKey(opts) {
|
|
2978
|
-
const path = opts.keyFile ?? process.env.GOOGLE_APPLICATION_CREDENTIALS;
|
|
2979
|
-
if (!path) fail("provide the source service-account key JSON via --key-file or GOOGLE_APPLICATION_CREDENTIALS (it needs roles/iam.serviceAccountTokenCreator on the target)");
|
|
2980
|
-
const raw = readFileSync(path, "utf8").trim();
|
|
2981
|
-
try {
|
|
2982
|
-
const parsed = JSON.parse(raw);
|
|
2983
|
-
if (typeof parsed.client_email !== "string" || typeof parsed.private_key !== "string") fail(`${path} is not a service-account key JSON (missing client_email/private_key)`);
|
|
2984
|
-
} catch {
|
|
2985
|
-
fail(`${path} is not valid JSON`);
|
|
2986
|
-
}
|
|
2987
|
-
return raw;
|
|
2988
|
-
}
|
|
2989
|
-
function registerGcpCommands(program) {
|
|
2990
|
-
const gcp = program.command("gcp").description("temporary GCP credentials (IAM generateAccessToken, zero-knowledge)");
|
|
2991
|
-
const target = gcp.command("target").description("manage GCP service-account targets");
|
|
2992
|
-
target.command("add").description("register an impersonable service account to issue temporary tokens from").requiredOption("--name <name>", "display name, e.g. prod-deploy").requiredOption("--service-account <email>", "the service account to impersonate, name@project.iam.gserviceaccount.com").option("--org <slug>").option("--scope <scope>", "OAuth scope to grant (repeatable; default cloud-platform)", collectList$1).option("--delegate <email>", "delegation-chain service account (repeatable)", collectList$1).option("--max-ttl <duration>", "clamp requested token lifetime, e.g. 1h").option("--key-file <path>", "source SA key JSON (else GOOGLE_APPLICATION_CREDENTIALS)").action(async (options) => {
|
|
2993
|
-
const ctx = buildContext();
|
|
2994
|
-
const org = await resolveOrg(ctx, options.org);
|
|
2995
|
-
const config = {
|
|
2996
|
-
provider: "gcp",
|
|
2997
|
-
executor: "in_do",
|
|
2998
|
-
serviceAccount: options.serviceAccount,
|
|
2999
|
-
...options.scope?.length ? { scopes: options.scope } : {},
|
|
3000
|
-
...options.delegate?.length ? { delegates: options.delegate } : {},
|
|
3001
|
-
...options.maxTtl ? { maxTtlSeconds: parseTtlSeconds$5(options.maxTtl) } : {}
|
|
3002
|
-
};
|
|
3003
|
-
const keyJson = resolveServiceAccountKey(options);
|
|
3004
|
-
const { publicKeyJwk } = await ctx.client.getLeaseBrokerKey(org.id);
|
|
3005
|
-
const wrappedAdminSecret = await wrapDek(new TextEncoder().encode(keyJson), publicKeyJwk);
|
|
3006
|
-
const { target: created } = await ctx.client.registerLeaseTarget(org.id, {
|
|
3007
|
-
name: options.name,
|
|
3008
|
-
config,
|
|
3009
|
-
wrappedAdminSecret
|
|
3010
|
-
});
|
|
3011
|
-
console.error(`registered GCP target ${created.name} (${created.id})`);
|
|
3012
|
-
console.error("\nGrant the source SA the token-creator role, then `seekrit gcp lease`:\n");
|
|
3013
|
-
console.log(gcpSetupInstructions(config));
|
|
3014
|
-
});
|
|
3015
|
-
target.command("list").description("list GCP service-account targets").option("--org <slug>").action(async (options) => {
|
|
3016
|
-
const ctx = buildContext();
|
|
3017
|
-
const org = await resolveOrg(ctx, options.org);
|
|
3018
|
-
const { targets } = await ctx.client.listLeaseTargets(org.id);
|
|
3019
|
-
for (const t of targets) {
|
|
3020
|
-
const cfg = t.config;
|
|
3021
|
-
if (cfg.provider !== "gcp") continue;
|
|
3022
|
-
console.log(`${t.id}\t${t.name}\t${cfg.serviceAccount}`);
|
|
3023
|
-
}
|
|
3024
|
-
});
|
|
3025
|
-
target.command("setup <targetId>").description("reprint the IAM setup for a GCP target").option("--org <slug>").action(async (targetId, options) => {
|
|
3026
|
-
const ctx = buildContext();
|
|
3027
|
-
const org = await resolveOrg(ctx, options.org);
|
|
3028
|
-
const { targets } = await ctx.client.listLeaseTargets(org.id);
|
|
3029
|
-
const t = targets.find((x) => x.id === targetId || x.name === targetId);
|
|
3030
|
-
if (!t) fail(`no target "${targetId}" in ${org.slug}`);
|
|
3031
|
-
const cfg = t.config;
|
|
3032
|
-
if (cfg.provider !== "gcp") fail("not a gcp target (see `seekrit aws`/`seekrit ssh`)");
|
|
3033
|
-
console.log(gcpSetupInstructions(cfg));
|
|
3034
|
-
});
|
|
3035
|
-
target.command("rm <targetId>").description("delete a GCP service-account target").option("--org <slug>").action(async (targetId, options) => {
|
|
3036
|
-
const ctx = buildContext();
|
|
3037
|
-
const org = await resolveOrg(ctx, options.org);
|
|
3038
|
-
await ctx.client.deleteLeaseTarget(org.id, targetId);
|
|
3039
|
-
console.error(`deleted ${targetId}`);
|
|
3040
|
-
});
|
|
3041
|
-
gcp.command("lease <target>").description("mint a short-lived GCP access token; prints ready-to-source export lines").option("--org <slug>").option("--ttl <duration>", "token lifetime, e.g. 15m, 1h", "1h").option("--json", "print the full credential as JSON").action(async (targetRef, options) => {
|
|
3042
|
-
const ctx = buildContext();
|
|
3043
|
-
const org = await resolveOrg(ctx, options.org);
|
|
3044
|
-
const { targets } = await ctx.client.listLeaseTargets(org.id);
|
|
3045
|
-
const t = targets.find((x) => x.id === targetRef || x.name === targetRef);
|
|
3046
|
-
if (!t) fail(`no target "${targetRef}" in ${org.slug}`);
|
|
3047
|
-
if (t.config.provider !== "gcp") fail(`"${t.name}" is not a gcp target (see \`seekrit aws\`)`);
|
|
3048
|
-
const ttlSeconds = parseTtlSeconds$5(options.ttl);
|
|
3049
|
-
if (ttlSeconds < 60) fail(`--ttl must be at least 60s`);
|
|
3050
|
-
if (ttlSeconds > 43200) fail(`--ttl must be at most ${GCP_MAX_TTL_SECONDS / 3600}h`);
|
|
3051
|
-
const recipient = await generateGcpRecipientKeyPair();
|
|
3052
|
-
const { gcp: leased } = await ctx.client.mintLease(org.id, {
|
|
3053
|
-
provider: "gcp",
|
|
3054
|
-
targetId: t.id,
|
|
3055
|
-
recipientPublicKey: recipient.publicKeyJwk,
|
|
3056
|
-
ttlSeconds
|
|
3057
|
-
});
|
|
3058
|
-
const cred = await unwrapGcpCredential(leased.wrappedCredential, recipient.privateKeyJwk);
|
|
3059
|
-
console.error(`leased ${cred.serviceAccount} — expires ${cred.expiration}`);
|
|
3060
|
-
if (options.json) console.log(JSON.stringify(cred, null, 2));
|
|
3061
|
-
else {
|
|
3062
|
-
console.log(`export CLOUDSDK_AUTH_ACCESS_TOKEN=${cred.accessToken}`);
|
|
3063
|
-
console.log(`export GOOGLE_OAUTH_ACCESS_TOKEN=${cred.accessToken}`);
|
|
3064
|
-
}
|
|
3065
|
-
});
|
|
3066
|
-
gcp.command("leases").description("list GCP leases (the ledger — never secret material)").option("--org <slug>").action(async (options) => {
|
|
3067
|
-
const ctx = buildContext();
|
|
3068
|
-
const org = await resolveOrg(ctx, options.org);
|
|
3069
|
-
const { leases } = await ctx.client.listLeases(org.id);
|
|
3070
|
-
for (const l of leases) {
|
|
3071
|
-
if (l.provider !== "gcp") continue;
|
|
3072
|
-
console.log(`${l.id}\t${l.status}\texpires ${l.expiresAt}`);
|
|
3073
|
-
}
|
|
3074
|
-
});
|
|
3075
|
-
gcp.command("revoke <leaseId>").description("mark a lease revoked in the ledger (tokens stay valid until they expire)").option("--org <slug>").action(async (leaseId, options) => {
|
|
3076
|
-
const ctx = buildContext();
|
|
3077
|
-
const org = await resolveOrg(ctx, options.org);
|
|
3078
|
-
await ctx.client.revokeLease(org.id, leaseId);
|
|
3079
|
-
console.error(`revoked ${leaseId} (issued tokens remain valid until they expire)`);
|
|
3080
|
-
});
|
|
3081
|
-
}
|
|
3082
|
-
//#endregion
|
|
3083
3003
|
//#region src/kms.ts
|
|
3084
3004
|
/** Collect a repeatable option into a list. */
|
|
3085
3005
|
function collect$6(value, acc = []) {
|
|
@@ -3367,12 +3287,495 @@ function registerKmsCommands(program) {
|
|
|
3367
3287
|
});
|
|
3368
3288
|
}
|
|
3369
3289
|
//#endregion
|
|
3370
|
-
//#region src/
|
|
3290
|
+
//#region src/recovery.ts
|
|
3291
|
+
/** Collect a repeatable option into a list. */
|
|
3292
|
+
function collect$5(value, acc = []) {
|
|
3293
|
+
acc.push(value);
|
|
3294
|
+
return acc;
|
|
3295
|
+
}
|
|
3296
|
+
/** Resolve a custodian reference: a `skt_…` token id, otherwise a member email. */
|
|
3297
|
+
function resolveCustodian(ctx, orgId, ref) {
|
|
3298
|
+
return ref.startsWith("skt_") ? kmsResolveRecipient(ctx, orgId, { token: ref }) : kmsResolveRecipient(ctx, orgId, { user: ref });
|
|
3299
|
+
}
|
|
3371
3300
|
/**
|
|
3372
|
-
*
|
|
3373
|
-
*
|
|
3374
|
-
*
|
|
3375
|
-
|
|
3301
|
+
* The env DEK additionally wrapped to the org recovery key, when recovery is
|
|
3302
|
+
* enabled — so a newly created environment is recovery-protected from birth.
|
|
3303
|
+
* Returns undefined when recovery is off (the env is backfilled by `recovery
|
|
3304
|
+
* sync` later).
|
|
3305
|
+
*/
|
|
3306
|
+
async function recoveryWrapForNewEnv(ctx, orgId, dek) {
|
|
3307
|
+
let recoveryPublicKeyJwk;
|
|
3308
|
+
try {
|
|
3309
|
+
const { recovery } = await ctx.client.getRecovery(orgId);
|
|
3310
|
+
recoveryPublicKeyJwk = recovery.enabled ? recovery.recoveryPublicKeyJwk : null;
|
|
3311
|
+
} catch (e) {
|
|
3312
|
+
if (e instanceof SeekritApiError && (e.status === 403 || e.status === 404)) return void 0;
|
|
3313
|
+
throw e;
|
|
3314
|
+
}
|
|
3315
|
+
if (!recoveryPublicKeyJwk) return void 0;
|
|
3316
|
+
return wrapDek(dek, recoveryPublicKeyJwk);
|
|
3317
|
+
}
|
|
3318
|
+
/**
|
|
3319
|
+
* Wrap every environment the caller can decrypt but that lacks a recovery grant,
|
|
3320
|
+
* and upload the grants. Idempotent — safe to re-run and to run from several
|
|
3321
|
+
* admins to complete coverage.
|
|
3322
|
+
*/
|
|
3323
|
+
async function syncRecoveryGrants(ctx, orgId) {
|
|
3324
|
+
const { recovery } = await ctx.client.getRecovery(orgId);
|
|
3325
|
+
if (!recovery.enabled || !recovery.recoveryPublicKeyJwk) fail("recovery is not enabled");
|
|
3326
|
+
const recoveryPublicKeyJwk = recovery.recoveryPublicKeyJwk;
|
|
3327
|
+
const privateKey = await getPrivateKey(ctx);
|
|
3328
|
+
const grants = [];
|
|
3329
|
+
let skipped = 0;
|
|
3330
|
+
for (const environmentId of recovery.coverage.unprotectedEnvIds) {
|
|
3331
|
+
let wrappedDek;
|
|
3332
|
+
try {
|
|
3333
|
+
({wrappedDek} = await ctx.client.getMyEnvKey(orgId, environmentId));
|
|
3334
|
+
} catch (e) {
|
|
3335
|
+
if (e instanceof SeekritApiError && (e.status === 403 || e.status === 404)) {
|
|
3336
|
+
skipped++;
|
|
3337
|
+
continue;
|
|
3338
|
+
}
|
|
3339
|
+
throw e;
|
|
3340
|
+
}
|
|
3341
|
+
const dek = await unwrapDek(wrappedDek, privateKey);
|
|
3342
|
+
grants.push({
|
|
3343
|
+
environmentId,
|
|
3344
|
+
wrappedDek: await wrapDek(dek, recoveryPublicKeyJwk)
|
|
3345
|
+
});
|
|
3346
|
+
}
|
|
3347
|
+
if (grants.length > 0) await ctx.client.uploadRecoveryGrants(orgId, { grants });
|
|
3348
|
+
return {
|
|
3349
|
+
wrapped: grants.length,
|
|
3350
|
+
skipped
|
|
3351
|
+
};
|
|
3352
|
+
}
|
|
3353
|
+
/** Generate + split a fresh recovery key across the given custodians. */
|
|
3354
|
+
async function buildRecoveryConfig(ctx, orgId, thresholdRaw, custodianRefs) {
|
|
3355
|
+
const threshold = Number.parseInt(thresholdRaw, 10);
|
|
3356
|
+
if (!Number.isInteger(threshold) || threshold < 1) fail("--threshold must be a positive integer");
|
|
3357
|
+
if (custodianRefs.length === 0) fail("pass at least one --custodian <email|skt_id>");
|
|
3358
|
+
if (threshold > custodianRefs.length) fail("--threshold cannot exceed the number of custodians");
|
|
3359
|
+
const custodians = await Promise.all(custodianRefs.map((ref) => resolveCustodian(ctx, orgId, ref)));
|
|
3360
|
+
const recovery = await generateRecoveryKey();
|
|
3361
|
+
const shares = await splitRecoveryKey(recovery.privateKeyJwk, threshold, custodians);
|
|
3362
|
+
return {
|
|
3363
|
+
recoveryPublicKeyJwk: recovery.publicKeyJwk,
|
|
3364
|
+
threshold,
|
|
3365
|
+
shares: shares.map((s) => ({
|
|
3366
|
+
principalType: s.principalType,
|
|
3367
|
+
principalId: s.principalId,
|
|
3368
|
+
shareIndex: s.shareIndex,
|
|
3369
|
+
wrappedShare: s.wrappedShare
|
|
3370
|
+
}))
|
|
3371
|
+
};
|
|
3372
|
+
}
|
|
3373
|
+
function registerRecoveryCommands(program) {
|
|
3374
|
+
const recovery = program.command("recovery").description("customer-controlled M-of-N recovery (zero-knowledge)");
|
|
3375
|
+
recovery.command("status").description("show recovery configuration and environment coverage").option("--org <slug>").action(async (options) => {
|
|
3376
|
+
const ctx = buildContext();
|
|
3377
|
+
const org = await resolveOrg(ctx, options.org);
|
|
3378
|
+
const { recovery: status } = await ctx.client.getRecovery(org.id);
|
|
3379
|
+
if (!status.enabled) {
|
|
3380
|
+
console.log("recovery: disabled");
|
|
3381
|
+
return;
|
|
3382
|
+
}
|
|
3383
|
+
console.log(`recovery: enabled (${status.threshold}-of-${status.shareCount})`);
|
|
3384
|
+
console.log(`coverage: ${status.coverage.protected}/${status.coverage.total} environments protected`);
|
|
3385
|
+
console.log("custodians:");
|
|
3386
|
+
for (const cst of status.custodians) console.log(` - ${cst.label ?? cst.principalId} (${cst.principalType}, share #${cst.shareIndex})`);
|
|
3387
|
+
if (status.coverage.unprotectedEnvIds.length > 0) console.log(`${status.coverage.unprotectedEnvIds.length} environment(s) not yet protected — run \`seekrit recovery sync\``);
|
|
3388
|
+
});
|
|
3389
|
+
recovery.command("setup").description("enable recovery: split a fresh recovery key across custodians").requiredOption("--threshold <M>", "custodians required to recover (M in M-of-N)").option("--custodian <email|skt_id>", "a recovery custodian (repeatable)", collect$5, []).option("--org <slug>").action(async (options) => {
|
|
3390
|
+
const ctx = buildContext();
|
|
3391
|
+
const org = await resolveOrg(ctx, options.org);
|
|
3392
|
+
const config = await buildRecoveryConfig(ctx, org.id, options.threshold, options.custodian);
|
|
3393
|
+
await ctx.client.configureRecovery(org.id, {
|
|
3394
|
+
...config,
|
|
3395
|
+
grants: []
|
|
3396
|
+
});
|
|
3397
|
+
console.error(`recovery enabled: ${config.threshold}-of-${config.shares.length}`);
|
|
3398
|
+
const { wrapped, skipped } = await syncRecoveryGrants(ctx, org.id);
|
|
3399
|
+
console.error(`recovery-protected ${wrapped} environment(s) you can decrypt`);
|
|
3400
|
+
if (skipped > 0) console.error(`${skipped} environment(s) need another admin to run \`seekrit recovery sync\``);
|
|
3401
|
+
});
|
|
3402
|
+
recovery.command("sync").description("recovery-protect environments you can decrypt but that aren't yet covered").option("--org <slug>").action(async (options) => {
|
|
3403
|
+
const ctx = buildContext();
|
|
3404
|
+
const { wrapped, skipped } = await syncRecoveryGrants(ctx, (await resolveOrg(ctx, options.org)).id);
|
|
3405
|
+
console.error(`recovery-protected ${wrapped} environment(s); skipped ${skipped} you cannot decrypt`);
|
|
3406
|
+
});
|
|
3407
|
+
recovery.command("rotate").description("rotate the recovery key (new keypair, custodians, and env re-wraps)").requiredOption("--threshold <M>", "custodians required to recover (M in M-of-N)").option("--custodian <email|skt_id>", "a recovery custodian (repeatable)", collect$5, []).option("--org <slug>").action(async (options) => {
|
|
3408
|
+
const ctx = buildContext();
|
|
3409
|
+
const org = await resolveOrg(ctx, options.org);
|
|
3410
|
+
const config = await buildRecoveryConfig(ctx, org.id, options.threshold, options.custodian);
|
|
3411
|
+
await ctx.client.rotateRecovery(org.id, {
|
|
3412
|
+
...config,
|
|
3413
|
+
grants: []
|
|
3414
|
+
});
|
|
3415
|
+
console.error(`recovery rotated: ${config.threshold}-of-${config.shares.length}`);
|
|
3416
|
+
const { wrapped, skipped } = await syncRecoveryGrants(ctx, org.id);
|
|
3417
|
+
console.error(`re-wrapped ${wrapped} environment(s) you can decrypt to the new recovery key`);
|
|
3418
|
+
if (skipped > 0) console.error(`${skipped} environment(s) still need another admin to run \`seekrit recovery sync\``);
|
|
3419
|
+
});
|
|
3420
|
+
recovery.command("disable").description("disable recovery and remove all recovery grants").option("--org <slug>").action(async (options) => {
|
|
3421
|
+
const ctx = buildContext();
|
|
3422
|
+
const org = await resolveOrg(ctx, options.org);
|
|
3423
|
+
await ctx.client.disableRecovery(org.id);
|
|
3424
|
+
console.error("recovery disabled; recovery grants removed");
|
|
3425
|
+
});
|
|
3426
|
+
recovery.command("request").description("start a recovery ceremony (defaults to recovering access for yourself)").option("--target-user <email>", "recover access for another member").option("--target-token <id>", "recover access for a service token").option("--reason <text>", "note recorded in the audit trail").option("--org <slug>").action(async (options) => {
|
|
3427
|
+
const ctx = buildContext();
|
|
3428
|
+
const org = await resolveOrg(ctx, options.org);
|
|
3429
|
+
const target = options.targetUser || options.targetToken ? await kmsResolveRecipient(ctx, org.id, {
|
|
3430
|
+
user: options.targetUser,
|
|
3431
|
+
token: options.targetToken
|
|
3432
|
+
}) : await kmsCallerIdentity(ctx);
|
|
3433
|
+
const { request } = await ctx.client.createRecoveryRequest(org.id, {
|
|
3434
|
+
targetPublicKeyJwk: target.publicKeyJwk,
|
|
3435
|
+
targetType: target.principalType,
|
|
3436
|
+
targetId: target.principalId,
|
|
3437
|
+
reason: options.reason
|
|
3438
|
+
});
|
|
3439
|
+
console.error(`recovery request ${request.id} created (needs ${request.threshold} custodians)`);
|
|
3440
|
+
console.error(` custodians run: seekrit recovery approve ${request.id}`);
|
|
3441
|
+
console.error(` then the target: seekrit recovery complete ${request.id}`);
|
|
3442
|
+
});
|
|
3443
|
+
recovery.command("approve").description("as a custodian, contribute your share to a recovery request").argument("<requestId>").option("--org <slug>").action(async (requestId, options) => {
|
|
3444
|
+
const ctx = buildContext();
|
|
3445
|
+
const org = await resolveOrg(ctx, options.org);
|
|
3446
|
+
const { request } = await ctx.client.getRecoveryRequest(org.id, requestId);
|
|
3447
|
+
const myShare = await ctx.client.getMyRecoveryShare(org.id);
|
|
3448
|
+
const privateKey = await getPrivateKey(ctx);
|
|
3449
|
+
const contributedShare = await rewrapRecoveryShare(await unwrapRecoveryShare(myShare.wrappedShare, privateKey), request.targetPublicKeyJwk);
|
|
3450
|
+
const res = await ctx.client.contributeRecoveryShare(org.id, requestId, {
|
|
3451
|
+
shareIndex: myShare.shareIndex,
|
|
3452
|
+
contributedShare
|
|
3453
|
+
});
|
|
3454
|
+
console.error(`contributed share #${myShare.shareIndex}: ${res.contributed}/${res.threshold} collected${res.quorumReached ? " — quorum reached" : ""}`);
|
|
3455
|
+
});
|
|
3456
|
+
recovery.command("complete").description("as the recovery target, reconstruct the key and restore your access").argument("<requestId>").option("--org <slug>").action(async (requestId, options) => {
|
|
3457
|
+
const ctx = buildContext();
|
|
3458
|
+
const org = await resolveOrg(ctx, options.org);
|
|
3459
|
+
const { request, contributions, quorumReached } = await ctx.client.getRecoveryRequest(org.id, requestId);
|
|
3460
|
+
if (!quorumReached) fail(`only ${contributions.length}/${request.threshold} custodians have contributed`);
|
|
3461
|
+
const me = await kmsCallerIdentity(ctx);
|
|
3462
|
+
const targetPrivateKey = await getPrivateKey(ctx);
|
|
3463
|
+
const recoveryPrivateKey = await combineRecoveryShares(await Promise.all(contributions.map((cont) => unwrapRecoveryShare(cont.contributedShare, targetPrivateKey))));
|
|
3464
|
+
const { grants: recoveryEnvKeys } = await ctx.client.getRecoveryEnvKeys(org.id);
|
|
3465
|
+
const restored = [];
|
|
3466
|
+
for (const g of recoveryEnvKeys) {
|
|
3467
|
+
const dek = await unwrapDek(g.wrappedDek, recoveryPrivateKey);
|
|
3468
|
+
restored.push({
|
|
3469
|
+
environmentId: g.environmentId,
|
|
3470
|
+
wrappedDek: await wrapDek(dek, me.publicKeyJwk)
|
|
3471
|
+
});
|
|
3472
|
+
}
|
|
3473
|
+
await ctx.client.completeRecoveryRequest(org.id, requestId, {
|
|
3474
|
+
principalType: me.principalType,
|
|
3475
|
+
principalId: me.principalId,
|
|
3476
|
+
grants: restored
|
|
3477
|
+
});
|
|
3478
|
+
console.error(`recovery complete: restored access to ${restored.length} environment(s)`);
|
|
3479
|
+
});
|
|
3480
|
+
recovery.command("cancel").description("cancel an open recovery request").argument("<requestId>").option("--org <slug>").action(async (requestId, options) => {
|
|
3481
|
+
const ctx = buildContext();
|
|
3482
|
+
const org = await resolveOrg(ctx, options.org);
|
|
3483
|
+
await ctx.client.cancelRecoveryRequest(org.id, requestId);
|
|
3484
|
+
console.error(`recovery request ${requestId} canceled`);
|
|
3485
|
+
});
|
|
3486
|
+
}
|
|
3487
|
+
//#endregion
|
|
3488
|
+
//#region src/branches.ts
|
|
3489
|
+
/**
|
|
3490
|
+
* Resolve `--from` to the environment being branched.
|
|
3491
|
+
*
|
|
3492
|
+
* Not just `resolveAppEnv`: branch slugs live in the same namespace but are
|
|
3493
|
+
* excluded from the environment list, so naming one lands on "no environment
|
|
3494
|
+
* …". Branching a branch is a real thing people will try (depth is capped at
|
|
3495
|
+
* one), and it deserves an error that says so.
|
|
3496
|
+
*/
|
|
3497
|
+
async function resolveBranchParent(ctx, opts) {
|
|
3498
|
+
const app = await resolveApp(ctx, opts);
|
|
3499
|
+
const { environments } = await ctx.client.listEnvs(app.orgId, app.id);
|
|
3500
|
+
const env = environments.find((e) => e.slug === opts.from || e.id === opts.from);
|
|
3501
|
+
if (env) return {
|
|
3502
|
+
orgId: app.orgId,
|
|
3503
|
+
appId: app.id,
|
|
3504
|
+
appSlug: app.slug,
|
|
3505
|
+
envId: env.id,
|
|
3506
|
+
envSlug: env.slug
|
|
3507
|
+
};
|
|
3508
|
+
const { branches } = await ctx.client.listAppBranches(app.orgId, app.id);
|
|
3509
|
+
if (branches.some((b) => b.slug === opts.from || b.id === opts.from)) fail(`"${opts.from}" is itself a branch — branches are one level deep, so branch from the environment it overlays`);
|
|
3510
|
+
fail(`no environment "${opts.from}" in ${app.slug}`);
|
|
3511
|
+
}
|
|
3512
|
+
/**
|
|
3513
|
+
* Branch (ephemeral) configs: `seekrit branch create pr-142 --from dev`.
|
|
3514
|
+
*
|
|
3515
|
+
* A branch overlays its parent instead of copying it, so creating one encrypts
|
|
3516
|
+
* nothing — it mints a data key for the branch's own overrides and wraps that
|
|
3517
|
+
* key to whoever should read them. Everything the branch inherits stays where
|
|
3518
|
+
* it is, and stays live.
|
|
3519
|
+
*/
|
|
3520
|
+
function registerBranchCommands(program) {
|
|
3521
|
+
const branch = program.command("branch").description("ephemeral per-PR / preview configs layered on an environment");
|
|
3522
|
+
branch.command("create <slug>").description("fork an environment into an ephemeral branch (inherits its secrets)").option("--org <slug>").option("--app <slug>", "application slug (defaults to seekrit.json)").requiredOption("--from <env>", "the environment to branch").option("--name <name>", "display name (defaults to the slug)").option("--ttl <duration>", "lifetime: 12h, 7d, 2w, … or `never`", "7d").option("--no-share", "don't give the parent's other readers access to this branch").action(async (slug, options) => {
|
|
3523
|
+
const ctx = buildContext();
|
|
3524
|
+
const parent = await resolveBranchParent(ctx, {
|
|
3525
|
+
org: options.org,
|
|
3526
|
+
app: options.app,
|
|
3527
|
+
from: options.from
|
|
3528
|
+
});
|
|
3529
|
+
const parsedTtl = parseBranchTtl(options.ttl);
|
|
3530
|
+
if (parsedTtl === null) fail(`invalid --ttl "${options.ttl}" (try 12h, 7d, 2w, or never)`);
|
|
3531
|
+
const ttlSeconds = Number.isFinite(parsedTtl) ? parsedTtl : null;
|
|
3532
|
+
const me = await kmsCallerIdentity(ctx);
|
|
3533
|
+
const dek = generateDek();
|
|
3534
|
+
const wrappedDek = await wrapDek(dek, me.publicKeyJwk);
|
|
3535
|
+
const recoveryWrappedDek = await recoveryWrapForNewEnv(ctx, parent.orgId, dek);
|
|
3536
|
+
const grants = [];
|
|
3537
|
+
if (options.share !== false) {
|
|
3538
|
+
const { grantees } = await ctx.client.listGrantees(parent.orgId, parent.envId);
|
|
3539
|
+
for (const grantee of grantees) {
|
|
3540
|
+
if (grantee.principalType === me.principalType && grantee.principalId === me.principalId) continue;
|
|
3541
|
+
grants.push({
|
|
3542
|
+
principalType: grantee.principalType,
|
|
3543
|
+
principalId: grantee.principalId,
|
|
3544
|
+
wrappedDek: await wrapDek(dek, grantee.publicKeyJwk)
|
|
3545
|
+
});
|
|
3546
|
+
}
|
|
3547
|
+
}
|
|
3548
|
+
const created = await ctx.client.createBranch(parent.orgId, parent.envId, {
|
|
3549
|
+
slug,
|
|
3550
|
+
name: options.name,
|
|
3551
|
+
ttlSeconds,
|
|
3552
|
+
wrappedDek,
|
|
3553
|
+
recoveryWrappedDek,
|
|
3554
|
+
grants
|
|
3555
|
+
});
|
|
3556
|
+
console.error(`created branch ${parent.appSlug}/${parent.envSlug}#${created.branch.slug} (${created.branch.id})`);
|
|
3557
|
+
console.error(created.branch.expiresAt ? `expires ${created.branch.expiresAt}` : "no expiry — delete it explicitly when the PR closes");
|
|
3558
|
+
if (grants.length > 0) console.error(`shared with ${grants.length} other reader(s)`);
|
|
3559
|
+
});
|
|
3560
|
+
branch.command("list").description("list branches in an application (or of one environment)").option("--org <slug>").option("--app <slug>", "application slug (defaults to seekrit.json)").option("--env <slug>", "only branches of this environment").action(async (options) => {
|
|
3561
|
+
const ctx = buildContext();
|
|
3562
|
+
if (options.env) {
|
|
3563
|
+
const parent = await resolveAppEnv(ctx, options);
|
|
3564
|
+
const { branches } = await ctx.client.listBranches(parent.orgId, parent.envId);
|
|
3565
|
+
for (const b of branches) console.log(`${b.slug}\t${b.id}\t${b.expiresAt ?? "never"}`);
|
|
3566
|
+
return;
|
|
3567
|
+
}
|
|
3568
|
+
const app = await resolveApp(ctx, options);
|
|
3569
|
+
const { branches } = await ctx.client.listAppBranches(app.orgId, app.id);
|
|
3570
|
+
for (const b of branches) console.log(`${b.slug}\t${b.id}\t${b.expiresAt ?? "never"}`);
|
|
3571
|
+
});
|
|
3572
|
+
branch.command("delete <slug>").alias("rm").description("tear down a branch and everything it overrode").option("--org <slug>").option("--app <slug>", "application slug (defaults to seekrit.json)").action(async (slug, options) => {
|
|
3573
|
+
const ctx = buildContext();
|
|
3574
|
+
const app = await resolveApp(ctx, options);
|
|
3575
|
+
const target = await resolveBranch(ctx, app, slug);
|
|
3576
|
+
await ctx.client.deleteBranch(app.orgId, target.id);
|
|
3577
|
+
console.error(`deleted branch ${app.slug}#${target.slug}`);
|
|
3578
|
+
});
|
|
3579
|
+
}
|
|
3580
|
+
//#endregion
|
|
3581
|
+
//#region src/dotenv.ts
|
|
3582
|
+
/**
|
|
3583
|
+
* Minimal `.env` parser: `KEY=VALUE`, `#` comments, an optional `export`
|
|
3584
|
+
* prefix, and single/double-quoted values (double quotes honor `\n \t \r \" \\`
|
|
3585
|
+
* escapes; unquoted values drop trailing ` # comments`). Multiline values are
|
|
3586
|
+
* not supported — keep those in seekrit itself.
|
|
3587
|
+
*/
|
|
3588
|
+
function parseDotenv(content) {
|
|
3589
|
+
const out = {};
|
|
3590
|
+
for (const raw of content.split(/\r?\n/)) {
|
|
3591
|
+
let line = raw.trim();
|
|
3592
|
+
if (!line || line.startsWith("#")) continue;
|
|
3593
|
+
if (line.startsWith("export ")) line = line.slice(7).trimStart();
|
|
3594
|
+
const eq = line.indexOf("=");
|
|
3595
|
+
if (eq === -1) continue;
|
|
3596
|
+
const key = line.slice(0, eq).trim();
|
|
3597
|
+
if (!key) continue;
|
|
3598
|
+
let value = line.slice(eq + 1).trim();
|
|
3599
|
+
const quote = value[0];
|
|
3600
|
+
if (value.length >= 2 && (quote === "\"" || quote === "'") && value.at(-1) === quote) {
|
|
3601
|
+
value = value.slice(1, -1);
|
|
3602
|
+
if (quote === "\"") value = value.replace(/\\n/g, "\n").replace(/\\r/g, "\r").replace(/\\t/g, " ").replace(/\\"/g, "\"").replace(/\\\\/g, "\\");
|
|
3603
|
+
} else {
|
|
3604
|
+
const comment = value.indexOf(" #");
|
|
3605
|
+
if (comment !== -1) value = value.slice(0, comment).trim();
|
|
3606
|
+
}
|
|
3607
|
+
out[key] = value;
|
|
3608
|
+
}
|
|
3609
|
+
return out;
|
|
3610
|
+
}
|
|
3611
|
+
//#endregion
|
|
3612
|
+
//#region src/format.ts
|
|
3613
|
+
function needsQuoting(value) {
|
|
3614
|
+
return /[\s"'`$\\#]/.test(value) || value === "";
|
|
3615
|
+
}
|
|
3616
|
+
function dotenvQuote(value) {
|
|
3617
|
+
if (!needsQuoting(value)) return value;
|
|
3618
|
+
return `"${value.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"").replaceAll("\n", "\\n")}"`;
|
|
3619
|
+
}
|
|
3620
|
+
function shellQuote(value) {
|
|
3621
|
+
return `'${value.replaceAll("'", `'\\''`)}'`;
|
|
3622
|
+
}
|
|
3623
|
+
function formatSecrets(values, format) {
|
|
3624
|
+
const names = Object.keys(values).sort();
|
|
3625
|
+
switch (format) {
|
|
3626
|
+
case "json": return JSON.stringify(values, names, 2);
|
|
3627
|
+
case "shell": return names.map((name) => `export ${name}=${shellQuote(values[name] ?? "")}`).join("\n");
|
|
3628
|
+
case "dotenv": return names.map((name) => `${name}=${dotenvQuote(values[name] ?? "")}`).join("\n");
|
|
3629
|
+
}
|
|
3630
|
+
}
|
|
3631
|
+
//#endregion
|
|
3632
|
+
//#region src/gcp.ts
|
|
3633
|
+
/**
|
|
3634
|
+
* `seekrit gcp` — temporary GCP credentials via IAM Credentials
|
|
3635
|
+
* `generateAccessToken` (Vault-style dynamic secrets, the tier-2 sibling of
|
|
3636
|
+
* `seekrit aws`).
|
|
3637
|
+
*
|
|
3638
|
+
* Zero-knowledge for the leased credential: minting generates an ephemeral P-256
|
|
3639
|
+
* keypair on THIS machine and sends only the public key; GCP mints the token and
|
|
3640
|
+
* the broker returns it wrapped to that key, so the control plane only ever
|
|
3641
|
+
* relays ciphertext and only this machine can unwrap it. Registering a target
|
|
3642
|
+
* wraps the service-account key JSON to the broker's public key locally, so the
|
|
3643
|
+
* control plane never sees it either — the source service account needs only
|
|
3644
|
+
* `roles/iam.serviceAccountTokenCreator` on the target.
|
|
3645
|
+
*/
|
|
3646
|
+
/** Parse a duration like `30m`, `1h`, or a bare seconds count. */
|
|
3647
|
+
function parseTtlSeconds$5(input) {
|
|
3648
|
+
const m = /^(\d+)\s*([smhd]?)$/.exec(input.trim());
|
|
3649
|
+
if (!m) fail(`invalid --ttl "${input}" (try 30m, 1h, 12h)`);
|
|
3650
|
+
return Number(m[1]) * ({
|
|
3651
|
+
s: 1,
|
|
3652
|
+
m: 60,
|
|
3653
|
+
h: 3600,
|
|
3654
|
+
d: 86400
|
|
3655
|
+
}[m[2] || "s"] ?? 1);
|
|
3656
|
+
}
|
|
3657
|
+
/** Collect a repeatable flag (e.g. --scope) into a list. */
|
|
3658
|
+
function collectList$1(value, acc = []) {
|
|
3659
|
+
acc.push(value);
|
|
3660
|
+
return acc;
|
|
3661
|
+
}
|
|
3662
|
+
/**
|
|
3663
|
+
* The service-account key JSON the broker impersonates with. From --key-file or
|
|
3664
|
+
* GOOGLE_APPLICATION_CREDENTIALS. Never leaves this machine unwrapped — it is
|
|
3665
|
+
* wrapped to the broker key before upload.
|
|
3666
|
+
*/
|
|
3667
|
+
function resolveServiceAccountKey(opts) {
|
|
3668
|
+
const path = opts.keyFile ?? process.env.GOOGLE_APPLICATION_CREDENTIALS;
|
|
3669
|
+
if (!path) fail("provide the source service-account key JSON via --key-file or GOOGLE_APPLICATION_CREDENTIALS (it needs roles/iam.serviceAccountTokenCreator on the target)");
|
|
3670
|
+
const raw = readFileSync(path, "utf8").trim();
|
|
3671
|
+
try {
|
|
3672
|
+
const parsed = JSON.parse(raw);
|
|
3673
|
+
if (typeof parsed.client_email !== "string" || typeof parsed.private_key !== "string") fail(`${path} is not a service-account key JSON (missing client_email/private_key)`);
|
|
3674
|
+
} catch {
|
|
3675
|
+
fail(`${path} is not valid JSON`);
|
|
3676
|
+
}
|
|
3677
|
+
return raw;
|
|
3678
|
+
}
|
|
3679
|
+
function registerGcpCommands(program) {
|
|
3680
|
+
const gcp = program.command("gcp").description("temporary GCP credentials (IAM generateAccessToken, zero-knowledge)");
|
|
3681
|
+
const target = gcp.command("target").description("manage GCP service-account targets");
|
|
3682
|
+
target.command("add").description("register an impersonable service account to issue temporary tokens from").requiredOption("--name <name>", "display name, e.g. prod-deploy").requiredOption("--service-account <email>", "the service account to impersonate, name@project.iam.gserviceaccount.com").option("--org <slug>").option("--scope <scope>", "OAuth scope to grant (repeatable; default cloud-platform)", collectList$1).option("--delegate <email>", "delegation-chain service account (repeatable)", collectList$1).option("--max-ttl <duration>", "clamp requested token lifetime, e.g. 1h").option("--key-file <path>", "source SA key JSON (else GOOGLE_APPLICATION_CREDENTIALS)").action(async (options) => {
|
|
3683
|
+
const ctx = buildContext();
|
|
3684
|
+
const org = await resolveOrg(ctx, options.org);
|
|
3685
|
+
const config = {
|
|
3686
|
+
provider: "gcp",
|
|
3687
|
+
executor: "in_do",
|
|
3688
|
+
serviceAccount: options.serviceAccount,
|
|
3689
|
+
...options.scope?.length ? { scopes: options.scope } : {},
|
|
3690
|
+
...options.delegate?.length ? { delegates: options.delegate } : {},
|
|
3691
|
+
...options.maxTtl ? { maxTtlSeconds: parseTtlSeconds$5(options.maxTtl) } : {}
|
|
3692
|
+
};
|
|
3693
|
+
const keyJson = resolveServiceAccountKey(options);
|
|
3694
|
+
const { publicKeyJwk } = await ctx.client.getLeaseBrokerKey(org.id);
|
|
3695
|
+
const wrappedAdminSecret = await wrapDek(new TextEncoder().encode(keyJson), publicKeyJwk);
|
|
3696
|
+
const { target: created } = await ctx.client.registerLeaseTarget(org.id, {
|
|
3697
|
+
name: options.name,
|
|
3698
|
+
config,
|
|
3699
|
+
wrappedAdminSecret
|
|
3700
|
+
});
|
|
3701
|
+
console.error(`registered GCP target ${created.name} (${created.id})`);
|
|
3702
|
+
console.error("\nGrant the source SA the token-creator role, then `seekrit gcp lease`:\n");
|
|
3703
|
+
console.log(gcpSetupInstructions(config));
|
|
3704
|
+
});
|
|
3705
|
+
target.command("list").description("list GCP service-account targets").option("--org <slug>").action(async (options) => {
|
|
3706
|
+
const ctx = buildContext();
|
|
3707
|
+
const org = await resolveOrg(ctx, options.org);
|
|
3708
|
+
const { targets } = await ctx.client.listLeaseTargets(org.id);
|
|
3709
|
+
for (const t of targets) {
|
|
3710
|
+
const cfg = t.config;
|
|
3711
|
+
if (cfg.provider !== "gcp") continue;
|
|
3712
|
+
console.log(`${t.id}\t${t.name}\t${cfg.serviceAccount}`);
|
|
3713
|
+
}
|
|
3714
|
+
});
|
|
3715
|
+
target.command("setup <targetId>").description("reprint the IAM setup for a GCP target").option("--org <slug>").action(async (targetId, options) => {
|
|
3716
|
+
const ctx = buildContext();
|
|
3717
|
+
const org = await resolveOrg(ctx, options.org);
|
|
3718
|
+
const { targets } = await ctx.client.listLeaseTargets(org.id);
|
|
3719
|
+
const t = targets.find((x) => x.id === targetId || x.name === targetId);
|
|
3720
|
+
if (!t) fail(`no target "${targetId}" in ${org.slug}`);
|
|
3721
|
+
const cfg = t.config;
|
|
3722
|
+
if (cfg.provider !== "gcp") fail("not a gcp target (see `seekrit aws`/`seekrit ssh`)");
|
|
3723
|
+
console.log(gcpSetupInstructions(cfg));
|
|
3724
|
+
});
|
|
3725
|
+
target.command("rm <targetId>").description("delete a GCP service-account target").option("--org <slug>").action(async (targetId, options) => {
|
|
3726
|
+
const ctx = buildContext();
|
|
3727
|
+
const org = await resolveOrg(ctx, options.org);
|
|
3728
|
+
await ctx.client.deleteLeaseTarget(org.id, targetId);
|
|
3729
|
+
console.error(`deleted ${targetId}`);
|
|
3730
|
+
});
|
|
3731
|
+
gcp.command("lease <target>").description("mint a short-lived GCP access token; prints ready-to-source export lines").option("--org <slug>").option("--ttl <duration>", "token lifetime, e.g. 15m, 1h", "1h").option("--json", "print the full credential as JSON").action(async (targetRef, options) => {
|
|
3732
|
+
const ctx = buildContext();
|
|
3733
|
+
const org = await resolveOrg(ctx, options.org);
|
|
3734
|
+
const { targets } = await ctx.client.listLeaseTargets(org.id);
|
|
3735
|
+
const t = targets.find((x) => x.id === targetRef || x.name === targetRef);
|
|
3736
|
+
if (!t) fail(`no target "${targetRef}" in ${org.slug}`);
|
|
3737
|
+
if (t.config.provider !== "gcp") fail(`"${t.name}" is not a gcp target (see \`seekrit aws\`)`);
|
|
3738
|
+
const ttlSeconds = parseTtlSeconds$5(options.ttl);
|
|
3739
|
+
if (ttlSeconds < 60) fail(`--ttl must be at least 60s`);
|
|
3740
|
+
if (ttlSeconds > 43200) fail(`--ttl must be at most ${GCP_MAX_TTL_SECONDS / 3600}h`);
|
|
3741
|
+
const recipient = await generateGcpRecipientKeyPair();
|
|
3742
|
+
const { gcp: leased } = await ctx.client.mintLease(org.id, {
|
|
3743
|
+
provider: "gcp",
|
|
3744
|
+
targetId: t.id,
|
|
3745
|
+
recipientPublicKey: recipient.publicKeyJwk,
|
|
3746
|
+
ttlSeconds
|
|
3747
|
+
});
|
|
3748
|
+
const cred = await unwrapGcpCredential(leased.wrappedCredential, recipient.privateKeyJwk);
|
|
3749
|
+
console.error(`leased ${cred.serviceAccount} — expires ${cred.expiration}`);
|
|
3750
|
+
if (options.json) console.log(JSON.stringify(cred, null, 2));
|
|
3751
|
+
else {
|
|
3752
|
+
console.log(`export CLOUDSDK_AUTH_ACCESS_TOKEN=${cred.accessToken}`);
|
|
3753
|
+
console.log(`export GOOGLE_OAUTH_ACCESS_TOKEN=${cred.accessToken}`);
|
|
3754
|
+
}
|
|
3755
|
+
});
|
|
3756
|
+
gcp.command("leases").description("list GCP leases (the ledger — never secret material)").option("--org <slug>").action(async (options) => {
|
|
3757
|
+
const ctx = buildContext();
|
|
3758
|
+
const org = await resolveOrg(ctx, options.org);
|
|
3759
|
+
const { leases } = await ctx.client.listLeases(org.id);
|
|
3760
|
+
for (const l of leases) {
|
|
3761
|
+
if (l.provider !== "gcp") continue;
|
|
3762
|
+
console.log(`${l.id}\t${l.status}\texpires ${l.expiresAt}`);
|
|
3763
|
+
}
|
|
3764
|
+
});
|
|
3765
|
+
gcp.command("revoke <leaseId>").description("mark a lease revoked in the ledger (tokens stay valid until they expire)").option("--org <slug>").action(async (leaseId, options) => {
|
|
3766
|
+
const ctx = buildContext();
|
|
3767
|
+
const org = await resolveOrg(ctx, options.org);
|
|
3768
|
+
await ctx.client.revokeLease(org.id, leaseId);
|
|
3769
|
+
console.error(`revoked ${leaseId} (issued tokens remain valid until they expire)`);
|
|
3770
|
+
});
|
|
3771
|
+
}
|
|
3772
|
+
//#endregion
|
|
3773
|
+
//#region src/m2m.ts
|
|
3774
|
+
/**
|
|
3775
|
+
* Resolve M2M client credentials from (in order) the process environment, a
|
|
3776
|
+
* `.env` overlay (for `seekrit run`), then saved config. Both halves must come
|
|
3777
|
+
* through for the credential to be usable.
|
|
3778
|
+
*/
|
|
3376
3779
|
function readM2mCreds(dotenvVars = {}) {
|
|
3377
3780
|
const config = readGlobalConfig();
|
|
3378
3781
|
const fromEnv = (key) => process.env[key] ?? dotenvVars[key];
|
|
@@ -3463,7 +3866,7 @@ function parseTtlSeconds$4(input) {
|
|
|
3463
3866
|
}[m[2] || "s"] ?? 1);
|
|
3464
3867
|
}
|
|
3465
3868
|
/** Collect a repeatable option into an array. */
|
|
3466
|
-
function collect$
|
|
3869
|
+
function collect$4(value, previous) {
|
|
3467
3870
|
return [...previous, value];
|
|
3468
3871
|
}
|
|
3469
3872
|
/** Parse `readWrite@app` → { role, db } for a custom target. */
|
|
@@ -3488,7 +3891,7 @@ function resolveAdminUri(uri) {
|
|
|
3488
3891
|
function registerMongoCommands(program) {
|
|
3489
3892
|
const mongo = program.command("mongodb").description("temporary MongoDB credentials (createUser, zero-knowledge delivery)");
|
|
3490
3893
|
const target = mongo.command("target").description("manage MongoDB targets");
|
|
3491
|
-
target.command("add").description("register a MongoDB cluster to issue temporary credentials from").requiredOption("--name <name>", "display name, e.g. prod-app").requiredOption("--database <db>", "the database leased users get access to, e.g. app").option("--uri <uri>", "admin connection string (else SEEKRIT_MONGODB_ADMIN_URL)").option("--org <slug>").option("--access <level>", "readonly | readwrite | custom", "readonly").option("--role <role@db>", "grant for a custom target (repeatable)", collect$
|
|
3894
|
+
target.command("add").description("register a MongoDB cluster to issue temporary credentials from").requiredOption("--name <name>", "display name, e.g. prod-app").requiredOption("--database <db>", "the database leased users get access to, e.g. app").option("--uri <uri>", "admin connection string (else SEEKRIT_MONGODB_ADMIN_URL)").option("--org <slug>").option("--access <level>", "readonly | readwrite | custom", "readonly").option("--role <role@db>", "grant for a custom target (repeatable)", collect$4, []).option("--auth-source <db>", "authentication database (default admin)").option("--max-ttl <duration>", "clamp requested credential lifetime, e.g. 8h").option("--no-tls", "disable TLS to the cluster (TLS is on by default)").action(async (options) => {
|
|
3492
3895
|
const ctx = buildContext();
|
|
3493
3896
|
const org = await resolveOrg(ctx, options.org);
|
|
3494
3897
|
const adminUri = resolveAdminUri(options.uri);
|
|
@@ -3648,7 +4051,7 @@ function generateUserName$1(prefix = "tmp") {
|
|
|
3648
4051
|
function registerMysqlCommands(program) {
|
|
3649
4052
|
const mysql = program.command("mysql").description("temporary MySQL/MariaDB credentials (short-lived, zero-knowledge)");
|
|
3650
4053
|
const target = mysql.command("target").description("manage provisioning targets");
|
|
3651
|
-
target.command("add").description("register a MySQL/MariaDB server to lease credentials from").requiredOption("--name <name>", "display name, e.g. prod-db").option("--org <slug>").requiredOption("--host <host>", "database host").option("--port <port>", "database port", "3306").requiredOption("--database <name>", "database to grant access to").option("--access <level>", "readonly | readwrite | custom", "readonly").option("--user-host <host>", "host part of created accounts ('name'@'<host>')", "%").option("--executor <mode>", "in_do | remote", "in_do").option("--provisioner-url <url>", "customer provisioner URL (remote executor)").option("--hmac-key <base64>", "shared HMAC key for the remote executor (or set SEEKRIT_PROVISIONER_HMAC_KEY); `seekrit provisioner keygen` mints one").option("--admin-url <url>", "in_do: admin mysql:// connection string (or set SEEKRIT_MYSQL_ADMIN_URL); wrapped locally").option("--create-statement <sql>", "custom CREATE template (repeatable)", collect$
|
|
4054
|
+
target.command("add").description("register a MySQL/MariaDB server to lease credentials from").requiredOption("--name <name>", "display name, e.g. prod-db").option("--org <slug>").requiredOption("--host <host>", "database host").option("--port <port>", "database port", "3306").requiredOption("--database <name>", "database to grant access to").option("--access <level>", "readonly | readwrite | custom", "readonly").option("--user-host <host>", "host part of created accounts ('name'@'<host>')", "%").option("--executor <mode>", "in_do | remote", "in_do").option("--provisioner-url <url>", "customer provisioner URL (remote executor)").option("--hmac-key <base64>", "shared HMAC key for the remote executor (or set SEEKRIT_PROVISIONER_HMAC_KEY); `seekrit provisioner keygen` mints one").option("--admin-url <url>", "in_do: admin mysql:// connection string (or set SEEKRIT_MYSQL_ADMIN_URL); wrapped locally").option("--create-statement <sql>", "custom CREATE template (repeatable)", collect$3, []).option("--revoke-statement <sql>", "custom REVOKE template (repeatable)", collect$3, []).action(async (options) => {
|
|
3652
4055
|
const ctx = buildContext();
|
|
3653
4056
|
const org = await resolveOrg(ctx, options.org);
|
|
3654
4057
|
const executor = options.executor === "remote" ? "remote" : "in_do";
|
|
@@ -3749,7 +4152,7 @@ function registerMysqlCommands(program) {
|
|
|
3749
4152
|
});
|
|
3750
4153
|
}
|
|
3751
4154
|
/** Collect a repeatable option into an array. */
|
|
3752
|
-
function collect$
|
|
4155
|
+
function collect$3(value, acc) {
|
|
3753
4156
|
acc.push(value);
|
|
3754
4157
|
return acc;
|
|
3755
4158
|
}
|
|
@@ -3786,7 +4189,7 @@ function generateRoleName(prefix = "tmp") {
|
|
|
3786
4189
|
function registerPgCommands(program) {
|
|
3787
4190
|
const pg = program.command("pg").description("temporary Postgres credentials (short-lived, zero-knowledge)");
|
|
3788
4191
|
const target = pg.command("target").description("manage provisioning targets");
|
|
3789
|
-
target.command("add").description("register a Postgres cluster to lease credentials from").requiredOption("--name <name>", "display name, e.g. prod-db").option("--org <slug>").requiredOption("--host <host>", "database host").option("--port <port>", "database port", "5432").requiredOption("--database <name>", "database to connect to").option("--access <level>", "readonly | readwrite | custom", "readonly").option("--schema <name>", "schema for preset grants", "public").option("--executor <mode>", "in_do | remote", "in_do").option("--provisioner-url <url>", "customer provisioner URL (remote executor)").option("--hmac-key <base64>", "shared HMAC key for the remote executor (or set SEEKRIT_PROVISIONER_HMAC_KEY); `seekrit provisioner keygen` mints one").option("--admin-url <url>", "in_do: admin postgres:// connection string (or set SEEKRIT_PG_ADMIN_URL); wrapped locally").option("--create-statement <sql>", "custom CREATE template (repeatable)", collect$
|
|
4192
|
+
target.command("add").description("register a Postgres cluster to lease credentials from").requiredOption("--name <name>", "display name, e.g. prod-db").option("--org <slug>").requiredOption("--host <host>", "database host").option("--port <port>", "database port", "5432").requiredOption("--database <name>", "database to connect to").option("--access <level>", "readonly | readwrite | custom", "readonly").option("--schema <name>", "schema for preset grants", "public").option("--executor <mode>", "in_do | remote", "in_do").option("--provisioner-url <url>", "customer provisioner URL (remote executor)").option("--hmac-key <base64>", "shared HMAC key for the remote executor (or set SEEKRIT_PROVISIONER_HMAC_KEY); `seekrit provisioner keygen` mints one").option("--admin-url <url>", "in_do: admin postgres:// connection string (or set SEEKRIT_PG_ADMIN_URL); wrapped locally").option("--create-statement <sql>", "custom CREATE template (repeatable)", collect$2, []).option("--revoke-statement <sql>", "custom REVOKE template (repeatable)", collect$2, []).action(async (options) => {
|
|
3790
4193
|
const ctx = buildContext();
|
|
3791
4194
|
const org = await resolveOrg(ctx, options.org);
|
|
3792
4195
|
const executor = options.executor === "remote" ? "remote" : "in_do";
|
|
@@ -3900,208 +4303,10 @@ function registerPgCommands(program) {
|
|
|
3900
4303
|
});
|
|
3901
4304
|
}
|
|
3902
4305
|
/** Collect a repeatable option into an array. */
|
|
3903
|
-
function collect$
|
|
3904
|
-
acc.push(value);
|
|
3905
|
-
return acc;
|
|
3906
|
-
}
|
|
3907
|
-
//#endregion
|
|
3908
|
-
//#region src/recovery.ts
|
|
3909
|
-
/** Collect a repeatable option into a list. */
|
|
3910
|
-
function collect$2(value, acc = []) {
|
|
4306
|
+
function collect$2(value, acc) {
|
|
3911
4307
|
acc.push(value);
|
|
3912
4308
|
return acc;
|
|
3913
4309
|
}
|
|
3914
|
-
/** Resolve a custodian reference: a `skt_…` token id, otherwise a member email. */
|
|
3915
|
-
function resolveCustodian(ctx, orgId, ref) {
|
|
3916
|
-
return ref.startsWith("skt_") ? kmsResolveRecipient(ctx, orgId, { token: ref }) : kmsResolveRecipient(ctx, orgId, { user: ref });
|
|
3917
|
-
}
|
|
3918
|
-
/**
|
|
3919
|
-
* The env DEK additionally wrapped to the org recovery key, when recovery is
|
|
3920
|
-
* enabled — so a newly created environment is recovery-protected from birth.
|
|
3921
|
-
* Returns undefined when recovery is off (the env is backfilled by `recovery
|
|
3922
|
-
* sync` later).
|
|
3923
|
-
*/
|
|
3924
|
-
async function recoveryWrapForNewEnv(ctx, orgId, dek) {
|
|
3925
|
-
let recoveryPublicKeyJwk;
|
|
3926
|
-
try {
|
|
3927
|
-
const { recovery } = await ctx.client.getRecovery(orgId);
|
|
3928
|
-
recoveryPublicKeyJwk = recovery.enabled ? recovery.recoveryPublicKeyJwk : null;
|
|
3929
|
-
} catch (e) {
|
|
3930
|
-
if (e instanceof SeekritApiError && (e.status === 403 || e.status === 404)) return void 0;
|
|
3931
|
-
throw e;
|
|
3932
|
-
}
|
|
3933
|
-
if (!recoveryPublicKeyJwk) return void 0;
|
|
3934
|
-
return wrapDek(dek, recoveryPublicKeyJwk);
|
|
3935
|
-
}
|
|
3936
|
-
/**
|
|
3937
|
-
* Wrap every environment the caller can decrypt but that lacks a recovery grant,
|
|
3938
|
-
* and upload the grants. Idempotent — safe to re-run and to run from several
|
|
3939
|
-
* admins to complete coverage.
|
|
3940
|
-
*/
|
|
3941
|
-
async function syncRecoveryGrants(ctx, orgId) {
|
|
3942
|
-
const { recovery } = await ctx.client.getRecovery(orgId);
|
|
3943
|
-
if (!recovery.enabled || !recovery.recoveryPublicKeyJwk) fail("recovery is not enabled");
|
|
3944
|
-
const recoveryPublicKeyJwk = recovery.recoveryPublicKeyJwk;
|
|
3945
|
-
const privateKey = await getPrivateKey(ctx);
|
|
3946
|
-
const grants = [];
|
|
3947
|
-
let skipped = 0;
|
|
3948
|
-
for (const environmentId of recovery.coverage.unprotectedEnvIds) {
|
|
3949
|
-
let wrappedDek;
|
|
3950
|
-
try {
|
|
3951
|
-
({wrappedDek} = await ctx.client.getMyEnvKey(orgId, environmentId));
|
|
3952
|
-
} catch (e) {
|
|
3953
|
-
if (e instanceof SeekritApiError && (e.status === 403 || e.status === 404)) {
|
|
3954
|
-
skipped++;
|
|
3955
|
-
continue;
|
|
3956
|
-
}
|
|
3957
|
-
throw e;
|
|
3958
|
-
}
|
|
3959
|
-
const dek = await unwrapDek(wrappedDek, privateKey);
|
|
3960
|
-
grants.push({
|
|
3961
|
-
environmentId,
|
|
3962
|
-
wrappedDek: await wrapDek(dek, recoveryPublicKeyJwk)
|
|
3963
|
-
});
|
|
3964
|
-
}
|
|
3965
|
-
if (grants.length > 0) await ctx.client.uploadRecoveryGrants(orgId, { grants });
|
|
3966
|
-
return {
|
|
3967
|
-
wrapped: grants.length,
|
|
3968
|
-
skipped
|
|
3969
|
-
};
|
|
3970
|
-
}
|
|
3971
|
-
/** Generate + split a fresh recovery key across the given custodians. */
|
|
3972
|
-
async function buildRecoveryConfig(ctx, orgId, thresholdRaw, custodianRefs) {
|
|
3973
|
-
const threshold = Number.parseInt(thresholdRaw, 10);
|
|
3974
|
-
if (!Number.isInteger(threshold) || threshold < 1) fail("--threshold must be a positive integer");
|
|
3975
|
-
if (custodianRefs.length === 0) fail("pass at least one --custodian <email|skt_id>");
|
|
3976
|
-
if (threshold > custodianRefs.length) fail("--threshold cannot exceed the number of custodians");
|
|
3977
|
-
const custodians = await Promise.all(custodianRefs.map((ref) => resolveCustodian(ctx, orgId, ref)));
|
|
3978
|
-
const recovery = await generateRecoveryKey();
|
|
3979
|
-
const shares = await splitRecoveryKey(recovery.privateKeyJwk, threshold, custodians);
|
|
3980
|
-
return {
|
|
3981
|
-
recoveryPublicKeyJwk: recovery.publicKeyJwk,
|
|
3982
|
-
threshold,
|
|
3983
|
-
shares: shares.map((s) => ({
|
|
3984
|
-
principalType: s.principalType,
|
|
3985
|
-
principalId: s.principalId,
|
|
3986
|
-
shareIndex: s.shareIndex,
|
|
3987
|
-
wrappedShare: s.wrappedShare
|
|
3988
|
-
}))
|
|
3989
|
-
};
|
|
3990
|
-
}
|
|
3991
|
-
function registerRecoveryCommands(program) {
|
|
3992
|
-
const recovery = program.command("recovery").description("customer-controlled M-of-N recovery (zero-knowledge)");
|
|
3993
|
-
recovery.command("status").description("show recovery configuration and environment coverage").option("--org <slug>").action(async (options) => {
|
|
3994
|
-
const ctx = buildContext();
|
|
3995
|
-
const org = await resolveOrg(ctx, options.org);
|
|
3996
|
-
const { recovery: status } = await ctx.client.getRecovery(org.id);
|
|
3997
|
-
if (!status.enabled) {
|
|
3998
|
-
console.log("recovery: disabled");
|
|
3999
|
-
return;
|
|
4000
|
-
}
|
|
4001
|
-
console.log(`recovery: enabled (${status.threshold}-of-${status.shareCount})`);
|
|
4002
|
-
console.log(`coverage: ${status.coverage.protected}/${status.coverage.total} environments protected`);
|
|
4003
|
-
console.log("custodians:");
|
|
4004
|
-
for (const cst of status.custodians) console.log(` - ${cst.label ?? cst.principalId} (${cst.principalType}, share #${cst.shareIndex})`);
|
|
4005
|
-
if (status.coverage.unprotectedEnvIds.length > 0) console.log(`${status.coverage.unprotectedEnvIds.length} environment(s) not yet protected — run \`seekrit recovery sync\``);
|
|
4006
|
-
});
|
|
4007
|
-
recovery.command("setup").description("enable recovery: split a fresh recovery key across custodians").requiredOption("--threshold <M>", "custodians required to recover (M in M-of-N)").option("--custodian <email|skt_id>", "a recovery custodian (repeatable)", collect$2, []).option("--org <slug>").action(async (options) => {
|
|
4008
|
-
const ctx = buildContext();
|
|
4009
|
-
const org = await resolveOrg(ctx, options.org);
|
|
4010
|
-
const config = await buildRecoveryConfig(ctx, org.id, options.threshold, options.custodian);
|
|
4011
|
-
await ctx.client.configureRecovery(org.id, {
|
|
4012
|
-
...config,
|
|
4013
|
-
grants: []
|
|
4014
|
-
});
|
|
4015
|
-
console.error(`recovery enabled: ${config.threshold}-of-${config.shares.length}`);
|
|
4016
|
-
const { wrapped, skipped } = await syncRecoveryGrants(ctx, org.id);
|
|
4017
|
-
console.error(`recovery-protected ${wrapped} environment(s) you can decrypt`);
|
|
4018
|
-
if (skipped > 0) console.error(`${skipped} environment(s) need another admin to run \`seekrit recovery sync\``);
|
|
4019
|
-
});
|
|
4020
|
-
recovery.command("sync").description("recovery-protect environments you can decrypt but that aren't yet covered").option("--org <slug>").action(async (options) => {
|
|
4021
|
-
const ctx = buildContext();
|
|
4022
|
-
const { wrapped, skipped } = await syncRecoveryGrants(ctx, (await resolveOrg(ctx, options.org)).id);
|
|
4023
|
-
console.error(`recovery-protected ${wrapped} environment(s); skipped ${skipped} you cannot decrypt`);
|
|
4024
|
-
});
|
|
4025
|
-
recovery.command("rotate").description("rotate the recovery key (new keypair, custodians, and env re-wraps)").requiredOption("--threshold <M>", "custodians required to recover (M in M-of-N)").option("--custodian <email|skt_id>", "a recovery custodian (repeatable)", collect$2, []).option("--org <slug>").action(async (options) => {
|
|
4026
|
-
const ctx = buildContext();
|
|
4027
|
-
const org = await resolveOrg(ctx, options.org);
|
|
4028
|
-
const config = await buildRecoveryConfig(ctx, org.id, options.threshold, options.custodian);
|
|
4029
|
-
await ctx.client.rotateRecovery(org.id, {
|
|
4030
|
-
...config,
|
|
4031
|
-
grants: []
|
|
4032
|
-
});
|
|
4033
|
-
console.error(`recovery rotated: ${config.threshold}-of-${config.shares.length}`);
|
|
4034
|
-
const { wrapped, skipped } = await syncRecoveryGrants(ctx, org.id);
|
|
4035
|
-
console.error(`re-wrapped ${wrapped} environment(s) you can decrypt to the new recovery key`);
|
|
4036
|
-
if (skipped > 0) console.error(`${skipped} environment(s) still need another admin to run \`seekrit recovery sync\``);
|
|
4037
|
-
});
|
|
4038
|
-
recovery.command("disable").description("disable recovery and remove all recovery grants").option("--org <slug>").action(async (options) => {
|
|
4039
|
-
const ctx = buildContext();
|
|
4040
|
-
const org = await resolveOrg(ctx, options.org);
|
|
4041
|
-
await ctx.client.disableRecovery(org.id);
|
|
4042
|
-
console.error("recovery disabled; recovery grants removed");
|
|
4043
|
-
});
|
|
4044
|
-
recovery.command("request").description("start a recovery ceremony (defaults to recovering access for yourself)").option("--target-user <email>", "recover access for another member").option("--target-token <id>", "recover access for a service token").option("--reason <text>", "note recorded in the audit trail").option("--org <slug>").action(async (options) => {
|
|
4045
|
-
const ctx = buildContext();
|
|
4046
|
-
const org = await resolveOrg(ctx, options.org);
|
|
4047
|
-
const target = options.targetUser || options.targetToken ? await kmsResolveRecipient(ctx, org.id, {
|
|
4048
|
-
user: options.targetUser,
|
|
4049
|
-
token: options.targetToken
|
|
4050
|
-
}) : await kmsCallerIdentity(ctx);
|
|
4051
|
-
const { request } = await ctx.client.createRecoveryRequest(org.id, {
|
|
4052
|
-
targetPublicKeyJwk: target.publicKeyJwk,
|
|
4053
|
-
targetType: target.principalType,
|
|
4054
|
-
targetId: target.principalId,
|
|
4055
|
-
reason: options.reason
|
|
4056
|
-
});
|
|
4057
|
-
console.error(`recovery request ${request.id} created (needs ${request.threshold} custodians)`);
|
|
4058
|
-
console.error(` custodians run: seekrit recovery approve ${request.id}`);
|
|
4059
|
-
console.error(` then the target: seekrit recovery complete ${request.id}`);
|
|
4060
|
-
});
|
|
4061
|
-
recovery.command("approve").description("as a custodian, contribute your share to a recovery request").argument("<requestId>").option("--org <slug>").action(async (requestId, options) => {
|
|
4062
|
-
const ctx = buildContext();
|
|
4063
|
-
const org = await resolveOrg(ctx, options.org);
|
|
4064
|
-
const { request } = await ctx.client.getRecoveryRequest(org.id, requestId);
|
|
4065
|
-
const myShare = await ctx.client.getMyRecoveryShare(org.id);
|
|
4066
|
-
const privateKey = await getPrivateKey(ctx);
|
|
4067
|
-
const contributedShare = await rewrapRecoveryShare(await unwrapRecoveryShare(myShare.wrappedShare, privateKey), request.targetPublicKeyJwk);
|
|
4068
|
-
const res = await ctx.client.contributeRecoveryShare(org.id, requestId, {
|
|
4069
|
-
shareIndex: myShare.shareIndex,
|
|
4070
|
-
contributedShare
|
|
4071
|
-
});
|
|
4072
|
-
console.error(`contributed share #${myShare.shareIndex}: ${res.contributed}/${res.threshold} collected${res.quorumReached ? " — quorum reached" : ""}`);
|
|
4073
|
-
});
|
|
4074
|
-
recovery.command("complete").description("as the recovery target, reconstruct the key and restore your access").argument("<requestId>").option("--org <slug>").action(async (requestId, options) => {
|
|
4075
|
-
const ctx = buildContext();
|
|
4076
|
-
const org = await resolveOrg(ctx, options.org);
|
|
4077
|
-
const { request, contributions, quorumReached } = await ctx.client.getRecoveryRequest(org.id, requestId);
|
|
4078
|
-
if (!quorumReached) fail(`only ${contributions.length}/${request.threshold} custodians have contributed`);
|
|
4079
|
-
const me = await kmsCallerIdentity(ctx);
|
|
4080
|
-
const targetPrivateKey = await getPrivateKey(ctx);
|
|
4081
|
-
const recoveryPrivateKey = await combineRecoveryShares(await Promise.all(contributions.map((cont) => unwrapRecoveryShare(cont.contributedShare, targetPrivateKey))));
|
|
4082
|
-
const { grants: recoveryEnvKeys } = await ctx.client.getRecoveryEnvKeys(org.id);
|
|
4083
|
-
const restored = [];
|
|
4084
|
-
for (const g of recoveryEnvKeys) {
|
|
4085
|
-
const dek = await unwrapDek(g.wrappedDek, recoveryPrivateKey);
|
|
4086
|
-
restored.push({
|
|
4087
|
-
environmentId: g.environmentId,
|
|
4088
|
-
wrappedDek: await wrapDek(dek, me.publicKeyJwk)
|
|
4089
|
-
});
|
|
4090
|
-
}
|
|
4091
|
-
await ctx.client.completeRecoveryRequest(org.id, requestId, {
|
|
4092
|
-
principalType: me.principalType,
|
|
4093
|
-
principalId: me.principalId,
|
|
4094
|
-
grants: restored
|
|
4095
|
-
});
|
|
4096
|
-
console.error(`recovery complete: restored access to ${restored.length} environment(s)`);
|
|
4097
|
-
});
|
|
4098
|
-
recovery.command("cancel").description("cancel an open recovery request").argument("<requestId>").option("--org <slug>").action(async (requestId, options) => {
|
|
4099
|
-
const ctx = buildContext();
|
|
4100
|
-
const org = await resolveOrg(ctx, options.org);
|
|
4101
|
-
await ctx.client.cancelRecoveryRequest(org.id, requestId);
|
|
4102
|
-
console.error(`recovery request ${requestId} canceled`);
|
|
4103
|
-
});
|
|
4104
|
-
}
|
|
4105
4310
|
//#endregion
|
|
4106
4311
|
//#region src/redis.ts
|
|
4107
4312
|
/**
|
|
@@ -4326,6 +4531,7 @@ async function importSecrets(ctx, orgId, envId, entries) {
|
|
|
4326
4531
|
async function materializeEnv(ctx, opts) {
|
|
4327
4532
|
const query = {};
|
|
4328
4533
|
if (opts.with && Object.keys(opts.with).length > 0) query.with = opts.with;
|
|
4534
|
+
if (opts.branch) query.branch = opts.branch;
|
|
4329
4535
|
if (!isTokenAuth(ctx)) {
|
|
4330
4536
|
if (!opts.envId) fail("specify --app and --env, or authenticate with a service token (SEEKRIT_TOKEN)");
|
|
4331
4537
|
query.env = opts.envId;
|
|
@@ -4336,7 +4542,10 @@ async function materializeEnv(ctx, opts) {
|
|
|
4336
4542
|
const provenance = {};
|
|
4337
4543
|
for (const layer of layers) {
|
|
4338
4544
|
const dek = await unwrapDek(layer.wrappedDek, privateKey);
|
|
4339
|
-
|
|
4545
|
+
let label;
|
|
4546
|
+
if (layer.source === "group") label = `group:${layer.groupSlug}@${layer.slug}`;
|
|
4547
|
+
else if (layer.source === "branch") label = `branch:${scope.appSlug}#${layer.slug}`;
|
|
4548
|
+
else label = `app:${scope.appSlug}/${layer.slug}`;
|
|
4340
4549
|
for (const secret of layer.secrets) {
|
|
4341
4550
|
values[secret.name] = await decryptSecret(dek, secret.ciphertext, secretAad(layer.environmentId, secret.name));
|
|
4342
4551
|
provenance[secret.name] = label;
|
|
@@ -4702,7 +4911,7 @@ function parseVersion(raw) {
|
|
|
4702
4911
|
}
|
|
4703
4912
|
/** Attach the environment-selection flags shared by every `secrets` command. */
|
|
4704
4913
|
function withTarget(cmd) {
|
|
4705
|
-
return cmd.option("--org <slug>", "organization slug").option("--app <slug>", "application slug (defaults to seekrit.json)").option("--group <slug>", "target a group environment instead of an app").requiredOption("--env <slug>", "environment slug");
|
|
4914
|
+
return cmd.option("--org <slug>", "organization slug").option("--app <slug>", "application slug (defaults to seekrit.json)").option("--group <slug>", "target a group environment instead of an app").option("--branch <slug>", "operate on a branch of --env").requiredOption("--env <slug>", "environment slug");
|
|
4706
4915
|
}
|
|
4707
4916
|
const secrets = program.command("secrets").description("manage secrets in an application or group environment");
|
|
4708
4917
|
withTarget(secrets.command("list").description("list secret names (no values)")).action(async (options) => {
|
|
@@ -4780,6 +4989,7 @@ async function materialize(ctx, options) {
|
|
|
4780
4989
|
if (!isTokenAuth(ctx)) envId = (await resolveAppEnv(ctx, options)).envId;
|
|
4781
4990
|
return materializeEnv(ctx, {
|
|
4782
4991
|
envId,
|
|
4992
|
+
branch: options.branch ?? process.env.SEEKRIT_BRANCH,
|
|
4783
4993
|
with: options.with,
|
|
4784
4994
|
envFiles: options.envFile ?? [".env"],
|
|
4785
4995
|
interpolate: options.interpolate
|
|
@@ -4800,7 +5010,11 @@ async function materializeForRun(options) {
|
|
|
4800
5010
|
await ensureM2mAdminToken(dotenvVars);
|
|
4801
5011
|
const ctx = tryBuildContext(dotenvVars);
|
|
4802
5012
|
if (!ctx) throw new Error("no credentials found (set SEEKRIT_TOKEN / SEEKRIT_DEV_USER or run `seekrit login`)");
|
|
4803
|
-
|
|
5013
|
+
const branch = options.branch ?? process.env.SEEKRIT_BRANCH ?? dotenvVars.SEEKRIT_BRANCH;
|
|
5014
|
+
return await materialize(ctx, {
|
|
5015
|
+
...options,
|
|
5016
|
+
branch
|
|
5017
|
+
});
|
|
4804
5018
|
} catch (err) {
|
|
4805
5019
|
const message = err instanceof Error ? err.message : String(err);
|
|
4806
5020
|
console.error(`seekrit: continuing without seekrit-managed secrets: ${message}`);
|
|
@@ -4923,7 +5137,7 @@ async function reapStragglers(pids, signal) {
|
|
|
4923
5137
|
process.kill(pid, "SIGKILL");
|
|
4924
5138
|
} catch {}
|
|
4925
5139
|
}
|
|
4926
|
-
program.command("run").description("run a command with decrypted secrets injected (process env > .env > app > group)").passThroughOptions().option("--org <slug>").option("--app <slug>", "application slug (token path infers this)").option("--env <slug>", "environment slug (token path infers this)").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").argument("<command...>", "command to run (prefix with -- to pass flags)").action(async (commandParts, options) => {
|
|
5140
|
+
program.command("run").description("run a command with decrypted secrets injected (process env > .env > app > group)").passThroughOptions().option("--org <slug>").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").argument("<command...>", "command to run (prefix with -- to pass flags)").action(async (commandParts, options) => {
|
|
4927
5141
|
const [cmd, ...args] = commandParts;
|
|
4928
5142
|
if (!cmd) fail("no command given");
|
|
4929
5143
|
const { values, provenance, interpolated, unresolvedRefs } = await materializeForRun(options);
|
|
@@ -4975,7 +5189,7 @@ program.command("run").description("run a command with decrypted secrets injecte
|
|
|
4975
5189
|
});
|
|
4976
5190
|
child.on("error", (err) => fail(`failed to start ${cmd}: ${err.message}`));
|
|
4977
5191
|
});
|
|
4978
|
-
program.command("export").description("print decrypted secrets (dotenv, json, or shell)").option("--org <slug>").option("--app <slug>", "application slug (token path infers this)").option("--env <slug>", "environment slug (token path infers this)").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("--format <format>", "dotenv | json | shell", "dotenv").action(async (options) => {
|
|
5192
|
+
program.command("export").description("print decrypted secrets (dotenv, json, or shell)").option("--org <slug>").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("--format <format>", "dotenv | json | shell", "dotenv").action(async (options) => {
|
|
4979
5193
|
if (![
|
|
4980
5194
|
"dotenv",
|
|
4981
5195
|
"json",
|
|
@@ -5095,6 +5309,7 @@ token.command("delete <tokenId>").description("permanently delete a revoked serv
|
|
|
5095
5309
|
await ctx.client.deleteToken(orgRef.id, tokenId);
|
|
5096
5310
|
console.error(`${tokenId} deleted`);
|
|
5097
5311
|
});
|
|
5312
|
+
registerBranchCommands(program);
|
|
5098
5313
|
registerPgCommands(program);
|
|
5099
5314
|
registerMysqlCommands(program);
|
|
5100
5315
|
registerRedisCommands(program);
|
|
@@ -5106,7 +5321,7 @@ registerMongoCommands(program);
|
|
|
5106
5321
|
registerKmsCommands(program);
|
|
5107
5322
|
registerRecoveryCommands(program);
|
|
5108
5323
|
program.command("mcp").description("run an MCP server over stdio so AI agents can drive seekrit").action(async () => {
|
|
5109
|
-
const { runMcpServer } = await import("./mcp-
|
|
5324
|
+
const { runMcpServer } = await import("./mcp-DLplPOvz.js");
|
|
5110
5325
|
await runMcpServer();
|
|
5111
5326
|
});
|
|
5112
5327
|
program.command("audit").description("show the org audit trail").option("--org <slug>").option("--limit <n>", "entries to fetch", "50").action(async (options) => {
|
|
@@ -5120,4 +5335,4 @@ program.parseAsync(argv).catch((err) => {
|
|
|
5120
5335
|
fail(err instanceof Error ? err.message : String(err));
|
|
5121
5336
|
});
|
|
5122
5337
|
//#endregion
|
|
5123
|
-
export {
|
|
5338
|
+
export { verifyMessage as A, toBase64 as B, isServiceToken as C, importVerifyingKey as D, importSigningKey as E, kmsBlobKeyRef as F, kmsDecrypt as I, kmsEncrypt as L, generateMysqlCredential as M, generateDataKey as N, signMessage as O, generateEncryptKeyMaterial as P, wrapDek as R, createServiceToken as S, generateSigningKeyMaterial as T, parseBranchTtl as V, isTokenAuth as _, ensureM2mAdminToken as a, writeProjectConfig as b, kmsResolveKey as c, resolveAppEnv as d, resolveBranch as f, getDek as g, resolveOrg as h, materializeEnv as i, generatePostgresCredential as j, signatureKeyRef as k, kmsResolveRecipient as l, resolveGroup as m, fetchDecryptedSecrets as n, kmsCallerIdentity as o, resolveEnvTarget as p, fetchDecryptedVersion as r, kmsRecoverMaterial as s, encryptAndSetSecret as t, resolveApp as u, tryBuildContext as v, parseServiceToken as w, version as x, setFailThrows as y, generateDek as z };
|