@envseal/core 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 +201 -0
- package/dist/approvals.d.ts +13 -0
- package/dist/approvals.js +73 -0
- package/dist/audit.d.ts +42 -0
- package/dist/audit.js +37 -0
- package/dist/broker.d.ts +31 -0
- package/dist/broker.js +449 -0
- package/dist/exec.d.ts +18 -0
- package/dist/exec.js +148 -0
- package/dist/guard.d.ts +66 -0
- package/dist/guard.js +157 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +15 -0
- package/dist/manifest.d.ts +24 -0
- package/dist/manifest.js +165 -0
- package/dist/paths.d.ts +14 -0
- package/dist/paths.js +87 -0
- package/dist/presence.d.ts +20 -0
- package/dist/presence.js +58 -0
- package/dist/redact.d.ts +20 -0
- package/dist/redact.js +338 -0
- package/dist/sinks/cli-sink-base.d.ts +88 -0
- package/dist/sinks/cli-sink-base.js +217 -0
- package/dist/sinks/doppler.d.ts +45 -0
- package/dist/sinks/doppler.js +198 -0
- package/dist/sinks/dotenv.d.ts +57 -0
- package/dist/sinks/dotenv.js +407 -0
- package/dist/sinks/keychain.d.ts +21 -0
- package/dist/sinks/keychain.js +333 -0
- package/dist/sinks/onepassword.d.ts +58 -0
- package/dist/sinks/onepassword.js +183 -0
- package/dist/sinks/registry.d.ts +4 -0
- package/dist/sinks/registry.js +63 -0
- package/dist/sinks/sops.d.ts +54 -0
- package/dist/sinks/sops.js +254 -0
- package/dist/sinks/types.d.ts +10 -0
- package/dist/sinks/types.js +2 -0
- package/dist/sinks/vault.d.ts +41 -0
- package/dist/sinks/vault.js +156 -0
- package/dist/tickets.d.ts +49 -0
- package/dist/tickets.js +179 -0
- package/dist/validation-state.d.ts +33 -0
- package/dist/validation-state.js +48 -0
- package/dist/verify.d.ts +8 -0
- package/dist/verify.js +133 -0
- package/package.json +38 -0
package/dist/broker.js
ADDED
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
import { createHmac } from 'node:crypto';
|
|
2
|
+
import { SepError, isSepError, asSecret, zero } from '@envseal/protocol';
|
|
3
|
+
import { getProvider, findKey } from '@envseal/registry';
|
|
4
|
+
import { selectPrompter } from '@envseal/prompters';
|
|
5
|
+
import { projectPaths, loadOrCreateSalt } from './paths.js';
|
|
6
|
+
import { loadManifest, declareEntries, emptyManifest } from './manifest.js';
|
|
7
|
+
import { resolvePresence } from './presence.js';
|
|
8
|
+
import { TicketStore } from './tickets.js';
|
|
9
|
+
import { appendAudit } from './audit.js';
|
|
10
|
+
import { verifyKey } from './verify.js';
|
|
11
|
+
import { runWithSecrets } from './exec.js';
|
|
12
|
+
import { getSink } from './sinks/registry.js';
|
|
13
|
+
import { getValidation, recordValidation } from './validation-state.js';
|
|
14
|
+
import { scanText, secretInRequestError } from './guard.js';
|
|
15
|
+
const LENGTH_BUCKETS = ['<8', '8-16', '16-32', '32-48', '48-64', '64-128', '128+'];
|
|
16
|
+
function getLengthBucket(length) {
|
|
17
|
+
if (length < 8)
|
|
18
|
+
return '<8';
|
|
19
|
+
if (length < 16)
|
|
20
|
+
return '8-16';
|
|
21
|
+
if (length < 32)
|
|
22
|
+
return '16-32';
|
|
23
|
+
if (length < 48)
|
|
24
|
+
return '32-48';
|
|
25
|
+
if (length < 64)
|
|
26
|
+
return '48-64';
|
|
27
|
+
if (length < 128)
|
|
28
|
+
return '64-128';
|
|
29
|
+
return '128+';
|
|
30
|
+
}
|
|
31
|
+
function computeFingerprint(value, salt) {
|
|
32
|
+
const hmac = createHmac('sha256', salt);
|
|
33
|
+
hmac.update(value);
|
|
34
|
+
const digest = hmac.digest('hex');
|
|
35
|
+
return `fp_${digest.slice(0, 8)}`;
|
|
36
|
+
}
|
|
37
|
+
export class Broker {
|
|
38
|
+
paths;
|
|
39
|
+
prompter;
|
|
40
|
+
prompterPromise;
|
|
41
|
+
ticketStore;
|
|
42
|
+
onConfirm;
|
|
43
|
+
onApprovalNeeded;
|
|
44
|
+
salt;
|
|
45
|
+
constructor(opts) {
|
|
46
|
+
this.paths = projectPaths(opts.root);
|
|
47
|
+
this.ticketStore = new TicketStore();
|
|
48
|
+
this.onConfirm = opts.onConfirm;
|
|
49
|
+
this.onApprovalNeeded = opts.onApprovalNeeded;
|
|
50
|
+
this.salt = loadOrCreateSalt(this.paths);
|
|
51
|
+
this.prompter = opts.prompter ?? null;
|
|
52
|
+
this.prompterPromise = opts.prompter ? null : selectPrompter();
|
|
53
|
+
}
|
|
54
|
+
async getPrompter() {
|
|
55
|
+
if (this.prompter) {
|
|
56
|
+
return this.prompter;
|
|
57
|
+
}
|
|
58
|
+
if (this.prompterPromise) {
|
|
59
|
+
this.prompter = await this.prompterPromise;
|
|
60
|
+
return this.prompter;
|
|
61
|
+
}
|
|
62
|
+
throw new Error('Prompter not available');
|
|
63
|
+
}
|
|
64
|
+
async describe() {
|
|
65
|
+
const manifest = loadManifest(this.paths) ?? emptyManifest();
|
|
66
|
+
// Sink-aware: a keychain-declared entry is only resolvable through its
|
|
67
|
+
// sink, so presence must consult it or status would report present:false
|
|
68
|
+
// forever (the write-only era bug).
|
|
69
|
+
const presence = await resolvePresence(this.paths, manifest.entries.map((e) => e.key), { sinks: new Map(manifest.entries.map((e) => [e.key, e.sink ?? 'dotenv'])) });
|
|
70
|
+
const entries = [];
|
|
71
|
+
const missingRequired = [];
|
|
72
|
+
for (const entry of manifest.entries) {
|
|
73
|
+
const presenceInfo = presence.get(entry.key);
|
|
74
|
+
const present = presenceInfo?.present ?? false;
|
|
75
|
+
const value = presenceInfo?.value ?? null;
|
|
76
|
+
const lengthBucket = value ? getLengthBucket(value.length) : '<8';
|
|
77
|
+
const fingerprint = value ? computeFingerprint(value, this.salt) : 'unknown';
|
|
78
|
+
// NEVER evaluate the manifest's format.pattern against the live value
|
|
79
|
+
// here. That pattern is model-supplied via env_declare, so compiling it
|
|
80
|
+
// against the secret and returning the boolean is an unlimited
|
|
81
|
+
// chosen-predicate oracle — enough to reconstruct the value in a few
|
|
82
|
+
// hundred calls. See validation-state.ts.
|
|
83
|
+
//
|
|
84
|
+
// Report the outcome recorded when the value was stored. If we have no
|
|
85
|
+
// record for THIS value (e.g. it predates envseal, or was written by
|
|
86
|
+
// hand), fall back to the registry's pattern, which is bundled data the
|
|
87
|
+
// model cannot influence — and otherwise report unknown.
|
|
88
|
+
let formatValid = null;
|
|
89
|
+
if (present && value) {
|
|
90
|
+
formatValid = getValidation(this.paths, entry.key, fingerprint);
|
|
91
|
+
if (formatValid === null) {
|
|
92
|
+
const trusted = findKey(entry.key)?.key.format?.pattern;
|
|
93
|
+
if (trusted !== undefined) {
|
|
94
|
+
formatValid = new RegExp(trusted).test(value.toString('utf8'));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const status = {
|
|
99
|
+
key: entry.key,
|
|
100
|
+
declared: true,
|
|
101
|
+
present,
|
|
102
|
+
sink: entry.sink ?? 'dotenv',
|
|
103
|
+
formatValid,
|
|
104
|
+
lengthBucket,
|
|
105
|
+
fingerprint,
|
|
106
|
+
lastVerified: null,
|
|
107
|
+
verifyResult: null,
|
|
108
|
+
source: 'user-prompt',
|
|
109
|
+
rotationDue: null,
|
|
110
|
+
};
|
|
111
|
+
entries.push(status);
|
|
112
|
+
if (entry.required && !present) {
|
|
113
|
+
missingRequired.push(entry.key);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
projectRoot: this.paths.root,
|
|
118
|
+
manifestPath: this.paths.manifest,
|
|
119
|
+
entries,
|
|
120
|
+
missingRequired,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
async declare(input) {
|
|
124
|
+
const manifest = loadManifest(this.paths) ?? emptyManifest();
|
|
125
|
+
const withDefaults = input.entries.map((entry) => {
|
|
126
|
+
if (entry.format || entry.provider || entry.verify) {
|
|
127
|
+
return entry;
|
|
128
|
+
}
|
|
129
|
+
// Look up by ENV VAR NAME, not provider id. `getProvider('OPENAI_API_KEY')`
|
|
130
|
+
// never matches — provider ids are 'openai', 'stripe', … — so this path
|
|
131
|
+
// silently filled in nothing, leaving a model-declared key with no format
|
|
132
|
+
// validation, no signup link in the prompt, and no verify probe.
|
|
133
|
+
const found = findKey(entry.key);
|
|
134
|
+
if (!found) {
|
|
135
|
+
return entry;
|
|
136
|
+
}
|
|
137
|
+
const { provider: registryEntry, key } = found;
|
|
138
|
+
return {
|
|
139
|
+
...entry,
|
|
140
|
+
format: entry.format ?? key.format,
|
|
141
|
+
provider: entry.provider ??
|
|
142
|
+
{
|
|
143
|
+
id: registryEntry.id,
|
|
144
|
+
name: registryEntry.name,
|
|
145
|
+
signupUrl: key.signupUrl,
|
|
146
|
+
docsUrl: key.docsUrl,
|
|
147
|
+
rotateUrl: key.rotateUrl,
|
|
148
|
+
},
|
|
149
|
+
verify: entry.verify ?? key.verify,
|
|
150
|
+
};
|
|
151
|
+
});
|
|
152
|
+
return declareEntries(this.paths, withDefaults);
|
|
153
|
+
}
|
|
154
|
+
async request(input) {
|
|
155
|
+
// Before the manifest is even read: `reason` is free text that goes
|
|
156
|
+
// verbatim into .envseal/audit.jsonl, which §4.1 says holds names only. A
|
|
157
|
+
// credential pasted there must not mint a ticket, reach the prompter, or
|
|
158
|
+
// appear in a log line — so nothing above this point may have a side effect.
|
|
159
|
+
// `keys` is not scanned here; every key has already been through the
|
|
160
|
+
// declare-time guard, and an undeclared one throws below.
|
|
161
|
+
const reasonFinding = scanText('reason', input.reason, 'strict');
|
|
162
|
+
if (reasonFinding !== null) {
|
|
163
|
+
appendAudit(this.paths, {
|
|
164
|
+
type: 'blocked',
|
|
165
|
+
reason: 'secret_in_request',
|
|
166
|
+
detail: `${reasonFinding.path}: ${reasonFinding.label}`,
|
|
167
|
+
});
|
|
168
|
+
throw secretInRequestError(reasonFinding);
|
|
169
|
+
}
|
|
170
|
+
const manifest = loadManifest(this.paths) ?? emptyManifest();
|
|
171
|
+
const declaredKeys = new Set(manifest.entries.map((e) => e.key));
|
|
172
|
+
for (const key of input.keys) {
|
|
173
|
+
if (!declaredKeys.has(key)) {
|
|
174
|
+
throw new SepError({
|
|
175
|
+
code: 'SEP_NOT_DECLARED',
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
// Report the surface actually selected. Hardcoding 'loopback-browser' told
|
|
180
|
+
// the caller a browser window had opened even when the resolved prompter was
|
|
181
|
+
// `none` (CI) or a native dialog — so the model relayed instructions to the
|
|
182
|
+
// user for a prompt that did not exist.
|
|
183
|
+
const prompter = await this.getPrompter();
|
|
184
|
+
const surface = prompter.id;
|
|
185
|
+
// Refuse before minting a ticket when there is no way to ask a human.
|
|
186
|
+
// Previously the `none` prompter threw inside startPrompt(), where a
|
|
187
|
+
// catch-all turned it into `cancelled` — so in CI the model was told the
|
|
188
|
+
// USER had declined, and the documented exit code 4 was unreachable.
|
|
189
|
+
if (surface === 'none') {
|
|
190
|
+
throw new SepError({
|
|
191
|
+
code: 'SEP_NO_INTERACTIVE_SURFACE',
|
|
192
|
+
userMessage: `No interactive surface is available to collect ${input.keys.join(', ')}. ` +
|
|
193
|
+
'Provide these values out of band (CI secret store, keychain, or a pre-populated .env), ' +
|
|
194
|
+
'or run in an environment with a browser or terminal.',
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
const ticket = this.ticketStore.create({
|
|
198
|
+
keys: input.keys,
|
|
199
|
+
reason: input.reason,
|
|
200
|
+
surface,
|
|
201
|
+
ttlMs: 600000,
|
|
202
|
+
});
|
|
203
|
+
appendAudit(this.paths, {
|
|
204
|
+
type: 'request',
|
|
205
|
+
ticket: ticket.ticket,
|
|
206
|
+
keys: input.keys,
|
|
207
|
+
reason: input.reason,
|
|
208
|
+
surface: ticket.surface,
|
|
209
|
+
});
|
|
210
|
+
this.startPrompt(ticket.ticket, input.keys, input.reason).catch(() => { });
|
|
211
|
+
return {
|
|
212
|
+
ticket: ticket.ticket,
|
|
213
|
+
nonce: ticket.nonce,
|
|
214
|
+
// Narrow rather than cast: the ticket stores the surface as a plain
|
|
215
|
+
// string, but the protocol type is a union. An `as any` here would hide a
|
|
216
|
+
// typo in a surface name until it reached a client.
|
|
217
|
+
surface: ticket.surface,
|
|
218
|
+
expiresAt: new Date(ticket.expiresAt).toISOString(),
|
|
219
|
+
// No 'none' branch: request() throws SEP_NO_INTERACTIVE_SURFACE before
|
|
220
|
+
// reaching here, so a ticket always corresponds to a real prompt.
|
|
221
|
+
userMessage: surface === 'loopback-browser'
|
|
222
|
+
? `A browser window has opened to collect ${input.keys.join(', ')}. Verify it shows code ${ticket.nonce} before typing anything.`
|
|
223
|
+
: `A prompt has opened to collect ${input.keys.join(', ')}. Verify it shows code ${ticket.nonce}.`,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
async startPrompt(ticketId, keys, reason) {
|
|
227
|
+
const manifest = loadManifest(this.paths) ?? emptyManifest();
|
|
228
|
+
const ticket = this.ticketStore.get(ticketId);
|
|
229
|
+
if (!ticket)
|
|
230
|
+
return;
|
|
231
|
+
const keyPrompts = keys.map((keyName) => {
|
|
232
|
+
const entry = manifest.entries.find((e) => e.key === keyName);
|
|
233
|
+
if (!entry) {
|
|
234
|
+
return {
|
|
235
|
+
key: keyName,
|
|
236
|
+
description: '',
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
return {
|
|
240
|
+
key: keyName,
|
|
241
|
+
description: entry.description,
|
|
242
|
+
providerName: entry.provider?.name,
|
|
243
|
+
signupUrl: entry.provider?.signupUrl,
|
|
244
|
+
docsUrl: entry.provider?.docsUrl,
|
|
245
|
+
formatHint: entry.format?.example,
|
|
246
|
+
pattern: entry.format?.pattern,
|
|
247
|
+
optional: !entry.required,
|
|
248
|
+
};
|
|
249
|
+
});
|
|
250
|
+
const prompter = await this.getPrompter();
|
|
251
|
+
const promptReq = {
|
|
252
|
+
ticket: ticketId,
|
|
253
|
+
nonce: ticket.nonce,
|
|
254
|
+
projectRoot: this.paths.root,
|
|
255
|
+
reason,
|
|
256
|
+
keys: keyPrompts,
|
|
257
|
+
timeoutMs: 600000,
|
|
258
|
+
};
|
|
259
|
+
try {
|
|
260
|
+
const response = await prompter.prompt(promptReq);
|
|
261
|
+
for (const result of response.results) {
|
|
262
|
+
const entry = manifest.entries.find((e) => e.key === result.key);
|
|
263
|
+
if (!entry) {
|
|
264
|
+
// The value-handling region starts here, not at the sink: a result
|
|
265
|
+
// for a key that is no longer in the manifest is dropped without a
|
|
266
|
+
// sink write, and its buffer must not survive that drop either.
|
|
267
|
+
if (result.outcome === 'entered') {
|
|
268
|
+
zero(result.value);
|
|
269
|
+
}
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
if (result.outcome === 'entered') {
|
|
273
|
+
if (entry.format?.pattern) {
|
|
274
|
+
const pattern = new RegExp(entry.format.pattern);
|
|
275
|
+
const valueStr = result.value.toString('utf8');
|
|
276
|
+
if (!pattern.test(valueStr)) {
|
|
277
|
+
this.ticketStore.setOutcome(ticketId, result.key, 'invalid_format');
|
|
278
|
+
appendAudit(this.paths, {
|
|
279
|
+
type: 'skipped',
|
|
280
|
+
ticket: ticketId,
|
|
281
|
+
key: result.key,
|
|
282
|
+
});
|
|
283
|
+
zero(result.value);
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
// Every exit from this region — including the sink write throwing
|
|
288
|
+
// SEP_SINK_WRITE_FAILED — must zero the entered value. The catch
|
|
289
|
+
// below records the failure but used to leave the buffer live in
|
|
290
|
+
// the heap.
|
|
291
|
+
try {
|
|
292
|
+
const sink = getSink(entry.sink ?? 'dotenv');
|
|
293
|
+
await sink.write(this.paths, result.key, result.value);
|
|
294
|
+
const fingerprint = computeFingerprint(result.value, this.salt);
|
|
295
|
+
// Record the outcome now, while we legitimately hold the value. This
|
|
296
|
+
// is the only place format validation touches a secret; env_describe
|
|
297
|
+
// afterwards reports THIS result rather than re-testing a pattern the
|
|
298
|
+
// model may have changed in the meantime.
|
|
299
|
+
recordValidation(this.paths, result.key, fingerprint, true);
|
|
300
|
+
this.ticketStore.setOutcome(ticketId, result.key, 'stored');
|
|
301
|
+
appendAudit(this.paths, {
|
|
302
|
+
type: 'stored',
|
|
303
|
+
ticket: ticketId,
|
|
304
|
+
key: result.key,
|
|
305
|
+
sink: sink.id,
|
|
306
|
+
fingerprint,
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
finally {
|
|
310
|
+
zero(result.value);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
else if (result.outcome === 'skipped') {
|
|
314
|
+
this.ticketStore.setOutcome(ticketId, result.key, 'skipped');
|
|
315
|
+
appendAudit(this.paths, {
|
|
316
|
+
type: 'skipped',
|
|
317
|
+
ticket: ticketId,
|
|
318
|
+
key: result.key,
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
else if (result.outcome === 'cancelled') {
|
|
322
|
+
this.ticketStore.setOutcome(ticketId, result.key, 'cancelled');
|
|
323
|
+
appendAudit(this.paths, {
|
|
324
|
+
type: 'cancelled',
|
|
325
|
+
ticket: ticketId,
|
|
326
|
+
key: result.key,
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
else if (result.outcome === 'timeout') {
|
|
330
|
+
this.ticketStore.setOutcome(ticketId, result.key, 'timeout');
|
|
331
|
+
appendAudit(this.paths, {
|
|
332
|
+
type: 'timeout',
|
|
333
|
+
ticket: ticketId,
|
|
334
|
+
key: result.key,
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
this.ticketStore.resolve(ticketId);
|
|
339
|
+
}
|
|
340
|
+
catch (error) {
|
|
341
|
+
// The prompt surface failed rather than the user declining. Record why,
|
|
342
|
+
// so the distinction survives into the audit log even though the ticket
|
|
343
|
+
// state cannot express it.
|
|
344
|
+
appendAudit(this.paths, {
|
|
345
|
+
type: 'blocked',
|
|
346
|
+
reason: 'prompt_failed',
|
|
347
|
+
detail: isSepError(error) ? error.code : 'unknown_prompter_error',
|
|
348
|
+
});
|
|
349
|
+
this.ticketStore.cancel(ticketId);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
async await(input) {
|
|
353
|
+
return this.ticketStore.await(input.ticket, input.timeoutMs ?? 90000);
|
|
354
|
+
}
|
|
355
|
+
async verify(input) {
|
|
356
|
+
const manifest = loadManifest(this.paths) ?? emptyManifest();
|
|
357
|
+
const results = [];
|
|
358
|
+
for (const keyName of input.keys) {
|
|
359
|
+
const entry = manifest.entries.find((e) => e.key === keyName);
|
|
360
|
+
if (!entry) {
|
|
361
|
+
results.push({
|
|
362
|
+
key: keyName,
|
|
363
|
+
result: 'no_probe',
|
|
364
|
+
message: 'Key not found in manifest',
|
|
365
|
+
checkedAt: new Date().toISOString(),
|
|
366
|
+
});
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
const sink = getSink(entry.sink ?? 'dotenv');
|
|
370
|
+
const value = await sink.read(this.paths, keyName);
|
|
371
|
+
if (!value) {
|
|
372
|
+
results.push({
|
|
373
|
+
key: keyName,
|
|
374
|
+
result: 'no_probe',
|
|
375
|
+
message: 'Key not stored',
|
|
376
|
+
checkedAt: new Date().toISOString(),
|
|
377
|
+
});
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
const result = await verifyKey(this.paths, entry, value, {
|
|
381
|
+
onApprovalNeeded: this.onApprovalNeeded,
|
|
382
|
+
});
|
|
383
|
+
results.push(result);
|
|
384
|
+
zero(value);
|
|
385
|
+
}
|
|
386
|
+
return results;
|
|
387
|
+
}
|
|
388
|
+
async use(input) {
|
|
389
|
+
const manifest = loadManifest(this.paths) ?? emptyManifest();
|
|
390
|
+
const secrets = new Map();
|
|
391
|
+
for (const keyName of input.keys) {
|
|
392
|
+
const entry = manifest.entries.find((e) => e.key === keyName);
|
|
393
|
+
if (!entry)
|
|
394
|
+
continue;
|
|
395
|
+
const sink = getSink(entry.sink ?? 'dotenv');
|
|
396
|
+
const value = await sink.read(this.paths, keyName);
|
|
397
|
+
if (value) {
|
|
398
|
+
secrets.set(keyName, value);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
const result = await runWithSecrets(input.command, secrets, {
|
|
402
|
+
onConfirm: this.onConfirm,
|
|
403
|
+
});
|
|
404
|
+
for (const value of secrets.values()) {
|
|
405
|
+
zero(value);
|
|
406
|
+
}
|
|
407
|
+
return result;
|
|
408
|
+
}
|
|
409
|
+
async revoke(input) {
|
|
410
|
+
const manifest = loadManifest(this.paths) ?? emptyManifest();
|
|
411
|
+
const results = [];
|
|
412
|
+
for (const keyName of input.keys) {
|
|
413
|
+
const entry = manifest.entries.find((e) => e.key === keyName);
|
|
414
|
+
if (!entry)
|
|
415
|
+
continue;
|
|
416
|
+
const sink = getSink(entry.sink ?? 'dotenv');
|
|
417
|
+
const removed = await sink.remove(this.paths, keyName);
|
|
418
|
+
// Fall back to the registry. A manifest entry commonly carries only
|
|
419
|
+
// `provider.id` — a model declaring a key has no reason to type out the
|
|
420
|
+
// rotation URL — and returning null there defeats the field's whole
|
|
421
|
+
// purpose, which is telling the user where to invalidate a burned key.
|
|
422
|
+
let rotateUrl = entry.provider?.rotateUrl ?? null;
|
|
423
|
+
if (rotateUrl === null && entry.provider?.id !== undefined) {
|
|
424
|
+
rotateUrl = getProvider(entry.provider.id)?.keys.find((k) => k.envVar === keyName)
|
|
425
|
+
?.rotateUrl
|
|
426
|
+
?? getProvider(entry.provider.id)?.keys[0]?.rotateUrl
|
|
427
|
+
?? null;
|
|
428
|
+
}
|
|
429
|
+
if (rotateUrl === null) {
|
|
430
|
+
rotateUrl = findKey(keyName)?.key.rotateUrl ?? null;
|
|
431
|
+
}
|
|
432
|
+
results.push({
|
|
433
|
+
key: keyName,
|
|
434
|
+
removed,
|
|
435
|
+
rotateUrl,
|
|
436
|
+
});
|
|
437
|
+
appendAudit(this.paths, {
|
|
438
|
+
type: 'revoke',
|
|
439
|
+
key: keyName,
|
|
440
|
+
sink: sink.id,
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
return results;
|
|
444
|
+
}
|
|
445
|
+
dispose() {
|
|
446
|
+
this.ticketStore.dispose();
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
//# sourceMappingURL=broker.js.map
|
package/dist/exec.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { SecretValue, ExecResult } from '@envseal/protocol';
|
|
2
|
+
/**
|
|
3
|
+
* Residual risk on Linux: A same-uid process can read /proc/<pid>/environ
|
|
4
|
+
* of the child process. This cannot be defended against without sandboxing.
|
|
5
|
+
* Users on shared systems should be aware of this limitation.
|
|
6
|
+
*/
|
|
7
|
+
export interface ExecOptions {
|
|
8
|
+
cwd?: string;
|
|
9
|
+
timeoutMs?: number;
|
|
10
|
+
onConfirm?: (info: {
|
|
11
|
+
command: string[];
|
|
12
|
+
keys: string[];
|
|
13
|
+
networkEgress: boolean;
|
|
14
|
+
}) => Promise<boolean>;
|
|
15
|
+
approvedCommands?: string[];
|
|
16
|
+
}
|
|
17
|
+
export declare function runWithSecrets(command: string[], secrets: Map<string, SecretValue>, opts?: ExecOptions): Promise<ExecResult>;
|
|
18
|
+
//# sourceMappingURL=exec.d.ts.map
|
package/dist/exec.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { SepError } from '@envseal/protocol';
|
|
3
|
+
import { redact } from './redact.js';
|
|
4
|
+
import { unsafeSecretToUtf8 } from './sinks/dotenv.js';
|
|
5
|
+
const NETWORK_TOOLS = new Set([
|
|
6
|
+
'curl',
|
|
7
|
+
'wget',
|
|
8
|
+
'nc',
|
|
9
|
+
'ncat',
|
|
10
|
+
'netcat',
|
|
11
|
+
'ssh',
|
|
12
|
+
'scp',
|
|
13
|
+
'rsync',
|
|
14
|
+
'http',
|
|
15
|
+
'httpie',
|
|
16
|
+
'telnet',
|
|
17
|
+
'socat',
|
|
18
|
+
]);
|
|
19
|
+
function detectNetworkEgress(command) {
|
|
20
|
+
if (command.length === 0) {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
const basename = command[0].split(/[\\/]/).pop()?.toLowerCase() ?? '';
|
|
24
|
+
if (NETWORK_TOOLS.has(basename)) {
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
for (const arg of command) {
|
|
28
|
+
if (/^https?:\/\//.test(arg)) {
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
export async function runWithSecrets(command, secrets, opts) {
|
|
35
|
+
if (command.length === 0) {
|
|
36
|
+
throw new SepError({
|
|
37
|
+
code: 'SEP_FORMAT_INVALID',
|
|
38
|
+
userMessage: 'Command cannot be empty',
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
const networkEgress = detectNetworkEgress(command);
|
|
42
|
+
const secretKeys = Array.from(secrets.keys());
|
|
43
|
+
const joinedCommand = command.join(' ');
|
|
44
|
+
const isApproved = opts?.approvedCommands?.some((approved) => approved === joinedCommand);
|
|
45
|
+
if (!isApproved && opts?.onConfirm) {
|
|
46
|
+
const confirmed = await opts.onConfirm({
|
|
47
|
+
command,
|
|
48
|
+
keys: secretKeys,
|
|
49
|
+
networkEgress,
|
|
50
|
+
});
|
|
51
|
+
if (!confirmed) {
|
|
52
|
+
throw new SepError({
|
|
53
|
+
code: 'SEP_CONFIRMATION_DENIED',
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
else if (!isApproved && !opts?.onConfirm) {
|
|
58
|
+
throw new SepError({
|
|
59
|
+
code: 'SEP_CONFIRMATION_DENIED',
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
const childEnv = { ...process.env };
|
|
63
|
+
const secretValues = [];
|
|
64
|
+
// W2-F31: docs/cli-contract.md §"redaction" promises masks read
|
|
65
|
+
// «redacted:KEY_NAME». Nothing but the key name rides along — redact()
|
|
66
|
+
// rejects a label that is not a plain identifier, so a label can never carry
|
|
67
|
+
// markup or a value fragment into the output stream.
|
|
68
|
+
const redactionLabels = new Map();
|
|
69
|
+
for (const [key, value] of secrets) {
|
|
70
|
+
const valueStr = unsafeSecretToUtf8(value);
|
|
71
|
+
childEnv[key] = valueStr;
|
|
72
|
+
secretValues.push(value);
|
|
73
|
+
redactionLabels.set(value, key);
|
|
74
|
+
}
|
|
75
|
+
const MAX_BUFFER = 1024 * 1024;
|
|
76
|
+
const proc = spawn(command[0], command.slice(1), {
|
|
77
|
+
cwd: opts?.cwd,
|
|
78
|
+
env: childEnv,
|
|
79
|
+
shell: false,
|
|
80
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
81
|
+
});
|
|
82
|
+
let stdout = Buffer.alloc(0);
|
|
83
|
+
let stderr = Buffer.alloc(0);
|
|
84
|
+
let timedOut = false;
|
|
85
|
+
let exitCode = null;
|
|
86
|
+
const stdoutListener = (chunk) => {
|
|
87
|
+
if (stdout.length < MAX_BUFFER) {
|
|
88
|
+
stdout = Buffer.concat([stdout, chunk], Math.min(stdout.length + chunk.length, MAX_BUFFER));
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
const stderrListener = (chunk) => {
|
|
92
|
+
if (stderr.length < MAX_BUFFER) {
|
|
93
|
+
stderr = Buffer.concat([stderr, chunk], Math.min(stderr.length + chunk.length, MAX_BUFFER));
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
proc.stdout?.on('data', stdoutListener);
|
|
97
|
+
proc.stderr?.on('data', stderrListener);
|
|
98
|
+
return new Promise((resolve, reject) => {
|
|
99
|
+
let timeoutHandle;
|
|
100
|
+
const cleanup = () => {
|
|
101
|
+
if (timeoutHandle !== undefined) {
|
|
102
|
+
clearTimeout(timeoutHandle);
|
|
103
|
+
}
|
|
104
|
+
proc.stdout?.removeListener('data', stdoutListener);
|
|
105
|
+
proc.stderr?.removeListener('data', stderrListener);
|
|
106
|
+
};
|
|
107
|
+
const finish = (code) => {
|
|
108
|
+
cleanup();
|
|
109
|
+
exitCode = code;
|
|
110
|
+
const stdoutStr = stdout.toString('utf8');
|
|
111
|
+
const stderrStr = stderr.toString('utf8');
|
|
112
|
+
const redactStdout = redact(stdoutStr, secretValues, redactionLabels);
|
|
113
|
+
const redactStderr = redact(stderrStr, secretValues, redactionLabels);
|
|
114
|
+
resolve({
|
|
115
|
+
exitCode,
|
|
116
|
+
stdout: redactStdout.text,
|
|
117
|
+
stderr: redactStderr.text,
|
|
118
|
+
timedOut,
|
|
119
|
+
redactedCount: redactStdout.count + redactStderr.count,
|
|
120
|
+
});
|
|
121
|
+
};
|
|
122
|
+
proc.on('exit', (code) => {
|
|
123
|
+
if (!timedOut) {
|
|
124
|
+
finish(code);
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
proc.on('error', (err) => {
|
|
128
|
+
cleanup();
|
|
129
|
+
reject(err);
|
|
130
|
+
});
|
|
131
|
+
if (opts?.timeoutMs && opts.timeoutMs > 0) {
|
|
132
|
+
timeoutHandle = setTimeout(() => {
|
|
133
|
+
if (proc.exitCode === null) {
|
|
134
|
+
timedOut = true;
|
|
135
|
+
proc.kill('SIGTERM');
|
|
136
|
+
setTimeout(() => {
|
|
137
|
+
if (proc.exitCode === null) {
|
|
138
|
+
proc.kill('SIGKILL');
|
|
139
|
+
}
|
|
140
|
+
finish(null);
|
|
141
|
+
}, 1000);
|
|
142
|
+
}
|
|
143
|
+
}, opts.timeoutMs);
|
|
144
|
+
timeoutHandle.unref();
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
//# sourceMappingURL=exec.js.map
|
package/dist/guard.d.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { Detection } from '@envseal/detector';
|
|
2
|
+
import { SepError } from '@envseal/protocol';
|
|
3
|
+
import type { ManifestEntry } from '@envseal/protocol';
|
|
4
|
+
/**
|
|
5
|
+
* Secret-shaped-input guard for the model-facing free-text boundary (PLAN §2.2 T3).
|
|
6
|
+
*
|
|
7
|
+
* `.strict()` on the protocol schemas rejects a literal `value` field, which is
|
|
8
|
+
* the only leak an honest caller commits by accident. It does nothing about the
|
|
9
|
+
* fields that are SUPPOSED to hold prose: a credential pasted into an
|
|
10
|
+
* `env_declare` description or a `format.example` was written verbatim into
|
|
11
|
+
* `env.schema.jsonc` — a file §6.1 commits to git — and one pasted into an
|
|
12
|
+
* `env_request` reason was written verbatim into `.envseal/audit.jsonl`, which
|
|
13
|
+
* §4.1 says holds "names only, no values". `@envseal/detector` existed for
|
|
14
|
+
* exactly this and had no consumer inside the broker.
|
|
15
|
+
*
|
|
16
|
+
* Nothing here ever echoes the matched text. A finding carries the field path
|
|
17
|
+
* and the detector's pattern label, both of which are safe to put in an error
|
|
18
|
+
* message and in the audit log; the value itself never leaves this module.
|
|
19
|
+
*/
|
|
20
|
+
/**
|
|
21
|
+
* Which detector confidences reject.
|
|
22
|
+
*
|
|
23
|
+
* `high` alone is not enough. Measured over 17 structurally realistic
|
|
24
|
+
* credentials with random bodies, a high-only threshold let three through —
|
|
25
|
+
* `wJalrXUtnFEMI7K2MDENGbPxRfiCYT7qLm2Xp9Rv` (AWS secret access key shape), a
|
|
26
|
+
* bare 56-char mixed-case token, and a base64 blob. Those have no vendor prefix
|
|
27
|
+
* to pattern-match, so they are reachable ONLY through the generic
|
|
28
|
+
* entropy heuristic, which is always `medium`. They are also the single most
|
|
29
|
+
* common shape of self-hosted credential.
|
|
30
|
+
*
|
|
31
|
+
* The medium tier is affordable here: over the detector's own 100-line
|
|
32
|
+
* false-positive corpus it fires zero times, and over 393 strings from the
|
|
33
|
+
* bundled provider registry it fires zero times (see `strict` below for why
|
|
34
|
+
* that number depends on the placeholder filter).
|
|
35
|
+
*/
|
|
36
|
+
export type GuardTier = 'strict' | 'high-only';
|
|
37
|
+
export interface SecretFinding {
|
|
38
|
+
/** Dotted path of the offending field, e.g. `entries[0].format.example`. */
|
|
39
|
+
path: string;
|
|
40
|
+
/** The detector's pattern label. Describes the SHAPE, never the text. */
|
|
41
|
+
label: string;
|
|
42
|
+
confidence: Detection['confidence'];
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The first credential-shaped span in `text`, or null. The returned finding
|
|
46
|
+
* deliberately carries no offsets and no excerpt — an offset pair plus a
|
|
47
|
+
* repeatable call is itself a substring oracle over the input.
|
|
48
|
+
*/
|
|
49
|
+
export declare function scanText(path: string, text: string, tier: GuardTier): SecretFinding | null;
|
|
50
|
+
/**
|
|
51
|
+
* Every free-text string on a parsed entry that reaches `env.schema.jsonc`.
|
|
52
|
+
*
|
|
53
|
+
* `key` is scanned at `high-only`, alone among the fields. It is the one field
|
|
54
|
+
* whose content is not the caller's to rephrase — it is the environment
|
|
55
|
+
* variable's actual name — so a false positive there is unfixable rather than
|
|
56
|
+
* merely annoying, and the generic entropy tier does produce them:
|
|
57
|
+
* `STRIPE_WEBHOOK_SIGNING_SECRET_V2` is a plausible name that scores as a
|
|
58
|
+
* medium-confidence hit. High-confidence patterns still apply, so a key named
|
|
59
|
+
* `AKIAT7QLM2XP9RV4NC3B` is rejected. Non-string fields (booleans, numbers,
|
|
60
|
+
* `sink`, `verify.method`) cannot carry a value past their zod types and are
|
|
61
|
+
* not scanned.
|
|
62
|
+
*/
|
|
63
|
+
export declare function scanManifestEntry(entry: ManifestEntry, basePath: string): SecretFinding | null;
|
|
64
|
+
export declare function secretInDeclarationError(finding: SecretFinding): SepError;
|
|
65
|
+
export declare function secretInRequestError(finding: SecretFinding): SepError;
|
|
66
|
+
//# sourceMappingURL=guard.d.ts.map
|