@envseal/core 0.1.5 → 0.1.6
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/audit.d.ts +74 -0
- package/dist/audit.js +231 -3
- package/dist/broker.js +38 -2
- package/dist/display.d.ts +11 -10
- package/dist/display.js +15 -2
- package/dist/egress.d.ts +30 -0
- package/dist/egress.js +191 -0
- package/dist/exec.d.ts +19 -0
- package/dist/exec.js +49 -32
- package/dist/paths.d.ts +3 -0
- package/dist/paths.js +18 -0
- package/dist/rotation-state.d.ts +26 -0
- package/dist/rotation-state.js +40 -0
- package/dist/sinks/dotenv.js +37 -1
- package/package.json +5 -5
package/dist/audit.d.ts
CHANGED
|
@@ -30,12 +30,86 @@ export type AuditEvent = {
|
|
|
30
30
|
type: 'blocked';
|
|
31
31
|
reason: string;
|
|
32
32
|
detail: string;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* One env_use execution attempt. Written after consent succeeds,
|
|
36
|
+
* immediately before spawn — a crash mid-run still leaves the attempt
|
|
37
|
+
* recorded; denied consent records nothing. The command is persisted only
|
|
38
|
+
* after passing through the redaction engine.
|
|
39
|
+
*/
|
|
40
|
+
| {
|
|
41
|
+
type: 'use';
|
|
42
|
+
command: string;
|
|
43
|
+
keys: string[];
|
|
44
|
+
networkEgress: boolean;
|
|
45
|
+
targetHashes?: Record<string, string>;
|
|
46
|
+
}
|
|
47
|
+
/** How the attempt ended; paired with the preceding 'use' record. */
|
|
48
|
+
| {
|
|
49
|
+
type: 'use_result';
|
|
50
|
+
exitCode: number | null;
|
|
51
|
+
signal: string | null;
|
|
52
|
+
durationMs: number;
|
|
33
53
|
};
|
|
34
54
|
export type AuditRecord = AuditEvent & {
|
|
35
55
|
at: string;
|
|
36
56
|
v: number;
|
|
37
57
|
};
|
|
58
|
+
/**
|
|
59
|
+
* Chain fields. `seq` starts at 1; `prev` is the sha256 hex of the previous
|
|
60
|
+
* RAW line (genesis = 64 zeros), so any edit to a surviving record — or its
|
|
61
|
+
* deletion, which surfaces as a seq gap — breaks every successor's
|
|
62
|
+
* attestation and is reported by verifyAuditChain(). The chain proves the
|
|
63
|
+
* records that survive are intact and ordered; nothing outside the log
|
|
64
|
+
* records how many records should exist, so tail truncation is NOT
|
|
65
|
+
* detectable and is documented as an honest boundary rather than hidden.
|
|
66
|
+
*/
|
|
67
|
+
export declare const GENESIS_PREV: string;
|
|
38
68
|
export declare function appendAudit(paths: ProjectPaths, event: AuditEvent): void;
|
|
69
|
+
/** Mirror file for one project root. The key is sha256 of the project's
|
|
70
|
+
* canonical path, so the same directory hashes to one mirror file no matter
|
|
71
|
+
* how the root was spelled — Windows 8.3 short names, symlinks, differing
|
|
72
|
+
* case, or a trailing separator all collapse to the same identity.
|
|
73
|
+
* realpathSync.native is the only core API that expands 8.3 short names
|
|
74
|
+
* (plain realpath does not); it falls back to realpath then to the resolved
|
|
75
|
+
* spelling when the filesystem or a torn-down project refuses it. */
|
|
76
|
+
export declare function auditMirrorPath(root: string): string;
|
|
77
|
+
/** Raw non-empty lines of the project's mirror, or null when there is none. */
|
|
78
|
+
export declare function readMirrorLines(root: string): string[] | null;
|
|
79
|
+
export interface MirrorComparison {
|
|
80
|
+
mirrorPresent: boolean;
|
|
81
|
+
mirrorRecords: number;
|
|
82
|
+
projectRecords: number;
|
|
83
|
+
/** The mirror holds records the project log no longer does. */
|
|
84
|
+
tailTruncated: boolean;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Compare the project log's raw bytes against its mirror.
|
|
88
|
+
*
|
|
89
|
+
* Truncation is chain-anchored, not count-based: the mirror's record with
|
|
90
|
+
* seq = projectMaxSeq + 1 must attest exactly the project's last surviving
|
|
91
|
+
* line (prev == sha256 of that raw line) for an alarm to fire. That is
|
|
92
|
+
* cryptographic proof those records existed and were deleted after mirroring.
|
|
93
|
+
* A legitimate full reset (project log restarted at seq 1) does not false-positive:
|
|
94
|
+
* its successor in the mirror was written before the reset, so its prev chain
|
|
95
|
+
* cannot match the new log's lines. An empty project log beside a non-empty
|
|
96
|
+
* mirror is always loss — records demonstrably existed for this project.
|
|
97
|
+
*/
|
|
98
|
+
export declare function compareWithMirror(projectRaw: string, mirrorLines: string[] | null): MirrorComparison;
|
|
99
|
+
/**
|
|
100
|
+
* Verify the hash chain over the raw lines of an audit log.
|
|
101
|
+
*
|
|
102
|
+
* Blame semantics: a deleted or wrongly-numbered record is reported at its own
|
|
103
|
+
* expected position; a record whose CONTENT was edited in place keeps a valid
|
|
104
|
+
* seq, so the break surfaces through its successor's chained hash — it is
|
|
105
|
+
* blamed on the tampered record itself (brokenAt = k, detected at k+1).
|
|
106
|
+
* Tail truncation verifies OK by design — see the chain-field comment above.
|
|
107
|
+
*/
|
|
108
|
+
export declare function verifyAuditChain(raw: string): {
|
|
109
|
+
ok: boolean;
|
|
110
|
+
brokenAt?: number;
|
|
111
|
+
count: number;
|
|
112
|
+
};
|
|
39
113
|
export declare function readAudit(paths: ProjectPaths): Array<AuditEvent & {
|
|
40
114
|
at: string;
|
|
41
115
|
}>;
|
package/dist/audit.js
CHANGED
|
@@ -1,12 +1,240 @@
|
|
|
1
|
-
import { appendFileSync, readFileSync,
|
|
1
|
+
import { appendFileSync, chmodSync, mkdirSync, readFileSync, realpathSync } from 'node:fs';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { dirname, join, resolve } from 'node:path';
|
|
2
5
|
import { ensureStateDir } from './paths.js';
|
|
6
|
+
/**
|
|
7
|
+
* Chain fields. `seq` starts at 1; `prev` is the sha256 hex of the previous
|
|
8
|
+
* RAW line (genesis = 64 zeros), so any edit to a surviving record — or its
|
|
9
|
+
* deletion, which surfaces as a seq gap — breaks every successor's
|
|
10
|
+
* attestation and is reported by verifyAuditChain(). The chain proves the
|
|
11
|
+
* records that survive are intact and ordered; nothing outside the log
|
|
12
|
+
* records how many records should exist, so tail truncation is NOT
|
|
13
|
+
* detectable and is documented as an honest boundary rather than hidden.
|
|
14
|
+
*/
|
|
15
|
+
export const GENESIS_PREV = '0'.repeat(64);
|
|
3
16
|
const isPosix = process.platform !== 'win32';
|
|
17
|
+
/** sha256 hex of one raw log line (no trailing newline). */
|
|
18
|
+
function lineHash(line) {
|
|
19
|
+
return createHash('sha256').update(line, 'utf8').digest('hex');
|
|
20
|
+
}
|
|
21
|
+
/** Last non-empty raw line of the audit file, or null when absent/empty. */
|
|
22
|
+
function lastRawLine(auditPath) {
|
|
23
|
+
let raw;
|
|
24
|
+
try {
|
|
25
|
+
raw = readFileSync(auditPath, 'utf8');
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
if (error.code === 'ENOENT')
|
|
29
|
+
return null;
|
|
30
|
+
throw error;
|
|
31
|
+
}
|
|
32
|
+
const lines = raw.split(/\r?\n/).filter((line) => line.length > 0);
|
|
33
|
+
return lines.length > 0 ? (lines[lines.length - 1] ?? null) : null;
|
|
34
|
+
}
|
|
35
|
+
function lastSeqOf(line) {
|
|
36
|
+
try {
|
|
37
|
+
const parsed = JSON.parse(line);
|
|
38
|
+
return typeof parsed.seq === 'number' && Number.isInteger(parsed.seq) && parsed.seq > 0 ? parsed.seq : 0;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return 0;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
4
44
|
export function appendAudit(paths, event) {
|
|
5
45
|
ensureStateDir(paths);
|
|
6
|
-
const
|
|
7
|
-
|
|
46
|
+
const prevLine = lastRawLine(paths.audit);
|
|
47
|
+
// A corrupt trailing line reads as seq 0, so the next record restarts at 1.
|
|
48
|
+
// That is the honest reading of a log whose head of chain is unverifiable.
|
|
49
|
+
const prev = prevLine === null ? GENESIS_PREV : lineHash(prevLine);
|
|
50
|
+
const seq = prevLine === null ? 1 : lastSeqOf(prevLine) + 1;
|
|
51
|
+
const record = { ...event, at: new Date().toISOString(), v: 1, seq, prev };
|
|
52
|
+
const line = `${JSON.stringify(record)}\n`;
|
|
53
|
+
appendFileSync(paths.audit, line, { mode: 0o600 });
|
|
8
54
|
if (isPosix)
|
|
9
55
|
chmodSync(paths.audit, 0o600);
|
|
56
|
+
mirrorAuditLine(paths.root, line);
|
|
57
|
+
}
|
|
58
|
+
// --- Out-of-band mirror -----------------------------------------------------
|
|
59
|
+
//
|
|
60
|
+
// Residual risk §10: the project audit log is agent-writable and tail
|
|
61
|
+
// truncation is undetectable from the log alone. The mirror streams the same
|
|
62
|
+
// records to a second file under the user's own ~/.envseal, outside the
|
|
63
|
+
// project the agent operates in, so `envseal audit --verify` can detect
|
|
64
|
+
// records the project log lost. It raises the bar; it is not immutability —
|
|
65
|
+
// a same-uid attacker can tamper both copies, and the docs say so.
|
|
66
|
+
let mirrorWarned = false;
|
|
67
|
+
/** Directory holding per-project mirrors. ENVSEAL_AUDIT_MIRROR=0 opts out;
|
|
68
|
+
* a path value redirects the mirror (e.g. at a synced folder so records also
|
|
69
|
+
* leave the machine); unset defaults to ~/.envseal/mirrors. */
|
|
70
|
+
function mirrorsDir() {
|
|
71
|
+
const setting = process.env.ENVSEAL_AUDIT_MIRROR;
|
|
72
|
+
if (setting !== undefined && setting !== '0' && setting.trim() !== '') {
|
|
73
|
+
return setting;
|
|
74
|
+
}
|
|
75
|
+
return join(homedir(), '.envseal', 'mirrors');
|
|
76
|
+
}
|
|
77
|
+
/** Mirror file for one project root. The key is sha256 of the project's
|
|
78
|
+
* canonical path, so the same directory hashes to one mirror file no matter
|
|
79
|
+
* how the root was spelled — Windows 8.3 short names, symlinks, differing
|
|
80
|
+
* case, or a trailing separator all collapse to the same identity.
|
|
81
|
+
* realpathSync.native is the only core API that expands 8.3 short names
|
|
82
|
+
* (plain realpath does not); it falls back to realpath then to the resolved
|
|
83
|
+
* spelling when the filesystem or a torn-down project refuses it. */
|
|
84
|
+
export function auditMirrorPath(root) {
|
|
85
|
+
const resolved = resolve(root);
|
|
86
|
+
let canonical = resolved;
|
|
87
|
+
try {
|
|
88
|
+
canonical = realpathSync.native(resolved);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
try {
|
|
92
|
+
canonical = realpathSync(resolved);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
// Nonexistent root: the resolved spelling is the best identity available.
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const key = createHash('sha256').update(resolvePosix(canonical), 'utf8').digest('hex');
|
|
99
|
+
return join(mirrorsDir(), `${key}.jsonl`);
|
|
100
|
+
}
|
|
101
|
+
function resolvePosix(p) {
|
|
102
|
+
return p.replace(/\\/g, '/');
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Best-effort append of one already-serialized audit line to the project's
|
|
106
|
+
* mirror. Default ON; ENVSEAL_AUDIT_MIRROR=0 opts out. A mirror failure never
|
|
107
|
+
* blocks provisioning and never throws: one fixed stderr warning per process,
|
|
108
|
+
* no path and no error detail (a path or error could carry secret-shaped text).
|
|
109
|
+
*/
|
|
110
|
+
function mirrorAuditLine(root, line) {
|
|
111
|
+
if (process.env.ENVSEAL_AUDIT_MIRROR === '0') {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
const mirror = auditMirrorPath(root);
|
|
116
|
+
mkdirSync(dirname(mirror), { recursive: true, mode: 0o700 });
|
|
117
|
+
appendFileSync(mirror, line, { mode: 0o600 });
|
|
118
|
+
if (isPosix)
|
|
119
|
+
chmodSync(mirror, 0o600);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
if (!mirrorWarned) {
|
|
123
|
+
mirrorWarned = true;
|
|
124
|
+
process.stderr.write('envseal: audit mirror unavailable — continuing without it\n');
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/** Raw non-empty lines of the project's mirror, or null when there is none. */
|
|
129
|
+
export function readMirrorLines(root) {
|
|
130
|
+
try {
|
|
131
|
+
const raw = readFileSync(auditMirrorPath(root), 'utf8');
|
|
132
|
+
return raw.split(/\r?\n/).filter((line) => line.length > 0);
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
if (error.code === 'ENOENT')
|
|
136
|
+
return null;
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
function parseSeqs(lines) {
|
|
141
|
+
const out = [];
|
|
142
|
+
for (const line of lines) {
|
|
143
|
+
try {
|
|
144
|
+
const parsed = JSON.parse(line);
|
|
145
|
+
if (typeof parsed.seq === 'number' &&
|
|
146
|
+
Number.isInteger(parsed.seq) &&
|
|
147
|
+
parsed.seq > 0 &&
|
|
148
|
+
typeof parsed.prev === 'string') {
|
|
149
|
+
out.push({ seq: parsed.seq, line, prev: parsed.prev });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
// Foreign/corrupt mirror lines carry no attestation and are skipped.
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Compare the project log's raw bytes against its mirror.
|
|
160
|
+
*
|
|
161
|
+
* Truncation is chain-anchored, not count-based: the mirror's record with
|
|
162
|
+
* seq = projectMaxSeq + 1 must attest exactly the project's last surviving
|
|
163
|
+
* line (prev == sha256 of that raw line) for an alarm to fire. That is
|
|
164
|
+
* cryptographic proof those records existed and were deleted after mirroring.
|
|
165
|
+
* A legitimate full reset (project log restarted at seq 1) does not false-positive:
|
|
166
|
+
* its successor in the mirror was written before the reset, so its prev chain
|
|
167
|
+
* cannot match the new log's lines. An empty project log beside a non-empty
|
|
168
|
+
* mirror is always loss — records demonstrably existed for this project.
|
|
169
|
+
*/
|
|
170
|
+
export function compareWithMirror(projectRaw, mirrorLines) {
|
|
171
|
+
const projectLines = projectRaw
|
|
172
|
+
.split(/\r?\n/)
|
|
173
|
+
.map((line) => line.replace(/\r$/, ''))
|
|
174
|
+
.filter((line) => line.length > 0);
|
|
175
|
+
if (mirrorLines === null) {
|
|
176
|
+
return { mirrorPresent: false, mirrorRecords: 0, projectRecords: projectLines.length, tailTruncated: false };
|
|
177
|
+
}
|
|
178
|
+
const project = parseSeqs(projectLines);
|
|
179
|
+
const mirror = parseSeqs(mirrorLines);
|
|
180
|
+
let tailTruncated = false;
|
|
181
|
+
if (project.length === 0) {
|
|
182
|
+
tailTruncated = mirror.length > 0;
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
const projectMax = project[project.length - 1].seq;
|
|
186
|
+
const successor = mirror.find((m) => m.seq === projectMax + 1);
|
|
187
|
+
if (successor !== undefined && successor.prev === lineHash(project[project.length - 1].line)) {
|
|
188
|
+
tailTruncated = true;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return {
|
|
192
|
+
mirrorPresent: true,
|
|
193
|
+
mirrorRecords: mirror.length,
|
|
194
|
+
projectRecords: project.length,
|
|
195
|
+
tailTruncated,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Verify the hash chain over the raw lines of an audit log.
|
|
200
|
+
*
|
|
201
|
+
* Blame semantics: a deleted or wrongly-numbered record is reported at its own
|
|
202
|
+
* expected position; a record whose CONTENT was edited in place keeps a valid
|
|
203
|
+
* seq, so the break surfaces through its successor's chained hash — it is
|
|
204
|
+
* blamed on the tampered record itself (brokenAt = k, detected at k+1).
|
|
205
|
+
* Tail truncation verifies OK by design — see the chain-field comment above.
|
|
206
|
+
*/
|
|
207
|
+
export function verifyAuditChain(raw) {
|
|
208
|
+
const lines = raw
|
|
209
|
+
.split(/\r?\n/)
|
|
210
|
+
.map((line) => line.replace(/\r$/, ''))
|
|
211
|
+
.filter((line) => line.length > 0);
|
|
212
|
+
let expectedSeq = 1;
|
|
213
|
+
let prevHash = GENESIS_PREV;
|
|
214
|
+
for (const line of lines) {
|
|
215
|
+
let parsed;
|
|
216
|
+
try {
|
|
217
|
+
parsed = JSON.parse(line);
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
// Unparseable line at this position IS the corruption.
|
|
221
|
+
return { ok: false, brokenAt: expectedSeq, count: expectedSeq - 1 };
|
|
222
|
+
}
|
|
223
|
+
if (parsed.seq !== expectedSeq) {
|
|
224
|
+
// Missing, duplicated, or renumbered record: blame the position itself.
|
|
225
|
+
return { ok: false, brokenAt: expectedSeq, count: expectedSeq - 1 };
|
|
226
|
+
}
|
|
227
|
+
if (parsed.prev !== prevHash) {
|
|
228
|
+
// This record's attestation over its predecessor fails, so the
|
|
229
|
+
// PREDECESSOR's bytes were changed after the fact. First record with a
|
|
230
|
+
// non-genesis prev means the log head itself was tampered with.
|
|
231
|
+
const blamed = Math.max(1, expectedSeq - 1);
|
|
232
|
+
return { ok: false, brokenAt: blamed, count: expectedSeq - 1 };
|
|
233
|
+
}
|
|
234
|
+
prevHash = lineHash(line);
|
|
235
|
+
expectedSeq += 1;
|
|
236
|
+
}
|
|
237
|
+
return { ok: true, count: expectedSeq - 1 };
|
|
10
238
|
}
|
|
11
239
|
export function readAudit(paths) {
|
|
12
240
|
let raw;
|
package/dist/broker.js
CHANGED
|
@@ -12,6 +12,7 @@ import { verifyKey } from './verify.js';
|
|
|
12
12
|
import { runWithSecrets } from './exec.js';
|
|
13
13
|
import { getSink } from './sinks/registry.js';
|
|
14
14
|
import { getValidation, recordValidation } from './validation-state.js';
|
|
15
|
+
import { loadRotationState, recordRotation } from './rotation-state.js';
|
|
15
16
|
import { scanText, secretInRequestError } from './guard.js';
|
|
16
17
|
function getLengthBucket(length) {
|
|
17
18
|
if (length < 8)
|
|
@@ -71,6 +72,10 @@ export class Broker {
|
|
|
71
72
|
const presence = await resolvePresence(this.paths, manifest.entries.map((e) => e.key), { sinks: new Map(manifest.entries.map((e) => [e.key, e.sink ?? 'dotenv'])) });
|
|
72
73
|
const entries = [];
|
|
73
74
|
const missingRequired = [];
|
|
75
|
+
// Age markers for rotation reporting, loaded once and stamped lazily here:
|
|
76
|
+
// hand-written .env values never cross an envseal write path, so first
|
|
77
|
+
// sight in describe() is the only hook that sees every stored value.
|
|
78
|
+
const rotationState = loadRotationState(this.paths);
|
|
74
79
|
for (const entry of manifest.entries) {
|
|
75
80
|
const presenceInfo = presence.get(entry.key);
|
|
76
81
|
const present = presenceInfo?.present ?? false;
|
|
@@ -97,6 +102,21 @@ export class Broker {
|
|
|
97
102
|
}
|
|
98
103
|
}
|
|
99
104
|
}
|
|
105
|
+
// rotationDue = first observation of THESE bytes + the declared
|
|
106
|
+
// maxAgeDays. Absent policy, absent value, or unknown age all report
|
|
107
|
+
// null: "no advice" must stay distinguishable from "overdue".
|
|
108
|
+
let rotationDue = null;
|
|
109
|
+
if (present && value) {
|
|
110
|
+
const maxAgeDays = entry.rotation?.maxAgeDays;
|
|
111
|
+
let ageRecord = rotationState[entry.key];
|
|
112
|
+
if (!ageRecord || ageRecord.fingerprint !== fingerprint) {
|
|
113
|
+
ageRecord = recordRotation(this.paths, entry.key, fingerprint);
|
|
114
|
+
rotationState[entry.key] = ageRecord;
|
|
115
|
+
}
|
|
116
|
+
if (maxAgeDays !== undefined) {
|
|
117
|
+
rotationDue = new Date(Date.parse(ageRecord.at) + maxAgeDays * 24 * 60 * 60 * 1000).toISOString();
|
|
118
|
+
}
|
|
119
|
+
}
|
|
100
120
|
const status = {
|
|
101
121
|
key: entry.key,
|
|
102
122
|
declared: true,
|
|
@@ -108,7 +128,7 @@ export class Broker {
|
|
|
108
128
|
lastVerified: null,
|
|
109
129
|
verifyResult: null,
|
|
110
130
|
source: 'user-prompt',
|
|
111
|
-
rotationDue
|
|
131
|
+
rotationDue,
|
|
112
132
|
};
|
|
113
133
|
entries.push(status);
|
|
114
134
|
if (entry.required && !present) {
|
|
@@ -150,7 +170,14 @@ export class Broker {
|
|
|
150
170
|
verify: entry.verify ?? key.verify,
|
|
151
171
|
};
|
|
152
172
|
});
|
|
153
|
-
|
|
173
|
+
const result = declareEntries(this.paths, withDefaults);
|
|
174
|
+
// The declaration itself is audited: a model reshaping the manifest is
|
|
175
|
+
// exactly the activity the log exists to answer for later.
|
|
176
|
+
appendAudit(this.paths, {
|
|
177
|
+
type: 'declare',
|
|
178
|
+
keys: withDefaults.map((entry) => entry.key),
|
|
179
|
+
});
|
|
180
|
+
return result;
|
|
154
181
|
}
|
|
155
182
|
async request(input) {
|
|
156
183
|
// Before the manifest is even read: `reason` is free text that goes
|
|
@@ -381,6 +408,9 @@ export class Broker {
|
|
|
381
408
|
const result = await verifyKey(this.paths, entry, value, {
|
|
382
409
|
onApprovalNeeded: this.onApprovalNeeded,
|
|
383
410
|
});
|
|
411
|
+
// Probe outcomes are audited: a verify against an attacker-approved
|
|
412
|
+
// host is a forensic event even when the probe itself leaks nothing.
|
|
413
|
+
appendAudit(this.paths, { type: 'verify', key: keyName, result: result.result });
|
|
384
414
|
results.push(result);
|
|
385
415
|
zero(value);
|
|
386
416
|
}
|
|
@@ -415,6 +445,12 @@ export class Broker {
|
|
|
415
445
|
}
|
|
416
446
|
const result = await runWithSecrets(input.command, secrets, {
|
|
417
447
|
onConfirm: this.onConfirm,
|
|
448
|
+
// The project's standing egress rule: allowlist mode refuses
|
|
449
|
+
// non-allowlisted network targets before any dialog opens.
|
|
450
|
+
egressPolicy: manifest.policy?.egress,
|
|
451
|
+
// Execution auditing (use / use_result) is wired here so the product
|
|
452
|
+
// path records every attempt in the chained audit log.
|
|
453
|
+
auditPaths: this.paths,
|
|
418
454
|
});
|
|
419
455
|
for (const value of secrets.values()) {
|
|
420
456
|
zero(value);
|
package/dist/display.d.ts
CHANGED
|
@@ -1,11 +1,3 @@
|
|
|
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
1
|
/**
|
|
10
2
|
* Model-supplied argv, key names and probe metadata land in a dialog the user
|
|
11
3
|
* is about to trust. Control characters let a crafted argument forge extra
|
|
@@ -19,13 +11,22 @@ export declare function displayArg(arg: string): string;
|
|
|
19
11
|
/**
|
|
20
12
|
* The full `env_use` approval dialog: project, keys, argv one-per-line,
|
|
21
13
|
* content fingerprints of every named file (see exec.ts target hashing),
|
|
22
|
-
* the egress warning
|
|
23
|
-
* format. Every binding renders
|
|
14
|
+
* the egress warning plus the extracted network targets, or the honest
|
|
15
|
+
* heuristics disclaimer, and the answer format. Every binding renders
|
|
16
|
+
* exactly this.
|
|
24
17
|
*/
|
|
25
18
|
export declare function useConfirmationBody(info: {
|
|
26
19
|
command: string[];
|
|
27
20
|
keys: string[];
|
|
28
21
|
networkEgress: boolean;
|
|
22
|
+
/**
|
|
23
|
+
* Where the command could reach the network, as extracted by
|
|
24
|
+
* extractEgressHosts (exec.ts computes it either way — this only decides
|
|
25
|
+
* whether the dialog names the destinations). Optional so older callers
|
|
26
|
+
* keep rendering; when present alongside networkEgress the user sees
|
|
27
|
+
* WHERE data could go, not merely THAT it could leave.
|
|
28
|
+
*/
|
|
29
|
+
egressHosts?: string[];
|
|
29
30
|
target?: import('./exec.js').TargetInfo;
|
|
30
31
|
}, projectRoot: string): string;
|
|
31
32
|
/**
|
package/dist/display.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* confirmation. One copy here feeds all three: cli, sdk and mcp-server all
|
|
7
7
|
* already depend on @envseal/core, so this adds no dependency edge.
|
|
8
8
|
*/
|
|
9
|
+
import { UNKNOWN_HOST } from './egress.js';
|
|
9
10
|
/** Per-argument display cap; longer arguments are shown truncated, and said to be. */
|
|
10
11
|
const MAX_ARG_CHARS = 300;
|
|
11
12
|
/**
|
|
@@ -43,8 +44,9 @@ export function displayArg(arg) {
|
|
|
43
44
|
/**
|
|
44
45
|
* The full `env_use` approval dialog: project, keys, argv one-per-line,
|
|
45
46
|
* content fingerprints of every named file (see exec.ts target hashing),
|
|
46
|
-
* the egress warning
|
|
47
|
-
* format. Every binding renders
|
|
47
|
+
* the egress warning plus the extracted network targets, or the honest
|
|
48
|
+
* heuristics disclaimer, and the answer format. Every binding renders
|
|
49
|
+
* exactly this.
|
|
48
50
|
*/
|
|
49
51
|
export function useConfirmationBody(info, projectRoot) {
|
|
50
52
|
const lines = [
|
|
@@ -85,6 +87,17 @@ export function useConfirmationBody(info, projectRoot) {
|
|
|
85
87
|
}
|
|
86
88
|
if (info.networkEgress) {
|
|
87
89
|
lines.push(' WARNING: this command can reach the network, so it could send these', ' values somewhere. Only continue if you trust it.');
|
|
90
|
+
if (info.egressHosts !== undefined && info.egressHosts.length > 0) {
|
|
91
|
+
// Name the destinations, not just the capability. A host that could
|
|
92
|
+
// not be determined (bare IP, encoded target — UNKNOWN_HOST from
|
|
93
|
+
// core/egress.ts) is said so plainly rather than silently dropped:
|
|
94
|
+
// an undeterminable destination is exactly what exfiltration looks
|
|
95
|
+
// like, and the dialog must not read as more specific than it is.
|
|
96
|
+
const shown = info.egressHosts.includes(UNKNOWN_HOST)
|
|
97
|
+
? 'undetermined (bare IP or encoded)'
|
|
98
|
+
: info.egressHosts.map(escapeForDisplay).join(', ');
|
|
99
|
+
lines.push(` Network targets: ${shown}`);
|
|
100
|
+
}
|
|
88
101
|
}
|
|
89
102
|
else {
|
|
90
103
|
// Honest about what the check is worth: NETWORK_TOOLS plus a URL scan is a
|
package/dist/egress.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Egress target extraction and allowlist matching for env_use.
|
|
3
|
+
*
|
|
4
|
+
* Single source of truth for "can this command reach the network, and where".
|
|
5
|
+
* exec.ts derives its boolean egress heuristic from extraction so the two can
|
|
6
|
+
* never disagree about whether a command is network-touching.
|
|
7
|
+
*
|
|
8
|
+
* The '(unknown)' sentinel marks a network command whose target cannot be
|
|
9
|
+
* determined (bare IPs, encoded hostnames, no target at all). It never
|
|
10
|
+
* matches an allowlist entry — under allowlist mode such a command is
|
|
11
|
+
* refused. That refusal is the feature, not a limitation: an undeterminable
|
|
12
|
+
* destination is exactly what an exfiltration attempt looks like.
|
|
13
|
+
*/
|
|
14
|
+
export declare const UNKNOWN_HOST = "(unknown)";
|
|
15
|
+
export declare const NETWORK_TOOLS: Set<string>;
|
|
16
|
+
/**
|
|
17
|
+
* Extract the network destinations a command could reach.
|
|
18
|
+
*
|
|
19
|
+
* Returns hostnames in lowercase plus UNKNOWN_HOST markers; an empty result
|
|
20
|
+
* means the command cannot reach the network at all. Order is unspecified;
|
|
21
|
+
* callers must treat the set collectively.
|
|
22
|
+
*/
|
|
23
|
+
export declare function extractEgressHosts(command: string[]): string[];
|
|
24
|
+
/**
|
|
25
|
+
* Anchored allowlist matching. '*.suffix' matches exactly one leading label
|
|
26
|
+
* plus '.suffix': api.openai.com yes, openai.com no, a.b.openai.com no,
|
|
27
|
+
* evil.openai.com.attacker.io no. Plain entries are exact (case-insensitive).
|
|
28
|
+
*/
|
|
29
|
+
export declare function hostIsAllowed(host: string, allow: string[]): boolean;
|
|
30
|
+
//# sourceMappingURL=egress.d.ts.map
|
package/dist/egress.js
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Egress target extraction and allowlist matching for env_use.
|
|
3
|
+
*
|
|
4
|
+
* Single source of truth for "can this command reach the network, and where".
|
|
5
|
+
* exec.ts derives its boolean egress heuristic from extraction so the two can
|
|
6
|
+
* never disagree about whether a command is network-touching.
|
|
7
|
+
*
|
|
8
|
+
* The '(unknown)' sentinel marks a network command whose target cannot be
|
|
9
|
+
* determined (bare IPs, encoded hostnames, no target at all). It never
|
|
10
|
+
* matches an allowlist entry — under allowlist mode such a command is
|
|
11
|
+
* refused. That refusal is the feature, not a limitation: an undeterminable
|
|
12
|
+
* destination is exactly what an exfiltration attempt looks like.
|
|
13
|
+
*/
|
|
14
|
+
export const UNKNOWN_HOST = '(unknown)';
|
|
15
|
+
export const NETWORK_TOOLS = new Set([
|
|
16
|
+
'curl',
|
|
17
|
+
'wget',
|
|
18
|
+
'nc',
|
|
19
|
+
'ncat',
|
|
20
|
+
'netcat',
|
|
21
|
+
'ssh',
|
|
22
|
+
'scp',
|
|
23
|
+
'rsync',
|
|
24
|
+
'http',
|
|
25
|
+
'httpie',
|
|
26
|
+
'telnet',
|
|
27
|
+
'socat',
|
|
28
|
+
]);
|
|
29
|
+
/** Flags whose NEXT argument is a value, not the network target. */
|
|
30
|
+
const VALUE_TAKING_FLAGS = new Set([
|
|
31
|
+
'-o',
|
|
32
|
+
'--output',
|
|
33
|
+
'-k',
|
|
34
|
+
'--config',
|
|
35
|
+
'--cookie',
|
|
36
|
+
'-c',
|
|
37
|
+
'--cookie-jar',
|
|
38
|
+
'-H',
|
|
39
|
+
'--header',
|
|
40
|
+
'--data',
|
|
41
|
+
'--data-raw',
|
|
42
|
+
'--data-binary',
|
|
43
|
+
'--data-urlencode',
|
|
44
|
+
'-d',
|
|
45
|
+
'-F',
|
|
46
|
+
'--form',
|
|
47
|
+
'-u',
|
|
48
|
+
'--user',
|
|
49
|
+
'--proxy',
|
|
50
|
+
'-x',
|
|
51
|
+
'--pass',
|
|
52
|
+
'--key',
|
|
53
|
+
'--cacert',
|
|
54
|
+
'--capath',
|
|
55
|
+
]);
|
|
56
|
+
/** Flags whose value IS a network target specification. */
|
|
57
|
+
const TARGET_FLAGS = new Set(['--url', '--connect-to', '--resolve']);
|
|
58
|
+
function basenameOf(head) {
|
|
59
|
+
return head.split(/[\\/]/).pop()?.toLowerCase() ?? '';
|
|
60
|
+
}
|
|
61
|
+
function looksLikeHostname(arg) {
|
|
62
|
+
if (!/^[a-z0-9][a-z0-9.-]*$/i.test(arg))
|
|
63
|
+
return false;
|
|
64
|
+
// A dotted quad has no letters — bare IPs stay undetermined by design.
|
|
65
|
+
if (!/[a-z]/i.test(arg) && arg !== 'localhost')
|
|
66
|
+
return false;
|
|
67
|
+
return arg.includes('.') || arg === 'localhost';
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Extract the network destinations a command could reach.
|
|
71
|
+
*
|
|
72
|
+
* Returns hostnames in lowercase plus UNKNOWN_HOST markers; an empty result
|
|
73
|
+
* means the command cannot reach the network at all. Order is unspecified;
|
|
74
|
+
* callers must treat the set collectively.
|
|
75
|
+
*/
|
|
76
|
+
export function extractEgressHosts(command) {
|
|
77
|
+
if (command.length === 0)
|
|
78
|
+
return [];
|
|
79
|
+
const hosts = new Set();
|
|
80
|
+
const isNetworkTool = NETWORK_TOOLS.has(basenameOf(command[0]));
|
|
81
|
+
for (let i = 1; i < command.length; i += 1) {
|
|
82
|
+
const arg = command[i];
|
|
83
|
+
if (/^https?:\/\//i.test(arg)) {
|
|
84
|
+
try {
|
|
85
|
+
const url = new URL(arg);
|
|
86
|
+
if (url.hostname.length > 0) {
|
|
87
|
+
hosts.add(url.hostname.toLowerCase());
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
hosts.add(UNKNOWN_HOST);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
// Malformed URL literal still proves network intent.
|
|
95
|
+
hosts.add(UNKNOWN_HOST);
|
|
96
|
+
}
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (isNetworkTool && TARGET_FLAGS.has(arg)) {
|
|
100
|
+
const value = command[i + 1];
|
|
101
|
+
if (value !== undefined) {
|
|
102
|
+
if (/^https?:\/\//i.test(value)) {
|
|
103
|
+
try {
|
|
104
|
+
const url = new URL(value);
|
|
105
|
+
if (url.hostname.length > 0)
|
|
106
|
+
hosts.add(url.hostname.toLowerCase());
|
|
107
|
+
else
|
|
108
|
+
hosts.add(UNKNOWN_HOST);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
hosts.add(UNKNOWN_HOST);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
else if (looksLikeHostname(value)) {
|
|
115
|
+
hosts.add(value.toLowerCase());
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
hosts.add(UNKNOWN_HOST);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (isNetworkTool) {
|
|
125
|
+
// Find the positional target: first operand that is neither a flag nor
|
|
126
|
+
// the consumed value of a value-taking flag.
|
|
127
|
+
let skipNext = false;
|
|
128
|
+
let positional;
|
|
129
|
+
for (let i = 1; i < command.length; i += 1) {
|
|
130
|
+
const arg = command[i];
|
|
131
|
+
if (skipNext) {
|
|
132
|
+
skipNext = false;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (arg.startsWith('-')) {
|
|
136
|
+
if (VALUE_TAKING_FLAGS.has(arg))
|
|
137
|
+
skipNext = true;
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
positional = arg;
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
const hasExplicitTarget = positional !== undefined ||
|
|
144
|
+
command.some((a) => /^https?:\/\//i.test(a)) ||
|
|
145
|
+
command.some((a) => TARGET_FLAGS.has(a));
|
|
146
|
+
if (!hasExplicitTarget) {
|
|
147
|
+
hosts.add(UNKNOWN_HOST);
|
|
148
|
+
}
|
|
149
|
+
else if (positional !== undefined) {
|
|
150
|
+
// A path (/x/y) or :port suffix does not make the host undeterminable —
|
|
151
|
+
// only strip them and judge the host component. Genuinely
|
|
152
|
+
// undeterminable targets (bare IPs, encoded junk) fail the hostname
|
|
153
|
+
// check and land in UNKNOWN_HOST by design.
|
|
154
|
+
const candidate = positional.split('/')[0].split(':')[0];
|
|
155
|
+
if (looksLikeHostname(candidate)) {
|
|
156
|
+
hosts.add(candidate.toLowerCase());
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
hosts.add(UNKNOWN_HOST);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return Array.from(hosts);
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Anchored allowlist matching. '*.suffix' matches exactly one leading label
|
|
167
|
+
* plus '.suffix': api.openai.com yes, openai.com no, a.b.openai.com no,
|
|
168
|
+
* evil.openai.com.attacker.io no. Plain entries are exact (case-insensitive).
|
|
169
|
+
*/
|
|
170
|
+
export function hostIsAllowed(host, allow) {
|
|
171
|
+
const normalized = host.toLowerCase();
|
|
172
|
+
if (normalized === UNKNOWN_HOST)
|
|
173
|
+
return false;
|
|
174
|
+
for (const entry of allow) {
|
|
175
|
+
const e = entry.toLowerCase();
|
|
176
|
+
if (e.startsWith('*.')) {
|
|
177
|
+
const rest = e.slice(1); // ".openai.com"
|
|
178
|
+
if (normalized.endsWith(rest)) {
|
|
179
|
+
const prefix = normalized.slice(0, normalized.length - rest.length);
|
|
180
|
+
// Exactly one label before the suffix: no dots left of it.
|
|
181
|
+
if (prefix.length > 0 && !prefix.includes('.'))
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
else if (normalized === e) {
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
//# sourceMappingURL=egress.js.map
|
package/dist/exec.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { SecretValue, ExecResult } from '@envseal/protocol';
|
|
2
|
+
import type { ProjectPaths } from './paths.js';
|
|
2
3
|
/**
|
|
3
4
|
* Residual risk on Linux: A same-uid process can read /proc/<pid>/environ
|
|
4
5
|
* of the child process. This cannot be defended against without sandboxing.
|
|
@@ -48,9 +49,27 @@ export interface ExecOptions {
|
|
|
48
49
|
command: string[];
|
|
49
50
|
keys: string[];
|
|
50
51
|
networkEgress: boolean;
|
|
52
|
+
/** Extracted network destinations; present whenever networkEgress is true. */
|
|
53
|
+
egressHosts: string[];
|
|
51
54
|
target: TargetInfo;
|
|
52
55
|
}) => Promise<boolean>;
|
|
53
56
|
approvedCommands?: string[];
|
|
57
|
+
/**
|
|
58
|
+
* When provided, every execution is audited: a 'use' record lands after
|
|
59
|
+
* consent succeeds and immediately before spawn, and a 'use_result' when
|
|
60
|
+
* the child exits. Denied or refused consent records nothing.
|
|
61
|
+
*/
|
|
62
|
+
auditPaths?: ProjectPaths;
|
|
63
|
+
/**
|
|
64
|
+
* Declarative egress restriction from the manifest (policy.egress).
|
|
65
|
+
* mode 'allowlist' refuses any network-touching command whose extracted
|
|
66
|
+
* hosts are not all allowed — BEFORE the consent dialog opens. Absent
|
|
67
|
+
* policy means 'warn': egress only adds a warning flag to the dialog.
|
|
68
|
+
*/
|
|
69
|
+
egressPolicy?: {
|
|
70
|
+
mode: 'warn' | 'allowlist';
|
|
71
|
+
allow: string[];
|
|
72
|
+
};
|
|
54
73
|
}
|
|
55
74
|
export declare function runWithSecrets(command: string[], secrets: Map<string, SecretValue>, opts?: ExecOptions): Promise<ExecResult>;
|
|
56
75
|
//# sourceMappingURL=exec.d.ts.map
|
package/dist/exec.js
CHANGED
|
@@ -5,38 +5,10 @@ import { stat } from 'node:fs/promises';
|
|
|
5
5
|
import { resolve as resolvePath } from 'node:path';
|
|
6
6
|
import { SepError } from '@envseal/protocol';
|
|
7
7
|
import { redact } from './redact.js';
|
|
8
|
+
import { appendAudit } from './audit.js';
|
|
9
|
+
import { extractEgressHosts, hostIsAllowed } from './egress.js';
|
|
8
10
|
import { unsafeSecretToUtf8 } from './sinks/dotenv.js';
|
|
9
|
-
const NETWORK_TOOLS = new Set([
|
|
10
|
-
'curl',
|
|
11
|
-
'wget',
|
|
12
|
-
'nc',
|
|
13
|
-
'ncat',
|
|
14
|
-
'netcat',
|
|
15
|
-
'ssh',
|
|
16
|
-
'scp',
|
|
17
|
-
'rsync',
|
|
18
|
-
'http',
|
|
19
|
-
'httpie',
|
|
20
|
-
'telnet',
|
|
21
|
-
'socat',
|
|
22
|
-
]);
|
|
23
|
-
/** Nothing remotely path-shaped is longer than this on any supported OS. */
|
|
24
11
|
const MAX_PATHISH_CHARS = 4096;
|
|
25
|
-
function detectNetworkEgress(command) {
|
|
26
|
-
if (command.length === 0) {
|
|
27
|
-
return false;
|
|
28
|
-
}
|
|
29
|
-
const basename = command[0].split(/[\\/]/).pop()?.toLowerCase() ?? '';
|
|
30
|
-
if (NETWORK_TOOLS.has(basename)) {
|
|
31
|
-
return true;
|
|
32
|
-
}
|
|
33
|
-
for (const arg of command) {
|
|
34
|
-
if (/^https?:\/\//.test(arg)) {
|
|
35
|
-
return true;
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
return false;
|
|
39
|
-
}
|
|
40
12
|
/**
|
|
41
13
|
* T11 hardening: hash the files the command names, so approval binds to
|
|
42
14
|
* content rather than to displayed text. Streaming read — a multi-gigabyte
|
|
@@ -138,7 +110,21 @@ export async function runWithSecrets(command, secrets, opts) {
|
|
|
138
110
|
userMessage: 'Command cannot be empty',
|
|
139
111
|
});
|
|
140
112
|
}
|
|
141
|
-
const
|
|
113
|
+
const egressHosts = extractEgressHosts(command);
|
|
114
|
+
const networkEgress = egressHosts.length > 0;
|
|
115
|
+
// Allowlist enforcement precedes every dialog: a policy refusal is not a
|
|
116
|
+
// question for the user to answer, it is the project's standing rule. An
|
|
117
|
+
// undetermined host ('(unknown)') never matches an allow entry, so bare-IP
|
|
118
|
+
// and encoded-target exfil is refused here by construction.
|
|
119
|
+
if (opts?.egressPolicy?.mode === 'allowlist') {
|
|
120
|
+
const denied = egressHosts.filter((host) => !hostIsAllowed(host, opts.egressPolicy.allow));
|
|
121
|
+
if (denied.length > 0) {
|
|
122
|
+
throw new SepError({
|
|
123
|
+
code: 'SEP_EGRESS_DENIED',
|
|
124
|
+
details: { hosts: denied },
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
142
128
|
const secretKeys = Array.from(secrets.keys());
|
|
143
129
|
const joinedCommand = command.join(' ');
|
|
144
130
|
const isApproved = opts?.approvedCommands?.some((approved) => approved === joinedCommand);
|
|
@@ -155,6 +141,7 @@ export async function runWithSecrets(command, secrets, opts) {
|
|
|
155
141
|
command,
|
|
156
142
|
keys: secretKeys,
|
|
157
143
|
networkEgress,
|
|
144
|
+
egressHosts,
|
|
158
145
|
target: approvedSnapshot.info,
|
|
159
146
|
});
|
|
160
147
|
if (!confirmed) {
|
|
@@ -184,6 +171,27 @@ export async function runWithSecrets(command, secrets, opts) {
|
|
|
184
171
|
redactionLabels.set(value, key);
|
|
185
172
|
}
|
|
186
173
|
const MAX_BUFFER = 1024 * 1024;
|
|
174
|
+
const startedAt = Date.now();
|
|
175
|
+
let exitSignal = null;
|
|
176
|
+
if (opts?.auditPaths) {
|
|
177
|
+
// What consent actually bound to: the content hashes of every named
|
|
178
|
+
// file, so the audit record stays meaningful even if the file is later
|
|
179
|
+
// rewritten or deleted.
|
|
180
|
+
const targetHashes = {};
|
|
181
|
+
for (const file of approvedSnapshot.info.hashedFiles) {
|
|
182
|
+
targetHashes[file.resolvedPath] = file.sha256;
|
|
183
|
+
}
|
|
184
|
+
// One filtered exit (PLAN.md principle 4): the persisted command goes
|
|
185
|
+
// through the same redaction engine as stdout/stderr, so a value the
|
|
186
|
+
// caller smuggled into argv cannot reach audit.jsonl either.
|
|
187
|
+
appendAudit(opts.auditPaths, {
|
|
188
|
+
type: 'use',
|
|
189
|
+
command: redact(joinedCommand, secretValues, redactionLabels).text,
|
|
190
|
+
keys: secretKeys,
|
|
191
|
+
networkEgress,
|
|
192
|
+
targetHashes,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
187
195
|
const proc = spawn(command[0], command.slice(1), {
|
|
188
196
|
cwd: opts?.cwd,
|
|
189
197
|
env: childEnv,
|
|
@@ -222,6 +230,14 @@ export async function runWithSecrets(command, secrets, opts) {
|
|
|
222
230
|
const stderrStr = stderr.toString('utf8');
|
|
223
231
|
const redactStdout = redact(stdoutStr, secretValues, redactionLabels);
|
|
224
232
|
const redactStderr = redact(stderrStr, secretValues, redactionLabels);
|
|
233
|
+
if (opts?.auditPaths) {
|
|
234
|
+
appendAudit(opts.auditPaths, {
|
|
235
|
+
type: 'use_result',
|
|
236
|
+
exitCode: code,
|
|
237
|
+
signal: exitSignal,
|
|
238
|
+
durationMs: Date.now() - startedAt,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
225
241
|
resolve({
|
|
226
242
|
exitCode,
|
|
227
243
|
stdout: redactStdout.text,
|
|
@@ -230,7 +246,8 @@ export async function runWithSecrets(command, secrets, opts) {
|
|
|
230
246
|
redactedCount: redactStdout.count + redactStderr.count,
|
|
231
247
|
});
|
|
232
248
|
};
|
|
233
|
-
proc.on('exit', (code) => {
|
|
249
|
+
proc.on('exit', (code, signal) => {
|
|
250
|
+
exitSignal = signal;
|
|
234
251
|
if (!timedOut) {
|
|
235
252
|
finish(code);
|
|
236
253
|
}
|
package/dist/paths.d.ts
CHANGED
|
@@ -11,4 +11,7 @@ export declare function projectPaths(root: string): ProjectPaths;
|
|
|
11
11
|
export declare function findProjectRoot(startDir: string): string;
|
|
12
12
|
export declare function ensureStateDir(paths: ProjectPaths): void;
|
|
13
13
|
export declare function loadOrCreateSalt(paths: ProjectPaths): Buffer;
|
|
14
|
+
export declare const HOOK_HEARTBEAT_FILE = "hook-heartbeat";
|
|
15
|
+
/** ISO timestamp of the hook's last observed run for a project, or null. */
|
|
16
|
+
export declare function readHookHeartbeat(root: string): string | null;
|
|
14
17
|
//# sourceMappingURL=paths.d.ts.map
|
package/dist/paths.js
CHANGED
|
@@ -84,4 +84,22 @@ export function loadOrCreateSalt(paths) {
|
|
|
84
84
|
chmodSync(paths.salt, 0o600);
|
|
85
85
|
return salt;
|
|
86
86
|
}
|
|
87
|
+
// --- Hook liveness heartbeat ------------------------------------------------
|
|
88
|
+
//
|
|
89
|
+
// The Claude Code PreToolUse hook refreshes this marker after every decision
|
|
90
|
+
// (at most once per minute), purely so `envseal doctor` can report WHEN the
|
|
91
|
+
// hook last ran instead of only whether its wiring exists. It is
|
|
92
|
+
// observational: never a gate, never an audit record. The marker name lives
|
|
93
|
+
// here so the writer (plugin) and the reader (doctor) cannot drift.
|
|
94
|
+
export const HOOK_HEARTBEAT_FILE = 'hook-heartbeat';
|
|
95
|
+
/** ISO timestamp of the hook's last observed run for a project, or null. */
|
|
96
|
+
export function readHookHeartbeat(root) {
|
|
97
|
+
try {
|
|
98
|
+
const raw = readFileSync(join(resolve(root), '.envseal', HOOK_HEARTBEAT_FILE), 'utf8').trim();
|
|
99
|
+
return raw === '' ? null : raw;
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
87
105
|
//# sourceMappingURL=paths.js.map
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ProjectPaths } from './paths.js';
|
|
2
|
+
/**
|
|
3
|
+
* Age tracking for stored secret bytes.
|
|
4
|
+
*
|
|
5
|
+
* The manifest's `rotation.maxAgeDays` policy is only actionable if we know
|
|
6
|
+
* when the stored value last changed. Nothing else records that: provisioning
|
|
7
|
+
* via `env_use` happens once, hand-written .env values never cross any envseal
|
|
8
|
+
* write path at all, and the audit log answers "who did what", not "how old
|
|
9
|
+
* are the bytes". So `Broker.describe()` lazily stamps a record the first time
|
|
10
|
+
* it sees a key, and re-stamps when the value's fingerprint changes.
|
|
11
|
+
*
|
|
12
|
+
* This is observational state, deliberately outside the audit chain: the
|
|
13
|
+
* caller of describe() is often the model itself, and a read that grows the
|
|
14
|
+
* audit log with model-attributed "rotation" events would manufacture
|
|
15
|
+
* evidence. The timestamp says the BYTES changed, never who changed them.
|
|
16
|
+
*/
|
|
17
|
+
export interface RotationRecord {
|
|
18
|
+
/** Fingerprint of the value this age stamp was computed for. */
|
|
19
|
+
fingerprint: string;
|
|
20
|
+
/** ISO timestamp of the first observation of this fingerprint. */
|
|
21
|
+
at: string;
|
|
22
|
+
}
|
|
23
|
+
export declare function loadRotationState(paths: ProjectPaths): Record<string, RotationRecord>;
|
|
24
|
+
/** Stamp (or re-stamp) a key's age marker. Returns the record written. */
|
|
25
|
+
export declare function recordRotation(paths: ProjectPaths, key: string, fingerprint: string, now?: Date): RotationRecord;
|
|
26
|
+
//# sourceMappingURL=rotation-state.d.ts.map
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, chmodSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { ensureStateDir } from './paths.js';
|
|
4
|
+
function statePath(paths) {
|
|
5
|
+
return join(paths.stateDir, 'rotation.json');
|
|
6
|
+
}
|
|
7
|
+
function load(paths) {
|
|
8
|
+
try {
|
|
9
|
+
const parsed = JSON.parse(readFileSync(statePath(paths), 'utf8'));
|
|
10
|
+
if (parsed !== null && typeof parsed === 'object')
|
|
11
|
+
return parsed;
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
// Missing or unreadable: no age known yet, reported as "due now is
|
|
15
|
+
// unknowable" rather than invented.
|
|
16
|
+
}
|
|
17
|
+
return {};
|
|
18
|
+
}
|
|
19
|
+
export function loadRotationState(paths) {
|
|
20
|
+
return load(paths);
|
|
21
|
+
}
|
|
22
|
+
/** Stamp (or re-stamp) a key's age marker. Returns the record written. */
|
|
23
|
+
export function recordRotation(paths, key, fingerprint, now = new Date()) {
|
|
24
|
+
ensureStateDir(paths);
|
|
25
|
+
const all = load(paths);
|
|
26
|
+
const record = { fingerprint, at: now.toISOString() };
|
|
27
|
+
all[key] = record;
|
|
28
|
+
const file = statePath(paths);
|
|
29
|
+
writeFileSync(file, `${JSON.stringify(all, null, 2)}\n`, 'utf8');
|
|
30
|
+
if (process.platform !== 'win32') {
|
|
31
|
+
try {
|
|
32
|
+
chmodSync(file, 0o600);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
// best effort
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return record;
|
|
39
|
+
}
|
|
40
|
+
//# sourceMappingURL=rotation-state.js.map
|
package/dist/sinks/dotenv.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { closeSync, fsyncSync, openSync, readFileSync, renameSync, unlinkSync, writeSync, chmodSync, } from 'node:fs';
|
|
1
|
+
import { appendFileSync, closeSync, fsyncSync, openSync, readFileSync, renameSync, unlinkSync, writeSync, chmodSync, } from 'node:fs';
|
|
2
2
|
import { execFileSync } from 'node:child_process';
|
|
3
3
|
import { dirname, basename, join, relative } from 'node:path';
|
|
4
4
|
import { randomBytes } from 'node:crypto';
|
|
@@ -223,6 +223,7 @@ function atomicWrite(paths, target, content) {
|
|
|
223
223
|
writeTempFile(tmp, content);
|
|
224
224
|
}
|
|
225
225
|
catch {
|
|
226
|
+
ensureSiblingTempIgnored(dirname(target));
|
|
226
227
|
tmp = sibling;
|
|
227
228
|
writeTempFile(tmp, content);
|
|
228
229
|
}
|
|
@@ -232,6 +233,7 @@ function atomicWrite(paths, target, content) {
|
|
|
232
233
|
catch (error) {
|
|
233
234
|
if (error.code !== 'EXDEV')
|
|
234
235
|
throw error;
|
|
236
|
+
ensureSiblingTempIgnored(dirname(target));
|
|
235
237
|
writeTempFile(sibling, content);
|
|
236
238
|
renameOverwrite(sibling, target);
|
|
237
239
|
}
|
|
@@ -253,6 +255,40 @@ function renameOverwrite(tmp, target) {
|
|
|
253
255
|
throw error;
|
|
254
256
|
}
|
|
255
257
|
}
|
|
258
|
+
const SIBLING_TEMP_IGNORE = '..env.*.tmp';
|
|
259
|
+
let siblingIgnoreWarned = false;
|
|
260
|
+
/**
|
|
261
|
+
* F-W7-3 residual hardening: a sibling staging temp holds the complete
|
|
262
|
+
* plaintext next to `.env`, and a `.env` gitignore entry does not match the
|
|
263
|
+
* name. Best-effort make the project `.gitignore` carry the pattern so a
|
|
264
|
+
* crash in exactly that window cannot leave the file stageable by an
|
|
265
|
+
* accidental `git add -A`. Append-once, never throws; a failure warns once
|
|
266
|
+
* on stderr with no path or error detail (either could carry secret-shaped
|
|
267
|
+
* text, same discipline as the audit mirror).
|
|
268
|
+
*/
|
|
269
|
+
function ensureSiblingTempIgnored(dir) {
|
|
270
|
+
try {
|
|
271
|
+
const gitignorePath = join(dir, '.gitignore');
|
|
272
|
+
let lines = [];
|
|
273
|
+
try {
|
|
274
|
+
lines = readFileSync(gitignorePath, 'utf8').split(/\r\n|\n|\r/);
|
|
275
|
+
}
|
|
276
|
+
catch (error) {
|
|
277
|
+
if (error.code !== 'ENOENT')
|
|
278
|
+
throw error;
|
|
279
|
+
}
|
|
280
|
+
if (lines.some((line) => line.trim() === SIBLING_TEMP_IGNORE)) {
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
appendFileSync(gitignorePath, `\n# envseal: plaintext staging temp for the fallback atomic-write path\n${SIBLING_TEMP_IGNORE}\n`, 'utf8');
|
|
284
|
+
}
|
|
285
|
+
catch {
|
|
286
|
+
if (!siblingIgnoreWarned) {
|
|
287
|
+
siblingIgnoreWarned = true;
|
|
288
|
+
process.stderr.write('envseal: staging temp could not be gitignored (gitignore unwritable) — check for ..env.*.tmp files after failures\n');
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
256
292
|
/**
|
|
257
293
|
* F-W7-4: every filesystem failure used to escape as a bare Node error, so the
|
|
258
294
|
* CLI never mapped it to exit code 5 and the message carried the target's
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@envseal/core",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
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.
|
|
26
|
-
"@envseal/detector": "0.1.
|
|
27
|
-
"@envseal/registry": "0.1.
|
|
28
|
-
"@envseal/prompters": "0.1.
|
|
25
|
+
"@envseal/protocol": "0.1.6",
|
|
26
|
+
"@envseal/detector": "0.1.6",
|
|
27
|
+
"@envseal/registry": "0.1.6",
|
|
28
|
+
"@envseal/prompters": "0.1.6"
|
|
29
29
|
},
|
|
30
30
|
"devDependencies": {
|
|
31
31
|
"fast-check": "^3.23.1"
|