@envseal/core 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/broker.d.ts +2 -0
- package/dist/broker.js +28 -4
- package/dist/display.d.ts +36 -0
- package/dist/display.js +113 -0
- package/dist/exec.d.ts +39 -1
- package/dist/exec.js +112 -1
- package/dist/guard.d.ts +1 -0
- package/dist/guard.js +8 -0
- package/dist/index.d.ts +5 -2
- package/dist/index.js +4 -1
- package/dist/manifest.js +10 -1
- package/dist/pattern.d.ts +3 -0
- package/dist/pattern.js +14 -0
- package/dist/sinks/dotenv.d.ts +6 -0
- package/dist/sinks/dotenv.js +52 -7
- package/dist/sinks/keychain.d.ts +7 -0
- package/dist/sinks/keychain.js +10 -10
- package/package.json +5 -5
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 =
|
|
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 =
|
|
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) {
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared rendering for the human-consent dialogs across every binding.
|
|
3
|
+
*
|
|
4
|
+
* This used to live twice — hand-maintained twins in @envseal/mcp-server and
|
|
5
|
+
* @envseal/sdk — and a third, weaker copy rolled its own in the CLI's `run`
|
|
6
|
+
* confirmation. One copy here feeds all three: cli, sdk and mcp-server all
|
|
7
|
+
* already depend on @envseal/core, so this adds no dependency edge.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Model-supplied argv, key names and probe metadata land in a dialog the user
|
|
11
|
+
* is about to trust. Control characters let a crafted argument forge extra
|
|
12
|
+
* lines — "keys: none", "this command is safe" — inside the very block that
|
|
13
|
+
* exists to tell the truth about the command; Unicode separators can split
|
|
14
|
+
* lines invisibly and bidi controls can reorder what a terminal shows.
|
|
15
|
+
* Render all of them visibly instead.
|
|
16
|
+
*/
|
|
17
|
+
export declare function escapeForDisplay(value: string): string;
|
|
18
|
+
export declare function displayArg(arg: string): string;
|
|
19
|
+
/**
|
|
20
|
+
* The full `env_use` approval dialog: project, keys, argv one-per-line,
|
|
21
|
+
* content fingerprints of every named file (see exec.ts target hashing),
|
|
22
|
+
* the egress warning or the honest heuristics disclaimer, and the answer
|
|
23
|
+
* format. Every binding renders exactly this.
|
|
24
|
+
*/
|
|
25
|
+
export declare function useConfirmationBody(info: {
|
|
26
|
+
command: string[];
|
|
27
|
+
keys: string[];
|
|
28
|
+
networkEgress: boolean;
|
|
29
|
+
target?: import('./exec.js').TargetInfo;
|
|
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;
|
|
36
|
+
//# sourceMappingURL=display.d.ts.map
|
package/dist/display.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared rendering for the human-consent dialogs across every binding.
|
|
3
|
+
*
|
|
4
|
+
* This used to live twice — hand-maintained twins in @envseal/mcp-server and
|
|
5
|
+
* @envseal/sdk — and a third, weaker copy rolled its own in the CLI's `run`
|
|
6
|
+
* confirmation. One copy here feeds all three: cli, sdk and mcp-server all
|
|
7
|
+
* already depend on @envseal/core, so this adds no dependency edge.
|
|
8
|
+
*/
|
|
9
|
+
/** Per-argument display cap; longer arguments are shown truncated, and said to be. */
|
|
10
|
+
const MAX_ARG_CHARS = 300;
|
|
11
|
+
/**
|
|
12
|
+
* Model-supplied argv, key names and probe metadata land in a dialog the user
|
|
13
|
+
* is about to trust. Control characters let a crafted argument forge extra
|
|
14
|
+
* lines — "keys: none", "this command is safe" — inside the very block that
|
|
15
|
+
* exists to tell the truth about the command; Unicode separators can split
|
|
16
|
+
* lines invisibly and bidi controls can reorder what a terminal shows.
|
|
17
|
+
* Render all of them visibly instead.
|
|
18
|
+
*/
|
|
19
|
+
export function escapeForDisplay(value) {
|
|
20
|
+
let out = '';
|
|
21
|
+
for (const ch of value) {
|
|
22
|
+
const code = ch.codePointAt(0) ?? 0;
|
|
23
|
+
const c1 = code < 0x20 ||
|
|
24
|
+
(code >= 0x7f && code <= 0x9f) ||
|
|
25
|
+
code === 0x2028 ||
|
|
26
|
+
code === 0x2029 ||
|
|
27
|
+
(code >= 0x200b && code <= 0x200f) ||
|
|
28
|
+
(code >= 0x202a && code <= 0x202e) ||
|
|
29
|
+
(code >= 0x2066 && code <= 0x2069) ||
|
|
30
|
+
code === 0xfeff;
|
|
31
|
+
out += c1 ? `<U+${code.toString(16).toUpperCase().padStart(4, '0')}>` : ch;
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
export function displayArg(arg) {
|
|
36
|
+
const escaped = escapeForDisplay(arg);
|
|
37
|
+
if (escaped.length <= MAX_ARG_CHARS) {
|
|
38
|
+
return escaped;
|
|
39
|
+
}
|
|
40
|
+
const hidden = escaped.length - MAX_ARG_CHARS;
|
|
41
|
+
return `${escaped.slice(0, MAX_ARG_CHARS)}[... ${hidden} more characters, not shown]`;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The full `env_use` approval dialog: project, keys, argv one-per-line,
|
|
45
|
+
* content fingerprints of every named file (see exec.ts target hashing),
|
|
46
|
+
* the egress warning or the honest heuristics disclaimer, and the answer
|
|
47
|
+
* format. Every binding renders exactly this.
|
|
48
|
+
*/
|
|
49
|
+
export function useConfirmationBody(info, projectRoot) {
|
|
50
|
+
const lines = [
|
|
51
|
+
'EnvSeal is about to run a program with these secrets in its environment.',
|
|
52
|
+
'',
|
|
53
|
+
` project: ${escapeForDisplay(projectRoot)}`,
|
|
54
|
+
` keys: ${info.keys.length > 0 ? info.keys.map(escapeForDisplay).join(', ') : '(none)'}`,
|
|
55
|
+
'',
|
|
56
|
+
' command, one argument per line, exactly as it will be run (no shell):',
|
|
57
|
+
];
|
|
58
|
+
info.command.forEach((arg, index) => {
|
|
59
|
+
lines.push(` [${index}] ${displayArg(arg)}`);
|
|
60
|
+
});
|
|
61
|
+
lines.push('');
|
|
62
|
+
if (info.target) {
|
|
63
|
+
const { resolvedPath, sha256, hashedFiles } = info.target;
|
|
64
|
+
const targetLabel = sha256 !== null
|
|
65
|
+
? escapeForDisplay(resolvedPath)
|
|
66
|
+
: `${escapeForDisplay(resolvedPath)} (not a readable file)`;
|
|
67
|
+
lines.push(` target: ${targetLabel}`);
|
|
68
|
+
if (hashedFiles.length > 0) {
|
|
69
|
+
// The approval binds to these fingerprints, not to the text above:
|
|
70
|
+
// every named file is re-hashed just before spawn and any mismatch
|
|
71
|
+
// refuses with SEP_TARGET_CHANGED, so content swapped in after this
|
|
72
|
+
// dialog closes does not run.
|
|
73
|
+
for (const file of hashedFiles) {
|
|
74
|
+
lines.push(` ${escapeForDisplay(file.argument)}`);
|
|
75
|
+
lines.push(` sha256: ${file.sha256}`);
|
|
76
|
+
}
|
|
77
|
+
lines.push(' Each listed file is re-checked against its fingerprint immediately', ' before the program runs; one that changed since you read this will', ' not run.');
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
// Honest about the boundary of the control: nothing in the command
|
|
81
|
+
// named a readable file, so approval stays name-level.
|
|
82
|
+
lines.push(' No argument named a readable file, so approval covers names only.');
|
|
83
|
+
}
|
|
84
|
+
lines.push('');
|
|
85
|
+
}
|
|
86
|
+
if (info.networkEgress) {
|
|
87
|
+
lines.push(' WARNING: this command can reach the network, so it could send these', ' values somewhere. Only continue if you trust it.');
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
// Honest about what the check is worth: NETWORK_TOOLS plus a URL scan is a
|
|
91
|
+
// heuristic, and claiming more would be the kind of overstatement this
|
|
92
|
+
// project has already had to walk back once.
|
|
93
|
+
lines.push(' No network tool or URL was recognised in this command. That is a', ' heuristic, not a guarantee: any program can open a socket.');
|
|
94
|
+
}
|
|
95
|
+
lines.push('', 'Type yes to approve, or submit an empty box to deny. Nothing runs unless you approve.');
|
|
96
|
+
return lines.join('\\n');
|
|
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
|
+
}
|
|
113
|
+
//# sourceMappingURL=display.js.map
|
package/dist/exec.d.ts
CHANGED
|
@@ -2,8 +2,45 @@ import type { SecretValue, ExecResult } from '@envseal/protocol';
|
|
|
2
2
|
/**
|
|
3
3
|
* Residual risk on Linux: A same-uid process can read /proc/<pid>/environ
|
|
4
4
|
* of the child process. This cannot be defended against without sandboxing.
|
|
5
|
-
* Users on shared systems should
|
|
5
|
+
* Users on shared systems should see this limitation.
|
|
6
6
|
*/
|
|
7
|
+
/**
|
|
8
|
+
* One argument that named a readable file at approval time, bound to its
|
|
9
|
+
* content. Repo scripts usually ride as arguments (`node ./build/x.mjs`,
|
|
10
|
+
* `bash scripts/release.sh`), so consent must cover them, not just argv[0].
|
|
11
|
+
*/
|
|
12
|
+
export interface TargetFile {
|
|
13
|
+
/** The argument exactly as it appeared in the command. */
|
|
14
|
+
argument: string;
|
|
15
|
+
/** Absolute path whose content was hashed. */
|
|
16
|
+
resolvedPath: string;
|
|
17
|
+
/** SHA-256 of the file content at approval time. */
|
|
18
|
+
sha256: string;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* What the approver is told about the program they are approving. Consent for
|
|
22
|
+
* `env_use` binds to this content, not to the displayed argument text: a repo
|
|
23
|
+
* script named anywhere in argv can be rewritten while the approval dialog is
|
|
24
|
+
* open, so the dialog shows content fingerprints and every named file is
|
|
25
|
+
* re-checked immediately before spawn (see the T11 note below).
|
|
26
|
+
*/
|
|
27
|
+
export interface TargetInfo {
|
|
28
|
+
/** Absolute resolution of argv[0]; often a PATH lookup, not a file. */
|
|
29
|
+
resolvedPath: string;
|
|
30
|
+
/**
|
|
31
|
+
* SHA-256 of argv[0]'s own content when argv[0] names a readable file
|
|
32
|
+
* (direct script invocation: `./scripts/release.sh`), else null
|
|
33
|
+
* (PATH-resolved executables: `node`, `python`, ...).
|
|
34
|
+
*/
|
|
35
|
+
sha256: string | null;
|
|
36
|
+
/**
|
|
37
|
+
* Every distinct argument that resolved to a readable file — including
|
|
38
|
+
* argv[0] itself when it is one. Each entry is re-verified against fresh
|
|
39
|
+
* disk content just before spawn; any change refuses with
|
|
40
|
+
* SEP_TARGET_CHANGED.
|
|
41
|
+
*/
|
|
42
|
+
hashedFiles: TargetFile[];
|
|
43
|
+
}
|
|
7
44
|
export interface ExecOptions {
|
|
8
45
|
cwd?: string;
|
|
9
46
|
timeoutMs?: number;
|
|
@@ -11,6 +48,7 @@ export interface ExecOptions {
|
|
|
11
48
|
command: string[];
|
|
12
49
|
keys: string[];
|
|
13
50
|
networkEgress: boolean;
|
|
51
|
+
target: TargetInfo;
|
|
14
52
|
}) => Promise<boolean>;
|
|
15
53
|
approvedCommands?: string[];
|
|
16
54
|
}
|
package/dist/exec.js
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { createReadStream } from 'node:fs';
|
|
4
|
+
import { stat } from 'node:fs/promises';
|
|
5
|
+
import { resolve as resolvePath } from 'node:path';
|
|
2
6
|
import { SepError } from '@envseal/protocol';
|
|
3
7
|
import { redact } from './redact.js';
|
|
4
8
|
import { unsafeSecretToUtf8 } from './sinks/dotenv.js';
|
|
@@ -16,6 +20,8 @@ const NETWORK_TOOLS = new Set([
|
|
|
16
20
|
'telnet',
|
|
17
21
|
'socat',
|
|
18
22
|
]);
|
|
23
|
+
/** Nothing remotely path-shaped is longer than this on any supported OS. */
|
|
24
|
+
const MAX_PATHISH_CHARS = 4096;
|
|
19
25
|
function detectNetworkEgress(command) {
|
|
20
26
|
if (command.length === 0) {
|
|
21
27
|
return false;
|
|
@@ -31,6 +37,100 @@ function detectNetworkEgress(command) {
|
|
|
31
37
|
}
|
|
32
38
|
return false;
|
|
33
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* T11 hardening: hash the files the command names, so approval binds to
|
|
42
|
+
* content rather than to displayed text. Streaming read — a multi-gigabyte
|
|
43
|
+
* argument must not be loaded into memory to be fingerprinted. Any read
|
|
44
|
+
* failure yields null rather than throwing: unreadable targets fail later at
|
|
45
|
+
* spawn with their own honest error, and refusing here would report a denial
|
|
46
|
+
* nobody made.
|
|
47
|
+
*/
|
|
48
|
+
async function sha256File(path) {
|
|
49
|
+
try {
|
|
50
|
+
const info = await stat(path);
|
|
51
|
+
if (!info.isFile()) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
const hash = createHash('sha256');
|
|
55
|
+
await new Promise((resolveStream, rejectStream) => {
|
|
56
|
+
const stream = createReadStream(path);
|
|
57
|
+
stream.on('data', (chunk) => hash.update(chunk));
|
|
58
|
+
stream.on('end', () => resolveStream());
|
|
59
|
+
stream.on('error', rejectStream);
|
|
60
|
+
});
|
|
61
|
+
return hash.digest('hex');
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
async function snapshotNamedFiles(command, cwd) {
|
|
68
|
+
const base = cwd ?? process.cwd();
|
|
69
|
+
const files = new Map();
|
|
70
|
+
const pending = new Set();
|
|
71
|
+
let argv0Sha = null;
|
|
72
|
+
let argv0Resolved = '';
|
|
73
|
+
for (let index = 0; index < command.length; index += 1) {
|
|
74
|
+
const arg = command[index];
|
|
75
|
+
if (arg.length === 0 ||
|
|
76
|
+
arg.length > MAX_PATHISH_CHARS ||
|
|
77
|
+
// Scheme-shaped (https://..., file://...) — but NOT a Windows drive
|
|
78
|
+
// path: `C:\repo\script.mjs` matches the naive scheme regex and must
|
|
79
|
+
// stay hashable.
|
|
80
|
+
(/^[a-z][a-z0-9+.-]*:/i.test(arg) && !/^[a-zA-Z]:(\\|\/)/.test(arg))) {
|
|
81
|
+
continue; // empty, impossibly long, or URL-shaped
|
|
82
|
+
}
|
|
83
|
+
const abs = resolvePath(base, arg);
|
|
84
|
+
if (index === 0) {
|
|
85
|
+
argv0Resolved = abs;
|
|
86
|
+
}
|
|
87
|
+
const sha = await sha256File(abs);
|
|
88
|
+
if (sha !== null) {
|
|
89
|
+
if (index === 0) {
|
|
90
|
+
argv0Sha = sha;
|
|
91
|
+
}
|
|
92
|
+
files.set(abs, sha);
|
|
93
|
+
}
|
|
94
|
+
else if (!files.has(abs)) {
|
|
95
|
+
pending.add(abs);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const hashedFiles = [];
|
|
99
|
+
for (let index = 0; index < command.length; index += 1) {
|
|
100
|
+
const abs = resolvePath(base, command[index]);
|
|
101
|
+
const sha = files.get(abs);
|
|
102
|
+
if (sha !== undefined) {
|
|
103
|
+
hashedFiles.push({ argument: command[index], resolvedPath: abs, sha256: sha });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
info: { resolvedPath: argv0Resolved, sha256: argv0Sha, hashedFiles },
|
|
108
|
+
snapshot: { files, pending },
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
function assertUnchanged(approved, current, samplePath) {
|
|
112
|
+
for (const [path, sha] of approved.files) {
|
|
113
|
+
const now = current.files.get(path);
|
|
114
|
+
if (now === null || now === undefined || now !== sha) {
|
|
115
|
+
throw new SepError({
|
|
116
|
+
code: 'SEP_TARGET_CHANGED',
|
|
117
|
+
details: { target: path },
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
for (const path of approved.pending) {
|
|
122
|
+
// Named but absent (or directory) at approval time; a readable file
|
|
123
|
+
// appearing there before spawn means the command would execute content
|
|
124
|
+
// nobody could approve.
|
|
125
|
+
if (current.files.has(path)) {
|
|
126
|
+
throw new SepError({
|
|
127
|
+
code: 'SEP_TARGET_CHANGED',
|
|
128
|
+
details: { target: path },
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
void samplePath;
|
|
133
|
+
}
|
|
34
134
|
export async function runWithSecrets(command, secrets, opts) {
|
|
35
135
|
if (command.length === 0) {
|
|
36
136
|
throw new SepError({
|
|
@@ -42,17 +142,28 @@ export async function runWithSecrets(command, secrets, opts) {
|
|
|
42
142
|
const secretKeys = Array.from(secrets.keys());
|
|
43
143
|
const joinedCommand = command.join(' ');
|
|
44
144
|
const isApproved = opts?.approvedCommands?.some((approved) => approved === joinedCommand);
|
|
145
|
+
// The named files are fingerprinted twice: before the dialog is drawn (so
|
|
146
|
+
// the user approves content fingerprints, not just a command line) and
|
|
147
|
+
// again after consent, immediately before spawn. Content that changed in
|
|
148
|
+
// between — the injected-content-mutates-a-repo-script window — refuses
|
|
149
|
+
// with SEP_TARGET_CHANGED and nothing executes. The second read narrows
|
|
150
|
+
// the race to microseconds; closing it entirely would need an fd handed to
|
|
151
|
+
// the OS loader, which Node's spawn does not expose.
|
|
152
|
+
const approvedSnapshot = await snapshotNamedFiles(command, opts?.cwd);
|
|
45
153
|
if (!isApproved && opts?.onConfirm) {
|
|
46
154
|
const confirmed = await opts.onConfirm({
|
|
47
155
|
command,
|
|
48
156
|
keys: secretKeys,
|
|
49
157
|
networkEgress,
|
|
158
|
+
target: approvedSnapshot.info,
|
|
50
159
|
});
|
|
51
160
|
if (!confirmed) {
|
|
52
161
|
throw new SepError({
|
|
53
162
|
code: 'SEP_CONFIRMATION_DENIED',
|
|
54
163
|
});
|
|
55
164
|
}
|
|
165
|
+
const justBeforeSpawn = await snapshotNamedFiles(command, opts?.cwd);
|
|
166
|
+
assertUnchanged(approvedSnapshot.snapshot, justBeforeSpawn.snapshot, approvedSnapshot.info.resolvedPath);
|
|
56
167
|
}
|
|
57
168
|
else if (!isApproved && !opts?.onConfirm) {
|
|
58
169
|
throw new SepError({
|
|
@@ -61,7 +172,7 @@ export async function runWithSecrets(command, secrets, opts) {
|
|
|
61
172
|
}
|
|
62
173
|
const childEnv = { ...process.env };
|
|
63
174
|
const secretValues = [];
|
|
64
|
-
// W2-F31: docs/cli-contract.md
|
|
175
|
+
// W2-F31: docs/cli-contract.md promises masks read
|
|
65
176
|
// «redacted:KEY_NAME». Nothing but the key name rides along — redact()
|
|
66
177
|
// rejects a label that is not a plain identifier, so a label can never carry
|
|
67
178
|
// markup or a value fragment into the output stream.
|
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,11 +6,14 @@ 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';
|
|
16
|
+
export * from './display.js';
|
|
14
17
|
export * from './sinks/registry.js';
|
|
15
18
|
export { keychainSink } from './sinks/keychain.js';
|
|
16
19
|
export * from './broker.js';
|
package/dist/index.js
CHANGED
|
@@ -6,10 +6,13 @@ 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';
|
|
15
|
+
export * from './display.js';
|
|
13
16
|
export * from './sinks/registry.js';
|
|
14
17
|
export { keychainSink } from './sinks/keychain.js';
|
|
15
18
|
export * from './broker.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,
|
package/dist/pattern.js
ADDED
|
@@ -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
|
package/dist/sinks/dotenv.d.ts
CHANGED
|
@@ -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;
|
package/dist/sinks/dotenv.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
293
|
-
if (!ignored) {
|
|
338
|
+
if (!status.ignored) {
|
|
294
339
|
throw new SepError({ code: 'SEP_GITIGNORE_UNSAFE' });
|
|
295
340
|
}
|
|
296
341
|
}
|
package/dist/sinks/keychain.d.ts
CHANGED
|
@@ -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>;
|
package/dist/sinks/keychain.js
CHANGED
|
@@ -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
|
+
"version": "0.1.4",
|
|
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/
|
|
26
|
-
"@envseal/
|
|
27
|
-
"@envseal/detector": "0.1.
|
|
28
|
-
"@envseal/
|
|
25
|
+
"@envseal/protocol": "0.1.4",
|
|
26
|
+
"@envseal/registry": "0.1.4",
|
|
27
|
+
"@envseal/detector": "0.1.4",
|
|
28
|
+
"@envseal/prompters": "0.1.4"
|
|
29
29
|
},
|
|
30
30
|
"devDependencies": {
|
|
31
31
|
"fast-check": "^3.23.1"
|