envpkt 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +507 -0
- package/dist/cli.js +2254 -0
- package/dist/index.d.ts +392 -0
- package/dist/index.js +1769 -0
- package/package.json +71 -0
- package/schemas/envpkt.schema.json +211 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,2254 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { Cond, Left, List, Option, Right, Try } from "functype";
|
|
5
|
+
import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { TypeCompiler } from "@sinclair/typebox/compiler";
|
|
7
|
+
import { TomlDate, parse, stringify } from "smol-toml";
|
|
8
|
+
import { FormatRegistry, Type } from "@sinclair/typebox";
|
|
9
|
+
import { execFileSync } from "node:child_process";
|
|
10
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
11
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
12
|
+
import { CallToolRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
13
|
+
|
|
14
|
+
//#region src/core/audit.ts
|
|
15
|
+
const MS_PER_DAY = 864e5;
|
|
16
|
+
const WARN_BEFORE_DAYS = 30;
|
|
17
|
+
const daysBetween = (from, to) => Math.floor((to.getTime() - from.getTime()) / MS_PER_DAY);
|
|
18
|
+
const parseDate = (dateStr) => {
|
|
19
|
+
const d = /* @__PURE__ */ new Date(dateStr + "T00:00:00Z");
|
|
20
|
+
return Number.isNaN(d.getTime()) ? Option(void 0) : Option(d);
|
|
21
|
+
};
|
|
22
|
+
const classifySecret = (key, meta, fnoxKeys, staleWarningDays, requireExpiration, requireService, today) => {
|
|
23
|
+
const issues = [];
|
|
24
|
+
const created = Option(meta?.created).flatMap(parseDate);
|
|
25
|
+
const expires = Option(meta?.expires).flatMap(parseDate);
|
|
26
|
+
const rotationUrl = Option(meta?.rotation_url);
|
|
27
|
+
const purpose = Option(meta?.purpose);
|
|
28
|
+
const service = Option(meta?.service);
|
|
29
|
+
const daysRemaining = expires.map((exp) => daysBetween(today, exp));
|
|
30
|
+
const daysSinceCreated = created.map((c) => daysBetween(c, today));
|
|
31
|
+
const isExpired = daysRemaining.fold(() => false, (d) => d < 0);
|
|
32
|
+
const isExpiringSoon = daysRemaining.fold(() => false, (d) => d >= 0 && d <= WARN_BEFORE_DAYS);
|
|
33
|
+
const isStale = daysSinceCreated.fold(() => false, (d) => d > staleWarningDays);
|
|
34
|
+
const isMissing = fnoxKeys.size > 0 && !fnoxKeys.has(key);
|
|
35
|
+
const isMissingMetadata = requireExpiration && expires.isNone() || requireService && service.isNone();
|
|
36
|
+
if (isExpired) issues.push("Secret has expired");
|
|
37
|
+
if (isExpiringSoon) issues.push(`Expires in ${daysRemaining.fold(() => "?", (d) => String(d))} days`);
|
|
38
|
+
if (isStale) issues.push("Secret is stale (no rotation detected)");
|
|
39
|
+
if (isMissing) issues.push("Key not found in fnox");
|
|
40
|
+
if (isMissingMetadata) {
|
|
41
|
+
if (requireExpiration && expires.isNone()) issues.push("Missing required expiration date");
|
|
42
|
+
if (requireService && service.isNone()) issues.push("Missing required service");
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
key,
|
|
46
|
+
service,
|
|
47
|
+
status: Cond.of().when(isExpired, "expired").elseWhen(isMissing, "missing").elseWhen(isMissingMetadata, "missing_metadata").elseWhen(isExpiringSoon, "expiring_soon").elseWhen(isStale, "stale").else("healthy"),
|
|
48
|
+
days_remaining: daysRemaining,
|
|
49
|
+
rotation_url: rotationUrl,
|
|
50
|
+
purpose,
|
|
51
|
+
created: Option(meta?.created),
|
|
52
|
+
expires: Option(meta?.expires),
|
|
53
|
+
issues: List(issues)
|
|
54
|
+
};
|
|
55
|
+
};
|
|
56
|
+
const computeAudit = (config, fnoxKeys, today) => {
|
|
57
|
+
const now = today ?? /* @__PURE__ */ new Date();
|
|
58
|
+
const lifecycle = config.lifecycle ?? {};
|
|
59
|
+
const staleWarningDays = lifecycle.stale_warning_days ?? 90;
|
|
60
|
+
const requireExpiration = lifecycle.require_expiration ?? false;
|
|
61
|
+
const requireService = lifecycle.require_service ?? false;
|
|
62
|
+
const keys = fnoxKeys ?? /* @__PURE__ */ new Set();
|
|
63
|
+
const metaKeys = new Set(Object.keys(config.meta));
|
|
64
|
+
const secrets = List(Object.entries(config.meta).map(([key, meta]) => classifySecret(key, meta, keys, staleWarningDays, requireExpiration, requireService, now)));
|
|
65
|
+
const orphaned = keys.size > 0 ? [...metaKeys].filter((k) => !keys.has(k)).length : 0;
|
|
66
|
+
const total = secrets.size;
|
|
67
|
+
const expired = secrets.count((s) => s.status === "expired");
|
|
68
|
+
const missing = secrets.count((s) => s.status === "missing");
|
|
69
|
+
const missing_metadata = secrets.count((s) => s.status === "missing_metadata");
|
|
70
|
+
const expiring_soon = secrets.count((s) => s.status === "expiring_soon");
|
|
71
|
+
const stale = secrets.count((s) => s.status === "stale");
|
|
72
|
+
const healthy = secrets.count((s) => s.status === "healthy");
|
|
73
|
+
return {
|
|
74
|
+
status: Cond.of().when(expired > 0 || missing > 0, "critical").elseWhen(expiring_soon > 0 || stale > 0 || missing_metadata > 0, "degraded").else("healthy"),
|
|
75
|
+
secrets,
|
|
76
|
+
total,
|
|
77
|
+
healthy,
|
|
78
|
+
expiring_soon,
|
|
79
|
+
expired,
|
|
80
|
+
stale,
|
|
81
|
+
missing,
|
|
82
|
+
missing_metadata,
|
|
83
|
+
orphaned,
|
|
84
|
+
agent: config.agent
|
|
85
|
+
};
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
//#endregion
|
|
89
|
+
//#region src/core/schema.ts
|
|
90
|
+
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
91
|
+
const URI_RE = /^https?:\/\/.+/;
|
|
92
|
+
if (!FormatRegistry.Has("date")) FormatRegistry.Set("date", (v) => DATE_RE.test(v));
|
|
93
|
+
if (!FormatRegistry.Has("uri")) FormatRegistry.Set("uri", (v) => URI_RE.test(v));
|
|
94
|
+
const ConsumerType = Type.Union([
|
|
95
|
+
Type.Literal("agent"),
|
|
96
|
+
Type.Literal("service"),
|
|
97
|
+
Type.Literal("developer"),
|
|
98
|
+
Type.Literal("ci")
|
|
99
|
+
], { description: "Classification of the agent's consumer type" });
|
|
100
|
+
const AgentIdentitySchema = Type.Object({
|
|
101
|
+
name: Type.String({ description: "Agent display name" }),
|
|
102
|
+
consumer: Type.Optional(ConsumerType),
|
|
103
|
+
description: Type.Optional(Type.String({ description: "Agent description or role" })),
|
|
104
|
+
capabilities: Type.Optional(Type.Array(Type.String(), { description: "List of capabilities this agent provides" })),
|
|
105
|
+
expires: Type.Optional(Type.String({
|
|
106
|
+
format: "date",
|
|
107
|
+
description: "Agent credential expiration date (YYYY-MM-DD)"
|
|
108
|
+
})),
|
|
109
|
+
services: Type.Optional(Type.Array(Type.String(), { description: "Service dependencies for this agent" })),
|
|
110
|
+
identity: Type.Optional(Type.String({ description: "Path to encrypted agent key file (relative to config directory)" })),
|
|
111
|
+
recipient: Type.Optional(Type.String({ description: "Agent's age public key for encryption" })),
|
|
112
|
+
secrets: Type.Optional(Type.Array(Type.String(), { description: "Secret keys this agent needs from the catalog" }))
|
|
113
|
+
}, { description: "Identity and capabilities of the AI agent using this envpkt" });
|
|
114
|
+
const SecretMetaSchema = Type.Object({
|
|
115
|
+
service: Type.Optional(Type.String({ description: "Service or system this secret authenticates to" })),
|
|
116
|
+
expires: Type.Optional(Type.String({
|
|
117
|
+
format: "date",
|
|
118
|
+
description: "Date the secret expires (YYYY-MM-DD)"
|
|
119
|
+
})),
|
|
120
|
+
rotation_url: Type.Optional(Type.String({
|
|
121
|
+
format: "uri",
|
|
122
|
+
description: "URL or reference for secret rotation procedure"
|
|
123
|
+
})),
|
|
124
|
+
purpose: Type.Optional(Type.String({ description: "Why this secret exists and what it enables" })),
|
|
125
|
+
capabilities: Type.Optional(Type.Array(Type.String(), { description: "What operations this secret grants (e.g. read, write, admin)" })),
|
|
126
|
+
created: Type.Optional(Type.String({
|
|
127
|
+
format: "date",
|
|
128
|
+
description: "Date the secret was provisioned (YYYY-MM-DD)"
|
|
129
|
+
})),
|
|
130
|
+
rotates: Type.Optional(Type.String({ description: "Rotation schedule (e.g. '90d', 'quarterly')" })),
|
|
131
|
+
rate_limit: Type.Optional(Type.String({ description: "Rate limit or quota info (e.g. '1000/min')" })),
|
|
132
|
+
model_hint: Type.Optional(Type.String({ description: "Suggested model or tier for this credential" })),
|
|
133
|
+
source: Type.Optional(Type.String({ description: "Where the secret value originates (e.g. 'vault', 'ci')" })),
|
|
134
|
+
required: Type.Optional(Type.Boolean({ description: "Whether this secret is required for operation" })),
|
|
135
|
+
tags: Type.Optional(Type.Record(Type.String(), Type.String(), { description: "Key-value tags for grouping and filtering" }))
|
|
136
|
+
}, { description: "Metadata about a single secret" });
|
|
137
|
+
const LifecycleConfigSchema = Type.Object({
|
|
138
|
+
stale_warning_days: Type.Optional(Type.Number({
|
|
139
|
+
default: 90,
|
|
140
|
+
description: "Days since creation to consider a secret stale"
|
|
141
|
+
})),
|
|
142
|
+
require_expiration: Type.Optional(Type.Boolean({
|
|
143
|
+
default: false,
|
|
144
|
+
description: "Require expires on all secrets"
|
|
145
|
+
})),
|
|
146
|
+
require_service: Type.Optional(Type.Boolean({
|
|
147
|
+
default: false,
|
|
148
|
+
description: "Require service on all secrets"
|
|
149
|
+
}))
|
|
150
|
+
}, { description: "Policy configuration for credential lifecycle management" });
|
|
151
|
+
const CallbackConfigSchema = Type.Object({
|
|
152
|
+
on_expiring: Type.Optional(Type.String({ description: "Command or webhook to invoke when secrets are expiring" })),
|
|
153
|
+
on_expired: Type.Optional(Type.String({ description: "Command or webhook to invoke when secrets have expired" })),
|
|
154
|
+
on_audit_fail: Type.Optional(Type.String({ description: "Command or webhook on audit failure" }))
|
|
155
|
+
}, { description: "Automation callbacks for lifecycle events" });
|
|
156
|
+
const ToolsConfigSchema = Type.Record(Type.String(), Type.Unknown(), { description: "Tool integration configuration — open namespace for third-party extensions" });
|
|
157
|
+
const EnvpktConfigSchema = Type.Object({
|
|
158
|
+
version: Type.Number({
|
|
159
|
+
description: "Schema version number",
|
|
160
|
+
default: 1
|
|
161
|
+
}),
|
|
162
|
+
catalog: Type.Optional(Type.String({ description: "Path to shared secret catalog (relative to this config file)" })),
|
|
163
|
+
agent: Type.Optional(AgentIdentitySchema),
|
|
164
|
+
meta: Type.Record(Type.String(), SecretMetaSchema, { description: "Per-secret metadata keyed by secret name" }),
|
|
165
|
+
lifecycle: Type.Optional(LifecycleConfigSchema),
|
|
166
|
+
callbacks: Type.Optional(CallbackConfigSchema),
|
|
167
|
+
tools: Type.Optional(ToolsConfigSchema)
|
|
168
|
+
}, {
|
|
169
|
+
$id: "envpkt",
|
|
170
|
+
title: "envpkt configuration",
|
|
171
|
+
description: "Credential lifecycle and fleet management configuration for AI agents"
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
//#endregion
|
|
175
|
+
//#region src/core/config.ts
|
|
176
|
+
const CONFIG_FILENAME$2 = "envpkt.toml";
|
|
177
|
+
const ENV_VAR_CONFIG = "ENVPKT_CONFIG";
|
|
178
|
+
const compiledSchema = TypeCompiler.Compile(EnvpktConfigSchema);
|
|
179
|
+
/** Recursively convert TomlDate instances to ISO date strings */
|
|
180
|
+
const normalizeDates = (obj) => {
|
|
181
|
+
if (obj instanceof TomlDate) return obj.toISOString().split("T")[0];
|
|
182
|
+
if (Array.isArray(obj)) return obj.map(normalizeDates);
|
|
183
|
+
if (obj !== null && typeof obj === "object") return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, normalizeDates(v)]));
|
|
184
|
+
return obj;
|
|
185
|
+
};
|
|
186
|
+
/** Find envpkt.toml in the given directory */
|
|
187
|
+
const findConfigPath = (dir) => {
|
|
188
|
+
const candidate = join(dir, CONFIG_FILENAME$2);
|
|
189
|
+
return existsSync(candidate) ? Option(candidate) : Option(void 0);
|
|
190
|
+
};
|
|
191
|
+
/** Read a config file, returning Either<ConfigError, string> */
|
|
192
|
+
const readConfigFile = (path) => {
|
|
193
|
+
if (!existsSync(path)) return Left({
|
|
194
|
+
_tag: "FileNotFound",
|
|
195
|
+
path
|
|
196
|
+
});
|
|
197
|
+
return Try(() => readFileSync(path, "utf-8")).fold((err) => Left({
|
|
198
|
+
_tag: "ReadError",
|
|
199
|
+
message: String(err)
|
|
200
|
+
}), (content) => Right(content));
|
|
201
|
+
};
|
|
202
|
+
/** Ensure required fields have defaults for valid configs (e.g. agent configs with catalog may omit meta) */
|
|
203
|
+
const applyDefaults = (data) => {
|
|
204
|
+
if (data !== null && typeof data === "object" && !Array.isArray(data)) {
|
|
205
|
+
const obj = data;
|
|
206
|
+
if (!("meta" in obj)) return {
|
|
207
|
+
...obj,
|
|
208
|
+
meta: {}
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
return data;
|
|
212
|
+
};
|
|
213
|
+
/** Parse a TOML string, returning Either<ConfigError, unknown> */
|
|
214
|
+
const parseToml = (raw) => Try(() => parse(raw)).fold((err) => Left({
|
|
215
|
+
_tag: "ParseError",
|
|
216
|
+
message: String(err)
|
|
217
|
+
}), (data) => Right(applyDefaults(normalizeDates(data))));
|
|
218
|
+
/** Validate parsed data against the TypeBox schema */
|
|
219
|
+
const validateConfig = (data) => {
|
|
220
|
+
if (compiledSchema.Check(data)) return Right(data);
|
|
221
|
+
return Left({
|
|
222
|
+
_tag: "ValidationError",
|
|
223
|
+
errors: List([...compiledSchema.Errors(data)].map((e) => `${e.path}: ${e.message}`))
|
|
224
|
+
});
|
|
225
|
+
};
|
|
226
|
+
/** Load and validate an envpkt.toml from a file path */
|
|
227
|
+
const loadConfig = (path) => readConfigFile(path).flatMap(parseToml).flatMap(validateConfig);
|
|
228
|
+
/**
|
|
229
|
+
* Resolve config path via priority chain:
|
|
230
|
+
* 1. Explicit flag path
|
|
231
|
+
* 2. ENVPKT_CONFIG env var
|
|
232
|
+
* 3. CWD discovery
|
|
233
|
+
*/
|
|
234
|
+
const resolveConfigPath = (flagPath, envVar, cwd) => {
|
|
235
|
+
if (flagPath) {
|
|
236
|
+
const resolved = resolve(flagPath);
|
|
237
|
+
return existsSync(resolved) ? Right(resolved) : Left({
|
|
238
|
+
_tag: "FileNotFound",
|
|
239
|
+
path: resolved
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
const envPath = envVar ?? process.env[ENV_VAR_CONFIG];
|
|
243
|
+
if (envPath) {
|
|
244
|
+
const resolved = resolve(envPath);
|
|
245
|
+
return existsSync(resolved) ? Right(resolved) : Left({
|
|
246
|
+
_tag: "FileNotFound",
|
|
247
|
+
path: resolved
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
const dir = cwd ?? process.cwd();
|
|
251
|
+
return findConfigPath(dir).fold(() => Left({
|
|
252
|
+
_tag: "FileNotFound",
|
|
253
|
+
path: join(dir, CONFIG_FILENAME$2)
|
|
254
|
+
}), (path) => Right(path));
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
//#endregion
|
|
258
|
+
//#region src/core/catalog.ts
|
|
259
|
+
/** Load and validate a catalog file, mapping ConfigError → CatalogError */
|
|
260
|
+
const loadCatalog = (catalogPath) => loadConfig(catalogPath).fold((err) => {
|
|
261
|
+
if (err._tag === "FileNotFound") return Left({
|
|
262
|
+
_tag: "CatalogNotFound",
|
|
263
|
+
path: err.path
|
|
264
|
+
});
|
|
265
|
+
return Left({
|
|
266
|
+
_tag: "CatalogLoadError",
|
|
267
|
+
message: `${err._tag}: ${"message" in err ? err.message : String(err)}`
|
|
268
|
+
});
|
|
269
|
+
}, (config) => Right(config));
|
|
270
|
+
/** Resolve secrets by merging catalog meta with agent overrides (shallow merge) */
|
|
271
|
+
const resolveSecrets = (agentMeta, catalogMeta, agentSecrets, catalogPath) => {
|
|
272
|
+
const resolved = {};
|
|
273
|
+
for (const key of agentSecrets) {
|
|
274
|
+
const catalogEntry = catalogMeta[key];
|
|
275
|
+
if (!catalogEntry) return Left({
|
|
276
|
+
_tag: "SecretNotInCatalog",
|
|
277
|
+
key,
|
|
278
|
+
catalogPath
|
|
279
|
+
});
|
|
280
|
+
const agentOverride = agentMeta[key];
|
|
281
|
+
if (agentOverride) resolved[key] = {
|
|
282
|
+
...catalogEntry,
|
|
283
|
+
...agentOverride
|
|
284
|
+
};
|
|
285
|
+
else resolved[key] = catalogEntry;
|
|
286
|
+
}
|
|
287
|
+
return Right(resolved);
|
|
288
|
+
};
|
|
289
|
+
/** Resolve an agent config against its catalog (if any), producing a flat self-contained config */
|
|
290
|
+
const resolveConfig = (agentConfig, agentConfigDir) => {
|
|
291
|
+
if (!agentConfig.catalog) return Right({
|
|
292
|
+
config: agentConfig,
|
|
293
|
+
merged: [],
|
|
294
|
+
overridden: [],
|
|
295
|
+
warnings: []
|
|
296
|
+
});
|
|
297
|
+
if (!agentConfig.agent?.secrets || agentConfig.agent.secrets.length === 0) return Left({
|
|
298
|
+
_tag: "MissingSecretsList",
|
|
299
|
+
message: "Config has 'catalog' but agent.secrets is missing — declare which catalog secrets this agent needs"
|
|
300
|
+
});
|
|
301
|
+
const catalogPath = resolve(agentConfigDir, agentConfig.catalog);
|
|
302
|
+
const agentSecrets = agentConfig.agent.secrets;
|
|
303
|
+
return loadCatalog(catalogPath).flatMap((catalogConfig) => resolveSecrets(agentConfig.meta, catalogConfig.meta, agentSecrets, catalogPath).map((resolvedMeta) => {
|
|
304
|
+
const merged = [];
|
|
305
|
+
const overridden = [];
|
|
306
|
+
const warnings = [];
|
|
307
|
+
for (const key of agentSecrets) {
|
|
308
|
+
merged.push(key);
|
|
309
|
+
if (agentConfig.meta[key]) overridden.push(key);
|
|
310
|
+
}
|
|
311
|
+
const { catalog: _catalog, ...agentWithoutCatalog } = agentConfig;
|
|
312
|
+
const agentIdentity = agentConfig.agent ? (() => {
|
|
313
|
+
const { secrets: _secrets, ...rest } = agentConfig.agent;
|
|
314
|
+
return rest;
|
|
315
|
+
})() : void 0;
|
|
316
|
+
return {
|
|
317
|
+
config: {
|
|
318
|
+
...agentWithoutCatalog,
|
|
319
|
+
agent: agentIdentity ? {
|
|
320
|
+
...agentIdentity,
|
|
321
|
+
name: agentIdentity.name
|
|
322
|
+
} : void 0,
|
|
323
|
+
meta: resolvedMeta
|
|
324
|
+
},
|
|
325
|
+
catalogPath,
|
|
326
|
+
merged,
|
|
327
|
+
overridden,
|
|
328
|
+
warnings
|
|
329
|
+
};
|
|
330
|
+
}));
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
//#endregion
|
|
334
|
+
//#region src/cli/output.ts
|
|
335
|
+
const RESET = "\x1B[0m";
|
|
336
|
+
const BOLD = "\x1B[1m";
|
|
337
|
+
const DIM = "\x1B[2m";
|
|
338
|
+
const RED = "\x1B[31m";
|
|
339
|
+
const GREEN = "\x1B[32m";
|
|
340
|
+
const YELLOW = "\x1B[33m";
|
|
341
|
+
const CYAN = "\x1B[36m";
|
|
342
|
+
const statusColor = (status) => {
|
|
343
|
+
switch (status) {
|
|
344
|
+
case "healthy": return GREEN;
|
|
345
|
+
case "degraded": return YELLOW;
|
|
346
|
+
case "critical": return RED;
|
|
347
|
+
}
|
|
348
|
+
};
|
|
349
|
+
const statusIcon$1 = (status) => {
|
|
350
|
+
switch (status) {
|
|
351
|
+
case "healthy": return `${GREEN}✓${RESET}`;
|
|
352
|
+
case "degraded": return `${YELLOW}⚠${RESET}`;
|
|
353
|
+
case "critical": return `${RED}✗${RESET}`;
|
|
354
|
+
}
|
|
355
|
+
};
|
|
356
|
+
const secretStatusIcon = (status) => {
|
|
357
|
+
switch (status) {
|
|
358
|
+
case "healthy": return `${GREEN}✓${RESET}`;
|
|
359
|
+
case "expiring_soon": return `${YELLOW}⚠${RESET}`;
|
|
360
|
+
case "expired": return `${RED}✗${RESET}`;
|
|
361
|
+
case "stale": return `${YELLOW}○${RESET}`;
|
|
362
|
+
case "missing": return `${RED}?${RESET}`;
|
|
363
|
+
case "missing_metadata": return `${YELLOW}!${RESET}`;
|
|
364
|
+
default: return " ";
|
|
365
|
+
}
|
|
366
|
+
};
|
|
367
|
+
const formatSecretRow = (secret) => {
|
|
368
|
+
const icon = secretStatusIcon(secret.status);
|
|
369
|
+
const days = secret.days_remaining.fold(() => "", (d) => `${d}d`);
|
|
370
|
+
const rotation = secret.rotation_url.fold(() => "", (url) => `${DIM}${url}${RESET}`);
|
|
371
|
+
const svc = secret.service.fold(() => secret.key, (s) => s);
|
|
372
|
+
return ` ${icon} ${BOLD}${secret.key}${RESET} ${DIM}(${svc})${RESET} ${secret.status} ${days} ${rotation}`.trimEnd();
|
|
373
|
+
};
|
|
374
|
+
const formatAudit = (audit) => {
|
|
375
|
+
const color = statusColor(audit.status);
|
|
376
|
+
return [
|
|
377
|
+
`${statusIcon$1(audit.status)} ${BOLD}${color}${audit.status.toUpperCase()}${RESET} — ${audit.total} secrets`,
|
|
378
|
+
[
|
|
379
|
+
` ${GREEN}${audit.healthy}${RESET} healthy`,
|
|
380
|
+
audit.expiring_soon > 0 ? ` ${YELLOW}${audit.expiring_soon}${RESET} expiring soon` : null,
|
|
381
|
+
audit.expired > 0 ? ` ${RED}${audit.expired}${RESET} expired` : null,
|
|
382
|
+
audit.stale > 0 ? ` ${YELLOW}${audit.stale}${RESET} stale` : null,
|
|
383
|
+
audit.missing > 0 ? ` ${RED}${audit.missing}${RESET} missing` : null,
|
|
384
|
+
audit.missing_metadata > 0 ? ` ${YELLOW}${audit.missing_metadata}${RESET} missing metadata` : null,
|
|
385
|
+
audit.orphaned > 0 ? ` ${YELLOW}${audit.orphaned}${RESET} orphaned` : null
|
|
386
|
+
].filter(Boolean).join("\n"),
|
|
387
|
+
audit.secrets.filter((s) => s.status !== "healthy").map(formatSecretRow).toArray().join("\n")
|
|
388
|
+
].filter((s) => s.length > 0).join("\n\n");
|
|
389
|
+
};
|
|
390
|
+
const formatAuditJson = (audit) => JSON.stringify({
|
|
391
|
+
status: audit.status,
|
|
392
|
+
total: audit.total,
|
|
393
|
+
healthy: audit.healthy,
|
|
394
|
+
expiring_soon: audit.expiring_soon,
|
|
395
|
+
expired: audit.expired,
|
|
396
|
+
stale: audit.stale,
|
|
397
|
+
missing: audit.missing,
|
|
398
|
+
missing_metadata: audit.missing_metadata,
|
|
399
|
+
orphaned: audit.orphaned,
|
|
400
|
+
secrets: audit.secrets.map((s) => ({
|
|
401
|
+
key: s.key,
|
|
402
|
+
service: s.service.fold(() => null, (sv) => sv),
|
|
403
|
+
status: s.status,
|
|
404
|
+
days_remaining: s.days_remaining.fold(() => null, (d) => d),
|
|
405
|
+
rotation_url: s.rotation_url.fold(() => null, (u) => u),
|
|
406
|
+
purpose: s.purpose.fold(() => null, (p) => p),
|
|
407
|
+
issues: s.issues.toArray()
|
|
408
|
+
})).toArray()
|
|
409
|
+
}, null, 2);
|
|
410
|
+
const formatFleetJson = (fleet) => JSON.stringify({
|
|
411
|
+
status: fleet.status,
|
|
412
|
+
total_agents: fleet.total_agents,
|
|
413
|
+
total_secrets: fleet.total_secrets,
|
|
414
|
+
expired: fleet.expired,
|
|
415
|
+
expiring_soon: fleet.expiring_soon,
|
|
416
|
+
agents: fleet.agents.map((a) => ({
|
|
417
|
+
path: a.path,
|
|
418
|
+
name: a.agent?.name ?? null,
|
|
419
|
+
consumer: a.agent?.consumer ?? null,
|
|
420
|
+
description: a.agent?.description ?? null,
|
|
421
|
+
status: a.audit.status,
|
|
422
|
+
secrets: a.audit.total
|
|
423
|
+
})).toArray()
|
|
424
|
+
}, null, 2);
|
|
425
|
+
const formatError = (error) => {
|
|
426
|
+
const tag = error._tag;
|
|
427
|
+
switch (tag) {
|
|
428
|
+
case "FileNotFound": return `${RED}Error:${RESET} Config file not found: ${error.path}`;
|
|
429
|
+
case "ParseError": return `${RED}Error:${RESET} Failed to parse TOML: ${error.message}`;
|
|
430
|
+
case "ValidationError": return `${RED}Error:${RESET} Config validation failed:\n${String(error.errors)}`;
|
|
431
|
+
case "ReadError": return `${RED}Error:${RESET} Could not read file: ${error.message}`;
|
|
432
|
+
case "AgeNotFound": return `${RED}Error:${RESET} age CLI not found: ${error.message}`;
|
|
433
|
+
case "DecryptFailed": return `${RED}Error:${RESET} Decrypt failed: ${error.message}`;
|
|
434
|
+
case "IdentityNotFound": return `${RED}Error:${RESET} Identity file not found: ${error.path}`;
|
|
435
|
+
case "AuditFailed": return `${RED}Error:${RESET} Audit failed: ${error.message}`;
|
|
436
|
+
case "CatalogNotFound": return `${RED}Error:${RESET} Catalog not found: ${error.path}`;
|
|
437
|
+
case "CatalogLoadError": return `${RED}Error:${RESET} Catalog load error: ${error.message}`;
|
|
438
|
+
case "SecretNotInCatalog": return `${RED}Error:${RESET} Secret "${error.key}" not found in catalog: ${error.path}`;
|
|
439
|
+
case "MissingSecretsList": return `${RED}Error:${RESET} ${error.message}`;
|
|
440
|
+
default: return `${RED}Error:${RESET} ${error.message ?? tag}`;
|
|
441
|
+
}
|
|
442
|
+
};
|
|
443
|
+
const exitCodeForAudit = (audit) => {
|
|
444
|
+
switch (audit.status) {
|
|
445
|
+
case "healthy": return 0;
|
|
446
|
+
case "degraded": return 1;
|
|
447
|
+
case "critical": return 2;
|
|
448
|
+
}
|
|
449
|
+
};
|
|
450
|
+
const confidenceIcon = (confidence) => {
|
|
451
|
+
switch (confidence) {
|
|
452
|
+
case "high": return `${GREEN}●${RESET}`;
|
|
453
|
+
case "medium": return `${YELLOW}◐${RESET}`;
|
|
454
|
+
case "low": return `${DIM}○${RESET}`;
|
|
455
|
+
}
|
|
456
|
+
};
|
|
457
|
+
const formatScanTable = (scan) => {
|
|
458
|
+
return [
|
|
459
|
+
`${BOLD}Scan Results${RESET} — ${scan.discovered.size} credential(s) found in ${scan.total_scanned} env vars`,
|
|
460
|
+
[
|
|
461
|
+
scan.high_confidence > 0 ? ` ${GREEN}${scan.high_confidence}${RESET} high confidence` : null,
|
|
462
|
+
scan.medium_confidence > 0 ? ` ${YELLOW}${scan.medium_confidence}${RESET} medium confidence` : null,
|
|
463
|
+
scan.low_confidence > 0 ? ` ${DIM}${scan.low_confidence}${RESET} low confidence` : null
|
|
464
|
+
].filter(Boolean).join("\n"),
|
|
465
|
+
scan.discovered.map((m) => {
|
|
466
|
+
const icon = confidenceIcon(m.confidence);
|
|
467
|
+
const svc = m.service.fold(() => `${DIM}unknown${RESET}`, (s) => s);
|
|
468
|
+
return ` ${icon} ${BOLD}${m.envVar}${RESET} → ${CYAN}${svc}${RESET} ${DIM}(${m.matchedBy})${RESET}`;
|
|
469
|
+
}).toArray().join("\n")
|
|
470
|
+
].filter((s) => s.length > 0).join("\n\n");
|
|
471
|
+
};
|
|
472
|
+
const formatScanJson = (scan) => JSON.stringify({
|
|
473
|
+
total_scanned: scan.total_scanned,
|
|
474
|
+
discovered: scan.discovered.size,
|
|
475
|
+
high_confidence: scan.high_confidence,
|
|
476
|
+
medium_confidence: scan.medium_confidence,
|
|
477
|
+
low_confidence: scan.low_confidence,
|
|
478
|
+
matches: scan.discovered.map((m) => ({
|
|
479
|
+
envVar: m.envVar,
|
|
480
|
+
service: m.service.fold(() => null, (s) => s),
|
|
481
|
+
confidence: m.confidence,
|
|
482
|
+
matchedBy: m.matchedBy
|
|
483
|
+
})).toArray()
|
|
484
|
+
}, null, 2);
|
|
485
|
+
const driftIcon = (status) => {
|
|
486
|
+
switch (status) {
|
|
487
|
+
case "tracked": return `${GREEN}✓${RESET}`;
|
|
488
|
+
case "missing_from_env": return `${RED}✗${RESET}`;
|
|
489
|
+
case "untracked": return `${YELLOW}?${RESET}`;
|
|
490
|
+
default: return " ";
|
|
491
|
+
}
|
|
492
|
+
};
|
|
493
|
+
const formatCheckTable = (check) => {
|
|
494
|
+
const clean = check.is_clean;
|
|
495
|
+
return [
|
|
496
|
+
`${clean ? `${GREEN}✓${RESET}` : `${YELLOW}⚠${RESET}`} ${BOLD}${clean ? `${GREEN}CLEAN${RESET}` : `${YELLOW}DRIFT DETECTED${RESET}`}${RESET}`,
|
|
497
|
+
[
|
|
498
|
+
` ${GREEN}${check.tracked_and_present}${RESET} tracked and present`,
|
|
499
|
+
check.missing_from_env > 0 ? ` ${RED}${check.missing_from_env}${RESET} missing from env` : null,
|
|
500
|
+
check.untracked_credentials > 0 ? ` ${YELLOW}${check.untracked_credentials}${RESET} untracked credentials` : null
|
|
501
|
+
].filter(Boolean).join("\n"),
|
|
502
|
+
check.entries.filter((e) => e.status !== "tracked").map((e) => {
|
|
503
|
+
const di = driftIcon(e.status);
|
|
504
|
+
const svc = e.service.fold(() => "", (s) => ` ${DIM}(${s})${RESET}`);
|
|
505
|
+
const conf = e.confidence.fold(() => "", (c) => ` ${confidenceIcon(c)}`);
|
|
506
|
+
return ` ${di} ${BOLD}${e.envVar}${RESET}${svc} ${e.status}${conf}`;
|
|
507
|
+
}).toArray().join("\n")
|
|
508
|
+
].filter((s) => s.length > 0).join("\n\n");
|
|
509
|
+
};
|
|
510
|
+
const formatCheckJson = (check) => JSON.stringify({
|
|
511
|
+
is_clean: check.is_clean,
|
|
512
|
+
tracked_and_present: check.tracked_and_present,
|
|
513
|
+
missing_from_env: check.missing_from_env,
|
|
514
|
+
untracked_credentials: check.untracked_credentials,
|
|
515
|
+
entries: check.entries.map((e) => ({
|
|
516
|
+
envVar: e.envVar,
|
|
517
|
+
service: e.service.fold(() => null, (s) => s),
|
|
518
|
+
status: e.status,
|
|
519
|
+
confidence: e.confidence.fold(() => null, (c) => c)
|
|
520
|
+
})).toArray()
|
|
521
|
+
}, null, 2);
|
|
522
|
+
const formatAuditMinimal = (audit) => {
|
|
523
|
+
if (audit.status === "healthy") return `${GREEN}✓${RESET} ${audit.total} secrets healthy`;
|
|
524
|
+
const parts = [];
|
|
525
|
+
if (audit.expired > 0) parts.push(`${audit.expired} expired`);
|
|
526
|
+
if (audit.expiring_soon > 0) parts.push(`${audit.expiring_soon} expiring`);
|
|
527
|
+
if (audit.stale > 0) parts.push(`${audit.stale} stale`);
|
|
528
|
+
if (audit.missing > 0) parts.push(`${audit.missing} missing`);
|
|
529
|
+
return `${audit.status === "critical" ? `${RED}✗${RESET}` : `${YELLOW}⚠${RESET}`} ${parts.join(", ")}`;
|
|
530
|
+
};
|
|
531
|
+
|
|
532
|
+
//#endregion
|
|
533
|
+
//#region src/cli/commands/audit.ts
|
|
534
|
+
const runAudit = (options) => {
|
|
535
|
+
resolveConfigPath(options.config).fold((err) => {
|
|
536
|
+
console.error(formatError(err));
|
|
537
|
+
process.exit(2);
|
|
538
|
+
}, (path) => {
|
|
539
|
+
loadConfig(path).fold((err) => {
|
|
540
|
+
console.error(formatError(err));
|
|
541
|
+
process.exit(2);
|
|
542
|
+
}, (rawConfig) => {
|
|
543
|
+
resolveConfig(rawConfig, dirname(path)).fold((err) => {
|
|
544
|
+
console.error(formatError(err));
|
|
545
|
+
process.exit(2);
|
|
546
|
+
}, (resolveResult) => {
|
|
547
|
+
if (resolveResult.catalogPath) console.log(`${DIM}Catalog: ${CYAN}${resolveResult.catalogPath}${RESET}`);
|
|
548
|
+
runAuditOnConfig(resolveResult.config, options);
|
|
549
|
+
});
|
|
550
|
+
});
|
|
551
|
+
});
|
|
552
|
+
};
|
|
553
|
+
const runAuditOnConfig = (config, options) => {
|
|
554
|
+
const audit = computeAudit(config);
|
|
555
|
+
let filtered = audit;
|
|
556
|
+
if (options.status) {
|
|
557
|
+
const statusFilter = options.status;
|
|
558
|
+
const filteredSecrets = audit.secrets.filter((s) => s.status === statusFilter);
|
|
559
|
+
filtered = {
|
|
560
|
+
...audit,
|
|
561
|
+
secrets: filteredSecrets
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
if (options.expiring !== void 0) {
|
|
565
|
+
const days = options.expiring;
|
|
566
|
+
const filteredSecrets = filtered.secrets.filter((s) => s.days_remaining.fold(() => false, (d) => d >= 0 && d <= days));
|
|
567
|
+
filtered = {
|
|
568
|
+
...filtered,
|
|
569
|
+
secrets: filteredSecrets
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
if (options.format === "json") console.log(formatAuditJson(filtered));
|
|
573
|
+
else if (options.format === "minimal") console.log(formatAuditMinimal(filtered));
|
|
574
|
+
else console.log(formatAudit(filtered));
|
|
575
|
+
const code = options.strict ? exitCodeForAudit(audit) : audit.status === "critical" ? 2 : 0;
|
|
576
|
+
process.exit(code);
|
|
577
|
+
};
|
|
578
|
+
|
|
579
|
+
//#endregion
|
|
580
|
+
//#region src/core/patterns.ts
|
|
581
|
+
const EXCLUDED_VARS = new Set([
|
|
582
|
+
"PATH",
|
|
583
|
+
"HOME",
|
|
584
|
+
"USER",
|
|
585
|
+
"SHELL",
|
|
586
|
+
"TERM",
|
|
587
|
+
"LANG",
|
|
588
|
+
"LC_ALL",
|
|
589
|
+
"LC_CTYPE",
|
|
590
|
+
"DISPLAY",
|
|
591
|
+
"EDITOR",
|
|
592
|
+
"VISUAL",
|
|
593
|
+
"PAGER",
|
|
594
|
+
"HOSTNAME",
|
|
595
|
+
"LOGNAME",
|
|
596
|
+
"MAIL",
|
|
597
|
+
"OLDPWD",
|
|
598
|
+
"PWD",
|
|
599
|
+
"SHLVL",
|
|
600
|
+
"TMPDIR",
|
|
601
|
+
"TZ",
|
|
602
|
+
"XDG_CACHE_HOME",
|
|
603
|
+
"XDG_CONFIG_HOME",
|
|
604
|
+
"XDG_DATA_HOME",
|
|
605
|
+
"XDG_RUNTIME_DIR",
|
|
606
|
+
"XDG_SESSION_TYPE",
|
|
607
|
+
"NODE_ENV",
|
|
608
|
+
"NODE_PATH",
|
|
609
|
+
"NODE_OPTIONS",
|
|
610
|
+
"NVM_DIR",
|
|
611
|
+
"NVM_BIN",
|
|
612
|
+
"NVM_INC",
|
|
613
|
+
"NVM_CD_FLAGS",
|
|
614
|
+
"NPM_CONFIG_PREFIX",
|
|
615
|
+
"GOPATH",
|
|
616
|
+
"GOROOT",
|
|
617
|
+
"CARGO_HOME",
|
|
618
|
+
"RUSTUP_HOME",
|
|
619
|
+
"JAVA_HOME",
|
|
620
|
+
"ANDROID_HOME",
|
|
621
|
+
"PYENV_ROOT",
|
|
622
|
+
"VIRTUAL_ENV",
|
|
623
|
+
"CONDA_PREFIX",
|
|
624
|
+
"CONDA_DEFAULT_ENV",
|
|
625
|
+
"MANPATH",
|
|
626
|
+
"INFOPATH",
|
|
627
|
+
"LESS",
|
|
628
|
+
"LSCOLORS",
|
|
629
|
+
"LS_COLORS",
|
|
630
|
+
"COLORTERM",
|
|
631
|
+
"TERM_PROGRAM",
|
|
632
|
+
"TERM_PROGRAM_VERSION",
|
|
633
|
+
"TERM_SESSION_ID",
|
|
634
|
+
"ITERM_SESSION_ID",
|
|
635
|
+
"ITERM_PROFILE",
|
|
636
|
+
"SSH_AUTH_SOCK",
|
|
637
|
+
"SSH_AGENT_PID",
|
|
638
|
+
"GPG_TTY",
|
|
639
|
+
"GNUPGHOME",
|
|
640
|
+
"DBUS_SESSION_BUS_ADDRESS",
|
|
641
|
+
"WAYLAND_DISPLAY",
|
|
642
|
+
"ZDOTDIR",
|
|
643
|
+
"ZSH",
|
|
644
|
+
"HISTFILE",
|
|
645
|
+
"HISTSIZE",
|
|
646
|
+
"SAVEHIST",
|
|
647
|
+
"_",
|
|
648
|
+
"__CF_USER_TEXT_ENCODING",
|
|
649
|
+
"Apple_PubSub_Socket_Render",
|
|
650
|
+
"COMMAND_MODE",
|
|
651
|
+
"SECURITYSESSIONID",
|
|
652
|
+
"LaunchInstanceID",
|
|
653
|
+
"PNPM_HOME",
|
|
654
|
+
"BUN_INSTALL",
|
|
655
|
+
"FNM_DIR",
|
|
656
|
+
"FNM_MULTISHELL_PATH",
|
|
657
|
+
"FNM_VERSION_FILE_STRATEGY",
|
|
658
|
+
"FNM_LOGLEVEL",
|
|
659
|
+
"FNM_NODE_DIST_MIRROR",
|
|
660
|
+
"FNM_ARCH",
|
|
661
|
+
"VOLTA_HOME"
|
|
662
|
+
]);
|
|
663
|
+
const EXACT_NAME_PATTERNS = [
|
|
664
|
+
{
|
|
665
|
+
kind: "name",
|
|
666
|
+
pattern: "OPENAI_API_KEY",
|
|
667
|
+
service: "openai",
|
|
668
|
+
confidence: "high",
|
|
669
|
+
description: "OpenAI API key"
|
|
670
|
+
},
|
|
671
|
+
{
|
|
672
|
+
kind: "name",
|
|
673
|
+
pattern: "OPENAI_ORG_ID",
|
|
674
|
+
service: "openai",
|
|
675
|
+
confidence: "high",
|
|
676
|
+
description: "OpenAI org ID"
|
|
677
|
+
},
|
|
678
|
+
{
|
|
679
|
+
kind: "name",
|
|
680
|
+
pattern: "ANTHROPIC_API_KEY",
|
|
681
|
+
service: "anthropic",
|
|
682
|
+
confidence: "high",
|
|
683
|
+
description: "Anthropic API key"
|
|
684
|
+
},
|
|
685
|
+
{
|
|
686
|
+
kind: "name",
|
|
687
|
+
pattern: "AWS_ACCESS_KEY_ID",
|
|
688
|
+
service: "aws",
|
|
689
|
+
confidence: "high",
|
|
690
|
+
description: "AWS access key ID"
|
|
691
|
+
},
|
|
692
|
+
{
|
|
693
|
+
kind: "name",
|
|
694
|
+
pattern: "AWS_SECRET_ACCESS_KEY",
|
|
695
|
+
service: "aws",
|
|
696
|
+
confidence: "high",
|
|
697
|
+
description: "AWS secret access key"
|
|
698
|
+
},
|
|
699
|
+
{
|
|
700
|
+
kind: "name",
|
|
701
|
+
pattern: "AWS_SESSION_TOKEN",
|
|
702
|
+
service: "aws",
|
|
703
|
+
confidence: "high",
|
|
704
|
+
description: "AWS session token"
|
|
705
|
+
},
|
|
706
|
+
{
|
|
707
|
+
kind: "name",
|
|
708
|
+
pattern: "GOOGLE_APPLICATION_CREDENTIALS",
|
|
709
|
+
service: "gcp",
|
|
710
|
+
confidence: "high",
|
|
711
|
+
description: "Google Cloud service account path"
|
|
712
|
+
},
|
|
713
|
+
{
|
|
714
|
+
kind: "name",
|
|
715
|
+
pattern: "GOOGLE_API_KEY",
|
|
716
|
+
service: "google",
|
|
717
|
+
confidence: "high",
|
|
718
|
+
description: "Google API key"
|
|
719
|
+
},
|
|
720
|
+
{
|
|
721
|
+
kind: "name",
|
|
722
|
+
pattern: "GCP_PROJECT_ID",
|
|
723
|
+
service: "gcp",
|
|
724
|
+
confidence: "medium",
|
|
725
|
+
description: "GCP project ID"
|
|
726
|
+
},
|
|
727
|
+
{
|
|
728
|
+
kind: "name",
|
|
729
|
+
pattern: "AZURE_CLIENT_ID",
|
|
730
|
+
service: "azure",
|
|
731
|
+
confidence: "high",
|
|
732
|
+
description: "Azure client ID"
|
|
733
|
+
},
|
|
734
|
+
{
|
|
735
|
+
kind: "name",
|
|
736
|
+
pattern: "AZURE_CLIENT_SECRET",
|
|
737
|
+
service: "azure",
|
|
738
|
+
confidence: "high",
|
|
739
|
+
description: "Azure client secret"
|
|
740
|
+
},
|
|
741
|
+
{
|
|
742
|
+
kind: "name",
|
|
743
|
+
pattern: "AZURE_TENANT_ID",
|
|
744
|
+
service: "azure",
|
|
745
|
+
confidence: "high",
|
|
746
|
+
description: "Azure tenant ID"
|
|
747
|
+
},
|
|
748
|
+
{
|
|
749
|
+
kind: "name",
|
|
750
|
+
pattern: "STRIPE_SECRET_KEY",
|
|
751
|
+
service: "stripe",
|
|
752
|
+
confidence: "high",
|
|
753
|
+
description: "Stripe secret key"
|
|
754
|
+
},
|
|
755
|
+
{
|
|
756
|
+
kind: "name",
|
|
757
|
+
pattern: "STRIPE_PUBLISHABLE_KEY",
|
|
758
|
+
service: "stripe",
|
|
759
|
+
confidence: "high",
|
|
760
|
+
description: "Stripe publishable key"
|
|
761
|
+
},
|
|
762
|
+
{
|
|
763
|
+
kind: "name",
|
|
764
|
+
pattern: "STRIPE_WEBHOOK_SECRET",
|
|
765
|
+
service: "stripe",
|
|
766
|
+
confidence: "high",
|
|
767
|
+
description: "Stripe webhook secret"
|
|
768
|
+
},
|
|
769
|
+
{
|
|
770
|
+
kind: "name",
|
|
771
|
+
pattern: "GITHUB_TOKEN",
|
|
772
|
+
service: "github",
|
|
773
|
+
confidence: "high",
|
|
774
|
+
description: "GitHub token"
|
|
775
|
+
},
|
|
776
|
+
{
|
|
777
|
+
kind: "name",
|
|
778
|
+
pattern: "GH_TOKEN",
|
|
779
|
+
service: "github",
|
|
780
|
+
confidence: "high",
|
|
781
|
+
description: "GitHub token (gh CLI)"
|
|
782
|
+
},
|
|
783
|
+
{
|
|
784
|
+
kind: "name",
|
|
785
|
+
pattern: "SLACK_BOT_TOKEN",
|
|
786
|
+
service: "slack",
|
|
787
|
+
confidence: "high",
|
|
788
|
+
description: "Slack bot token"
|
|
789
|
+
},
|
|
790
|
+
{
|
|
791
|
+
kind: "name",
|
|
792
|
+
pattern: "SLACK_SIGNING_SECRET",
|
|
793
|
+
service: "slack",
|
|
794
|
+
confidence: "high",
|
|
795
|
+
description: "Slack signing secret"
|
|
796
|
+
},
|
|
797
|
+
{
|
|
798
|
+
kind: "name",
|
|
799
|
+
pattern: "SLACK_WEBHOOK_URL",
|
|
800
|
+
service: "slack",
|
|
801
|
+
confidence: "high",
|
|
802
|
+
description: "Slack webhook URL"
|
|
803
|
+
},
|
|
804
|
+
{
|
|
805
|
+
kind: "name",
|
|
806
|
+
pattern: "TWILIO_ACCOUNT_SID",
|
|
807
|
+
service: "twilio",
|
|
808
|
+
confidence: "high",
|
|
809
|
+
description: "Twilio account SID"
|
|
810
|
+
},
|
|
811
|
+
{
|
|
812
|
+
kind: "name",
|
|
813
|
+
pattern: "TWILIO_AUTH_TOKEN",
|
|
814
|
+
service: "twilio",
|
|
815
|
+
confidence: "high",
|
|
816
|
+
description: "Twilio auth token"
|
|
817
|
+
},
|
|
818
|
+
{
|
|
819
|
+
kind: "name",
|
|
820
|
+
pattern: "SENDGRID_API_KEY",
|
|
821
|
+
service: "sendgrid",
|
|
822
|
+
confidence: "high",
|
|
823
|
+
description: "SendGrid API key"
|
|
824
|
+
},
|
|
825
|
+
{
|
|
826
|
+
kind: "name",
|
|
827
|
+
pattern: "SUPABASE_URL",
|
|
828
|
+
service: "supabase",
|
|
829
|
+
confidence: "high",
|
|
830
|
+
description: "Supabase project URL"
|
|
831
|
+
},
|
|
832
|
+
{
|
|
833
|
+
kind: "name",
|
|
834
|
+
pattern: "SUPABASE_ANON_KEY",
|
|
835
|
+
service: "supabase",
|
|
836
|
+
confidence: "high",
|
|
837
|
+
description: "Supabase anon key"
|
|
838
|
+
},
|
|
839
|
+
{
|
|
840
|
+
kind: "name",
|
|
841
|
+
pattern: "SUPABASE_SERVICE_ROLE_KEY",
|
|
842
|
+
service: "supabase",
|
|
843
|
+
confidence: "high",
|
|
844
|
+
description: "Supabase service role key"
|
|
845
|
+
},
|
|
846
|
+
{
|
|
847
|
+
kind: "name",
|
|
848
|
+
pattern: "DATABASE_URL",
|
|
849
|
+
service: "database",
|
|
850
|
+
confidence: "high",
|
|
851
|
+
description: "Database URL"
|
|
852
|
+
},
|
|
853
|
+
{
|
|
854
|
+
kind: "name",
|
|
855
|
+
pattern: "DATABASE_PASSWORD",
|
|
856
|
+
service: "database",
|
|
857
|
+
confidence: "high",
|
|
858
|
+
description: "Database password"
|
|
859
|
+
},
|
|
860
|
+
{
|
|
861
|
+
kind: "name",
|
|
862
|
+
pattern: "REDIS_URL",
|
|
863
|
+
service: "redis",
|
|
864
|
+
confidence: "high",
|
|
865
|
+
description: "Redis URL"
|
|
866
|
+
},
|
|
867
|
+
{
|
|
868
|
+
kind: "name",
|
|
869
|
+
pattern: "MONGODB_URI",
|
|
870
|
+
service: "mongodb",
|
|
871
|
+
confidence: "high",
|
|
872
|
+
description: "MongoDB URI"
|
|
873
|
+
},
|
|
874
|
+
{
|
|
875
|
+
kind: "name",
|
|
876
|
+
pattern: "DD_API_KEY",
|
|
877
|
+
service: "datadog",
|
|
878
|
+
confidence: "high",
|
|
879
|
+
description: "Datadog API key"
|
|
880
|
+
},
|
|
881
|
+
{
|
|
882
|
+
kind: "name",
|
|
883
|
+
pattern: "DD_APP_KEY",
|
|
884
|
+
service: "datadog",
|
|
885
|
+
confidence: "high",
|
|
886
|
+
description: "Datadog app key"
|
|
887
|
+
},
|
|
888
|
+
{
|
|
889
|
+
kind: "name",
|
|
890
|
+
pattern: "SENTRY_DSN",
|
|
891
|
+
service: "sentry",
|
|
892
|
+
confidence: "high",
|
|
893
|
+
description: "Sentry DSN"
|
|
894
|
+
},
|
|
895
|
+
{
|
|
896
|
+
kind: "name",
|
|
897
|
+
pattern: "SENTRY_AUTH_TOKEN",
|
|
898
|
+
service: "sentry",
|
|
899
|
+
confidence: "high",
|
|
900
|
+
description: "Sentry auth token"
|
|
901
|
+
},
|
|
902
|
+
{
|
|
903
|
+
kind: "name",
|
|
904
|
+
pattern: "VERCEL_TOKEN",
|
|
905
|
+
service: "vercel",
|
|
906
|
+
confidence: "high",
|
|
907
|
+
description: "Vercel token"
|
|
908
|
+
},
|
|
909
|
+
{
|
|
910
|
+
kind: "name",
|
|
911
|
+
pattern: "NETLIFY_AUTH_TOKEN",
|
|
912
|
+
service: "netlify",
|
|
913
|
+
confidence: "high",
|
|
914
|
+
description: "Netlify auth token"
|
|
915
|
+
},
|
|
916
|
+
{
|
|
917
|
+
kind: "name",
|
|
918
|
+
pattern: "CLOUDFLARE_API_TOKEN",
|
|
919
|
+
service: "cloudflare",
|
|
920
|
+
confidence: "high",
|
|
921
|
+
description: "Cloudflare API token"
|
|
922
|
+
},
|
|
923
|
+
{
|
|
924
|
+
kind: "name",
|
|
925
|
+
pattern: "CF_API_TOKEN",
|
|
926
|
+
service: "cloudflare",
|
|
927
|
+
confidence: "high",
|
|
928
|
+
description: "Cloudflare API token"
|
|
929
|
+
},
|
|
930
|
+
{
|
|
931
|
+
kind: "name",
|
|
932
|
+
pattern: "DOCKER_PASSWORD",
|
|
933
|
+
service: "docker",
|
|
934
|
+
confidence: "high",
|
|
935
|
+
description: "Docker password"
|
|
936
|
+
},
|
|
937
|
+
{
|
|
938
|
+
kind: "name",
|
|
939
|
+
pattern: "DOCKER_TOKEN",
|
|
940
|
+
service: "docker",
|
|
941
|
+
confidence: "high",
|
|
942
|
+
description: "Docker token"
|
|
943
|
+
},
|
|
944
|
+
{
|
|
945
|
+
kind: "name",
|
|
946
|
+
pattern: "NPM_TOKEN",
|
|
947
|
+
service: "npm",
|
|
948
|
+
confidence: "high",
|
|
949
|
+
description: "npm token"
|
|
950
|
+
},
|
|
951
|
+
{
|
|
952
|
+
kind: "name",
|
|
953
|
+
pattern: "HF_TOKEN",
|
|
954
|
+
service: "huggingface",
|
|
955
|
+
confidence: "high",
|
|
956
|
+
description: "Hugging Face token"
|
|
957
|
+
},
|
|
958
|
+
{
|
|
959
|
+
kind: "name",
|
|
960
|
+
pattern: "HUGGING_FACE_HUB_TOKEN",
|
|
961
|
+
service: "huggingface",
|
|
962
|
+
confidence: "high",
|
|
963
|
+
description: "Hugging Face Hub token"
|
|
964
|
+
},
|
|
965
|
+
{
|
|
966
|
+
kind: "name",
|
|
967
|
+
pattern: "COHERE_API_KEY",
|
|
968
|
+
service: "cohere",
|
|
969
|
+
confidence: "high",
|
|
970
|
+
description: "Cohere API key"
|
|
971
|
+
},
|
|
972
|
+
{
|
|
973
|
+
kind: "name",
|
|
974
|
+
pattern: "REPLICATE_API_TOKEN",
|
|
975
|
+
service: "replicate",
|
|
976
|
+
confidence: "high",
|
|
977
|
+
description: "Replicate API token"
|
|
978
|
+
},
|
|
979
|
+
{
|
|
980
|
+
kind: "name",
|
|
981
|
+
pattern: "PINECONE_API_KEY",
|
|
982
|
+
service: "pinecone",
|
|
983
|
+
confidence: "high",
|
|
984
|
+
description: "Pinecone API key"
|
|
985
|
+
},
|
|
986
|
+
{
|
|
987
|
+
kind: "name",
|
|
988
|
+
pattern: "LINEAR_API_KEY",
|
|
989
|
+
service: "linear",
|
|
990
|
+
confidence: "high",
|
|
991
|
+
description: "Linear API key"
|
|
992
|
+
}
|
|
993
|
+
];
|
|
994
|
+
const SUFFIX_PATTERNS = [
|
|
995
|
+
{
|
|
996
|
+
suffix: "_API_KEY",
|
|
997
|
+
description: "API key"
|
|
998
|
+
},
|
|
999
|
+
{
|
|
1000
|
+
suffix: "_SECRET_KEY",
|
|
1001
|
+
description: "Secret key"
|
|
1002
|
+
},
|
|
1003
|
+
{
|
|
1004
|
+
suffix: "_SECRET",
|
|
1005
|
+
description: "Secret"
|
|
1006
|
+
},
|
|
1007
|
+
{
|
|
1008
|
+
suffix: "_TOKEN",
|
|
1009
|
+
description: "Token"
|
|
1010
|
+
},
|
|
1011
|
+
{
|
|
1012
|
+
suffix: "_PASSWORD",
|
|
1013
|
+
description: "Password"
|
|
1014
|
+
},
|
|
1015
|
+
{
|
|
1016
|
+
suffix: "_PASS",
|
|
1017
|
+
description: "Password"
|
|
1018
|
+
},
|
|
1019
|
+
{
|
|
1020
|
+
suffix: "_AUTH_TOKEN",
|
|
1021
|
+
description: "Auth token"
|
|
1022
|
+
},
|
|
1023
|
+
{
|
|
1024
|
+
suffix: "_ACCESS_TOKEN",
|
|
1025
|
+
description: "Access token"
|
|
1026
|
+
},
|
|
1027
|
+
{
|
|
1028
|
+
suffix: "_PRIVATE_KEY",
|
|
1029
|
+
description: "Private key"
|
|
1030
|
+
},
|
|
1031
|
+
{
|
|
1032
|
+
suffix: "_SIGNING_KEY",
|
|
1033
|
+
description: "Signing key"
|
|
1034
|
+
},
|
|
1035
|
+
{
|
|
1036
|
+
suffix: "_WEBHOOK_SECRET",
|
|
1037
|
+
description: "Webhook secret"
|
|
1038
|
+
},
|
|
1039
|
+
{
|
|
1040
|
+
suffix: "_DSN",
|
|
1041
|
+
description: "DSN"
|
|
1042
|
+
},
|
|
1043
|
+
{
|
|
1044
|
+
suffix: "_CONNECTION_STRING",
|
|
1045
|
+
description: "Connection string"
|
|
1046
|
+
}
|
|
1047
|
+
];
|
|
1048
|
+
const VALUE_SHAPE_PATTERNS = [
|
|
1049
|
+
{
|
|
1050
|
+
prefix: "sk-ant-",
|
|
1051
|
+
service: "anthropic",
|
|
1052
|
+
description: "Anthropic API key"
|
|
1053
|
+
},
|
|
1054
|
+
{
|
|
1055
|
+
prefix: "sk-",
|
|
1056
|
+
service: "openai",
|
|
1057
|
+
description: "OpenAI API key"
|
|
1058
|
+
},
|
|
1059
|
+
{
|
|
1060
|
+
prefix: "sk_live_",
|
|
1061
|
+
service: "stripe",
|
|
1062
|
+
description: "Stripe live secret key"
|
|
1063
|
+
},
|
|
1064
|
+
{
|
|
1065
|
+
prefix: "sk_test_",
|
|
1066
|
+
service: "stripe",
|
|
1067
|
+
description: "Stripe test secret key"
|
|
1068
|
+
},
|
|
1069
|
+
{
|
|
1070
|
+
prefix: "pk_live_",
|
|
1071
|
+
service: "stripe",
|
|
1072
|
+
description: "Stripe live publishable key"
|
|
1073
|
+
},
|
|
1074
|
+
{
|
|
1075
|
+
prefix: "pk_test_",
|
|
1076
|
+
service: "stripe",
|
|
1077
|
+
description: "Stripe test publishable key"
|
|
1078
|
+
},
|
|
1079
|
+
{
|
|
1080
|
+
prefix: "whsec_",
|
|
1081
|
+
service: "stripe",
|
|
1082
|
+
description: "Stripe webhook secret"
|
|
1083
|
+
},
|
|
1084
|
+
{
|
|
1085
|
+
prefix: "AKIA",
|
|
1086
|
+
service: "aws",
|
|
1087
|
+
description: "AWS access key ID"
|
|
1088
|
+
},
|
|
1089
|
+
{
|
|
1090
|
+
prefix: "ghp_",
|
|
1091
|
+
service: "github",
|
|
1092
|
+
description: "GitHub personal access token"
|
|
1093
|
+
},
|
|
1094
|
+
{
|
|
1095
|
+
prefix: "gho_",
|
|
1096
|
+
service: "github",
|
|
1097
|
+
description: "GitHub OAuth token"
|
|
1098
|
+
},
|
|
1099
|
+
{
|
|
1100
|
+
prefix: "ghs_",
|
|
1101
|
+
service: "github",
|
|
1102
|
+
description: "GitHub server-to-server token"
|
|
1103
|
+
},
|
|
1104
|
+
{
|
|
1105
|
+
prefix: "ghu_",
|
|
1106
|
+
service: "github",
|
|
1107
|
+
description: "GitHub user-to-server token"
|
|
1108
|
+
},
|
|
1109
|
+
{
|
|
1110
|
+
prefix: "github_pat_",
|
|
1111
|
+
service: "github",
|
|
1112
|
+
description: "GitHub fine-grained PAT"
|
|
1113
|
+
},
|
|
1114
|
+
{
|
|
1115
|
+
prefix: "xoxb-",
|
|
1116
|
+
service: "slack",
|
|
1117
|
+
description: "Slack bot token"
|
|
1118
|
+
},
|
|
1119
|
+
{
|
|
1120
|
+
prefix: "xoxp-",
|
|
1121
|
+
service: "slack",
|
|
1122
|
+
description: "Slack user token"
|
|
1123
|
+
},
|
|
1124
|
+
{
|
|
1125
|
+
prefix: "xoxa-",
|
|
1126
|
+
service: "slack",
|
|
1127
|
+
description: "Slack app token"
|
|
1128
|
+
},
|
|
1129
|
+
{
|
|
1130
|
+
prefix: "xoxs-",
|
|
1131
|
+
service: "slack",
|
|
1132
|
+
description: "Slack legacy token"
|
|
1133
|
+
},
|
|
1134
|
+
{
|
|
1135
|
+
prefix: "SG.",
|
|
1136
|
+
service: "sendgrid",
|
|
1137
|
+
description: "SendGrid API key"
|
|
1138
|
+
},
|
|
1139
|
+
{
|
|
1140
|
+
prefix: "hf_",
|
|
1141
|
+
service: "huggingface",
|
|
1142
|
+
description: "Hugging Face token"
|
|
1143
|
+
},
|
|
1144
|
+
{
|
|
1145
|
+
prefix: "r8_",
|
|
1146
|
+
service: "replicate",
|
|
1147
|
+
description: "Replicate API token"
|
|
1148
|
+
},
|
|
1149
|
+
{
|
|
1150
|
+
prefix: "eyJ",
|
|
1151
|
+
service: "jwt",
|
|
1152
|
+
description: "JWT token"
|
|
1153
|
+
},
|
|
1154
|
+
{
|
|
1155
|
+
prefix: "postgres://",
|
|
1156
|
+
service: "postgresql",
|
|
1157
|
+
description: "PostgreSQL connection string"
|
|
1158
|
+
},
|
|
1159
|
+
{
|
|
1160
|
+
prefix: "postgresql://",
|
|
1161
|
+
service: "postgresql",
|
|
1162
|
+
description: "PostgreSQL connection string"
|
|
1163
|
+
},
|
|
1164
|
+
{
|
|
1165
|
+
prefix: "mysql://",
|
|
1166
|
+
service: "mysql",
|
|
1167
|
+
description: "MySQL connection string"
|
|
1168
|
+
},
|
|
1169
|
+
{
|
|
1170
|
+
prefix: "mongodb://",
|
|
1171
|
+
service: "mongodb",
|
|
1172
|
+
description: "MongoDB connection string"
|
|
1173
|
+
},
|
|
1174
|
+
{
|
|
1175
|
+
prefix: "mongodb+srv://",
|
|
1176
|
+
service: "mongodb",
|
|
1177
|
+
description: "MongoDB SRV connection string"
|
|
1178
|
+
},
|
|
1179
|
+
{
|
|
1180
|
+
prefix: "redis://",
|
|
1181
|
+
service: "redis",
|
|
1182
|
+
description: "Redis connection string"
|
|
1183
|
+
},
|
|
1184
|
+
{
|
|
1185
|
+
prefix: "rediss://",
|
|
1186
|
+
service: "redis",
|
|
1187
|
+
description: "Redis TLS connection string"
|
|
1188
|
+
},
|
|
1189
|
+
{
|
|
1190
|
+
prefix: "amqp://",
|
|
1191
|
+
service: "rabbitmq",
|
|
1192
|
+
description: "RabbitMQ connection string"
|
|
1193
|
+
},
|
|
1194
|
+
{
|
|
1195
|
+
prefix: "amqps://",
|
|
1196
|
+
service: "rabbitmq",
|
|
1197
|
+
description: "RabbitMQ TLS connection string"
|
|
1198
|
+
}
|
|
1199
|
+
];
|
|
1200
|
+
/** Detect service from value prefix/shape */
|
|
1201
|
+
const matchValueShape = (value) => {
|
|
1202
|
+
for (const vp of VALUE_SHAPE_PATTERNS) if (value.startsWith(vp.prefix)) return Option({
|
|
1203
|
+
service: vp.service,
|
|
1204
|
+
description: vp.description
|
|
1205
|
+
});
|
|
1206
|
+
return Option(void 0);
|
|
1207
|
+
};
|
|
1208
|
+
/** Strip common suffixes and derive a service name from an env var name */
|
|
1209
|
+
const deriveServiceFromName = (name) => {
|
|
1210
|
+
const suffixes = [
|
|
1211
|
+
"_API_KEY",
|
|
1212
|
+
"_SECRET_KEY",
|
|
1213
|
+
"_ACCESS_KEY",
|
|
1214
|
+
"_PRIVATE_KEY",
|
|
1215
|
+
"_SIGNING_KEY",
|
|
1216
|
+
"_AUTH_TOKEN",
|
|
1217
|
+
"_ACCESS_TOKEN",
|
|
1218
|
+
"_WEBHOOK_SECRET",
|
|
1219
|
+
"_CONNECTION_STRING",
|
|
1220
|
+
"_SECRET",
|
|
1221
|
+
"_TOKEN",
|
|
1222
|
+
"_PASSWORD",
|
|
1223
|
+
"_PASS",
|
|
1224
|
+
"_KEY",
|
|
1225
|
+
"_DSN",
|
|
1226
|
+
"_URL",
|
|
1227
|
+
"_URI"
|
|
1228
|
+
];
|
|
1229
|
+
let stripped = name;
|
|
1230
|
+
for (const suffix of suffixes) if (stripped.endsWith(suffix)) {
|
|
1231
|
+
stripped = stripped.slice(0, -suffix.length);
|
|
1232
|
+
break;
|
|
1233
|
+
}
|
|
1234
|
+
return stripped.toLowerCase().replace(/_/g, "-");
|
|
1235
|
+
};
|
|
1236
|
+
/** Match a single env var against all patterns */
|
|
1237
|
+
const matchEnvVar = (name, value) => {
|
|
1238
|
+
if (EXCLUDED_VARS.has(name)) return Option(void 0);
|
|
1239
|
+
for (const p of EXACT_NAME_PATTERNS) if (name === p.pattern) return Option({
|
|
1240
|
+
envVar: name,
|
|
1241
|
+
value,
|
|
1242
|
+
service: Option(p.service),
|
|
1243
|
+
confidence: p.confidence,
|
|
1244
|
+
matchedBy: `exact:${p.pattern}`
|
|
1245
|
+
});
|
|
1246
|
+
return matchValueShape(value).fold(() => {
|
|
1247
|
+
for (const sp of SUFFIX_PATTERNS) if (name.endsWith(sp.suffix)) return Option({
|
|
1248
|
+
envVar: name,
|
|
1249
|
+
value,
|
|
1250
|
+
service: Option(deriveServiceFromName(name)),
|
|
1251
|
+
confidence: "medium",
|
|
1252
|
+
matchedBy: `suffix:${sp.suffix}`
|
|
1253
|
+
});
|
|
1254
|
+
return Option(void 0);
|
|
1255
|
+
}, (vm) => Option({
|
|
1256
|
+
envVar: name,
|
|
1257
|
+
value,
|
|
1258
|
+
service: Option(vm.service),
|
|
1259
|
+
confidence: "high",
|
|
1260
|
+
matchedBy: `value:${vm.description}`
|
|
1261
|
+
}));
|
|
1262
|
+
};
|
|
1263
|
+
/** Scan full env, sorted by confidence (high first) then alphabetically */
|
|
1264
|
+
const scanEnv = (env) => {
|
|
1265
|
+
const results = [];
|
|
1266
|
+
for (const [name, value] of Object.entries(env)) {
|
|
1267
|
+
if (value === void 0 || value === "") continue;
|
|
1268
|
+
matchEnvVar(name, value).fold(() => {}, (m) => results.push(m));
|
|
1269
|
+
}
|
|
1270
|
+
const confidenceOrder = {
|
|
1271
|
+
high: 0,
|
|
1272
|
+
medium: 1,
|
|
1273
|
+
low: 2
|
|
1274
|
+
};
|
|
1275
|
+
results.sort((a, b) => {
|
|
1276
|
+
const conf = confidenceOrder[a.confidence] - confidenceOrder[b.confidence];
|
|
1277
|
+
if (conf !== 0) return conf;
|
|
1278
|
+
return a.envVar.localeCompare(b.envVar);
|
|
1279
|
+
});
|
|
1280
|
+
return results;
|
|
1281
|
+
};
|
|
1282
|
+
|
|
1283
|
+
//#endregion
|
|
1284
|
+
//#region src/core/env.ts
|
|
1285
|
+
/** Scan env for credentials, returning structured results */
|
|
1286
|
+
const envScan = (env, options) => {
|
|
1287
|
+
const allMatches = scanEnv(env);
|
|
1288
|
+
const discovered = options?.includeUnknown ? allMatches : allMatches.filter((m) => m.service.isSome());
|
|
1289
|
+
const total_scanned = Object.keys(env).length;
|
|
1290
|
+
const high_confidence = discovered.filter((m) => m.confidence === "high").length;
|
|
1291
|
+
const medium_confidence = discovered.filter((m) => m.confidence === "medium").length;
|
|
1292
|
+
const low_confidence = discovered.filter((m) => m.confidence === "low").length;
|
|
1293
|
+
return {
|
|
1294
|
+
discovered: List(discovered),
|
|
1295
|
+
total_scanned,
|
|
1296
|
+
high_confidence,
|
|
1297
|
+
medium_confidence,
|
|
1298
|
+
low_confidence
|
|
1299
|
+
};
|
|
1300
|
+
};
|
|
1301
|
+
/** Bidirectional drift detection between config and live environment */
|
|
1302
|
+
const envCheck = (config, env) => {
|
|
1303
|
+
const entries = [];
|
|
1304
|
+
const metaKeys = Object.keys(config.meta);
|
|
1305
|
+
const trackedSet = new Set(metaKeys);
|
|
1306
|
+
for (const key of metaKeys) {
|
|
1307
|
+
const meta = config.meta[key];
|
|
1308
|
+
const present = env[key] !== void 0 && env[key] !== "";
|
|
1309
|
+
entries.push({
|
|
1310
|
+
envVar: key,
|
|
1311
|
+
service: Option(meta?.service),
|
|
1312
|
+
status: present ? "tracked" : "missing_from_env",
|
|
1313
|
+
confidence: Option(void 0)
|
|
1314
|
+
});
|
|
1315
|
+
}
|
|
1316
|
+
const envMatches = scanEnv(env);
|
|
1317
|
+
for (const match of envMatches) if (!trackedSet.has(match.envVar)) entries.push({
|
|
1318
|
+
envVar: match.envVar,
|
|
1319
|
+
service: match.service,
|
|
1320
|
+
status: "untracked",
|
|
1321
|
+
confidence: Option(match.confidence)
|
|
1322
|
+
});
|
|
1323
|
+
const tracked_and_present = entries.filter((e) => e.status === "tracked").length;
|
|
1324
|
+
const missing_from_env = entries.filter((e) => e.status === "missing_from_env").length;
|
|
1325
|
+
const untracked_credentials = entries.filter((e) => e.status === "untracked").length;
|
|
1326
|
+
return {
|
|
1327
|
+
entries: List(entries),
|
|
1328
|
+
tracked_and_present,
|
|
1329
|
+
missing_from_env,
|
|
1330
|
+
untracked_credentials,
|
|
1331
|
+
is_clean: missing_from_env === 0 && untracked_credentials === 0
|
|
1332
|
+
};
|
|
1333
|
+
};
|
|
1334
|
+
const todayIso$1 = () => (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
|
|
1335
|
+
/** Generate TOML [meta.*] blocks from scan results, mirroring init.ts pattern */
|
|
1336
|
+
const generateTomlFromScan = (matches) => {
|
|
1337
|
+
const blocks = [];
|
|
1338
|
+
for (const match of matches) {
|
|
1339
|
+
const svc = match.service.fold(() => match.envVar.toLowerCase().replace(/_/g, "-"), (s) => s);
|
|
1340
|
+
blocks.push(`[meta.${match.envVar}]
|
|
1341
|
+
service = "${svc}"
|
|
1342
|
+
# purpose = "" # Why: what this secret enables
|
|
1343
|
+
# capabilities = [] # What operations this grants
|
|
1344
|
+
created = "${todayIso$1()}"
|
|
1345
|
+
# expires = "" # When: YYYY-MM-DD expiration date
|
|
1346
|
+
# rotation_url = "" # URL for rotation procedure
|
|
1347
|
+
# source = "" # Where the value originates (e.g. vault, ci)
|
|
1348
|
+
# tags = {}
|
|
1349
|
+
`);
|
|
1350
|
+
}
|
|
1351
|
+
return blocks.join("\n");
|
|
1352
|
+
};
|
|
1353
|
+
|
|
1354
|
+
//#endregion
|
|
1355
|
+
//#region src/cli/commands/env.ts
|
|
1356
|
+
const runEnvScan = (options) => {
|
|
1357
|
+
const scan = envScan(process.env, { includeUnknown: options.includeUnknown });
|
|
1358
|
+
if (scan.discovered.size === 0) {
|
|
1359
|
+
console.log(`${DIM}No credentials detected in environment.${RESET}`);
|
|
1360
|
+
process.exit(0);
|
|
1361
|
+
}
|
|
1362
|
+
if (options.format === "json") console.log(formatScanJson(scan));
|
|
1363
|
+
else console.log(formatScanTable(scan));
|
|
1364
|
+
if (options.write || options.dryRun) {
|
|
1365
|
+
const toml = generateTomlFromScan(scan.discovered.toArray());
|
|
1366
|
+
if (options.dryRun) {
|
|
1367
|
+
console.log(`\n${BOLD}Preview (--dry-run):${RESET}\n`);
|
|
1368
|
+
console.log(toml);
|
|
1369
|
+
return;
|
|
1370
|
+
}
|
|
1371
|
+
const configPath = join(process.cwd(), "envpkt.toml");
|
|
1372
|
+
if (existsSync(configPath)) {
|
|
1373
|
+
const existing = Try(() => readFileSync(configPath, "utf-8")).fold(() => "", (c) => c);
|
|
1374
|
+
const newEntries = scan.discovered.toArray().filter((m) => !existing.includes(`[meta.${m.envVar}]`));
|
|
1375
|
+
if (newEntries.length === 0) {
|
|
1376
|
+
console.log(`\n${GREEN}✓${RESET} All discovered credentials already tracked in envpkt.toml`);
|
|
1377
|
+
return;
|
|
1378
|
+
}
|
|
1379
|
+
const newToml = generateTomlFromScan(newEntries);
|
|
1380
|
+
Try(() => writeFileSync(configPath, existing.trimEnd() + "\n\n" + newToml, "utf-8")).fold((err) => {
|
|
1381
|
+
console.error(`\n${RED}Error:${RESET} Failed to write: ${err}`);
|
|
1382
|
+
process.exit(1);
|
|
1383
|
+
}, () => {
|
|
1384
|
+
console.log(`\n${GREEN}✓${RESET} Appended ${BOLD}${newEntries.length}${RESET} new entry/entries to ${CYAN}${configPath}${RESET}`);
|
|
1385
|
+
});
|
|
1386
|
+
} else {
|
|
1387
|
+
const header = `#:schema https://raw.githubusercontent.com/jordanburke/envpkt/main/schemas/envpkt.schema.json\n\nversion = 1\n\n[lifecycle]\nstale_warning_days = 90\n\n`;
|
|
1388
|
+
Try(() => writeFileSync(configPath, header + toml, "utf-8")).fold((err) => {
|
|
1389
|
+
console.error(`\n${RED}Error:${RESET} Failed to write: ${err}`);
|
|
1390
|
+
process.exit(1);
|
|
1391
|
+
}, () => {
|
|
1392
|
+
console.log(`\n${GREEN}✓${RESET} Created ${BOLD}envpkt.toml${RESET} with ${CYAN}${scan.discovered.size}${RESET} credential(s)`);
|
|
1393
|
+
});
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
};
|
|
1397
|
+
const runEnvCheck = (options) => {
|
|
1398
|
+
resolveConfigPath(options.config).fold((err) => {
|
|
1399
|
+
console.error(formatError(err));
|
|
1400
|
+
process.exit(2);
|
|
1401
|
+
}, (path) => {
|
|
1402
|
+
loadConfig(path).fold((err) => {
|
|
1403
|
+
console.error(formatError(err));
|
|
1404
|
+
process.exit(2);
|
|
1405
|
+
}, (rawConfig) => {
|
|
1406
|
+
resolveConfig(rawConfig, dirname(path)).fold((err) => {
|
|
1407
|
+
console.error(formatError(err));
|
|
1408
|
+
process.exit(2);
|
|
1409
|
+
}, (resolveResult) => {
|
|
1410
|
+
const check = envCheck(resolveResult.config, process.env);
|
|
1411
|
+
if (options.format === "json") console.log(formatCheckJson(check));
|
|
1412
|
+
else console.log(formatCheckTable(check));
|
|
1413
|
+
if (options.strict && !check.is_clean) process.exit(1);
|
|
1414
|
+
});
|
|
1415
|
+
});
|
|
1416
|
+
});
|
|
1417
|
+
};
|
|
1418
|
+
|
|
1419
|
+
//#endregion
|
|
1420
|
+
//#region src/fnox/detect.ts
|
|
1421
|
+
/** Check if fnox CLI is available on PATH */
|
|
1422
|
+
const fnoxAvailable = () => Try(() => {
|
|
1423
|
+
execFileSync("fnox", ["--version"], { stdio: "pipe" });
|
|
1424
|
+
return true;
|
|
1425
|
+
}).fold(() => false, (v) => v);
|
|
1426
|
+
|
|
1427
|
+
//#endregion
|
|
1428
|
+
//#region src/fnox/identity.ts
|
|
1429
|
+
/** Check if the age CLI is available on PATH */
|
|
1430
|
+
const ageAvailable = () => Try(() => {
|
|
1431
|
+
execFileSync("age", ["--version"], { stdio: "pipe" });
|
|
1432
|
+
return true;
|
|
1433
|
+
}).fold(() => false, (v) => v);
|
|
1434
|
+
/** Unwrap an encrypted agent key using age --decrypt */
|
|
1435
|
+
const unwrapAgentKey = (identityPath) => {
|
|
1436
|
+
if (!existsSync(identityPath)) return Left({
|
|
1437
|
+
_tag: "IdentityNotFound",
|
|
1438
|
+
path: identityPath
|
|
1439
|
+
});
|
|
1440
|
+
if (!ageAvailable()) return Left({
|
|
1441
|
+
_tag: "AgeNotFound",
|
|
1442
|
+
message: "age CLI not found on PATH"
|
|
1443
|
+
});
|
|
1444
|
+
return Try(() => execFileSync("age", ["--decrypt", identityPath], {
|
|
1445
|
+
stdio: [
|
|
1446
|
+
"pipe",
|
|
1447
|
+
"pipe",
|
|
1448
|
+
"pipe"
|
|
1449
|
+
],
|
|
1450
|
+
encoding: "utf-8"
|
|
1451
|
+
})).fold((err) => Left({
|
|
1452
|
+
_tag: "DecryptFailed",
|
|
1453
|
+
message: `age decrypt failed: ${err}`
|
|
1454
|
+
}), (output) => Right(output.trim()));
|
|
1455
|
+
};
|
|
1456
|
+
|
|
1457
|
+
//#endregion
|
|
1458
|
+
//#region src/cli/commands/exec.ts
|
|
1459
|
+
const runExec = (args, options) => {
|
|
1460
|
+
if (args.length === 0) {
|
|
1461
|
+
console.error(`${RED}Error:${RESET} No command specified`);
|
|
1462
|
+
process.exit(2);
|
|
1463
|
+
return;
|
|
1464
|
+
}
|
|
1465
|
+
const skipAudit = options.skipAudit || options.check === false;
|
|
1466
|
+
const configData = resolveConfigPath(options.config).fold((err) => {
|
|
1467
|
+
console.error(formatError(err));
|
|
1468
|
+
process.exit(2);
|
|
1469
|
+
}, (path) => loadConfig(path).fold((err) => {
|
|
1470
|
+
console.error(formatError(err));
|
|
1471
|
+
process.exit(2);
|
|
1472
|
+
}, (config) => ({
|
|
1473
|
+
config,
|
|
1474
|
+
path
|
|
1475
|
+
})));
|
|
1476
|
+
if (!configData) return;
|
|
1477
|
+
const { config, path } = configData;
|
|
1478
|
+
const configDir = dirname(path);
|
|
1479
|
+
if (!skipAudit) {
|
|
1480
|
+
const audit = computeAudit(config);
|
|
1481
|
+
console.error(`${BOLD}envpkt${RESET} pre-flight audit ${path}`);
|
|
1482
|
+
console.error(formatAudit(audit));
|
|
1483
|
+
console.error("");
|
|
1484
|
+
if (options.strict && audit.status !== "healthy") {
|
|
1485
|
+
console.error(`${RED}Aborting:${RESET} --strict mode and audit status is ${audit.status}`);
|
|
1486
|
+
process.exit(exitCodeForAudit(audit));
|
|
1487
|
+
return;
|
|
1488
|
+
}
|
|
1489
|
+
if (audit.status === "critical" && !options.warnOnly) {
|
|
1490
|
+
console.error(`${RED}Aborting:${RESET} audit status is critical (use --warn-only to proceed)`);
|
|
1491
|
+
process.exit(exitCodeForAudit(audit));
|
|
1492
|
+
return;
|
|
1493
|
+
}
|
|
1494
|
+
if (audit.status === "critical" && options.warnOnly) console.error(`${YELLOW}Warning:${RESET} Proceeding despite critical audit status (--warn-only)`);
|
|
1495
|
+
}
|
|
1496
|
+
let agentKey;
|
|
1497
|
+
if (config.agent?.identity) unwrapAgentKey(resolve(configDir, config.agent.identity)).fold((err) => {
|
|
1498
|
+
console.error(`${YELLOW}Warning:${RESET} Agent key unwrap failed: ${err._tag}`);
|
|
1499
|
+
}, (key) => {
|
|
1500
|
+
agentKey = key;
|
|
1501
|
+
});
|
|
1502
|
+
if (!fnoxAvailable()) console.error(`${YELLOW}Warning:${RESET} fnox not available — running command without secret injection`);
|
|
1503
|
+
const env = { ...process.env };
|
|
1504
|
+
if (fnoxAvailable()) {
|
|
1505
|
+
const fnoxArgs = options.profile ? [
|
|
1506
|
+
"export",
|
|
1507
|
+
"--profile",
|
|
1508
|
+
options.profile
|
|
1509
|
+
] : ["export"];
|
|
1510
|
+
const fnoxEnv = agentKey ? {
|
|
1511
|
+
...process.env,
|
|
1512
|
+
FNOX_AGE_KEY: agentKey
|
|
1513
|
+
} : void 0;
|
|
1514
|
+
Try(() => execFileSync("fnox", fnoxArgs, {
|
|
1515
|
+
stdio: "pipe",
|
|
1516
|
+
encoding: "utf-8",
|
|
1517
|
+
env: fnoxEnv
|
|
1518
|
+
})).fold((err) => {
|
|
1519
|
+
console.error(`${YELLOW}Warning:${RESET} fnox export failed: ${err}`);
|
|
1520
|
+
}, (output) => {
|
|
1521
|
+
for (const line of output.split("\n")) {
|
|
1522
|
+
const eq = line.indexOf("=");
|
|
1523
|
+
if (eq > 0) {
|
|
1524
|
+
const key = line.slice(0, eq).trim();
|
|
1525
|
+
env[key] = line.slice(eq + 1).trim();
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
});
|
|
1529
|
+
}
|
|
1530
|
+
const [cmd, ...cmdArgs] = args;
|
|
1531
|
+
try {
|
|
1532
|
+
execFileSync(cmd, cmdArgs, {
|
|
1533
|
+
env,
|
|
1534
|
+
stdio: "inherit"
|
|
1535
|
+
});
|
|
1536
|
+
} catch (err) {
|
|
1537
|
+
const exitCode = err.status ?? 1;
|
|
1538
|
+
process.exit(exitCode);
|
|
1539
|
+
}
|
|
1540
|
+
};
|
|
1541
|
+
|
|
1542
|
+
//#endregion
|
|
1543
|
+
//#region src/core/fleet.ts
|
|
1544
|
+
const CONFIG_FILENAME$1 = "envpkt.toml";
|
|
1545
|
+
const SKIP_DIRS = new Set([
|
|
1546
|
+
"node_modules",
|
|
1547
|
+
".git",
|
|
1548
|
+
".hg",
|
|
1549
|
+
".svn",
|
|
1550
|
+
"dist",
|
|
1551
|
+
"build",
|
|
1552
|
+
"lib",
|
|
1553
|
+
".claude",
|
|
1554
|
+
"__pycache__",
|
|
1555
|
+
"target",
|
|
1556
|
+
"out",
|
|
1557
|
+
"tmp",
|
|
1558
|
+
".terraform",
|
|
1559
|
+
".gradle",
|
|
1560
|
+
".cargo",
|
|
1561
|
+
".venv",
|
|
1562
|
+
".next",
|
|
1563
|
+
".cache",
|
|
1564
|
+
".tox",
|
|
1565
|
+
"vendor",
|
|
1566
|
+
"coverage",
|
|
1567
|
+
".nyc_output",
|
|
1568
|
+
".turbo"
|
|
1569
|
+
]);
|
|
1570
|
+
function* findEnvpktFiles(dir, maxDepth, currentDepth = 0) {
|
|
1571
|
+
if (currentDepth > maxDepth) return;
|
|
1572
|
+
const configPath = join(dir, CONFIG_FILENAME$1);
|
|
1573
|
+
if (Try(() => statSync(configPath).isFile()).fold(() => false, (v) => v)) yield configPath;
|
|
1574
|
+
if (currentDepth >= maxDepth) return;
|
|
1575
|
+
let entries = [];
|
|
1576
|
+
Try(() => readdirSync(dir, { withFileTypes: true })).fold(() => {}, (e) => {
|
|
1577
|
+
entries = e;
|
|
1578
|
+
});
|
|
1579
|
+
for (const entry of entries) if (entry.isDirectory() && !SKIP_DIRS.has(entry.name) && !entry.name.startsWith(".")) yield* findEnvpktFiles(join(dir, entry.name), maxDepth, currentDepth + 1);
|
|
1580
|
+
}
|
|
1581
|
+
const scanFleet = (rootDir, options) => {
|
|
1582
|
+
const maxDepth = options?.maxDepth ?? 3;
|
|
1583
|
+
const agents = [];
|
|
1584
|
+
for (const configPath of findEnvpktFiles(rootDir, maxDepth)) loadConfig(configPath).fold(() => {}, (config) => {
|
|
1585
|
+
const audit = computeAudit(config);
|
|
1586
|
+
agents.push({
|
|
1587
|
+
path: configPath,
|
|
1588
|
+
agent: config.agent,
|
|
1589
|
+
min_expiry_days: audit.secrets.toArray().reduce((min, s) => s.days_remaining.fold(() => min, (d) => min === void 0 ? d : Math.min(min, d)), void 0),
|
|
1590
|
+
audit
|
|
1591
|
+
});
|
|
1592
|
+
});
|
|
1593
|
+
const agentList = List(agents);
|
|
1594
|
+
const total_agents = agentList.size;
|
|
1595
|
+
const total_secrets = agentList.toArray().reduce((acc, a) => acc + a.audit.total, 0);
|
|
1596
|
+
const expired = agentList.toArray().reduce((acc, a) => acc + a.audit.expired, 0);
|
|
1597
|
+
const expiring_soon = agentList.toArray().reduce((acc, a) => acc + a.audit.expiring_soon, 0);
|
|
1598
|
+
const criticalCount = agentList.count((a) => a.audit.status === "critical");
|
|
1599
|
+
const degradedCount = agentList.count((a) => a.audit.status === "degraded");
|
|
1600
|
+
return {
|
|
1601
|
+
status: Cond.of().when(criticalCount > 0, "critical").elseWhen(degradedCount > 0, "degraded").else("healthy"),
|
|
1602
|
+
agents: agentList,
|
|
1603
|
+
total_agents,
|
|
1604
|
+
total_secrets,
|
|
1605
|
+
expired,
|
|
1606
|
+
expiring_soon
|
|
1607
|
+
};
|
|
1608
|
+
};
|
|
1609
|
+
|
|
1610
|
+
//#endregion
|
|
1611
|
+
//#region src/cli/commands/fleet.ts
|
|
1612
|
+
const statusIcon = (status) => {
|
|
1613
|
+
switch (status) {
|
|
1614
|
+
case "healthy": return `${GREEN}✓${RESET}`;
|
|
1615
|
+
case "degraded": return `${YELLOW}⚠${RESET}`;
|
|
1616
|
+
case "critical": return `${RED}✗${RESET}`;
|
|
1617
|
+
}
|
|
1618
|
+
};
|
|
1619
|
+
const runFleet = (options) => {
|
|
1620
|
+
const fleet = scanFleet(resolve(options.dir ?? "."), { maxDepth: options.depth });
|
|
1621
|
+
if (options.format === "json") {
|
|
1622
|
+
console.log(formatFleetJson(fleet));
|
|
1623
|
+
process.exit(fleet.status === "critical" ? 2 : 0);
|
|
1624
|
+
return;
|
|
1625
|
+
}
|
|
1626
|
+
const statusFilter = options.status;
|
|
1627
|
+
const agents = statusFilter ? fleet.agents.filter((a) => a.audit.status === statusFilter) : fleet.agents;
|
|
1628
|
+
console.log(`${statusIcon(fleet.status)} ${BOLD}Fleet: ${fleet.status.toUpperCase()}${RESET} — ${fleet.total_agents} agents, ${fleet.total_secrets} secrets`);
|
|
1629
|
+
if (fleet.expired > 0) console.log(` ${RED}${fleet.expired}${RESET} expired`);
|
|
1630
|
+
if (fleet.expiring_soon > 0) console.log(` ${YELLOW}${fleet.expiring_soon}${RESET} expiring soon`);
|
|
1631
|
+
console.log("");
|
|
1632
|
+
for (const agent of agents) {
|
|
1633
|
+
const name = agent.agent?.name ? BOLD + agent.agent.name + RESET : DIM + agent.path + RESET;
|
|
1634
|
+
const icon = statusIcon(agent.audit.status);
|
|
1635
|
+
console.log(` ${icon} ${name} ${DIM}(${agent.audit.total} secrets)${RESET}`);
|
|
1636
|
+
}
|
|
1637
|
+
process.exit(fleet.status === "critical" ? 2 : 0);
|
|
1638
|
+
};
|
|
1639
|
+
|
|
1640
|
+
//#endregion
|
|
1641
|
+
//#region src/cli/commands/init.ts
|
|
1642
|
+
const CONFIG_FILENAME = "envpkt.toml";
|
|
1643
|
+
const todayIso = () => (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
|
|
1644
|
+
const generateSecretBlock = (key, service) => {
|
|
1645
|
+
return `[meta.${key}]
|
|
1646
|
+
service = "${service ?? key}"
|
|
1647
|
+
# purpose = "" # Why: what this secret enables
|
|
1648
|
+
# capabilities = [] # What operations this grants
|
|
1649
|
+
created = "${todayIso()}"
|
|
1650
|
+
# expires = "" # When: YYYY-MM-DD expiration date
|
|
1651
|
+
# rotation_url = "" # URL for rotation procedure
|
|
1652
|
+
# source = "" # Where the value originates (e.g. vault, ci)
|
|
1653
|
+
# tags = {}
|
|
1654
|
+
`;
|
|
1655
|
+
};
|
|
1656
|
+
const generateAgentSection = (name, capabilities, expires) => {
|
|
1657
|
+
return `[agent]
|
|
1658
|
+
name = "${name}"
|
|
1659
|
+
# consumer = "agent" # agent | service | developer | ci${capabilities ? `\ncapabilities = [${capabilities.split(",").map((c) => `"${c.trim()}"`).join(", ")}]` : ""}${expires ? `\nexpires = "${expires}"` : ""}
|
|
1660
|
+
`;
|
|
1661
|
+
};
|
|
1662
|
+
const generateTemplate = (options, fnoxKeys) => {
|
|
1663
|
+
const lines = [];
|
|
1664
|
+
lines.push(`#:schema https://raw.githubusercontent.com/jordanburke/envpkt/main/schemas/envpkt.schema.json`);
|
|
1665
|
+
lines.push(``);
|
|
1666
|
+
lines.push(`version = 1`);
|
|
1667
|
+
lines.push(``);
|
|
1668
|
+
if (options.catalog) {
|
|
1669
|
+
lines.push(`catalog = "${options.catalog}"`);
|
|
1670
|
+
lines.push(``);
|
|
1671
|
+
}
|
|
1672
|
+
if (options.agent && options.name) {
|
|
1673
|
+
lines.push(generateAgentSection(options.name, options.capabilities, options.expires));
|
|
1674
|
+
if (options.catalog) lines.push(`secrets = [] # Add catalog secret keys this agent needs`);
|
|
1675
|
+
lines.push(``);
|
|
1676
|
+
}
|
|
1677
|
+
if (!options.catalog) {
|
|
1678
|
+
lines.push(`# Lifecycle policy`);
|
|
1679
|
+
lines.push(`[lifecycle]`);
|
|
1680
|
+
lines.push(`stale_warning_days = 90`);
|
|
1681
|
+
lines.push(`# require_expiration = false`);
|
|
1682
|
+
lines.push(`# require_service = false`);
|
|
1683
|
+
lines.push(``);
|
|
1684
|
+
if (fnoxKeys && fnoxKeys.length > 0) {
|
|
1685
|
+
lines.push(`# Secrets detected from fnox.toml`);
|
|
1686
|
+
for (const key of fnoxKeys) lines.push(generateSecretBlock(key));
|
|
1687
|
+
} else {
|
|
1688
|
+
lines.push(`# Add your secret metadata below.`);
|
|
1689
|
+
lines.push(`# Each [meta.<key>] describes a secret your agent needs.`);
|
|
1690
|
+
lines.push(``);
|
|
1691
|
+
lines.push(generateSecretBlock("EXAMPLE_API_KEY", "example-service"));
|
|
1692
|
+
}
|
|
1693
|
+
} else {
|
|
1694
|
+
lines.push(`# Optional: override catalog metadata for specific secrets`);
|
|
1695
|
+
lines.push(`# [meta.KEY_NAME]`);
|
|
1696
|
+
lines.push(`# capabilities = ["read"] # narrows catalog's broader definition`);
|
|
1697
|
+
}
|
|
1698
|
+
return lines.join("\n");
|
|
1699
|
+
};
|
|
1700
|
+
const readFnoxKeys = (fnoxPath) => Try(() => readFileSync(fnoxPath, "utf-8")).fold((err) => Left({
|
|
1701
|
+
_tag: "ReadError",
|
|
1702
|
+
message: String(err)
|
|
1703
|
+
}), (content) => Try(() => parse(content)).fold((err) => Left({
|
|
1704
|
+
_tag: "ParseError",
|
|
1705
|
+
message: String(err)
|
|
1706
|
+
}), (data) => Right(Object.keys(data))));
|
|
1707
|
+
const formatConfigError = (err) => {
|
|
1708
|
+
switch (err._tag) {
|
|
1709
|
+
case "FileNotFound": return err.path;
|
|
1710
|
+
case "ParseError": return err.message;
|
|
1711
|
+
case "ReadError": return err.message;
|
|
1712
|
+
case "ValidationError": return err.errors.toArray().join(", ");
|
|
1713
|
+
}
|
|
1714
|
+
};
|
|
1715
|
+
const runInit = (dir, options) => {
|
|
1716
|
+
const outPath = join(dir, CONFIG_FILENAME);
|
|
1717
|
+
if (existsSync(outPath) && !options.force) {
|
|
1718
|
+
console.error(`${RED}Error:${RESET} ${CONFIG_FILENAME} already exists. Use --force to overwrite.`);
|
|
1719
|
+
process.exit(1);
|
|
1720
|
+
}
|
|
1721
|
+
let fnoxKeys;
|
|
1722
|
+
if (options.fromFnox) {
|
|
1723
|
+
const fnoxPath = options.fromFnox === "true" || options.fromFnox === "" ? join(dir, "fnox.toml") : options.fromFnox;
|
|
1724
|
+
if (!existsSync(fnoxPath)) {
|
|
1725
|
+
console.error(`${RED}Error:${RESET} fnox.toml not found at ${fnoxPath}`);
|
|
1726
|
+
process.exit(1);
|
|
1727
|
+
}
|
|
1728
|
+
readFnoxKeys(fnoxPath).fold((err) => {
|
|
1729
|
+
console.error(`${RED}Error:${RESET} Failed to read fnox.toml: ${formatConfigError(err)}`);
|
|
1730
|
+
process.exit(1);
|
|
1731
|
+
}, (keys) => {
|
|
1732
|
+
fnoxKeys = keys;
|
|
1733
|
+
});
|
|
1734
|
+
}
|
|
1735
|
+
const content = generateTemplate(options, fnoxKeys);
|
|
1736
|
+
Try(() => writeFileSync(outPath, content, "utf-8")).fold((err) => {
|
|
1737
|
+
console.error(`${RED}Error:${RESET} Failed to write ${CONFIG_FILENAME}: ${err}`);
|
|
1738
|
+
process.exit(1);
|
|
1739
|
+
}, () => {
|
|
1740
|
+
console.log(`${GREEN}✓${RESET} Created ${BOLD}${CONFIG_FILENAME}${RESET} in ${CYAN}${dir}${RESET}`);
|
|
1741
|
+
if (fnoxKeys) console.log(` Scaffolded ${fnoxKeys.length} secret(s) from fnox.toml`);
|
|
1742
|
+
console.log(` ${BOLD}Next:${RESET} Fill in metadata for each secret`);
|
|
1743
|
+
});
|
|
1744
|
+
};
|
|
1745
|
+
|
|
1746
|
+
//#endregion
|
|
1747
|
+
//#region src/core/format.ts
|
|
1748
|
+
const maskValue = (value) => {
|
|
1749
|
+
if (value.length > 8) return `${value.slice(0, 3)}${"•".repeat(5)}${value.slice(-4)}`;
|
|
1750
|
+
return "•".repeat(5);
|
|
1751
|
+
};
|
|
1752
|
+
|
|
1753
|
+
//#endregion
|
|
1754
|
+
//#region src/cli/commands/inspect.ts
|
|
1755
|
+
const printSecretMeta = (meta, indent) => {
|
|
1756
|
+
if (meta.purpose) console.log(`${indent}purpose: ${meta.purpose}`);
|
|
1757
|
+
if (meta.capabilities) console.log(`${indent}capabilities: ${DIM}${meta.capabilities.join(", ")}${RESET}`);
|
|
1758
|
+
const dateParts = [];
|
|
1759
|
+
if (meta.created) dateParts.push(`created: ${meta.created}`);
|
|
1760
|
+
if (meta.expires) dateParts.push(`expires: ${meta.expires}`);
|
|
1761
|
+
if (dateParts.length > 0) console.log(`${indent}${dateParts.join(" ")}`);
|
|
1762
|
+
const opsParts = [];
|
|
1763
|
+
if (meta.rotates) opsParts.push(`rotates: ${meta.rotates}`);
|
|
1764
|
+
if (meta.rate_limit) opsParts.push(`rate_limit: ${meta.rate_limit}`);
|
|
1765
|
+
if (opsParts.length > 0) console.log(`${indent}${opsParts.join(" ")}`);
|
|
1766
|
+
if (meta.source) console.log(`${indent}source: ${meta.source}`);
|
|
1767
|
+
if (meta.model_hint) console.log(`${indent}model_hint: ${meta.model_hint}`);
|
|
1768
|
+
if (meta.rotation_url) console.log(`${indent}rotation_url: ${DIM}${meta.rotation_url}${RESET}`);
|
|
1769
|
+
if (meta.required !== void 0) console.log(`${indent}required: ${meta.required}`);
|
|
1770
|
+
if (meta.tags) {
|
|
1771
|
+
const tagStr = Object.entries(meta.tags).map(([k, v]) => `${k}=${v}`).join(", ");
|
|
1772
|
+
console.log(`${indent}tags: ${tagStr}`);
|
|
1773
|
+
}
|
|
1774
|
+
};
|
|
1775
|
+
const printConfig = (config, path, resolveResult, opts) => {
|
|
1776
|
+
console.log(`${BOLD}envpkt.toml${RESET} ${DIM}(${path})${RESET}`);
|
|
1777
|
+
if (resolveResult?.catalogPath) console.log(`${DIM}Catalog: ${CYAN}${resolveResult.catalogPath}${RESET}`);
|
|
1778
|
+
console.log(`version: ${config.version}`);
|
|
1779
|
+
console.log("");
|
|
1780
|
+
if (config.agent) {
|
|
1781
|
+
console.log(`${BOLD}Agent:${RESET} ${config.agent.name}`);
|
|
1782
|
+
if (config.agent.consumer) console.log(` consumer: ${config.agent.consumer}`);
|
|
1783
|
+
if (config.agent.description) console.log(` description: ${config.agent.description}`);
|
|
1784
|
+
if (config.agent.capabilities) console.log(` capabilities: ${config.agent.capabilities.join(", ")}`);
|
|
1785
|
+
if (config.agent.expires) console.log(` expires: ${config.agent.expires}`);
|
|
1786
|
+
if (config.agent.services) console.log(` services: ${config.agent.services.join(", ")}`);
|
|
1787
|
+
if (config.agent.secrets) console.log(` secrets: ${config.agent.secrets.join(", ")}`);
|
|
1788
|
+
console.log("");
|
|
1789
|
+
}
|
|
1790
|
+
console.log(`${BOLD}Secrets:${RESET} ${Object.keys(config.meta).length}`);
|
|
1791
|
+
for (const [key, meta] of Object.entries(config.meta)) {
|
|
1792
|
+
const secretValue = opts?.secrets?.[key];
|
|
1793
|
+
const valueSuffix = secretValue !== void 0 ? ` = ${YELLOW}${(opts?.secretDisplay ?? "encrypted") === "plaintext" ? secretValue : maskValue(secretValue)}${RESET}` : "";
|
|
1794
|
+
console.log(` ${BOLD}${key}${RESET} → ${meta.service ?? key}${valueSuffix}`);
|
|
1795
|
+
printSecretMeta(meta, " ");
|
|
1796
|
+
}
|
|
1797
|
+
if (config.lifecycle) {
|
|
1798
|
+
console.log("");
|
|
1799
|
+
console.log(`${BOLD}Lifecycle:${RESET}`);
|
|
1800
|
+
if (config.lifecycle.stale_warning_days !== void 0) console.log(` stale_warning_days: ${config.lifecycle.stale_warning_days}`);
|
|
1801
|
+
if (config.lifecycle.require_expiration !== void 0) console.log(` require_expiration: ${config.lifecycle.require_expiration}`);
|
|
1802
|
+
if (config.lifecycle.require_service !== void 0) console.log(` require_service: ${config.lifecycle.require_service}`);
|
|
1803
|
+
}
|
|
1804
|
+
if (resolveResult?.catalogPath) {
|
|
1805
|
+
console.log("");
|
|
1806
|
+
console.log(`${BOLD}Catalog Resolution:${RESET}`);
|
|
1807
|
+
console.log(` merged: ${resolveResult.merged.length} keys`);
|
|
1808
|
+
if (resolveResult.overridden.length > 0) console.log(` overridden: ${resolveResult.overridden.join(", ")}`);
|
|
1809
|
+
else console.log(` overridden: ${DIM}(none)${RESET}`);
|
|
1810
|
+
for (const w of resolveResult.warnings) console.log(` ${YELLOW}warning:${RESET} ${w}`);
|
|
1811
|
+
}
|
|
1812
|
+
};
|
|
1813
|
+
const runInspect = (options) => {
|
|
1814
|
+
resolveConfigPath(options.config).fold((err) => {
|
|
1815
|
+
console.error(formatError(err));
|
|
1816
|
+
process.exit(2);
|
|
1817
|
+
}, (path) => {
|
|
1818
|
+
loadConfig(path).fold((err) => {
|
|
1819
|
+
console.error(formatError(err));
|
|
1820
|
+
process.exit(2);
|
|
1821
|
+
}, (config) => {
|
|
1822
|
+
resolveConfig(config, dirname(path)).fold((err) => {
|
|
1823
|
+
console.error(formatError(err));
|
|
1824
|
+
process.exit(2);
|
|
1825
|
+
}, (resolveResult) => {
|
|
1826
|
+
const showResolved = options.resolved || !!resolveResult.catalogPath;
|
|
1827
|
+
const showConfig = showResolved ? resolveResult.config : config;
|
|
1828
|
+
if (options.format === "json") {
|
|
1829
|
+
console.log(JSON.stringify(showConfig, null, 2));
|
|
1830
|
+
return;
|
|
1831
|
+
}
|
|
1832
|
+
const printOpts = options.secrets ? {
|
|
1833
|
+
secrets: Object.fromEntries(Object.keys(showConfig.meta).filter((key) => process.env[key] !== void 0).map((key) => [key, process.env[key]])),
|
|
1834
|
+
secretDisplay: options.plaintext ? "plaintext" : "encrypted"
|
|
1835
|
+
} : void 0;
|
|
1836
|
+
printConfig(showConfig, path, showResolved ? resolveResult : void 0, printOpts);
|
|
1837
|
+
});
|
|
1838
|
+
});
|
|
1839
|
+
});
|
|
1840
|
+
};
|
|
1841
|
+
|
|
1842
|
+
//#endregion
|
|
1843
|
+
//#region src/mcp/resources.ts
|
|
1844
|
+
const loadConfigSafe = () => {
|
|
1845
|
+
return resolveConfigPath().fold(() => void 0, (path) => loadConfig(path).fold(() => void 0, (config) => ({
|
|
1846
|
+
config,
|
|
1847
|
+
path
|
|
1848
|
+
})));
|
|
1849
|
+
};
|
|
1850
|
+
const resourceDefinitions = [{
|
|
1851
|
+
uri: "envpkt://health",
|
|
1852
|
+
name: "Credential Health",
|
|
1853
|
+
description: "Current health status of the envpkt credential packet",
|
|
1854
|
+
mimeType: "application/json"
|
|
1855
|
+
}, {
|
|
1856
|
+
uri: "envpkt://capabilities",
|
|
1857
|
+
name: "Agent Capabilities",
|
|
1858
|
+
description: "Capabilities declared by the agent and per-secret capability grants",
|
|
1859
|
+
mimeType: "application/json"
|
|
1860
|
+
}];
|
|
1861
|
+
const readHealth = () => {
|
|
1862
|
+
const loaded = loadConfigSafe();
|
|
1863
|
+
if (!loaded) return { contents: [{
|
|
1864
|
+
uri: "envpkt://health",
|
|
1865
|
+
mimeType: "application/json",
|
|
1866
|
+
text: JSON.stringify({ error: "No envpkt.toml found" })
|
|
1867
|
+
}] };
|
|
1868
|
+
const { config, path } = loaded;
|
|
1869
|
+
const audit = computeAudit(config);
|
|
1870
|
+
return { contents: [{
|
|
1871
|
+
uri: "envpkt://health",
|
|
1872
|
+
mimeType: "application/json",
|
|
1873
|
+
text: JSON.stringify({
|
|
1874
|
+
path,
|
|
1875
|
+
status: audit.status,
|
|
1876
|
+
total: audit.total,
|
|
1877
|
+
healthy: audit.healthy,
|
|
1878
|
+
expiring_soon: audit.expiring_soon,
|
|
1879
|
+
expired: audit.expired,
|
|
1880
|
+
stale: audit.stale,
|
|
1881
|
+
missing: audit.missing
|
|
1882
|
+
}, null, 2)
|
|
1883
|
+
}] };
|
|
1884
|
+
};
|
|
1885
|
+
const readCapabilities = () => {
|
|
1886
|
+
const loaded = loadConfigSafe();
|
|
1887
|
+
if (!loaded) return { contents: [{
|
|
1888
|
+
uri: "envpkt://capabilities",
|
|
1889
|
+
mimeType: "application/json",
|
|
1890
|
+
text: JSON.stringify({ error: "No envpkt.toml found" })
|
|
1891
|
+
}] };
|
|
1892
|
+
const { config } = loaded;
|
|
1893
|
+
const agentCapabilities = config.agent?.capabilities ?? [];
|
|
1894
|
+
const secretCapabilities = {};
|
|
1895
|
+
for (const [key, meta] of Object.entries(config.meta)) if (meta.capabilities && meta.capabilities.length > 0) secretCapabilities[key] = meta.capabilities;
|
|
1896
|
+
return { contents: [{
|
|
1897
|
+
uri: "envpkt://capabilities",
|
|
1898
|
+
mimeType: "application/json",
|
|
1899
|
+
text: JSON.stringify({
|
|
1900
|
+
agent: config.agent ? {
|
|
1901
|
+
name: config.agent.name,
|
|
1902
|
+
consumer: config.agent.consumer,
|
|
1903
|
+
description: config.agent.description,
|
|
1904
|
+
capabilities: agentCapabilities
|
|
1905
|
+
} : null,
|
|
1906
|
+
secrets: secretCapabilities
|
|
1907
|
+
}, null, 2)
|
|
1908
|
+
}] };
|
|
1909
|
+
};
|
|
1910
|
+
const resourceHandlers = {
|
|
1911
|
+
"envpkt://health": readHealth,
|
|
1912
|
+
"envpkt://capabilities": readCapabilities
|
|
1913
|
+
};
|
|
1914
|
+
const readResource = (uri) => {
|
|
1915
|
+
const handler = resourceHandlers[uri];
|
|
1916
|
+
return handler?.();
|
|
1917
|
+
};
|
|
1918
|
+
|
|
1919
|
+
//#endregion
|
|
1920
|
+
//#region src/mcp/tools.ts
|
|
1921
|
+
const textResult = (text) => ({ content: [{
|
|
1922
|
+
type: "text",
|
|
1923
|
+
text
|
|
1924
|
+
}] });
|
|
1925
|
+
const errorResult = (message) => ({
|
|
1926
|
+
content: [{
|
|
1927
|
+
type: "text",
|
|
1928
|
+
text: message
|
|
1929
|
+
}],
|
|
1930
|
+
isError: true
|
|
1931
|
+
});
|
|
1932
|
+
const loadConfigForTool = (configPath) => {
|
|
1933
|
+
return resolveConfigPath(configPath).fold((err) => ({
|
|
1934
|
+
ok: false,
|
|
1935
|
+
result: errorResult(`Config error: ${err._tag} — ${err._tag === "FileNotFound" ? err.path : ""}`)
|
|
1936
|
+
}), (path) => loadConfig(path).fold((err) => ({
|
|
1937
|
+
ok: false,
|
|
1938
|
+
result: errorResult(`Config error: ${err._tag} — ${err._tag === "ValidationError" ? err.errors.toArray().join(", ") : ""}`)
|
|
1939
|
+
}), (config) => ({
|
|
1940
|
+
ok: true,
|
|
1941
|
+
config,
|
|
1942
|
+
path
|
|
1943
|
+
})));
|
|
1944
|
+
};
|
|
1945
|
+
const toolDefinitions = [
|
|
1946
|
+
{
|
|
1947
|
+
name: "getPacketHealth",
|
|
1948
|
+
description: "Get overall health status of the envpkt credential packet — returns audit results including secret statuses, expiration info, and issues",
|
|
1949
|
+
inputSchema: {
|
|
1950
|
+
type: "object",
|
|
1951
|
+
properties: { configPath: {
|
|
1952
|
+
type: "string",
|
|
1953
|
+
description: "Optional path to envpkt.toml"
|
|
1954
|
+
} }
|
|
1955
|
+
}
|
|
1956
|
+
},
|
|
1957
|
+
{
|
|
1958
|
+
name: "listCapabilities",
|
|
1959
|
+
description: "List capabilities declared by the agent and per-secret capabilities",
|
|
1960
|
+
inputSchema: {
|
|
1961
|
+
type: "object",
|
|
1962
|
+
properties: { configPath: {
|
|
1963
|
+
type: "string",
|
|
1964
|
+
description: "Optional path to envpkt.toml"
|
|
1965
|
+
} }
|
|
1966
|
+
}
|
|
1967
|
+
},
|
|
1968
|
+
{
|
|
1969
|
+
name: "getSecretMeta",
|
|
1970
|
+
description: "Get metadata for a specific secret by key name — returns service, purpose, expiration, provisioner, and other five-W details",
|
|
1971
|
+
inputSchema: {
|
|
1972
|
+
type: "object",
|
|
1973
|
+
properties: {
|
|
1974
|
+
key: {
|
|
1975
|
+
type: "string",
|
|
1976
|
+
description: "Secret key name to look up"
|
|
1977
|
+
},
|
|
1978
|
+
configPath: {
|
|
1979
|
+
type: "string",
|
|
1980
|
+
description: "Optional path to envpkt.toml"
|
|
1981
|
+
}
|
|
1982
|
+
},
|
|
1983
|
+
required: ["key"]
|
|
1984
|
+
}
|
|
1985
|
+
},
|
|
1986
|
+
{
|
|
1987
|
+
name: "checkExpiration",
|
|
1988
|
+
description: "Check expiration status of a specific secret — returns days remaining and whether it needs rotation",
|
|
1989
|
+
inputSchema: {
|
|
1990
|
+
type: "object",
|
|
1991
|
+
properties: {
|
|
1992
|
+
key: {
|
|
1993
|
+
type: "string",
|
|
1994
|
+
description: "Secret key name to check"
|
|
1995
|
+
},
|
|
1996
|
+
configPath: {
|
|
1997
|
+
type: "string",
|
|
1998
|
+
description: "Optional path to envpkt.toml"
|
|
1999
|
+
}
|
|
2000
|
+
},
|
|
2001
|
+
required: ["key"]
|
|
2002
|
+
}
|
|
2003
|
+
}
|
|
2004
|
+
];
|
|
2005
|
+
const handleGetPacketHealth = (args) => {
|
|
2006
|
+
const loaded = loadConfigForTool(args.configPath);
|
|
2007
|
+
if (!loaded.ok) return loaded.result;
|
|
2008
|
+
const { config, path } = loaded;
|
|
2009
|
+
const audit = computeAudit(config);
|
|
2010
|
+
const secretDetails = audit.secrets.toArray().map((s) => ({
|
|
2011
|
+
key: s.key,
|
|
2012
|
+
service: s.service.fold(() => null, (sv) => sv),
|
|
2013
|
+
status: s.status,
|
|
2014
|
+
days_remaining: s.days_remaining.fold(() => null, (d) => d),
|
|
2015
|
+
rotation_url: s.rotation_url.fold(() => null, (u) => u),
|
|
2016
|
+
issues: s.issues.toArray()
|
|
2017
|
+
}));
|
|
2018
|
+
return textResult(JSON.stringify({
|
|
2019
|
+
path,
|
|
2020
|
+
status: audit.status,
|
|
2021
|
+
total: audit.total,
|
|
2022
|
+
healthy: audit.healthy,
|
|
2023
|
+
expiring_soon: audit.expiring_soon,
|
|
2024
|
+
expired: audit.expired,
|
|
2025
|
+
stale: audit.stale,
|
|
2026
|
+
missing: audit.missing,
|
|
2027
|
+
secrets: secretDetails
|
|
2028
|
+
}, null, 2));
|
|
2029
|
+
};
|
|
2030
|
+
const handleListCapabilities = (args) => {
|
|
2031
|
+
const loaded = loadConfigForTool(args.configPath);
|
|
2032
|
+
if (!loaded.ok) return loaded.result;
|
|
2033
|
+
const { config } = loaded;
|
|
2034
|
+
const agentCapabilities = config.agent?.capabilities ?? [];
|
|
2035
|
+
const secretCapabilities = {};
|
|
2036
|
+
for (const [key, meta] of Object.entries(config.meta)) if (meta.capabilities && meta.capabilities.length > 0) secretCapabilities[key] = meta.capabilities;
|
|
2037
|
+
return textResult(JSON.stringify({
|
|
2038
|
+
agent: config.agent ? {
|
|
2039
|
+
name: config.agent.name,
|
|
2040
|
+
consumer: config.agent.consumer,
|
|
2041
|
+
description: config.agent.description,
|
|
2042
|
+
capabilities: agentCapabilities
|
|
2043
|
+
} : null,
|
|
2044
|
+
secrets: secretCapabilities
|
|
2045
|
+
}, null, 2));
|
|
2046
|
+
};
|
|
2047
|
+
const handleGetSecretMeta = (args) => {
|
|
2048
|
+
const key = args.key;
|
|
2049
|
+
if (!key) return errorResult("Missing required argument: key");
|
|
2050
|
+
const loaded = loadConfigForTool(args.configPath);
|
|
2051
|
+
if (!loaded.ok) return loaded.result;
|
|
2052
|
+
const { config } = loaded;
|
|
2053
|
+
const meta = config.meta[key];
|
|
2054
|
+
if (!meta) return errorResult(`Secret not found: ${key}`);
|
|
2055
|
+
return textResult(JSON.stringify({
|
|
2056
|
+
key,
|
|
2057
|
+
...meta
|
|
2058
|
+
}, null, 2));
|
|
2059
|
+
};
|
|
2060
|
+
const handleCheckExpiration = (args) => {
|
|
2061
|
+
const key = args.key;
|
|
2062
|
+
if (!key) return errorResult("Missing required argument: key");
|
|
2063
|
+
const loaded = loadConfigForTool(args.configPath);
|
|
2064
|
+
if (!loaded.ok) return loaded.result;
|
|
2065
|
+
const { config } = loaded;
|
|
2066
|
+
return computeAudit(config).secrets.find((s) => s.key === key).fold(() => errorResult(`Secret not found: ${key}`), (s) => textResult(JSON.stringify({
|
|
2067
|
+
key: s.key,
|
|
2068
|
+
status: s.status,
|
|
2069
|
+
days_remaining: s.days_remaining.fold(() => null, (d) => d),
|
|
2070
|
+
expires: s.expires.fold(() => null, (e) => e),
|
|
2071
|
+
rotation_url: s.rotation_url.fold(() => null, (u) => u),
|
|
2072
|
+
needs_rotation: s.status === "expired" || s.status === "expiring_soon",
|
|
2073
|
+
issues: s.issues.toArray()
|
|
2074
|
+
}, null, 2)));
|
|
2075
|
+
};
|
|
2076
|
+
const handlers = {
|
|
2077
|
+
getPacketHealth: handleGetPacketHealth,
|
|
2078
|
+
listCapabilities: handleListCapabilities,
|
|
2079
|
+
getSecretMeta: handleGetSecretMeta,
|
|
2080
|
+
checkExpiration: handleCheckExpiration
|
|
2081
|
+
};
|
|
2082
|
+
const callTool = (name, args) => {
|
|
2083
|
+
const handler = handlers[name];
|
|
2084
|
+
if (!handler) return errorResult(`Unknown tool: ${name}`);
|
|
2085
|
+
return handler(args);
|
|
2086
|
+
};
|
|
2087
|
+
|
|
2088
|
+
//#endregion
|
|
2089
|
+
//#region src/mcp/server.ts
|
|
2090
|
+
const createServer = () => {
|
|
2091
|
+
const server = new Server({
|
|
2092
|
+
name: "envpkt",
|
|
2093
|
+
version: "0.1.0"
|
|
2094
|
+
}, {
|
|
2095
|
+
capabilities: {
|
|
2096
|
+
tools: {},
|
|
2097
|
+
resources: {}
|
|
2098
|
+
},
|
|
2099
|
+
instructions: "envpkt provides credential lifecycle awareness for AI agents. Use tools to check health, capabilities, and secret metadata. No secret values are ever exposed."
|
|
2100
|
+
});
|
|
2101
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: toolDefinitions.map((t) => ({
|
|
2102
|
+
name: t.name,
|
|
2103
|
+
description: t.description,
|
|
2104
|
+
inputSchema: t.inputSchema
|
|
2105
|
+
})) }));
|
|
2106
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
2107
|
+
const { name, arguments: args } = request.params;
|
|
2108
|
+
return callTool(name, args ?? {});
|
|
2109
|
+
});
|
|
2110
|
+
server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: [...resourceDefinitions] }));
|
|
2111
|
+
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
|
2112
|
+
const { uri } = request.params;
|
|
2113
|
+
const result = readResource(uri);
|
|
2114
|
+
if (!result) return { contents: [{
|
|
2115
|
+
uri,
|
|
2116
|
+
mimeType: "text/plain",
|
|
2117
|
+
text: `Resource not found: ${uri}`
|
|
2118
|
+
}] };
|
|
2119
|
+
return result;
|
|
2120
|
+
});
|
|
2121
|
+
return server;
|
|
2122
|
+
};
|
|
2123
|
+
const startServer = async () => {
|
|
2124
|
+
const server = createServer();
|
|
2125
|
+
const transport = new StdioServerTransport();
|
|
2126
|
+
await server.connect(transport);
|
|
2127
|
+
};
|
|
2128
|
+
|
|
2129
|
+
//#endregion
|
|
2130
|
+
//#region src/cli/commands/mcp.ts
|
|
2131
|
+
const runMcp = (_options) => {
|
|
2132
|
+
startServer().catch((err) => {
|
|
2133
|
+
console.error("MCP server error:", err);
|
|
2134
|
+
process.exit(1);
|
|
2135
|
+
});
|
|
2136
|
+
};
|
|
2137
|
+
|
|
2138
|
+
//#endregion
|
|
2139
|
+
//#region src/cli/commands/resolve.ts
|
|
2140
|
+
const runResolve = (options) => {
|
|
2141
|
+
resolveConfigPath(options.config).fold((err) => {
|
|
2142
|
+
console.error(formatError(err));
|
|
2143
|
+
process.exit(2);
|
|
2144
|
+
}, (configPath) => {
|
|
2145
|
+
loadConfig(configPath).fold((err) => {
|
|
2146
|
+
console.error(formatError(err));
|
|
2147
|
+
process.exit(2);
|
|
2148
|
+
}, (config) => {
|
|
2149
|
+
resolveConfig(config, dirname(configPath)).fold((err) => {
|
|
2150
|
+
console.error(formatError(err));
|
|
2151
|
+
process.exit(2);
|
|
2152
|
+
}, (result) => {
|
|
2153
|
+
const outputFormat = options.format ?? "toml";
|
|
2154
|
+
let content;
|
|
2155
|
+
if (outputFormat === "json") content = JSON.stringify(result.config, null, 2) + "\n";
|
|
2156
|
+
else content = `# Generated by envpkt resolve — do not edit\n${stringify(result.config)}\n`;
|
|
2157
|
+
if (options.dryRun) {
|
|
2158
|
+
console.log(`${DIM}# Dry run — would write:${RESET}`);
|
|
2159
|
+
console.log(content);
|
|
2160
|
+
} else if (options.output) {
|
|
2161
|
+
writeFileSync(options.output, content, "utf-8");
|
|
2162
|
+
console.log(`${GREEN}✓${RESET} Resolved config written to ${BOLD}${options.output}${RESET}`);
|
|
2163
|
+
} else process.stdout.write(content);
|
|
2164
|
+
if (result.catalogPath) {
|
|
2165
|
+
const summaryTarget = options.output ? process.stdout : process.stderr;
|
|
2166
|
+
summaryTarget.write(`\n${CYAN}Catalog:${RESET} ${result.catalogPath}\n${GREEN}Merged:${RESET} ${result.merged.length} key(s)` + (result.overridden.length > 0 ? ` ${YELLOW}(${result.overridden.length} overridden: ${result.overridden.join(", ")})${RESET}` : "") + "\n");
|
|
2167
|
+
for (const w of result.warnings) summaryTarget.write(`${RED}Warning:${RESET} ${w}\n`);
|
|
2168
|
+
}
|
|
2169
|
+
});
|
|
2170
|
+
});
|
|
2171
|
+
});
|
|
2172
|
+
};
|
|
2173
|
+
|
|
2174
|
+
//#endregion
|
|
2175
|
+
//#region src/cli/commands/shell-hook.ts
|
|
2176
|
+
const ZSH_HOOK = `# envpkt shell hook — add to your .zshrc
|
|
2177
|
+
_envpkt_chpwd() {
|
|
2178
|
+
if [[ -f envpkt.toml ]]; then
|
|
2179
|
+
envpkt audit --format minimal 2>/dev/null
|
|
2180
|
+
fi
|
|
2181
|
+
}
|
|
2182
|
+
|
|
2183
|
+
if (( $+functions[add-zsh-hook] )); then
|
|
2184
|
+
autoload -Uz add-zsh-hook
|
|
2185
|
+
add-zsh-hook chpwd _envpkt_chpwd
|
|
2186
|
+
else
|
|
2187
|
+
autoload -Uz add-zsh-hook
|
|
2188
|
+
add-zsh-hook chpwd _envpkt_chpwd
|
|
2189
|
+
fi
|
|
2190
|
+
`;
|
|
2191
|
+
const BASH_HOOK = `# envpkt shell hook — add to your .bashrc
|
|
2192
|
+
_envpkt_prompt() {
|
|
2193
|
+
if [[ -f envpkt.toml ]]; then
|
|
2194
|
+
envpkt audit --format minimal 2>/dev/null
|
|
2195
|
+
fi
|
|
2196
|
+
}
|
|
2197
|
+
|
|
2198
|
+
if [[ ! "$PROMPT_COMMAND" == *"_envpkt_prompt"* ]]; then
|
|
2199
|
+
PROMPT_COMMAND="_envpkt_prompt;$PROMPT_COMMAND"
|
|
2200
|
+
fi
|
|
2201
|
+
`;
|
|
2202
|
+
const runShellHook = (shell) => {
|
|
2203
|
+
switch (shell) {
|
|
2204
|
+
case "zsh":
|
|
2205
|
+
console.log(ZSH_HOOK);
|
|
2206
|
+
break;
|
|
2207
|
+
case "bash":
|
|
2208
|
+
console.log(BASH_HOOK);
|
|
2209
|
+
break;
|
|
2210
|
+
default:
|
|
2211
|
+
console.error(`${RED}Error:${RESET} Unsupported shell: ${shell}. Use "zsh" or "bash".`);
|
|
2212
|
+
process.exit(1);
|
|
2213
|
+
}
|
|
2214
|
+
};
|
|
2215
|
+
|
|
2216
|
+
//#endregion
|
|
2217
|
+
//#region src/cli/index.ts
|
|
2218
|
+
const program = new Command();
|
|
2219
|
+
program.name("envpkt").description("Credential lifecycle and fleet management for AI agents").version("0.1.0");
|
|
2220
|
+
program.command("init").description("Initialize a new envpkt.toml in the current directory").option("--from-fnox [path]", "Scaffold from fnox.toml (optionally specify path)").option("--catalog <path>", "Path to shared secret catalog").option("--agent", "Include [agent] section").option("--name <name>", "Agent name (requires --agent)").option("--capabilities <caps>", "Comma-separated capabilities (requires --agent)").option("--expires <date>", "Agent credential expiration YYYY-MM-DD (requires --agent)").option("--force", "Overwrite existing envpkt.toml").action((options) => {
|
|
2221
|
+
runInit(process.cwd(), options);
|
|
2222
|
+
});
|
|
2223
|
+
program.command("audit").description("Audit credential health from envpkt.toml").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").action((options) => {
|
|
2224
|
+
runAudit(options);
|
|
2225
|
+
});
|
|
2226
|
+
program.command("fleet").description("Scan directory tree for envpkt.toml files and aggregate health").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) => {
|
|
2227
|
+
runFleet(options);
|
|
2228
|
+
});
|
|
2229
|
+
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) => {
|
|
2230
|
+
runInspect(options);
|
|
2231
|
+
});
|
|
2232
|
+
program.command("exec").description("Run pre-flight audit then execute a command with fnox-injected env").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) => {
|
|
2233
|
+
runExec(args, options);
|
|
2234
|
+
});
|
|
2235
|
+
program.command("resolve").description("Resolve catalog references and output a flat, self-contained config").option("-c, --config <path>", "Path to envpkt.toml").option("-o, --output <path>", "Write resolved config to file (default: stdout)").option("--format <format>", "Output format: toml | json", "toml").option("--dry-run", "Show what would be resolved without writing").action((options) => {
|
|
2236
|
+
runResolve(options);
|
|
2237
|
+
});
|
|
2238
|
+
program.command("mcp").description("Start the envpkt MCP server (stdio transport)").option("-c, --config <path>", "Path to envpkt.toml").action((options) => {
|
|
2239
|
+
runMcp(options);
|
|
2240
|
+
});
|
|
2241
|
+
const env = program.command("env").description("Discover and check credentials in your shell environment");
|
|
2242
|
+
env.command("scan").description("Auto-discover credentials from process.env and scaffold TOML entries").option("--format <format>", "Output format: table | json", "table").option("--write", "Write discovered credentials to envpkt.toml").option("--dry-run", "Preview TOML that would be written (implies --write)").option("--include-unknown", "Include vars where service could not be inferred").action((options) => {
|
|
2243
|
+
runEnvScan(options);
|
|
2244
|
+
});
|
|
2245
|
+
env.command("check").description("Bidirectional drift detection between envpkt.toml and live environment").option("-c, --config <path>", "Path to envpkt.toml").option("--format <format>", "Output format: table | json", "table").option("--strict", "Exit non-zero on any drift").action((options) => {
|
|
2246
|
+
runEnvCheck(options);
|
|
2247
|
+
});
|
|
2248
|
+
program.command("shell-hook").description("Output shell function for ambient credential warnings on cd").argument("<shell>", "Shell type: zsh | bash").action((shell) => {
|
|
2249
|
+
runShellHook(shell);
|
|
2250
|
+
});
|
|
2251
|
+
program.parse();
|
|
2252
|
+
|
|
2253
|
+
//#endregion
|
|
2254
|
+
export { };
|