@envseal/core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/LICENSE +201 -0
  2. package/dist/approvals.d.ts +13 -0
  3. package/dist/approvals.js +73 -0
  4. package/dist/audit.d.ts +42 -0
  5. package/dist/audit.js +37 -0
  6. package/dist/broker.d.ts +31 -0
  7. package/dist/broker.js +449 -0
  8. package/dist/exec.d.ts +18 -0
  9. package/dist/exec.js +148 -0
  10. package/dist/guard.d.ts +66 -0
  11. package/dist/guard.js +157 -0
  12. package/dist/index.d.ts +16 -0
  13. package/dist/index.js +15 -0
  14. package/dist/manifest.d.ts +24 -0
  15. package/dist/manifest.js +165 -0
  16. package/dist/paths.d.ts +14 -0
  17. package/dist/paths.js +87 -0
  18. package/dist/presence.d.ts +20 -0
  19. package/dist/presence.js +58 -0
  20. package/dist/redact.d.ts +20 -0
  21. package/dist/redact.js +338 -0
  22. package/dist/sinks/cli-sink-base.d.ts +88 -0
  23. package/dist/sinks/cli-sink-base.js +217 -0
  24. package/dist/sinks/doppler.d.ts +45 -0
  25. package/dist/sinks/doppler.js +198 -0
  26. package/dist/sinks/dotenv.d.ts +57 -0
  27. package/dist/sinks/dotenv.js +407 -0
  28. package/dist/sinks/keychain.d.ts +21 -0
  29. package/dist/sinks/keychain.js +333 -0
  30. package/dist/sinks/onepassword.d.ts +58 -0
  31. package/dist/sinks/onepassword.js +183 -0
  32. package/dist/sinks/registry.d.ts +4 -0
  33. package/dist/sinks/registry.js +63 -0
  34. package/dist/sinks/sops.d.ts +54 -0
  35. package/dist/sinks/sops.js +254 -0
  36. package/dist/sinks/types.d.ts +10 -0
  37. package/dist/sinks/types.js +2 -0
  38. package/dist/sinks/vault.d.ts +41 -0
  39. package/dist/sinks/vault.js +156 -0
  40. package/dist/tickets.d.ts +49 -0
  41. package/dist/tickets.js +179 -0
  42. package/dist/validation-state.d.ts +33 -0
  43. package/dist/validation-state.js +48 -0
  44. package/dist/verify.d.ts +8 -0
  45. package/dist/verify.js +133 -0
  46. package/package.json +38 -0
@@ -0,0 +1,179 @@
1
+ /**
2
+ * In-memory ticket store. Records live only for the lifetime of the process and
3
+ * are NEVER persisted anywhere. They hold metadata and per-key *outcomes*
4
+ * (strings only) — never secret values.
5
+ */
6
+ import { randomInt } from 'node:crypto';
7
+ import { ulid } from 'ulid';
8
+ const CROCKFORD_BASE32 = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
9
+ function makeNonce() {
10
+ let chars = '';
11
+ for (let i = 0; i < 8; i++) {
12
+ chars += CROCKFORD_BASE32[randomInt(CROCKFORD_BASE32.length)];
13
+ }
14
+ return `${chars.slice(0, 4)}-${chars.slice(4)}`;
15
+ }
16
+ const DEFAULT_TTL_MS = 600_000;
17
+ const SWEEP_INTERVAL_MS = 60_000;
18
+ /** setTimeout silently fires immediately past this, which would settle early. */
19
+ const MAX_TIMER_MS = 2_147_483_647;
20
+ export class TicketStore {
21
+ records = new Map();
22
+ waiters = new Map();
23
+ pendingAwaits = new Set();
24
+ timer;
25
+ defaultTtlMs;
26
+ constructor(options = {}) {
27
+ this.defaultTtlMs = options.ttlMs ?? DEFAULT_TTL_MS;
28
+ this.timer = setInterval(() => this.sweep(), options.sweepIntervalMs ?? SWEEP_INTERVAL_MS);
29
+ this.timer.unref();
30
+ }
31
+ create(opts) {
32
+ const createdAt = Date.now();
33
+ const record = {
34
+ ticket: ulid(),
35
+ nonce: makeNonce(),
36
+ keys: [...opts.keys],
37
+ reason: opts.reason,
38
+ surface: opts.surface,
39
+ createdAt,
40
+ expiresAt: createdAt + (opts.ttlMs ?? this.defaultTtlMs),
41
+ state: 'pending',
42
+ outcomes: new Map(),
43
+ };
44
+ this.records.set(record.ticket, record);
45
+ return record;
46
+ }
47
+ get(ticket) {
48
+ return this.records.get(ticket);
49
+ }
50
+ setOutcome(ticket, key, outcome) {
51
+ const record = this.records.get(ticket);
52
+ if (!record)
53
+ return;
54
+ record.outcomes.set(key, outcome);
55
+ }
56
+ resolve(ticket) {
57
+ const record = this.records.get(ticket);
58
+ if (!record || record.state !== 'pending')
59
+ return;
60
+ record.state = 'resolved';
61
+ this.bump(ticket);
62
+ }
63
+ cancel(ticket) {
64
+ const record = this.records.get(ticket);
65
+ if (!record || record.state !== 'pending')
66
+ return;
67
+ record.state = 'cancelled';
68
+ this.bump(ticket);
69
+ }
70
+ sweep(now = Date.now()) {
71
+ for (const record of this.records.values()) {
72
+ this.expireIfDue(record, now);
73
+ }
74
+ }
75
+ /**
76
+ * F-W7-6: expiry used to be computed only by the 60s sweep, so an await that
77
+ * outlived a shorter TTL reported `pending` rather than `expired`. Every
78
+ * caller that can observe a record evaluates `expiresAt` itself.
79
+ */
80
+ expireIfDue(record, now = Date.now()) {
81
+ if (record.state === 'pending' && record.expiresAt <= now) {
82
+ record.state = 'expired';
83
+ this.bump(record.ticket);
84
+ }
85
+ }
86
+ await(ticket, timeoutMs) {
87
+ return new Promise((resolvePromise) => {
88
+ const record = this.records.get(ticket);
89
+ if (!record) {
90
+ resolvePromise({ ticket, state: 'expired', keys: [] });
91
+ return;
92
+ }
93
+ this.expireIfDue(record);
94
+ let settled = false;
95
+ let timeout;
96
+ const listener = () => {
97
+ if (record.state !== 'pending')
98
+ finish();
99
+ };
100
+ const finish = () => {
101
+ if (settled)
102
+ return;
103
+ settled = true;
104
+ this.unsubscribe(ticket, listener);
105
+ this.pendingAwaits.delete(finish);
106
+ if (timeout !== undefined)
107
+ clearTimeout(timeout);
108
+ resolvePromise(this.toOutcome(record));
109
+ };
110
+ if (record.state !== 'pending') {
111
+ finish();
112
+ return;
113
+ }
114
+ // Wake at whichever comes first: the caller's timeout, or the ticket's
115
+ // own expiry.
116
+ const untilExpiry = Math.max(0, record.expiresAt - Date.now());
117
+ const delay = Math.min(Math.max(0, timeoutMs), untilExpiry, MAX_TIMER_MS);
118
+ timeout = setTimeout(() => {
119
+ this.expireIfDue(record);
120
+ finish();
121
+ }, delay);
122
+ // NOT unref'd. F-W7-6: an unref'd timer let the process exit with this
123
+ // promise never settled — Node reported "Detected unsettled top-level
124
+ // await" and exited 13 with no output at all. An outstanding await is an
125
+ // outstanding operation and must hold the loop open; it is cleared the
126
+ // moment the promise settles, and dispose() settles it too.
127
+ this.pendingAwaits.add(finish);
128
+ this.subscribe(ticket, listener);
129
+ });
130
+ }
131
+ dispose() {
132
+ clearInterval(this.timer);
133
+ for (const listeners of this.waiters.values()) {
134
+ for (const listener of listeners)
135
+ listener();
136
+ }
137
+ // Settle anything still waiting, so a disposed store can never leave a
138
+ // ref'd timer (or a caller's promise) hanging.
139
+ for (const finish of [...this.pendingAwaits])
140
+ finish();
141
+ this.pendingAwaits.clear();
142
+ this.waiters.clear();
143
+ this.records.clear();
144
+ }
145
+ toOutcome(record) {
146
+ const keys = [...record.outcomes.entries()].map(([key, outcome]) => ({ key, outcome }));
147
+ return { ticket: record.ticket, state: record.state, keys };
148
+ }
149
+ subscribe(ticket, listener) {
150
+ let set = this.waiters.get(ticket);
151
+ if (set === undefined) {
152
+ set = new Set();
153
+ this.waiters.set(ticket, set);
154
+ }
155
+ set.add(listener);
156
+ }
157
+ unsubscribe(ticket, listener) {
158
+ const set = this.waiters.get(ticket);
159
+ if (set === undefined)
160
+ return;
161
+ set.delete(listener);
162
+ if (set.size === 0)
163
+ this.waiters.delete(ticket);
164
+ }
165
+ bump(ticket) {
166
+ const set = this.waiters.get(ticket);
167
+ if (set === undefined)
168
+ return;
169
+ for (const listener of set) {
170
+ try {
171
+ listener();
172
+ }
173
+ catch {
174
+ // listeners must never throw out of a state transition
175
+ }
176
+ }
177
+ }
178
+ }
179
+ //# sourceMappingURL=tickets.js.map
@@ -0,0 +1,33 @@
1
+ import type { ProjectPaths } from './paths.js';
2
+ /**
3
+ * Persisted format-validation outcomes.
4
+ *
5
+ * This exists to close a chosen-predicate oracle. `env_describe` used to compile
6
+ * the manifest's `format.pattern` — a value the MODEL supplies via `env_declare`
7
+ * — and test it against the live stored secret, returning the boolean. A model
8
+ * could therefore declare `^sk-a`, read the answer, declare `^sk-b`, and
9
+ * reconstruct the whole value one predicate at a time: W2's probe recovered a
10
+ * 33-character secret in 424 calls, with no user interaction and nothing
11
+ * secret-derived ever crossing the wire. The redactor is structurally incapable
12
+ * of catching that, because the value is reassembled from booleans inside the
13
+ * model's own context.
14
+ *
15
+ * The fix is to make the answer not depend on a model-chosen question: validate
16
+ * once, when the value is stored, and afterwards report the recorded outcome.
17
+ * Re-declaring a different pattern changes what future entries are checked
18
+ * against; it cannot re-interrogate a value that is already stored.
19
+ */
20
+ export interface ValidationRecord {
21
+ /** Fingerprint of the value this outcome was computed for. */
22
+ fingerprint: string;
23
+ formatValid: boolean;
24
+ at: string;
25
+ }
26
+ export declare function recordValidation(paths: ProjectPaths, key: string, fingerprint: string, formatValid: boolean): void;
27
+ /**
28
+ * The recorded outcome for a value, or null when we have not validated THIS
29
+ * value. Null means "unknown" and must be reported as such — guessing here
30
+ * would reopen the oracle.
31
+ */
32
+ export declare function getValidation(paths: ProjectPaths, key: string, fingerprint: string): boolean | null;
33
+ //# sourceMappingURL=validation-state.d.ts.map
@@ -0,0 +1,48 @@
1
+ import { readFileSync, writeFileSync, chmodSync } from 'node:fs';
2
+ import { ensureStateDir } from './paths.js';
3
+ function statePath(paths) {
4
+ return paths.stateDir.endsWith('.envseal')
5
+ ? `${paths.stateDir}/validation.json`
6
+ : `${paths.stateDir}/validation.json`;
7
+ }
8
+ function load(paths) {
9
+ try {
10
+ const parsed = JSON.parse(readFileSync(statePath(paths), 'utf8'));
11
+ if (parsed !== null && typeof parsed === 'object')
12
+ return parsed;
13
+ }
14
+ catch {
15
+ // Missing or unreadable: no recorded outcomes, which is reported as unknown
16
+ // rather than silently re-evaluated.
17
+ }
18
+ return {};
19
+ }
20
+ export function recordValidation(paths, key, fingerprint, formatValid) {
21
+ ensureStateDir(paths);
22
+ const all = load(paths);
23
+ all[key] = { fingerprint, formatValid, at: new Date().toISOString() };
24
+ const file = statePath(paths);
25
+ writeFileSync(file, `${JSON.stringify(all, null, 2)}\n`, 'utf8');
26
+ if (process.platform !== 'win32') {
27
+ try {
28
+ chmodSync(file, 0o600);
29
+ }
30
+ catch {
31
+ // best effort
32
+ }
33
+ }
34
+ }
35
+ /**
36
+ * The recorded outcome for a value, or null when we have not validated THIS
37
+ * value. Null means "unknown" and must be reported as such — guessing here
38
+ * would reopen the oracle.
39
+ */
40
+ export function getValidation(paths, key, fingerprint) {
41
+ const record = load(paths)[key];
42
+ if (record === undefined)
43
+ return null;
44
+ if (record.fingerprint !== fingerprint)
45
+ return null;
46
+ return record.formatValid;
47
+ }
48
+ //# sourceMappingURL=validation-state.js.map
@@ -0,0 +1,8 @@
1
+ import type { ManifestEntry, SecretValue, VerifyResult } from '@envseal/protocol';
2
+ import type { ProjectPaths } from './paths.js';
3
+ export interface VerifyOptions {
4
+ timeoutMs?: number;
5
+ onApprovalNeeded?: (entry: ManifestEntry) => Promise<boolean>;
6
+ }
7
+ export declare function verifyKey(paths: ProjectPaths, entry: ManifestEntry, value: SecretValue, opts?: VerifyOptions): Promise<VerifyResult>;
8
+ //# sourceMappingURL=verify.d.ts.map
package/dist/verify.js ADDED
@@ -0,0 +1,133 @@
1
+ import { isHostAllowlisted, isProbeApproved, recordProbeApproval } from './approvals.js';
2
+ import { redact } from './redact.js';
3
+ import { unsafeSecretToUtf8 } from './sinks/dotenv.js';
4
+ export async function verifyKey(paths, entry, value, opts) {
5
+ const now = new Date().toISOString();
6
+ if (!entry.verify) {
7
+ return {
8
+ key: entry.key,
9
+ result: 'no_probe',
10
+ message: 'No verification probe configured',
11
+ checkedAt: now,
12
+ };
13
+ }
14
+ const { url, method, headerTemplate, expectStatus } = entry.verify;
15
+ if (!url.startsWith('https://')) {
16
+ return {
17
+ key: entry.key,
18
+ result: 'network_error',
19
+ message: 'Probe URL must use https://',
20
+ checkedAt: now,
21
+ };
22
+ }
23
+ if (url.includes('{{value}}')) {
24
+ return {
25
+ key: entry.key,
26
+ result: 'network_error',
27
+ message: 'Probe URL must not contain {{value}}',
28
+ checkedAt: now,
29
+ };
30
+ }
31
+ const urlObj = new URL(url);
32
+ const hostname = urlObj.hostname;
33
+ const allowlisted = isHostAllowlisted(url);
34
+ const approved = isProbeApproved(paths, entry);
35
+ if (!allowlisted && !approved) {
36
+ if (opts?.onApprovalNeeded) {
37
+ const userApproved = await opts.onApprovalNeeded(entry);
38
+ if (userApproved) {
39
+ recordProbeApproval(paths, entry);
40
+ }
41
+ else {
42
+ return {
43
+ key: entry.key,
44
+ result: 'probe_not_approved',
45
+ message: `Probe to ${hostname} requires approval`,
46
+ checkedAt: now,
47
+ };
48
+ }
49
+ }
50
+ else {
51
+ return {
52
+ key: entry.key,
53
+ result: 'probe_not_approved',
54
+ message: `Probe to ${hostname} requires approval`,
55
+ checkedAt: now,
56
+ };
57
+ }
58
+ }
59
+ const valueStr = unsafeSecretToUtf8(value);
60
+ const headers = {};
61
+ for (const [key, templateVal] of Object.entries(headerTemplate)) {
62
+ headers[key] = templateVal.replace(/\{\{value\}\}/g, valueStr);
63
+ }
64
+ const timeoutMs = opts?.timeoutMs ?? 10000;
65
+ const controller = new AbortController();
66
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
67
+ try {
68
+ const response = await fetch(url, {
69
+ method,
70
+ headers,
71
+ redirect: 'manual',
72
+ signal: controller.signal,
73
+ });
74
+ const statusCode = response.status;
75
+ const expectedStatuses = expectStatus ?? [200];
76
+ if (expectedStatuses.includes(statusCode)) {
77
+ return {
78
+ key: entry.key,
79
+ result: 'ok',
80
+ message: `HTTP ${statusCode} from ${hostname}`,
81
+ checkedAt: now,
82
+ };
83
+ }
84
+ if (statusCode === 401) {
85
+ return {
86
+ key: entry.key,
87
+ result: 'auth_failed',
88
+ message: `HTTP 401 from ${hostname}`,
89
+ checkedAt: now,
90
+ };
91
+ }
92
+ if (statusCode === 403) {
93
+ return {
94
+ key: entry.key,
95
+ result: 'forbidden',
96
+ message: `HTTP 403 from ${hostname}`,
97
+ checkedAt: now,
98
+ };
99
+ }
100
+ if (statusCode === 429) {
101
+ return {
102
+ key: entry.key,
103
+ result: 'rate_limited',
104
+ message: `HTTP 429 from ${hostname}`,
105
+ checkedAt: now,
106
+ };
107
+ }
108
+ return {
109
+ key: entry.key,
110
+ result: 'auth_failed',
111
+ message: `HTTP ${statusCode} from ${hostname}`,
112
+ checkedAt: now,
113
+ };
114
+ }
115
+ catch (error) {
116
+ const message = error instanceof Error ? error.message : 'Unknown error';
117
+ let result = 'network_error';
118
+ if (message.includes('abort')) {
119
+ result = 'network_error';
120
+ }
121
+ const sanitized = redact(message, [value]).text;
122
+ return {
123
+ key: entry.key,
124
+ result,
125
+ message: sanitized,
126
+ checkedAt: now,
127
+ };
128
+ }
129
+ finally {
130
+ clearTimeout(timeout);
131
+ }
132
+ }
133
+ //# sourceMappingURL=verify.js.map
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@envseal/core",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "license": "Apache-2.0",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "default": "./dist/index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "!dist/**/*.map"
17
+ ],
18
+ "publishConfig": {
19
+ "access": "public",
20
+ "provenance": true
21
+ },
22
+ "dependencies": {
23
+ "jsonc-parser": "^3.3.1",
24
+ "ulid": "^2.3.0",
25
+ "@envseal/protocol": "0.1.0",
26
+ "@envseal/registry": "0.1.0",
27
+ "@envseal/detector": "0.1.0",
28
+ "@envseal/prompters": "0.1.0"
29
+ },
30
+ "devDependencies": {
31
+ "fast-check": "^3.23.1"
32
+ },
33
+ "scripts": {
34
+ "build": "tsc -p tsconfig.json",
35
+ "typecheck": "tsc -p tsconfig.json --noEmit",
36
+ "test": "vitest run"
37
+ }
38
+ }