@envseal/core 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/broker.d.ts CHANGED
@@ -6,6 +6,7 @@ export interface BrokerOptions {
6
6
  root: string;
7
7
  prompter?: Prompter;
8
8
  onConfirm?: ExecOptions['onConfirm'];
9
+ onRevokeConfirm?: (keys: string[]) => Promise<boolean>;
9
10
  onApprovalNeeded?: VerifyOptions['onApprovalNeeded'];
10
11
  }
11
12
  export declare class Broker {
@@ -14,6 +15,7 @@ export declare class Broker {
14
15
  private prompterPromise;
15
16
  private readonly ticketStore;
16
17
  private readonly onConfirm;
18
+ private readonly onRevokeConfirm;
17
19
  private readonly onApprovalNeeded;
18
20
  private readonly salt;
19
21
  constructor(opts: BrokerOptions);
package/dist/broker.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { createHmac } from 'node:crypto';
2
2
  import { SepError, isSepError, zero } from '@envseal/protocol';
3
+ import { compileSafePattern } from './pattern.js';
3
4
  import { getProvider, findKey } from '@envseal/registry';
4
5
  import { selectPrompter } from '@envseal/prompters';
5
6
  import { projectPaths, loadOrCreateSalt } from './paths.js';
@@ -39,12 +40,14 @@ export class Broker {
39
40
  prompterPromise;
40
41
  ticketStore;
41
42
  onConfirm;
43
+ onRevokeConfirm;
42
44
  onApprovalNeeded;
43
45
  salt;
44
46
  constructor(opts) {
45
47
  this.paths = projectPaths(opts.root);
46
48
  this.ticketStore = new TicketStore();
47
49
  this.onConfirm = opts.onConfirm;
50
+ this.onRevokeConfirm = opts.onRevokeConfirm;
48
51
  this.onApprovalNeeded = opts.onApprovalNeeded;
49
52
  this.salt = loadOrCreateSalt(this.paths);
50
53
  this.prompter = opts.prompter ?? null;
@@ -90,7 +93,7 @@ export class Broker {
90
93
  if (formatValid === null) {
91
94
  const trusted = findKey(entry.key)?.key.format?.pattern;
92
95
  if (trusted !== undefined) {
93
- formatValid = new RegExp(trusted).test(value.toString('utf8'));
96
+ formatValid = compileSafePattern(trusted).test(value.toString('utf8'));
94
97
  }
95
98
  }
96
99
  }
@@ -269,7 +272,7 @@ export class Broker {
269
272
  }
270
273
  if (result.outcome === 'entered') {
271
274
  if (entry.format?.pattern) {
272
- const pattern = new RegExp(entry.format.pattern);
275
+ const pattern = compileSafePattern(entry.format.pattern);
273
276
  const valueStr = result.value.toString('utf8');
274
277
  if (!pattern.test(valueStr)) {
275
278
  this.ticketStore.setOutcome(ticketId, result.key, 'invalid_format');
@@ -385,16 +388,30 @@ export class Broker {
385
388
  }
386
389
  async use(input) {
387
390
  const manifest = loadManifest(this.paths) ?? emptyManifest();
391
+ const declaredKeys = new Set(manifest.entries.map((e) => e.key));
392
+ for (const keyName of input.keys) {
393
+ if (!declaredKeys.has(keyName)) {
394
+ throw new SepError({ code: 'SEP_NOT_DECLARED' });
395
+ }
396
+ }
388
397
  const secrets = new Map();
398
+ const missing = [];
389
399
  for (const keyName of input.keys) {
390
400
  const entry = manifest.entries.find((e) => e.key === keyName);
391
- if (!entry)
392
- continue;
393
401
  const sink = getSink(entry.sink ?? 'dotenv');
394
402
  const value = await sink.read(this.paths, keyName);
395
403
  if (value) {
396
404
  secrets.set(keyName, value);
397
405
  }
406
+ else {
407
+ missing.push(keyName);
408
+ }
409
+ }
410
+ if (missing.length > 0) {
411
+ throw new SepError({
412
+ code: 'SEP_KEYS_MISSING',
413
+ userMessage: `Missing stored values for: ${missing.join(', ')}. Declare and store them before use.`,
414
+ });
398
415
  }
399
416
  const result = await runWithSecrets(input.command, secrets, {
400
417
  onConfirm: this.onConfirm,
@@ -405,6 +422,13 @@ export class Broker {
405
422
  return result;
406
423
  }
407
424
  async revoke(input) {
425
+ if (!this.onRevokeConfirm) {
426
+ throw new SepError({ code: 'SEP_CONFIRMATION_DENIED' });
427
+ }
428
+ const confirmed = await this.onRevokeConfirm(input.keys);
429
+ if (!confirmed) {
430
+ throw new SepError({ code: 'SEP_CONFIRMATION_DENIED' });
431
+ }
408
432
  const manifest = loadManifest(this.paths) ?? emptyManifest();
409
433
  const results = [];
410
434
  for (const keyName of input.keys) {
package/dist/display.d.ts CHANGED
@@ -28,4 +28,9 @@ export declare function useConfirmationBody(info: {
28
28
  networkEgress: boolean;
29
29
  target?: import('./exec.js').TargetInfo;
30
30
  }, projectRoot: string): string;
31
+ /**
32
+ * The full `env_revoke` approval dialog: project and key names only — never
33
+ * values. Every binding renders exactly this.
34
+ */
35
+ export declare function revokeConfirmationBody(keys: string[], projectRoot: string): string;
31
36
  //# sourceMappingURL=display.d.ts.map
package/dist/display.js CHANGED
@@ -95,4 +95,19 @@ export function useConfirmationBody(info, projectRoot) {
95
95
  lines.push('', 'Type yes to approve, or submit an empty box to deny. Nothing runs unless you approve.');
96
96
  return lines.join('\\n');
97
97
  }
98
+ /**
99
+ * The full `env_revoke` approval dialog: project and key names only — never
100
+ * values. Every binding renders exactly this.
101
+ */
102
+ export function revokeConfirmationBody(keys, projectRoot) {
103
+ const lines = [
104
+ 'EnvSeal is about to remove these stored credentials.',
105
+ '',
106
+ ` project: ${escapeForDisplay(projectRoot)}`,
107
+ ` keys: ${keys.length > 0 ? keys.map(escapeForDisplay).join(', ') : '(none)'}`,
108
+ '',
109
+ 'Type yes to approve, or submit an empty box to deny. Nothing is removed unless you approve.',
110
+ ];
111
+ return lines.join('\\n');
112
+ }
98
113
  //# sourceMappingURL=display.js.map
package/dist/guard.d.ts CHANGED
@@ -61,6 +61,7 @@ export declare function scanText(path: string, text: string, tier: GuardTier): S
61
61
  * not scanned.
62
62
  */
63
63
  export declare function scanManifestEntry(entry: ManifestEntry, basePath: string): SecretFinding | null;
64
+ export declare function secretInManifestFileError(finding: SecretFinding): SepError;
64
65
  export declare function secretInDeclarationError(finding: SecretFinding): SepError;
65
66
  export declare function secretInRequestError(finding: SecretFinding): SepError;
66
67
  //# sourceMappingURL=guard.d.ts.map
package/dist/guard.js CHANGED
@@ -133,6 +133,14 @@ export function scanManifestEntry(entry, basePath) {
133
133
  }
134
134
  return findings.find((finding) => finding !== null) ?? null;
135
135
  }
136
+ export function secretInManifestFileError(finding) {
137
+ return new SepError({
138
+ code: 'SEP_VALUE_IN_REQUEST',
139
+ userMessage: `Refusing to load manifest: ${finding.path} contains secret-shaped text (${finding.label}). ` +
140
+ 'Remove any credential from comments or other non-schema text before continuing.',
141
+ details: { field: finding.path, detected: finding.label, confidence: finding.confidence },
142
+ });
143
+ }
136
144
  export function secretInDeclarationError(finding) {
137
145
  return new SepError({
138
146
  code: 'SEP_VALUE_IN_REQUEST',
package/dist/index.d.ts CHANGED
@@ -6,8 +6,10 @@ export * from './redact.js';
6
6
  export * from './tickets.js';
7
7
  export * from './audit.js';
8
8
  export * from './sinks/types.js';
9
- export { parseDotenv, serializeDotenv, readDotenv, setDotenvValue, removeDotenvKey, DotenvSink, } from './sinks/dotenv.js';
10
- export type { DotenvLine, ParsedDotenv, WriteDotenvOptions } from './sinks/dotenv.js';
9
+ export { parseDotenv, serializeDotenv, readDotenv, setDotenvValue, removeDotenvKey, DotenvSink, inspectDotenvGitSafety, } from './sinks/dotenv.js';
10
+ export type { DotenvLine, ParsedDotenv, WriteDotenvOptions, DotenvGitSafety } from './sinks/dotenv.js';
11
+ export { compileSafePattern } from './pattern.js';
12
+ export { buildDarwinWriteArgs } from './sinks/keychain.js';
11
13
  export * from './approvals.js';
12
14
  export * from './verify.js';
13
15
  export * from './exec.js';
package/dist/index.js CHANGED
@@ -6,7 +6,9 @@ export * from './redact.js';
6
6
  export * from './tickets.js';
7
7
  export * from './audit.js';
8
8
  export * from './sinks/types.js';
9
- export { parseDotenv, serializeDotenv, readDotenv, setDotenvValue, removeDotenvKey, DotenvSink, } from './sinks/dotenv.js';
9
+ export { parseDotenv, serializeDotenv, readDotenv, setDotenvValue, removeDotenvKey, DotenvSink, inspectDotenvGitSafety, } from './sinks/dotenv.js';
10
+ export { compileSafePattern } from './pattern.js';
11
+ export { buildDarwinWriteArgs } from './sinks/keychain.js';
10
12
  export * from './approvals.js';
11
13
  export * from './verify.js';
12
14
  export * from './exec.js';
package/dist/manifest.js CHANGED
@@ -3,7 +3,7 @@ import { isDeepStrictEqual } from 'node:util';
3
3
  import * as jsonc from 'jsonc-parser';
4
4
  import { DeclareResult, Manifest, ManifestEntry, SepError } from '@envseal/protocol';
5
5
  import { appendAudit } from './audit.js';
6
- import { scanManifestEntry, secretInDeclarationError } from './guard.js';
6
+ import { scanText, scanManifestEntry, secretInDeclarationError, secretInManifestFileError } from './guard.js';
7
7
  export function emptyManifest() {
8
8
  return { version: 1, entries: [] };
9
9
  }
@@ -39,6 +39,15 @@ export function loadManifest(paths) {
39
39
  const text = readFileIfPresent(paths.manifest);
40
40
  if (text === null)
41
41
  return null;
42
+ const rawFinding = scanText('manifest', text, 'strict');
43
+ if (rawFinding !== null) {
44
+ appendAudit(paths, {
45
+ type: 'blocked',
46
+ reason: 'secret_in_declaration',
47
+ detail: `${rawFinding.path}: ${rawFinding.label}`,
48
+ });
49
+ throw secretInManifestFileError(rawFinding);
50
+ }
42
51
  const errors = [];
43
52
  const value = jsonc.parse(text, errors, {
44
53
  disallowComments: false,
@@ -0,0 +1,3 @@
1
+ /** Compile a manifest format.pattern after the protocol's linearish safety check. */
2
+ export declare function compileSafePattern(pattern: string): RegExp;
3
+ //# sourceMappingURL=pattern.d.ts.map
@@ -0,0 +1,14 @@
1
+ import { isLinearishRegex, SepError } from '@envseal/protocol';
2
+ /** Compile a manifest format.pattern after the protocol's linearish safety check. */
3
+ export function compileSafePattern(pattern) {
4
+ if (!isLinearishRegex(pattern)) {
5
+ throw new SepError({ code: 'SEP_PATTERN_UNSAFE' });
6
+ }
7
+ try {
8
+ return new RegExp(pattern);
9
+ }
10
+ catch {
11
+ throw new SepError({ code: 'SEP_PATTERN_UNSAFE' });
12
+ }
13
+ }
14
+ //# sourceMappingURL=pattern.js.map
@@ -36,6 +36,12 @@ export interface ParsedDotenv {
36
36
  export declare function unsafeSecretToUtf8(value: SecretValue): string;
37
37
  export declare function parseDotenv(text: string): ParsedDotenv;
38
38
  export declare function serializeDotenv(parsed: ParsedDotenv): string;
39
+ export interface DotenvGitSafety {
40
+ insideGit: boolean;
41
+ tracked: boolean;
42
+ ignored: boolean;
43
+ }
44
+ export declare function inspectDotenvGitSafety(paths: ProjectPaths): DotenvGitSafety;
39
45
  export interface WriteDotenvOptions {
40
46
  allowUnsafe?: boolean;
41
47
  description?: string;
@@ -239,6 +239,9 @@ function atomicWrite(paths, target, content) {
239
239
  function renameOverwrite(tmp, target) {
240
240
  try {
241
241
  withTransientRetry(() => renameSync(tmp, target));
242
+ if (isPosix) {
243
+ chmodSync(target, 0o600);
244
+ }
242
245
  }
243
246
  catch (error) {
244
247
  try {
@@ -279,18 +282,60 @@ function runGit(cwd, args) {
279
282
  return typeof status === 'number' ? status : 1;
280
283
  }
281
284
  }
285
+ function readGitignoreLines(root) {
286
+ try {
287
+ return readFileSync(join(root, '.gitignore'), 'utf8').split(/\r\n|\n|\r/);
288
+ }
289
+ catch {
290
+ return [];
291
+ }
292
+ }
293
+ /** Whether a single .gitignore line would ignore the project `.env` file. */
294
+ function gitignoreLineCoversDotenv(line) {
295
+ let trimmed = line.trim();
296
+ if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('!')) {
297
+ return false;
298
+ }
299
+ trimmed = trimmed.replace(/\/+$/, '');
300
+ if (trimmed === '.env' || trimmed === '.env*' || trimmed.startsWith('.env*')) {
301
+ return true;
302
+ }
303
+ if (trimmed === '**/.env' || trimmed.endsWith('/.env')) {
304
+ return true;
305
+ }
306
+ if (trimmed === '**/.env*' || trimmed.endsWith('/.env*')) {
307
+ return true;
308
+ }
309
+ return /(^|\/)\.env(\*|$)/.test(trimmed);
310
+ }
311
+ function dotenvCoveredByGitignore(root) {
312
+ return readGitignoreLines(root).some(gitignoreLineCoversDotenv);
313
+ }
314
+ export function inspectDotenvGitSafety(paths) {
315
+ const insideGit = runGit(paths.root, ['rev-parse', '--is-inside-work-tree']) === 0;
316
+ if (!insideGit) {
317
+ return {
318
+ insideGit: false,
319
+ tracked: false,
320
+ ignored: dotenvCoveredByGitignore(paths.root),
321
+ };
322
+ }
323
+ const relPath = relative(paths.root, paths.dotenv);
324
+ const tracked = runGit(paths.root, ['ls-files', '--error-unmatch', '--', relPath]) === 0;
325
+ const ignored = runGit(paths.root, ['check-ignore', '-q', relPath]) === 0;
326
+ return { insideGit, tracked, ignored };
327
+ }
282
328
  function assertGitSafe(paths, allowUnsafe) {
283
329
  if (allowUnsafe)
284
330
  return;
285
- if (runGit(paths.root, ['rev-parse', '--is-inside-work-tree']) !== 0)
331
+ const status = inspectDotenvGitSafety(paths);
332
+ if (status.insideGit) {
333
+ if (status.tracked || !status.ignored) {
334
+ throw new SepError({ code: 'SEP_GITIGNORE_UNSAFE' });
335
+ }
286
336
  return;
287
- const relPath = relative(paths.root, paths.dotenv);
288
- const tracked = runGit(paths.root, ['ls-files', '--error-unmatch', '--', relPath]) === 0;
289
- if (tracked) {
290
- throw new SepError({ code: 'SEP_GITIGNORE_UNSAFE' });
291
337
  }
292
- const ignored = runGit(paths.root, ['check-ignore', '-q', relPath]) === 0;
293
- if (!ignored) {
338
+ if (!status.ignored) {
294
339
  throw new SepError({ code: 'SEP_GITIGNORE_UNSAFE' });
295
340
  }
296
341
  }
@@ -1,6 +1,13 @@
1
1
  import type { SecretValue } from '@envseal/protocol';
2
2
  import type { ProjectPaths } from '../paths.js';
3
3
  import type { Sink } from './types.js';
4
+ /**
5
+ * Args for `security add-generic-password`. `security(1)` has no documented
6
+ * non-interactive stdin password path: omitting `-w` stores an empty secret
7
+ * (verified on macos-latest). The password therefore appears on argv for the
8
+ * lifetime of the spawn — residual-risks.md §2.
9
+ */
10
+ export declare function buildDarwinWriteArgs(account: string, secret: string): string[];
4
11
  declare class KeychainSink implements Sink {
5
12
  readonly id = "keychain";
6
13
  available(): Promise<boolean>;
@@ -44,6 +44,15 @@ function execCommand(file, args, input, env) {
44
44
  }
45
45
  });
46
46
  }
47
+ /**
48
+ * Args for `security add-generic-password`. `security(1)` has no documented
49
+ * non-interactive stdin password path: omitting `-w` stores an empty secret
50
+ * (verified on macos-latest). The password therefore appears on argv for the
51
+ * lifetime of the spawn — residual-risks.md §2.
52
+ */
53
+ export function buildDarwinWriteArgs(account, secret) {
54
+ return ['add-generic-password', '-U', '-s', 'envseal', '-a', account, '-w', secret];
55
+ }
47
56
  function exitCodeOf(error) {
48
57
  return error?.exitCode;
49
58
  }
@@ -241,16 +250,7 @@ class KeychainSink {
241
250
  const account = accountFor(_paths, key);
242
251
  const valueStr = unsafeSecretToUtf8(value);
243
252
  if (process.platform === 'darwin') {
244
- await execCommand('security', [
245
- 'add-generic-password',
246
- '-U',
247
- '-s',
248
- 'envseal',
249
- '-a',
250
- account,
251
- '-w',
252
- valueStr,
253
- ]);
253
+ await execCommand('security', buildDarwinWriteArgs(account, valueStr));
254
254
  }
255
255
  else if (process.platform === 'win32') {
256
256
  const dir = windowsCredsDir();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@envseal/core",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "main": "./dist/index.js",
@@ -22,10 +22,10 @@
22
22
  "dependencies": {
23
23
  "jsonc-parser": "^3.3.1",
24
24
  "ulid": "^2.3.0",
25
- "@envseal/protocol": "0.1.3",
26
- "@envseal/registry": "0.1.3",
27
- "@envseal/detector": "0.1.3",
28
- "@envseal/prompters": "0.1.3"
25
+ "@envseal/protocol": "0.1.5",
26
+ "@envseal/detector": "0.1.5",
27
+ "@envseal/registry": "0.1.5",
28
+ "@envseal/prompters": "0.1.5"
29
29
  },
30
30
  "devDependencies": {
31
31
  "fast-check": "^3.23.1"