envpkt 0.11.2 → 0.11.4
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/cli.js +320 -15
- package/dist/index.d.ts +3 -0
- package/dist/index.js +16 -3
- package/package.json +1 -1
- package/schemas/envpkt.schema.json +5 -0
package/dist/cli.js
CHANGED
|
@@ -27,20 +27,26 @@ const classifySecret = (key, meta, fnoxKeys, staleWarningDays, requireExpiration
|
|
|
27
27
|
const issues = [];
|
|
28
28
|
const created = Option(meta.created).flatMap(parseDate);
|
|
29
29
|
const expires = Option(meta.expires).flatMap(parseDate);
|
|
30
|
+
const lastRotated = Option(meta.last_rotated_at).flatMap(parseDate);
|
|
30
31
|
const rotationUrl = Option(meta.rotation_url);
|
|
31
32
|
const purpose = Option(meta.purpose);
|
|
32
33
|
const service = Option(meta.service);
|
|
33
34
|
const daysRemaining = expires.map((exp) => daysBetween(today, exp));
|
|
34
|
-
const
|
|
35
|
+
const staleFromRotation = lastRotated.isSome();
|
|
36
|
+
const daysSinceRotation = (staleFromRotation ? lastRotated : created).map((d) => daysBetween(d, today));
|
|
35
37
|
const isExpired = daysRemaining.fold(() => false, (d) => d < 0);
|
|
36
38
|
const isExpiringSoon = daysRemaining.fold(() => false, (d) => d >= 0 && d <= WARN_BEFORE_DAYS);
|
|
37
|
-
const isStale =
|
|
39
|
+
const isStale = daysSinceRotation.fold(() => false, (d) => d > staleWarningDays);
|
|
38
40
|
const hasSealed = !!meta.encrypted_value;
|
|
39
41
|
const isMissing = fnoxKeys.size > 0 && !fnoxKeys.has(key) && !hasSealed;
|
|
40
42
|
const isMissingMetadata = requireExpiration && expires.isNone() || requireService && service.isNone();
|
|
41
43
|
if (isExpired) issues.push("Secret has expired");
|
|
42
44
|
if (isExpiringSoon) issues.push(`Expires in ${daysRemaining.fold(() => "?", (d) => String(d))} days`);
|
|
43
|
-
if (isStale)
|
|
45
|
+
if (isStale) {
|
|
46
|
+
const since = daysSinceRotation.fold(() => "?", (d) => String(d));
|
|
47
|
+
const label = staleFromRotation ? "last rotated" : "created";
|
|
48
|
+
issues.push(`Secret is stale (${label} ${since} days ago)`);
|
|
49
|
+
}
|
|
44
50
|
if (isMissing) issues.push("Key not found in fnox");
|
|
45
51
|
if (isMissingMetadata) {
|
|
46
52
|
if (requireExpiration && expires.isNone()) issues.push("Missing required expiration date");
|
|
@@ -55,6 +61,7 @@ const classifySecret = (key, meta, fnoxKeys, staleWarningDays, requireExpiration
|
|
|
55
61
|
purpose,
|
|
56
62
|
created: Option(meta.created),
|
|
57
63
|
expires: Option(meta.expires),
|
|
64
|
+
last_rotated_at: Option(meta.last_rotated_at),
|
|
58
65
|
issues: List(issues),
|
|
59
66
|
alias_of: Option(void 0)
|
|
60
67
|
};
|
|
@@ -73,6 +80,7 @@ const classifyAlias = (key, meta, targetHealth, targetRef) => ({
|
|
|
73
80
|
purpose: Option(meta.purpose).fold(() => targetHealth.purpose, (v) => Option(v)),
|
|
74
81
|
created: targetHealth.created,
|
|
75
82
|
expires: targetHealth.expires,
|
|
83
|
+
last_rotated_at: targetHealth.last_rotated_at,
|
|
76
84
|
issues: List([]),
|
|
77
85
|
alias_of: Option(targetRef)
|
|
78
86
|
});
|
|
@@ -106,6 +114,7 @@ const computeAudit = (config, fnoxKeys, today, aliasTable) => {
|
|
|
106
114
|
purpose: Option(meta.purpose),
|
|
107
115
|
created: Option(meta.created),
|
|
108
116
|
expires: Option(meta.expires),
|
|
117
|
+
last_rotated_at: Option(meta.last_rotated_at),
|
|
109
118
|
issues: List(["Alias target not resolvable"]),
|
|
110
119
|
alias_of: Option(targetRef)
|
|
111
120
|
}), (health) => classifyAlias(key, meta, health, targetRef));
|
|
@@ -205,6 +214,10 @@ const SecretMetaSchema = Type.Object({
|
|
|
205
214
|
format: "date",
|
|
206
215
|
description: "Date the secret was provisioned (YYYY-MM-DD)"
|
|
207
216
|
})),
|
|
217
|
+
last_rotated_at: Type.Optional(Type.String({
|
|
218
|
+
format: "date",
|
|
219
|
+
description: "Date the secret value was most recently rotated (YYYY-MM-DD). Used by audit for staleness."
|
|
220
|
+
})),
|
|
208
221
|
rotates: Type.Optional(Type.String({ description: "Rotation schedule (e.g. '90d', 'quarterly')" })),
|
|
209
222
|
rate_limit: Type.Optional(Type.String({ description: "Rate limit or quota info (e.g. '1000/min')" })),
|
|
210
223
|
model_hint: Type.Optional(Type.String({ description: "Suggested model or tier for this credential" })),
|
|
@@ -765,9 +778,13 @@ const runAuditOnConfig = (config, options) => {
|
|
|
765
778
|
...afterStatus,
|
|
766
779
|
secrets: afterStatus.secrets.filter((s) => s.days_remaining.fold(() => false, (d) => d >= 0 && d <= expiring))
|
|
767
780
|
}));
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
781
|
+
const sorted = options.sort ? {
|
|
782
|
+
...filtered,
|
|
783
|
+
secrets: filtered.secrets.sortBy((s) => s.key)
|
|
784
|
+
} : filtered;
|
|
785
|
+
if (options.format === "json") console.log(formatAuditJson(sorted));
|
|
786
|
+
else if (options.format === "minimal") console.log(formatAuditMinimal(sorted));
|
|
787
|
+
else console.log(formatAudit(sorted));
|
|
771
788
|
if (options.all) if (options.format === "json") console.log(formatEnvAuditJson(config));
|
|
772
789
|
else formatEnvAuditTable(config);
|
|
773
790
|
const code = options.strict ? exitCodeForAudit(audit) : audit.status === "critical" ? 2 : 0;
|
|
@@ -2347,6 +2364,145 @@ const updateSectionFields = (raw, sectionHeader, updates) => {
|
|
|
2347
2364
|
* Ensures proper spacing (double newline before the block).
|
|
2348
2365
|
*/
|
|
2349
2366
|
const appendSection = (raw, block) => `${raw.trimEnd()}\n\n${block}`;
|
|
2367
|
+
const ENV_HEADER_RE = /^\[env\.(.+)\]\s*$/;
|
|
2368
|
+
const SECRET_HEADER_RE = /^\[secret\.(.+)\]\s*$/;
|
|
2369
|
+
const ANY_HEADER_RE = /^\[.+\]\s*$/;
|
|
2370
|
+
/**
|
|
2371
|
+
* Find the end (exclusive) of a section starting at `start`, respecting
|
|
2372
|
+
* multiline `"""..."""` values so the scanner does not mistake content inside
|
|
2373
|
+
* a multiline string for a section header.
|
|
2374
|
+
*/
|
|
2375
|
+
const findSectionEnd = (lines, start) => {
|
|
2376
|
+
const initial = {
|
|
2377
|
+
end: lines.length,
|
|
2378
|
+
inMultiline: false,
|
|
2379
|
+
done: false
|
|
2380
|
+
};
|
|
2381
|
+
return List(lines.slice(start + 1)).zipWithIndex().foldLeft(initial)((state, entry) => scanSectionBoundary(state, entry[0], start + 1 + entry[1])).end;
|
|
2382
|
+
};
|
|
2383
|
+
/**
|
|
2384
|
+
* Walking backwards from `headerIdx`, return the index of the first line of the
|
|
2385
|
+
* "doc block" that should travel with this section. A doc block is a contiguous
|
|
2386
|
+
* run of `#`-comment lines *immediately* above the header (no blank line
|
|
2387
|
+
* between). A blank line acts as a paragraph break and stops the walk — so
|
|
2388
|
+
* `# Some heading\n\n[secret.X]` does NOT attach the heading to `[secret.X]`.
|
|
2389
|
+
*/
|
|
2390
|
+
const findPreambleStart = (lines, headerIdx) => {
|
|
2391
|
+
const stopOffset = [...lines.slice(0, headerIdx)].reverse().findIndex((l) => !l.trim().startsWith("#"));
|
|
2392
|
+
return stopOffset === -1 ? 0 : headerIdx - stopOffset;
|
|
2393
|
+
};
|
|
2394
|
+
const classifyHeader = (line, idx) => {
|
|
2395
|
+
if (!ANY_HEADER_RE.test(line)) return Option.none();
|
|
2396
|
+
const envMatch = line.match(ENV_HEADER_RE);
|
|
2397
|
+
if (envMatch) return Option({
|
|
2398
|
+
idx,
|
|
2399
|
+
kind: "env",
|
|
2400
|
+
key: envMatch[1]
|
|
2401
|
+
});
|
|
2402
|
+
const secretMatch = line.match(SECRET_HEADER_RE);
|
|
2403
|
+
if (secretMatch) return Option({
|
|
2404
|
+
idx,
|
|
2405
|
+
kind: "secret",
|
|
2406
|
+
key: secretMatch[1]
|
|
2407
|
+
});
|
|
2408
|
+
return Option({
|
|
2409
|
+
idx,
|
|
2410
|
+
kind: "other",
|
|
2411
|
+
key: ""
|
|
2412
|
+
});
|
|
2413
|
+
};
|
|
2414
|
+
const scanHeader = (state, line, idx) => {
|
|
2415
|
+
if (state.inMultiline) return line.includes(MULTILINE_OPEN) ? {
|
|
2416
|
+
...state,
|
|
2417
|
+
inMultiline: false
|
|
2418
|
+
} : state;
|
|
2419
|
+
if (line.includes(MULTILINE_OPEN)) return (line.slice(line.indexOf("=") + 1).trim().match(/* @__PURE__ */ new RegExp("\"\"\"", "g")) ?? []).length === 1 ? {
|
|
2420
|
+
...state,
|
|
2421
|
+
inMultiline: true
|
|
2422
|
+
} : state;
|
|
2423
|
+
return classifyHeader(line, idx).fold(() => state, (header) => ({
|
|
2424
|
+
...state,
|
|
2425
|
+
headers: [...state.headers, header]
|
|
2426
|
+
}));
|
|
2427
|
+
};
|
|
2428
|
+
const partitionSections = (raw) => {
|
|
2429
|
+
const lines = raw.split("\n");
|
|
2430
|
+
const { headers } = List(lines).zipWithIndex().foldLeft({
|
|
2431
|
+
headers: [],
|
|
2432
|
+
inMultiline: false
|
|
2433
|
+
})((state, entry) => scanHeader(state, entry[0], entry[1]));
|
|
2434
|
+
const envSecretHeaders = headers.filter((h) => h.kind === "env" || h.kind === "secret");
|
|
2435
|
+
const trueBodyRange = (headerIdx) => {
|
|
2436
|
+
const naiveEnd = findSectionEnd(lines, headerIdx);
|
|
2437
|
+
const lastContent = lines.slice(headerIdx, naiveEnd).findLastIndex((l) => {
|
|
2438
|
+
const t = l.trim();
|
|
2439
|
+
return t !== "" && !t.startsWith("#");
|
|
2440
|
+
});
|
|
2441
|
+
return {
|
|
2442
|
+
start: headerIdx,
|
|
2443
|
+
end: lastContent === -1 ? headerIdx + 1 : headerIdx + lastContent + 1
|
|
2444
|
+
};
|
|
2445
|
+
};
|
|
2446
|
+
const sections = envSecretHeaders.map((h) => {
|
|
2447
|
+
const headerIdx = h.idx;
|
|
2448
|
+
const preambleStart = findPreambleStart(lines, headerIdx);
|
|
2449
|
+
const body = lines.slice(headerIdx, trueBodyRange(headerIdx).end);
|
|
2450
|
+
const preamble = lines.slice(preambleStart, headerIdx);
|
|
2451
|
+
return {
|
|
2452
|
+
kind: h.kind,
|
|
2453
|
+
key: h.key,
|
|
2454
|
+
body,
|
|
2455
|
+
preamble
|
|
2456
|
+
};
|
|
2457
|
+
});
|
|
2458
|
+
const claimedRanges = envSecretHeaders.map((h) => {
|
|
2459
|
+
const headerIdx = h.idx;
|
|
2460
|
+
return {
|
|
2461
|
+
start: findPreambleStart(lines, headerIdx),
|
|
2462
|
+
end: trueBodyRange(headerIdx).end
|
|
2463
|
+
};
|
|
2464
|
+
});
|
|
2465
|
+
const isClaimed = (idx) => claimedRanges.some((r) => idx >= r.start && idx < r.end);
|
|
2466
|
+
return {
|
|
2467
|
+
preambleLines: lines.map((l, idx) => isClaimed(idx) ? null : l).filter((l) => l !== null),
|
|
2468
|
+
envSections: sections.filter((s) => s.kind === "env"),
|
|
2469
|
+
secretSections: sections.filter((s) => s.kind === "secret")
|
|
2470
|
+
};
|
|
2471
|
+
};
|
|
2472
|
+
const emitSection = (s) => {
|
|
2473
|
+
return `${s.preamble.length > 0 ? `${s.preamble.join("\n")}\n` : ""}${s.body.join("\n")}`;
|
|
2474
|
+
};
|
|
2475
|
+
/**
|
|
2476
|
+
* Reformat a TOML config with `[env.*]` and `[secret.*]` sections grouped and
|
|
2477
|
+
* alphabetized. Top-level content (version key, `[identity]`, `[lifecycle]`,
|
|
2478
|
+
* `[callbacks]`, `[tools]`, etc.) stays in its original position. Comment
|
|
2479
|
+
* doc-blocks immediately above a section header travel with that section.
|
|
2480
|
+
*
|
|
2481
|
+
* Pure — no I/O. Returns the raw input unchanged when there is no env or
|
|
2482
|
+
* secret content to reorder.
|
|
2483
|
+
*/
|
|
2484
|
+
const sortConfigToml = (raw) => {
|
|
2485
|
+
const { preambleLines, envSections, secretSections } = partitionSections(raw);
|
|
2486
|
+
if (envSections.length === 0 && secretSections.length === 0) return raw;
|
|
2487
|
+
const sortedEnv = [...envSections].sort((a, b) => a.key.localeCompare(b.key));
|
|
2488
|
+
const sortedSecret = [...secretSections].sort((a, b) => a.key.localeCompare(b.key));
|
|
2489
|
+
const trimTrailing = (xs) => {
|
|
2490
|
+
const lastNonBlank = xs.findLastIndex((l) => l.trim() !== "");
|
|
2491
|
+
return lastNonBlank === -1 ? [] : xs.slice(0, lastNonBlank + 1);
|
|
2492
|
+
};
|
|
2493
|
+
const collapseBlanks = (xs) => xs.reduce((acc, line) => {
|
|
2494
|
+
const isBlank = line.trim() === "";
|
|
2495
|
+
const prevBlank = acc.length > 0 && acc[acc.length - 1].trim() === "";
|
|
2496
|
+
if (isBlank && prevBlank) return acc;
|
|
2497
|
+
return [...acc, line];
|
|
2498
|
+
}, []);
|
|
2499
|
+
const preambleTrimmed = collapseBlanks(trimTrailing(preambleLines));
|
|
2500
|
+
const parts = [];
|
|
2501
|
+
if (preambleTrimmed.length > 0) parts.push(preambleTrimmed.join("\n"));
|
|
2502
|
+
if (sortedEnv.length > 0) parts.push(sortedEnv.map(emitSection).join("\n\n"));
|
|
2503
|
+
if (sortedSecret.length > 0) parts.push(sortedSecret.map(emitSection).join("\n\n"));
|
|
2504
|
+
return `${parts.join("\n\n")}\n`;
|
|
2505
|
+
};
|
|
2350
2506
|
//#endregion
|
|
2351
2507
|
//#region src/core/validate.ts
|
|
2352
2508
|
/**
|
|
@@ -2987,6 +3143,7 @@ const printSecretMeta = (meta, indent) => {
|
|
|
2987
3143
|
console.log(`${indent}${DIM}tags:${RESET} ${tagStr}`);
|
|
2988
3144
|
}
|
|
2989
3145
|
};
|
|
3146
|
+
const sortedIfRequested = (entries, sort) => sort ? [...entries].sort((a, b) => a[0].localeCompare(b[0])) : entries;
|
|
2990
3147
|
const printConfig = (config, path, resolveResult, opts) => {
|
|
2991
3148
|
console.log(`${BOLD}envpkt.toml${RESET} ${DIM}(${path})${RESET}`);
|
|
2992
3149
|
if (resolveResult?.catalogPath) console.log(`${DIM}Catalog: ${CYAN}${resolveResult.catalogPath}${RESET}`);
|
|
@@ -3004,7 +3161,7 @@ const printConfig = (config, path, resolveResult, opts) => {
|
|
|
3004
3161
|
}
|
|
3005
3162
|
const secretEntries = config.secret ?? {};
|
|
3006
3163
|
console.log(`${BOLD}Secrets:${RESET} ${Object.keys(secretEntries).length}`);
|
|
3007
|
-
Object.entries(secretEntries).forEach(([key, meta]) => {
|
|
3164
|
+
sortedIfRequested(Object.entries(secretEntries), opts?.sort).forEach(([key, meta]) => {
|
|
3008
3165
|
const valueSuffix = Option(opts?.secrets?.[key]).fold(() => "", (secretValue) => ` = ${YELLOW}${(opts?.secretDisplay ?? "encrypted") === "plaintext" ? secretValue : maskValue(secretValue)}${RESET}`);
|
|
3009
3166
|
const sealedTag = meta.encrypted_value ? ` ${CYAN}[sealed]${RESET}` : "";
|
|
3010
3167
|
console.log(` ${BOLD}${key}${RESET} → ${meta.service ?? key}${sealedTag}${valueSuffix}`);
|
|
@@ -3015,7 +3172,7 @@ const printConfig = (config, path, resolveResult, opts) => {
|
|
|
3015
3172
|
if (envKeys.length > 0) {
|
|
3016
3173
|
console.log("");
|
|
3017
3174
|
console.log(`${BOLD}Environment Defaults:${RESET} ${envKeys.length}`);
|
|
3018
|
-
Object.entries(envEntries).forEach(([key, entry]) => {
|
|
3175
|
+
sortedIfRequested(Object.entries(envEntries), opts?.sort).forEach(([key, entry]) => {
|
|
3019
3176
|
console.log(` ${BOLD}${key}${RESET} = ${GREEN}"${entry.value}"${RESET}`);
|
|
3020
3177
|
if (entry.purpose) console.log(` ${DIM}purpose:${RESET} ${entry.purpose}`);
|
|
3021
3178
|
if (entry.comment) console.log(` ${DIM}comment:${RESET} ${DIM}${entry.comment}${RESET}`);
|
|
@@ -3079,7 +3236,10 @@ const runInspect = (options) => {
|
|
|
3079
3236
|
secretDisplay: options.plaintext ? "plaintext" : "encrypted"
|
|
3080
3237
|
};
|
|
3081
3238
|
})() : void 0;
|
|
3082
|
-
printConfig(showConfig, path, showResolved ? resolveResult : void 0,
|
|
3239
|
+
printConfig(showConfig, path, showResolved ? resolveResult : void 0, {
|
|
3240
|
+
...printOpts ?? {},
|
|
3241
|
+
sort: options.sort
|
|
3242
|
+
});
|
|
3083
3243
|
});
|
|
3084
3244
|
});
|
|
3085
3245
|
});
|
|
@@ -3544,9 +3704,13 @@ const META_SECTION_RE = /^\[secret\.(.+)\]\s*$/;
|
|
|
3544
3704
|
const ENCRYPTED_VALUE_RE = /^encrypted_value\s*=/;
|
|
3545
3705
|
const NEW_SECTION_RE = /^\[/;
|
|
3546
3706
|
const MULTILINE_DELIM = "\"\"\"";
|
|
3547
|
-
/**
|
|
3548
|
-
|
|
3549
|
-
|
|
3707
|
+
/**
|
|
3708
|
+
* In-memory transform: inject sealed encrypted_value blocks into raw TOML,
|
|
3709
|
+
* replacing existing encrypted_value lines (single or multiline) or appending
|
|
3710
|
+
* a new block at the end of the section. Pure — no I/O.
|
|
3711
|
+
*/
|
|
3712
|
+
const applySealedToml = (raw, sealedMeta) => {
|
|
3713
|
+
const lines = raw.split("\n");
|
|
3550
3714
|
const getSeal = (key) => Option(sealedMeta[key]?.encrypted_value);
|
|
3551
3715
|
const isPending = (state, key) => !getSeal(key).isEmpty && !state.consumedKeys.has(key);
|
|
3552
3716
|
const sealLinesFor = (key) => getSeal(key).fold(() => [], (v) => [
|
|
@@ -3628,7 +3792,11 @@ const writeSealedToml = (configPath, sealedMeta) => {
|
|
|
3628
3792
|
consumedKeys: Set$1.empty(),
|
|
3629
3793
|
skipUntil: -1
|
|
3630
3794
|
};
|
|
3631
|
-
|
|
3795
|
+
return flushPending(List(lines).zipWithIndex().foldLeft(initial)((state, entry) => step(state, entry[0], entry[1]))).output.join("\n");
|
|
3796
|
+
};
|
|
3797
|
+
/** Write sealed values back into the TOML file, preserving structure. */
|
|
3798
|
+
const writeSealedToml = (configPath, sealedMeta) => {
|
|
3799
|
+
const finalContent = applySealedToml(readFileSync(configPath, "utf-8"), sealedMeta);
|
|
3632
3800
|
validateOrExit(finalContent);
|
|
3633
3801
|
writeFileSync(configPath, finalContent);
|
|
3634
3802
|
};
|
|
@@ -4005,6 +4173,98 @@ const runSecretAlias = (name, options) => {
|
|
|
4005
4173
|
});
|
|
4006
4174
|
});
|
|
4007
4175
|
};
|
|
4176
|
+
const readNewValue = async (key) => {
|
|
4177
|
+
if (!process.stdin.isTTY) {
|
|
4178
|
+
const chunks = [];
|
|
4179
|
+
return new Promise((resolveP, rejectP) => {
|
|
4180
|
+
process.stdin.on("data", (c) => chunks.push(Buffer.from(c)));
|
|
4181
|
+
process.stdin.on("end", () => resolveP(Buffer.concat(chunks).toString("utf-8").replace(/\r?\n$/, "")));
|
|
4182
|
+
process.stdin.on("error", rejectP);
|
|
4183
|
+
});
|
|
4184
|
+
}
|
|
4185
|
+
const rl = createInterface({
|
|
4186
|
+
input: process.stdin,
|
|
4187
|
+
output: process.stderr
|
|
4188
|
+
});
|
|
4189
|
+
return new Promise((resolveP) => {
|
|
4190
|
+
rl.question(`Enter new value for ${key}: `, (answer) => {
|
|
4191
|
+
rl.close();
|
|
4192
|
+
resolveP(answer);
|
|
4193
|
+
});
|
|
4194
|
+
});
|
|
4195
|
+
};
|
|
4196
|
+
const runSecretRotate = async (name, options) => {
|
|
4197
|
+
const { configPath, source } = resolveConfigPath(options.config).fold((err) => {
|
|
4198
|
+
console.error(formatError(err));
|
|
4199
|
+
process.exit(2);
|
|
4200
|
+
return {
|
|
4201
|
+
configPath: "",
|
|
4202
|
+
source: "flag"
|
|
4203
|
+
};
|
|
4204
|
+
}, ({ path, source: s }) => ({
|
|
4205
|
+
configPath: path,
|
|
4206
|
+
source: s
|
|
4207
|
+
}));
|
|
4208
|
+
const sourceMsg = formatConfigSource(configPath, source);
|
|
4209
|
+
if (sourceMsg) console.error(sourceMsg);
|
|
4210
|
+
const config = loadConfig(configPath).fold((err) => {
|
|
4211
|
+
console.error(formatError(err));
|
|
4212
|
+
process.exit(2);
|
|
4213
|
+
}, (c) => c);
|
|
4214
|
+
const meta = config.secret?.[name];
|
|
4215
|
+
if (!meta) {
|
|
4216
|
+
console.error(`${RED}Error:${RESET} Secret "${name}" not found in ${configPath}`);
|
|
4217
|
+
process.exit(1);
|
|
4218
|
+
}
|
|
4219
|
+
if (meta.from_key) {
|
|
4220
|
+
console.error(`${RED}Error:${RESET} "${name}" is an alias (from_key = "${meta.from_key}"). Rotate the target secret instead.`);
|
|
4221
|
+
process.exit(1);
|
|
4222
|
+
}
|
|
4223
|
+
const today = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
|
|
4224
|
+
const wasSealed = !!meta.encrypted_value;
|
|
4225
|
+
const raw = readFileSync(configPath, "utf-8");
|
|
4226
|
+
if (!wasSealed) {
|
|
4227
|
+
updateSectionFields(raw, `[secret.${name}]`, { last_rotated_at: `"${today}"` }).fold((err) => {
|
|
4228
|
+
console.error(`${RED}Error:${RESET} ${err._tag}: ${err.section}`);
|
|
4229
|
+
process.exit(2);
|
|
4230
|
+
}, (result) => {
|
|
4231
|
+
if (options.dryRun) {
|
|
4232
|
+
console.log(`${DIM}# Preview (--dry-run):${RESET}\n`);
|
|
4233
|
+
console.log(result);
|
|
4234
|
+
return;
|
|
4235
|
+
}
|
|
4236
|
+
writeIfValid(configPath, result, `${GREEN}✓${RESET} Stamped ${BOLD}last_rotated_at${RESET} on ${BOLD}${name}${RESET} (unsealed — no ciphertext to update)`);
|
|
4237
|
+
});
|
|
4238
|
+
return;
|
|
4239
|
+
}
|
|
4240
|
+
if (!config.identity?.recipient) {
|
|
4241
|
+
console.error(`${RED}Error:${RESET} identity.recipient is required to rotate a sealed secret`);
|
|
4242
|
+
console.error(`${DIM}Run ${CYAN}envpkt keygen${DIM} to configure one.${RESET}`);
|
|
4243
|
+
process.exit(2);
|
|
4244
|
+
}
|
|
4245
|
+
const { recipient } = config.identity;
|
|
4246
|
+
const value = await readNewValue(name);
|
|
4247
|
+
if (value === "") {
|
|
4248
|
+
console.error(`${YELLOW}Cancelled:${RESET} no value provided — ${BOLD}${name}${RESET} was not rotated.`);
|
|
4249
|
+
process.exit(1);
|
|
4250
|
+
}
|
|
4251
|
+
const ciphertext = ageEncrypt(value, recipient).fold((err) => {
|
|
4252
|
+
console.error(`${RED}Error:${RESET} Encryption failed: ${err.message}`);
|
|
4253
|
+
process.exit(2);
|
|
4254
|
+
return "";
|
|
4255
|
+
}, (ct) => ct);
|
|
4256
|
+
updateSectionFields(applySealedToml(raw, { [name]: { encrypted_value: ciphertext } }), `[secret.${name}]`, { last_rotated_at: `"${today}"` }).fold((err) => {
|
|
4257
|
+
console.error(`${RED}Error:${RESET} ${err._tag}: ${err.section}`);
|
|
4258
|
+
process.exit(2);
|
|
4259
|
+
}, (result) => {
|
|
4260
|
+
if (options.dryRun) {
|
|
4261
|
+
console.log(`${DIM}# Preview (--dry-run):${RESET}\n`);
|
|
4262
|
+
console.log(result);
|
|
4263
|
+
return;
|
|
4264
|
+
}
|
|
4265
|
+
writeIfValid(configPath, result, `${GREEN}✓${RESET} Rotated ${BOLD}${name}${RESET} (resealed + stamped ${CYAN}${today}${RESET})`);
|
|
4266
|
+
});
|
|
4267
|
+
};
|
|
4008
4268
|
const addSecretFlags = (cmd) => cmd.option("--service <service>", "Service this secret authenticates to").option("--purpose <purpose>", "Why this secret exists").option("--comment <comment>", "Free-form annotation").option("--expires <date>", "Expiration date (YYYY-MM-DD)").option("--capabilities <caps>", "Comma-separated capabilities (e.g. read,write)").option("--rotates <schedule>", "Rotation schedule (e.g. 90d, quarterly)").option("--rate-limit <limit>", "Rate limit info (e.g. 1000/min)").option("--model-hint <hint>", "Suggested model or tier").option("--source <source>", "Where the value originates (e.g. vault, ci)").option("--rotation-url <url>", "URL for secret rotation procedure").option("--tags <tags>", "Comma-separated key=value tags (e.g. env=prod,team=payments)");
|
|
4009
4269
|
const registerSecretCommands = (program) => {
|
|
4010
4270
|
const secret = program.command("secret").description("Manage secret entries in envpkt.toml");
|
|
@@ -4020,6 +4280,9 @@ const registerSecretCommands = (program) => {
|
|
|
4020
4280
|
secret.command("rename").description("Rename a secret entry, preserving all metadata").argument("<old>", "Current secret name").argument("<new>", "New secret name").option("-c, --config <path>", "Path to envpkt.toml").option("--dry-run", "Preview the result without writing").action((oldName, newName, options) => {
|
|
4021
4281
|
runSecretRename(oldName, newName, options);
|
|
4022
4282
|
});
|
|
4283
|
+
secret.command("rotate").description("Rotate a secret's value (sealed: reseal + stamp; unsealed: stamp only)").argument("<name>", "Secret name to rotate").option("-c, --config <path>", "Path to envpkt.toml").option("--dry-run", "Preview the result without writing").action(async (name, options) => {
|
|
4284
|
+
await runSecretRotate(name, options);
|
|
4285
|
+
});
|
|
4023
4286
|
secret.command("alias").description("Create an alias entry that reuses another secret's resolved value").argument("<name>", "Alias name (becomes the env var key)").requiredOption("--from <ref>", "Target reference — must be \"secret.<KEY>\"").option("-c, --config <path>", "Path to envpkt.toml").option("--purpose <purpose>", "Why this alias exists (local metadata)").option("--comment <comment>", "Free-form annotation").option("--tags <tags>", "Comma-separated key=value tags (e.g. env=prod,team=payments)").option("--force", "Overwrite the entry if <name> already exists").option("--dry-run", "Preview the TOML block without writing").action((name, options) => {
|
|
4024
4287
|
runSecretAlias(name, options);
|
|
4025
4288
|
});
|
|
@@ -4066,6 +4329,45 @@ const runShellHook = (shell) => {
|
|
|
4066
4329
|
}
|
|
4067
4330
|
};
|
|
4068
4331
|
//#endregion
|
|
4332
|
+
//#region src/cli/commands/sort.ts
|
|
4333
|
+
const SECTION_HEADER_RE = /^\[(env|secret)\.(.+)\]\s*$/;
|
|
4334
|
+
const countSections = (raw) => raw.split("\n").reduce((acc, line) => {
|
|
4335
|
+
const m = SECTION_HEADER_RE.exec(line);
|
|
4336
|
+
if (!m) return acc;
|
|
4337
|
+
return m[1] === "env" ? {
|
|
4338
|
+
...acc,
|
|
4339
|
+
env: acc.env + 1
|
|
4340
|
+
} : {
|
|
4341
|
+
...acc,
|
|
4342
|
+
secret: acc.secret + 1
|
|
4343
|
+
};
|
|
4344
|
+
}, {
|
|
4345
|
+
env: 0,
|
|
4346
|
+
secret: 0
|
|
4347
|
+
});
|
|
4348
|
+
const runSort = (options) => {
|
|
4349
|
+
resolveConfigPath(options.config).fold((err) => {
|
|
4350
|
+
console.error(formatError(err));
|
|
4351
|
+
process.exit(2);
|
|
4352
|
+
}, ({ path: configPath, source }) => {
|
|
4353
|
+
const sourceMsg = formatConfigSource(configPath, source);
|
|
4354
|
+
if (sourceMsg) console.error(sourceMsg);
|
|
4355
|
+
const raw = readFileSync(configPath, "utf-8");
|
|
4356
|
+
const sorted = sortConfigToml(raw);
|
|
4357
|
+
const counts = countSections(sorted);
|
|
4358
|
+
if (sorted === raw) {
|
|
4359
|
+
console.log(`${GREEN}✓${RESET} ${BOLD}Already sorted${RESET} — ${counts.env} env, ${counts.secret} secret`);
|
|
4360
|
+
return;
|
|
4361
|
+
}
|
|
4362
|
+
if (options.dryRun) {
|
|
4363
|
+
console.log(`${DIM}# Preview (--dry-run):${RESET}\n`);
|
|
4364
|
+
console.log(sorted);
|
|
4365
|
+
return;
|
|
4366
|
+
}
|
|
4367
|
+
writeIfValid(configPath, sorted, `${GREEN}✓${RESET} Sorted ${BOLD}${counts.env}${RESET} env and ${BOLD}${counts.secret}${RESET} secret entries in ${CYAN}${configPath}${RESET}`);
|
|
4368
|
+
});
|
|
4369
|
+
};
|
|
4370
|
+
//#endregion
|
|
4069
4371
|
//#region src/cli/commands/upgrade.ts
|
|
4070
4372
|
const getCurrentVersion = () => Try(() => execFileSync("npm", [
|
|
4071
4373
|
"list",
|
|
@@ -4322,7 +4624,7 @@ program.command("init").description("Initialize a new envpkt.toml in the current
|
|
|
4322
4624
|
program.command("keygen").description("Generate an age keypair for sealing secrets — run this before `seal` if you don't have a key yet").option("-c, --config <path>", "Path to envpkt.toml (updates identity.recipient if found)").option("-o, --output <path>", "Output path for identity file (default: ~/.envpkt/<project>-key.txt)").option("--global", "Write key to the shared default path (~/.envpkt/age-key.txt) instead of a project-specific one").action((options) => {
|
|
4323
4625
|
runKeygen(options);
|
|
4324
4626
|
});
|
|
4325
|
-
program.command("audit").description("Audit credential health from envpkt.toml (use --strict in CI pipelines to gate deploys)").option("-c, --config <path>", "Path to envpkt.toml").option("--format <format>", "Output format: table | json | minimal", "table").option("--expiring <days>", "Show secrets expiring within N days", parseInt).option("--status <status>", "Filter by status: healthy | expiring_soon | expired | stale | missing").option("--strict", "Exit non-zero on any non-healthy secret").option("--all", "Show both secrets and env defaults").option("--env-only", "Show only env defaults (drift detection)").option("--sealed", "Show only secrets with encrypted_value").option("--external", "Show only secrets without encrypted_value").action((options) => {
|
|
4627
|
+
program.command("audit").description("Audit credential health from envpkt.toml (use --strict in CI pipelines to gate deploys)").option("-c, --config <path>", "Path to envpkt.toml").option("--format <format>", "Output format: table | json | minimal", "table").option("--expiring <days>", "Show secrets expiring within N days", parseInt).option("--status <status>", "Filter by status: healthy | expiring_soon | expired | stale | missing").option("--strict", "Exit non-zero on any non-healthy secret").option("--all", "Show both secrets and env defaults").option("--env-only", "Show only env defaults (drift detection)").option("--sealed", "Show only secrets with encrypted_value").option("--external", "Show only secrets without encrypted_value").option("--sort", "Sort secrets alphabetically within each status bucket (no file mutation)").action((options) => {
|
|
4326
4628
|
runAudit(options);
|
|
4327
4629
|
});
|
|
4328
4630
|
program.command("fleet").description("Scan directory tree for envpkt.toml files and aggregate health (use in CI for fleet-wide monitoring)").option("-d, --dir <path>", "Root directory to scan", ".").option("--depth <n>", "Max directory depth", parseInt).option("--format <format>", "Output format: table | json", "table").option("--status <status>", "Filter agents by health status").action((options) => {
|
|
@@ -4331,7 +4633,7 @@ program.command("fleet").description("Scan directory tree for envpkt.toml files
|
|
|
4331
4633
|
program.command("validate").description("Verify envpkt.toml integrity — runs TOML syntax, schema, catalog, alias, and sealed-block structural checks").option("-c, --config <path>", "Path to envpkt.toml").option("--json", "Output structured JSON instead of human-readable text").action((options) => {
|
|
4332
4634
|
runValidate(options);
|
|
4333
4635
|
});
|
|
4334
|
-
program.command("inspect").description("Display structured view of envpkt.toml").option("-c, --config <path>", "Path to envpkt.toml").option("--format <format>", "Output format: table | json", "table").option("--resolved", "Show resolved view (catalog merged)").option("--secrets", "Show secret values from environment (masked by default)").option("--plaintext", "Show secret values in plaintext (requires --secrets)").action((options) => {
|
|
4636
|
+
program.command("inspect").description("Display structured view of envpkt.toml").option("-c, --config <path>", "Path to envpkt.toml").option("--format <format>", "Output format: table | json", "table").option("--resolved", "Show resolved view (catalog merged)").option("--secrets", "Show secret values from environment (masked by default)").option("--plaintext", "Show secret values in plaintext (requires --secrets)").option("--sort", "Display secrets and env entries in alphabetical order (no file mutation)").action((options) => {
|
|
4335
4637
|
runInspect(options);
|
|
4336
4638
|
});
|
|
4337
4639
|
program.command("exec").description("Run pre-flight audit then execute a command with injected secrets (sealed → fnox → env cascade)").argument("<command...>", "Command to execute").option("-c, --config <path>", "Path to envpkt.toml").option("--profile <profile>", "fnox profile to use").option("--skip-audit", "Skip the pre-flight audit (alias: --no-check)").option("--no-check", "Skip the pre-flight audit").option("--warn-only", "Warn on critical audit but do not abort").option("--strict", "Abort on any non-healthy secret").action((args, options) => {
|
|
@@ -4348,6 +4650,9 @@ program.command("mcp").description("Start the envpkt MCP server (stdio transport
|
|
|
4348
4650
|
});
|
|
4349
4651
|
registerSecretCommands(program);
|
|
4350
4652
|
registerEnvCommands(program);
|
|
4653
|
+
program.command("sort").description("Group [env.*] and [secret.*] sections and alphabetize within each region").option("-c, --config <path>", "Path to envpkt.toml").option("--dry-run", "Preview the result without writing").action((options) => {
|
|
4654
|
+
runSort(options);
|
|
4655
|
+
});
|
|
4351
4656
|
program.command("upgrade").description("Upgrade envpkt to the latest version (npm install -g envpkt@latest)").action(() => {
|
|
4352
4657
|
runUpgrade();
|
|
4353
4658
|
});
|
package/dist/index.d.ts
CHANGED
|
@@ -40,6 +40,7 @@ declare const SecretMetaSchema: import("@sinclair/typebox").TObject<{
|
|
|
40
40
|
comment: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
41
41
|
capabilities: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TArray<import("@sinclair/typebox").TString>>;
|
|
42
42
|
created: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
43
|
+
last_rotated_at: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
43
44
|
rotates: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
44
45
|
rate_limit: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
45
46
|
model_hint: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
@@ -94,6 +95,7 @@ declare const EnvpktConfigSchema: import("@sinclair/typebox").TObject<{
|
|
|
94
95
|
comment: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
95
96
|
capabilities: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TArray<import("@sinclair/typebox").TString>>;
|
|
96
97
|
created: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
98
|
+
last_rotated_at: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
97
99
|
rotates: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
98
100
|
rate_limit: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
99
101
|
model_hint: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
@@ -138,6 +140,7 @@ type SecretHealth = {
|
|
|
138
140
|
readonly purpose: Option<string>;
|
|
139
141
|
readonly created: Option<string>;
|
|
140
142
|
readonly expires: Option<string>;
|
|
143
|
+
readonly last_rotated_at: Option<string>;
|
|
141
144
|
readonly issues: List<string>; /** If this entry is an alias (from_key), the reference it points at (e.g. "secret.X") */
|
|
142
145
|
readonly alias_of: Option<string>;
|
|
143
146
|
};
|
package/dist/index.js
CHANGED
|
@@ -56,6 +56,10 @@ const SecretMetaSchema = Type.Object({
|
|
|
56
56
|
format: "date",
|
|
57
57
|
description: "Date the secret was provisioned (YYYY-MM-DD)"
|
|
58
58
|
})),
|
|
59
|
+
last_rotated_at: Type.Optional(Type.String({
|
|
60
|
+
format: "date",
|
|
61
|
+
description: "Date the secret value was most recently rotated (YYYY-MM-DD). Used by audit for staleness."
|
|
62
|
+
})),
|
|
59
63
|
rotates: Type.Optional(Type.String({ description: "Rotation schedule (e.g. '90d', 'quarterly')" })),
|
|
60
64
|
rate_limit: Type.Optional(Type.String({ description: "Rate limit or quota info (e.g. '1000/min')" })),
|
|
61
65
|
model_hint: Type.Optional(Type.String({ description: "Suggested model or tier for this credential" })),
|
|
@@ -565,20 +569,26 @@ const classifySecret = (key, meta, fnoxKeys, staleWarningDays, requireExpiration
|
|
|
565
569
|
const issues = [];
|
|
566
570
|
const created = Option(meta.created).flatMap(parseDate);
|
|
567
571
|
const expires = Option(meta.expires).flatMap(parseDate);
|
|
572
|
+
const lastRotated = Option(meta.last_rotated_at).flatMap(parseDate);
|
|
568
573
|
const rotationUrl = Option(meta.rotation_url);
|
|
569
574
|
const purpose = Option(meta.purpose);
|
|
570
575
|
const service = Option(meta.service);
|
|
571
576
|
const daysRemaining = expires.map((exp) => daysBetween(today, exp));
|
|
572
|
-
const
|
|
577
|
+
const staleFromRotation = lastRotated.isSome();
|
|
578
|
+
const daysSinceRotation = (staleFromRotation ? lastRotated : created).map((d) => daysBetween(d, today));
|
|
573
579
|
const isExpired = daysRemaining.fold(() => false, (d) => d < 0);
|
|
574
580
|
const isExpiringSoon = daysRemaining.fold(() => false, (d) => d >= 0 && d <= WARN_BEFORE_DAYS);
|
|
575
|
-
const isStale =
|
|
581
|
+
const isStale = daysSinceRotation.fold(() => false, (d) => d > staleWarningDays);
|
|
576
582
|
const hasSealed = !!meta.encrypted_value;
|
|
577
583
|
const isMissing = fnoxKeys.size > 0 && !fnoxKeys.has(key) && !hasSealed;
|
|
578
584
|
const isMissingMetadata = requireExpiration && expires.isNone() || requireService && service.isNone();
|
|
579
585
|
if (isExpired) issues.push("Secret has expired");
|
|
580
586
|
if (isExpiringSoon) issues.push(`Expires in ${daysRemaining.fold(() => "?", (d) => String(d))} days`);
|
|
581
|
-
if (isStale)
|
|
587
|
+
if (isStale) {
|
|
588
|
+
const since = daysSinceRotation.fold(() => "?", (d) => String(d));
|
|
589
|
+
const label = staleFromRotation ? "last rotated" : "created";
|
|
590
|
+
issues.push(`Secret is stale (${label} ${since} days ago)`);
|
|
591
|
+
}
|
|
582
592
|
if (isMissing) issues.push("Key not found in fnox");
|
|
583
593
|
if (isMissingMetadata) {
|
|
584
594
|
if (requireExpiration && expires.isNone()) issues.push("Missing required expiration date");
|
|
@@ -593,6 +603,7 @@ const classifySecret = (key, meta, fnoxKeys, staleWarningDays, requireExpiration
|
|
|
593
603
|
purpose,
|
|
594
604
|
created: Option(meta.created),
|
|
595
605
|
expires: Option(meta.expires),
|
|
606
|
+
last_rotated_at: Option(meta.last_rotated_at),
|
|
596
607
|
issues: List(issues),
|
|
597
608
|
alias_of: Option(void 0)
|
|
598
609
|
};
|
|
@@ -611,6 +622,7 @@ const classifyAlias = (key, meta, targetHealth, targetRef) => ({
|
|
|
611
622
|
purpose: Option(meta.purpose).fold(() => targetHealth.purpose, (v) => Option(v)),
|
|
612
623
|
created: targetHealth.created,
|
|
613
624
|
expires: targetHealth.expires,
|
|
625
|
+
last_rotated_at: targetHealth.last_rotated_at,
|
|
614
626
|
issues: List([]),
|
|
615
627
|
alias_of: Option(targetRef)
|
|
616
628
|
});
|
|
@@ -644,6 +656,7 @@ const computeAudit = (config, fnoxKeys, today, aliasTable) => {
|
|
|
644
656
|
purpose: Option(meta.purpose),
|
|
645
657
|
created: Option(meta.created),
|
|
646
658
|
expires: Option(meta.expires),
|
|
659
|
+
last_rotated_at: Option(meta.last_rotated_at),
|
|
647
660
|
issues: List(["Alias target not resolvable"]),
|
|
648
661
|
alias_of: Option(targetRef)
|
|
649
662
|
}), (health) => classifyAlias(key, meta, health, targetRef));
|
package/package.json
CHANGED
|
@@ -131,6 +131,11 @@
|
|
|
131
131
|
"description": "Date the secret was provisioned (YYYY-MM-DD)",
|
|
132
132
|
"type": "string"
|
|
133
133
|
},
|
|
134
|
+
"last_rotated_at": {
|
|
135
|
+
"format": "date",
|
|
136
|
+
"description": "Date the secret value was most recently rotated (YYYY-MM-DD). Used by audit for staleness.",
|
|
137
|
+
"type": "string"
|
|
138
|
+
},
|
|
134
139
|
"rotates": {
|
|
135
140
|
"description": "Rotation schedule (e.g. '90d', 'quarterly')",
|
|
136
141
|
"type": "string"
|