@envseal/core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/dist/approvals.d.ts +13 -0
- package/dist/approvals.js +73 -0
- package/dist/audit.d.ts +42 -0
- package/dist/audit.js +37 -0
- package/dist/broker.d.ts +31 -0
- package/dist/broker.js +449 -0
- package/dist/exec.d.ts +18 -0
- package/dist/exec.js +148 -0
- package/dist/guard.d.ts +66 -0
- package/dist/guard.js +157 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +15 -0
- package/dist/manifest.d.ts +24 -0
- package/dist/manifest.js +165 -0
- package/dist/paths.d.ts +14 -0
- package/dist/paths.js +87 -0
- package/dist/presence.d.ts +20 -0
- package/dist/presence.js +58 -0
- package/dist/redact.d.ts +20 -0
- package/dist/redact.js +338 -0
- package/dist/sinks/cli-sink-base.d.ts +88 -0
- package/dist/sinks/cli-sink-base.js +217 -0
- package/dist/sinks/doppler.d.ts +45 -0
- package/dist/sinks/doppler.js +198 -0
- package/dist/sinks/dotenv.d.ts +57 -0
- package/dist/sinks/dotenv.js +407 -0
- package/dist/sinks/keychain.d.ts +21 -0
- package/dist/sinks/keychain.js +333 -0
- package/dist/sinks/onepassword.d.ts +58 -0
- package/dist/sinks/onepassword.js +183 -0
- package/dist/sinks/registry.d.ts +4 -0
- package/dist/sinks/registry.js +63 -0
- package/dist/sinks/sops.d.ts +54 -0
- package/dist/sinks/sops.js +254 -0
- package/dist/sinks/types.d.ts +10 -0
- package/dist/sinks/types.js +2 -0
- package/dist/sinks/vault.d.ts +41 -0
- package/dist/sinks/vault.js +156 -0
- package/dist/tickets.d.ts +49 -0
- package/dist/tickets.js +179 -0
- package/dist/validation-state.d.ts +33 -0
- package/dist/validation-state.js +48 -0
- package/dist/verify.d.ts +8 -0
- package/dist/verify.js +133 -0
- package/package.json +38 -0
package/dist/guard.js
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { detect } from '@envseal/detector';
|
|
2
|
+
import { SepError } from '@envseal/protocol';
|
|
3
|
+
function tierAccepts(tier, confidence) {
|
|
4
|
+
return tier === 'strict' || confidence === 'high';
|
|
5
|
+
}
|
|
6
|
+
/** Minimum run of one repeated character before a span reads as filler. */
|
|
7
|
+
const MIN_PLACEHOLDER_RUN = 8;
|
|
8
|
+
/** …and how much of the span that run must cover. */
|
|
9
|
+
const MIN_PLACEHOLDER_RUN_RATIO = 0.4;
|
|
10
|
+
function longestRun(span) {
|
|
11
|
+
let best = 0;
|
|
12
|
+
let run = 0;
|
|
13
|
+
let previous = '';
|
|
14
|
+
for (const ch of span) {
|
|
15
|
+
run = ch === previous ? run + 1 : 1;
|
|
16
|
+
previous = ch;
|
|
17
|
+
if (run > best)
|
|
18
|
+
best = run;
|
|
19
|
+
}
|
|
20
|
+
return best;
|
|
21
|
+
}
|
|
22
|
+
function collapseRuns(span) {
|
|
23
|
+
return span.replace(/([\s\S])\1{2,}/g, '$1');
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* `sk-XXXXXXXXXXXXXXXXXXXX`, `AKIA11111111111111XX`, `ghp_XXXX…` — filler, not
|
|
27
|
+
* credentials. This filter is load-bearing rather than a nicety: 26 of the 393
|
|
28
|
+
* strings shipped in `packages/registry/providers/*.json` are placeholders of
|
|
29
|
+
* exactly this shape, and `Broker.declare` copies a registry `format` onto any
|
|
30
|
+
* entry that omits one. Without the filter, declaring any registry-known key —
|
|
31
|
+
* and every `envseal init` on a project that mentions one — would fail closed
|
|
32
|
+
* against our own bundled data.
|
|
33
|
+
*
|
|
34
|
+
* Padding a real credential with filler does not get past this. The run has to
|
|
35
|
+
* dominate the span (≥40%), and even then the span is re-scanned with its runs
|
|
36
|
+
* collapsed: `sk-proj-<20 real chars>XXXXXXXXXXXXXXXXXXXX` still matches after
|
|
37
|
+
* collapsing, so it is not treated as a placeholder. Random key material
|
|
38
|
+
* essentially never contains an 8-character run — for a 48-character base62
|
|
39
|
+
* body the probability is on the order of 1e-11.
|
|
40
|
+
*/
|
|
41
|
+
function isRunPlaceholder(span) {
|
|
42
|
+
const run = longestRun(span);
|
|
43
|
+
if (run < MIN_PLACEHOLDER_RUN)
|
|
44
|
+
return false;
|
|
45
|
+
if (run / span.length < MIN_PLACEHOLDER_RUN_RATIO)
|
|
46
|
+
return false;
|
|
47
|
+
return detect(collapseRuns(span)).length === 0;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Words that carry no key material, so a span built only from them is a
|
|
51
|
+
* template rather than a value. This exists for one shape that the run filter
|
|
52
|
+
* cannot see: `postgresql://USERNAME:PASSWORD@localhost:5432/mydb`, which the
|
|
53
|
+
* connection-string pattern matches at high confidence and which is the most
|
|
54
|
+
* natural thing to write in a DATABASE_URL description.
|
|
55
|
+
*
|
|
56
|
+
* Matching is per whole segment, never per substring, so a real password sitting
|
|
57
|
+
* next to these words still rejects: `postgres://user:hunter2ThisIsReal@host`
|
|
58
|
+
* has a segment that is not in this list.
|
|
59
|
+
*/
|
|
60
|
+
const PLACEHOLDER_WORDS = new Set([
|
|
61
|
+
'postgres', 'postgresql', 'mysql', 'mongodb', 'srv', 'redis', 'amqp',
|
|
62
|
+
'user', 'username', 'pass', 'password', 'passwd', 'host', 'hostname',
|
|
63
|
+
'localhost', 'port', 'dbname', 'database', 'example', 'com', 'net', 'org',
|
|
64
|
+
'your', 'key', 'apikey', 'token', 'secret', 'value', 'redacted', 'changeme',
|
|
65
|
+
'placeholder', 'foo', 'bar', 'baz', 'qux', 'xxx', 'yyy', 'zzz', 'abc',
|
|
66
|
+
'none', 'null', 'here', 'todo', 'sample', 'dummy',
|
|
67
|
+
]);
|
|
68
|
+
function isVocabularyPlaceholder(span) {
|
|
69
|
+
const segments = span.match(/[A-Za-z0-9]{3,}/g);
|
|
70
|
+
if (segments === null)
|
|
71
|
+
return false;
|
|
72
|
+
return segments.every((segment) => PLACEHOLDER_WORDS.has(segment.toLowerCase()));
|
|
73
|
+
}
|
|
74
|
+
function isPlaceholder(span) {
|
|
75
|
+
return isRunPlaceholder(span) || isVocabularyPlaceholder(span);
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* The first credential-shaped span in `text`, or null. The returned finding
|
|
79
|
+
* deliberately carries no offsets and no excerpt — an offset pair plus a
|
|
80
|
+
* repeatable call is itself a substring oracle over the input.
|
|
81
|
+
*/
|
|
82
|
+
export function scanText(path, text, tier) {
|
|
83
|
+
for (const detection of detect(text)) {
|
|
84
|
+
if (!tierAccepts(tier, detection.confidence))
|
|
85
|
+
continue;
|
|
86
|
+
if (isPlaceholder(text.slice(detection.start, detection.end)))
|
|
87
|
+
continue;
|
|
88
|
+
return { path, label: detection.label, confidence: detection.confidence };
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
function scanOptional(path, text, tier) {
|
|
93
|
+
return text === undefined ? null : scanText(path, text, tier);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Every free-text string on a parsed entry that reaches `env.schema.jsonc`.
|
|
97
|
+
*
|
|
98
|
+
* `key` is scanned at `high-only`, alone among the fields. It is the one field
|
|
99
|
+
* whose content is not the caller's to rephrase — it is the environment
|
|
100
|
+
* variable's actual name — so a false positive there is unfixable rather than
|
|
101
|
+
* merely annoying, and the generic entropy tier does produce them:
|
|
102
|
+
* `STRIPE_WEBHOOK_SIGNING_SECRET_V2` is a plausible name that scores as a
|
|
103
|
+
* medium-confidence hit. High-confidence patterns still apply, so a key named
|
|
104
|
+
* `AKIAT7QLM2XP9RV4NC3B` is rejected. Non-string fields (booleans, numbers,
|
|
105
|
+
* `sink`, `verify.method`) cannot carry a value past their zod types and are
|
|
106
|
+
* not scanned.
|
|
107
|
+
*/
|
|
108
|
+
export function scanManifestEntry(entry, basePath) {
|
|
109
|
+
const at = (field) => `${basePath}.${field}`;
|
|
110
|
+
const findings = [
|
|
111
|
+
scanText(at('key'), entry.key, 'high-only'),
|
|
112
|
+
scanText(at('description'), entry.description, 'strict'),
|
|
113
|
+
scanOptional(at('format.pattern'), entry.format?.pattern, 'strict'),
|
|
114
|
+
scanOptional(at('format.example'), entry.format?.example, 'strict'),
|
|
115
|
+
scanOptional(at('provider.id'), entry.provider?.id, 'strict'),
|
|
116
|
+
scanOptional(at('provider.name'), entry.provider?.name, 'strict'),
|
|
117
|
+
scanOptional(at('provider.signupUrl'), entry.provider?.signupUrl, 'strict'),
|
|
118
|
+
scanOptional(at('provider.docsUrl'), entry.provider?.docsUrl, 'strict'),
|
|
119
|
+
scanOptional(at('provider.rotateUrl'), entry.provider?.rotateUrl, 'strict'),
|
|
120
|
+
scanOptional(at('verify.url'), entry.verify?.url, 'strict'),
|
|
121
|
+
];
|
|
122
|
+
const scopes = entry.provider?.scopesNeeded ?? [];
|
|
123
|
+
for (const [index, scope] of scopes.entries()) {
|
|
124
|
+
findings.push(scanText(at(`provider.scopesNeeded[${index}]`), scope, 'strict'));
|
|
125
|
+
}
|
|
126
|
+
// Header VALUES are the sharpest edge on the entry: `{ "Authorization":
|
|
127
|
+
// "Bearer sk-proj-…" }` is both a manifest leak and a live credential handed
|
|
128
|
+
// to whatever host verify.url points at. Header NAMES are scanned too; they
|
|
129
|
+
// are equally free text.
|
|
130
|
+
for (const [name, template] of Object.entries(entry.verify?.headerTemplate ?? {})) {
|
|
131
|
+
findings.push(scanText(at(`verify.headerTemplate.${name}`), name, 'strict'));
|
|
132
|
+
findings.push(scanText(at(`verify.headerTemplate.${name}`), template, 'strict'));
|
|
133
|
+
}
|
|
134
|
+
return findings.find((finding) => finding !== null) ?? null;
|
|
135
|
+
}
|
|
136
|
+
export function secretInDeclarationError(finding) {
|
|
137
|
+
return new SepError({
|
|
138
|
+
code: 'SEP_VALUE_IN_REQUEST',
|
|
139
|
+
userMessage: `Refusing to declare: ${finding.path} looks like a real credential (${finding.label}). ` +
|
|
140
|
+
'Manifest fields are metadata and are committed to git, so they must never contain a value. ' +
|
|
141
|
+
'Describe the key instead — "starts with sk-proj-", or a placeholder such as ' +
|
|
142
|
+
'"sk-proj-XXXXXXXXXXXXXXXXXXXX" — and let the user supply the value through the prompt ' +
|
|
143
|
+
'that env_request opens. A value must never appear in a declaration.',
|
|
144
|
+
details: { field: finding.path, detected: finding.label, confidence: finding.confidence },
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
export function secretInRequestError(finding) {
|
|
148
|
+
return new SepError({
|
|
149
|
+
code: 'SEP_VALUE_IN_REQUEST',
|
|
150
|
+
userMessage: `Refusing to open a request: ${finding.path} looks like a real credential (${finding.label}). ` +
|
|
151
|
+
'The reason is written to the audit log, which records key names only. ' +
|
|
152
|
+
'Say why the key is needed without quoting any value — env_request exists so that the user ' +
|
|
153
|
+
'types the value into a prompt you never see, so it must never be in the request itself.',
|
|
154
|
+
details: { field: finding.path, detected: finding.label, confidence: finding.confidence },
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
//# sourceMappingURL=guard.js.map
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export * from './paths.js';
|
|
2
|
+
export * from './manifest.js';
|
|
3
|
+
export * from './presence.js';
|
|
4
|
+
export * from './redact.js';
|
|
5
|
+
export * from './tickets.js';
|
|
6
|
+
export * from './audit.js';
|
|
7
|
+
export * from './sinks/types.js';
|
|
8
|
+
export { parseDotenv, serializeDotenv, readDotenv, setDotenvValue, removeDotenvKey, DotenvSink, } from './sinks/dotenv.js';
|
|
9
|
+
export type { DotenvLine, ParsedDotenv, WriteDotenvOptions } from './sinks/dotenv.js';
|
|
10
|
+
export * from './approvals.js';
|
|
11
|
+
export * from './verify.js';
|
|
12
|
+
export * from './exec.js';
|
|
13
|
+
export * from './sinks/registry.js';
|
|
14
|
+
export { keychainSink } from './sinks/keychain.js';
|
|
15
|
+
export * from './broker.js';
|
|
16
|
+
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export * from './paths.js';
|
|
2
|
+
export * from './manifest.js';
|
|
3
|
+
export * from './presence.js';
|
|
4
|
+
export * from './redact.js';
|
|
5
|
+
export * from './tickets.js';
|
|
6
|
+
export * from './audit.js';
|
|
7
|
+
export * from './sinks/types.js';
|
|
8
|
+
export { parseDotenv, serializeDotenv, readDotenv, setDotenvValue, removeDotenvKey, DotenvSink, } from './sinks/dotenv.js';
|
|
9
|
+
export * from './approvals.js';
|
|
10
|
+
export * from './verify.js';
|
|
11
|
+
export * from './exec.js';
|
|
12
|
+
export * from './sinks/registry.js';
|
|
13
|
+
export { keychainSink } from './sinks/keychain.js';
|
|
14
|
+
export * from './broker.js';
|
|
15
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { DeclareResult } from '@envseal/protocol';
|
|
2
|
+
import type { Manifest as ManifestType } from '@envseal/protocol';
|
|
3
|
+
import type { ProjectPaths } from './paths.js';
|
|
4
|
+
export declare function emptyManifest(): ManifestType;
|
|
5
|
+
/**
|
|
6
|
+
* Load the manifest, or null when there is no manifest file.
|
|
7
|
+
*
|
|
8
|
+
* "Absent" and "unreadable" are different answers and must not share a return
|
|
9
|
+
* value. Collapsing them meant a truncated or schema-invalid manifest read as
|
|
10
|
+
* an empty one, and the next `env_declare` cheerfully wrote a fresh file over
|
|
11
|
+
* it — silently discarding every prior declaration while the corrupting field
|
|
12
|
+
* remained, so the cycle repeated. A corrupt manifest now throws; callers that
|
|
13
|
+
* genuinely tolerate absence still get null.
|
|
14
|
+
*/
|
|
15
|
+
export declare function loadManifest(paths: ProjectPaths): ManifestType | null;
|
|
16
|
+
export declare function saveManifest(paths: ProjectPaths, manifest: ManifestType): void;
|
|
17
|
+
/**
|
|
18
|
+
* Every write to `env.schema.jsonc` funnels through here — `Broker.declare` and
|
|
19
|
+
* the CLI's `init` both call it — so this is where the secret-shaped-input
|
|
20
|
+
* guard belongs. Placing it in `Broker.declare` alone would leave `envseal init`
|
|
21
|
+
* unguarded, and placing it after `saveManifest` would be no guard at all.
|
|
22
|
+
*/
|
|
23
|
+
export declare function declareEntries(paths: ProjectPaths, entries: unknown[]): DeclareResult;
|
|
24
|
+
//# sourceMappingURL=manifest.d.ts.map
|
package/dist/manifest.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
3
|
+
import * as jsonc from 'jsonc-parser';
|
|
4
|
+
import { DeclareResult, Manifest, ManifestEntry, SepError } from '@envseal/protocol';
|
|
5
|
+
import { appendAudit } from './audit.js';
|
|
6
|
+
import { scanManifestEntry, secretInDeclarationError } from './guard.js';
|
|
7
|
+
export function emptyManifest() {
|
|
8
|
+
return { version: 1, entries: [] };
|
|
9
|
+
}
|
|
10
|
+
function readFileIfPresent(path) {
|
|
11
|
+
try {
|
|
12
|
+
return readFileSync(path, 'utf8');
|
|
13
|
+
}
|
|
14
|
+
catch (error) {
|
|
15
|
+
if (error.code !== 'ENOENT')
|
|
16
|
+
throw error;
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function detectFormat(text) {
|
|
21
|
+
const eol = text.includes('\r\n') ? '\r\n' : '\n';
|
|
22
|
+
const indent = /^[ \t]+/m.exec(text)?.[0];
|
|
23
|
+
const insertSpaces = indent === undefined ? true : !indent.startsWith('\t');
|
|
24
|
+
const tabSize = indent !== undefined && insertSpaces ? indent.length : 2;
|
|
25
|
+
return { insertSpaces, tabSize, eol };
|
|
26
|
+
}
|
|
27
|
+
const MANIFEST_FIELDS = ['$schema', 'version', 'entries'];
|
|
28
|
+
/**
|
|
29
|
+
* Load the manifest, or null when there is no manifest file.
|
|
30
|
+
*
|
|
31
|
+
* "Absent" and "unreadable" are different answers and must not share a return
|
|
32
|
+
* value. Collapsing them meant a truncated or schema-invalid manifest read as
|
|
33
|
+
* an empty one, and the next `env_declare` cheerfully wrote a fresh file over
|
|
34
|
+
* it — silently discarding every prior declaration while the corrupting field
|
|
35
|
+
* remained, so the cycle repeated. A corrupt manifest now throws; callers that
|
|
36
|
+
* genuinely tolerate absence still get null.
|
|
37
|
+
*/
|
|
38
|
+
export function loadManifest(paths) {
|
|
39
|
+
const text = readFileIfPresent(paths.manifest);
|
|
40
|
+
if (text === null)
|
|
41
|
+
return null;
|
|
42
|
+
const errors = [];
|
|
43
|
+
const value = jsonc.parse(text, errors, {
|
|
44
|
+
disallowComments: false,
|
|
45
|
+
allowTrailingComma: false,
|
|
46
|
+
allowEmptyContent: false,
|
|
47
|
+
});
|
|
48
|
+
if (errors.length > 0) {
|
|
49
|
+
throw new SepError({
|
|
50
|
+
code: 'SEP_FORMAT_INVALID',
|
|
51
|
+
userMessage: `${paths.manifest} is not valid JSONC and was not overwritten. ` +
|
|
52
|
+
'Fix the syntax, or delete the file to start a fresh manifest.',
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
const result = Manifest.safeParse(value);
|
|
56
|
+
if (!result.success) {
|
|
57
|
+
const detail = result.error.issues
|
|
58
|
+
.slice(0, 3)
|
|
59
|
+
.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`)
|
|
60
|
+
.join('; ');
|
|
61
|
+
throw new SepError({
|
|
62
|
+
code: 'SEP_FORMAT_INVALID',
|
|
63
|
+
userMessage: `${paths.manifest} does not match the manifest schema and was not overwritten (${detail}). ` +
|
|
64
|
+
'Fix the file, or delete it to start a fresh manifest.',
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
return result.data;
|
|
68
|
+
}
|
|
69
|
+
function renderFreshManifest(manifest) {
|
|
70
|
+
const body = `{
|
|
71
|
+
"version": ${manifest.version},
|
|
72
|
+
"entries": ${JSON.stringify(manifest.entries, null, 2)}
|
|
73
|
+
}
|
|
74
|
+
`;
|
|
75
|
+
return [
|
|
76
|
+
'// envseal manifest — declares which environment variables this project uses.',
|
|
77
|
+
'// Values are NEVER stored here; only metadata. Declare with env_declare.',
|
|
78
|
+
'// JSON Schema: spec/sep-1/manifest.schema.json in the envseal repo (kept as a',
|
|
79
|
+
'// comment: a $schema field would dangle in every project but this one).',
|
|
80
|
+
body,
|
|
81
|
+
].join('\n');
|
|
82
|
+
}
|
|
83
|
+
export function saveManifest(paths, manifest) {
|
|
84
|
+
const text = readFileIfPresent(paths.manifest);
|
|
85
|
+
if (text === null) {
|
|
86
|
+
writeFileSync(paths.manifest, renderFreshManifest(manifest));
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const errors = [];
|
|
90
|
+
const existing = jsonc.parse(text, errors, { disallowComments: false });
|
|
91
|
+
if (typeof existing !== 'object' || existing === null) {
|
|
92
|
+
throw new SepError({
|
|
93
|
+
code: 'SEP_FORMAT_INVALID',
|
|
94
|
+
details: 'Cannot edit an unparseable manifest without losing its comments.',
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
const existingRecord = existing;
|
|
98
|
+
const formatting = detectFormat(text);
|
|
99
|
+
let out = text;
|
|
100
|
+
for (const field of MANIFEST_FIELDS) {
|
|
101
|
+
const next = manifest[field];
|
|
102
|
+
if (next === undefined)
|
|
103
|
+
continue;
|
|
104
|
+
if (isDeepStrictEqual(existingRecord[field], next))
|
|
105
|
+
continue;
|
|
106
|
+
const edits = jsonc.modify(out, [field], next, { formattingOptions: formatting });
|
|
107
|
+
out = jsonc.applyEdits(out, edits);
|
|
108
|
+
}
|
|
109
|
+
writeFileSync(paths.manifest, out);
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Every write to `env.schema.jsonc` funnels through here — `Broker.declare` and
|
|
113
|
+
* the CLI's `init` both call it — so this is where the secret-shaped-input
|
|
114
|
+
* guard belongs. Placing it in `Broker.declare` alone would leave `envseal init`
|
|
115
|
+
* unguarded, and placing it after `saveManifest` would be no guard at all.
|
|
116
|
+
*/
|
|
117
|
+
export function declareEntries(paths, entries) {
|
|
118
|
+
const manifest = loadManifest(paths) ?? emptyManifest();
|
|
119
|
+
const parsedEntries = [];
|
|
120
|
+
for (const [index, raw] of entries.entries()) {
|
|
121
|
+
const result = ManifestEntry.safeParse(raw);
|
|
122
|
+
if (!result.success) {
|
|
123
|
+
const hasUnrecognized = result.error.issues.some((issue) => issue.code === 'unrecognized_keys');
|
|
124
|
+
throw new SepError({
|
|
125
|
+
code: hasUnrecognized ? 'SEP_VALUE_IN_REQUEST' : 'SEP_FORMAT_INVALID',
|
|
126
|
+
details: result.error.flatten(),
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
const finding = scanManifestEntry(result.data, `entries[${index}]`);
|
|
130
|
+
if (finding !== null) {
|
|
131
|
+
// §2.2 T3 wants the rejection logged as well as surfaced. The record
|
|
132
|
+
// carries the field path and the pattern label — never the text that
|
|
133
|
+
// matched, which is the whole reason we are refusing the write.
|
|
134
|
+
appendAudit(paths, {
|
|
135
|
+
type: 'blocked',
|
|
136
|
+
reason: 'secret_in_declaration',
|
|
137
|
+
detail: `${finding.path}: ${finding.label}`,
|
|
138
|
+
});
|
|
139
|
+
throw secretInDeclarationError(finding);
|
|
140
|
+
}
|
|
141
|
+
parsedEntries.push(result.data);
|
|
142
|
+
}
|
|
143
|
+
const byKey = new Map(manifest.entries.map((entry) => [entry.key, entry]));
|
|
144
|
+
const added = [];
|
|
145
|
+
const updated = [];
|
|
146
|
+
const unchanged = [];
|
|
147
|
+
for (const entry of parsedEntries) {
|
|
148
|
+
const existing = byKey.get(entry.key);
|
|
149
|
+
if (existing === undefined) {
|
|
150
|
+
byKey.set(entry.key, entry);
|
|
151
|
+
added.push(entry.key);
|
|
152
|
+
}
|
|
153
|
+
else if (isDeepStrictEqual(existing, entry)) {
|
|
154
|
+
unchanged.push(entry.key);
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
byKey.set(entry.key, entry);
|
|
158
|
+
updated.push(entry.key);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
manifest.entries = [...byKey.values()];
|
|
162
|
+
saveManifest(paths, manifest);
|
|
163
|
+
return { added, updated, unchanged };
|
|
164
|
+
}
|
|
165
|
+
//# sourceMappingURL=manifest.js.map
|
package/dist/paths.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface ProjectPaths {
|
|
2
|
+
root: string;
|
|
3
|
+
manifest: string;
|
|
4
|
+
dotenv: string;
|
|
5
|
+
stateDir: string;
|
|
6
|
+
salt: string;
|
|
7
|
+
approvals: string;
|
|
8
|
+
audit: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function projectPaths(root: string): ProjectPaths;
|
|
11
|
+
export declare function findProjectRoot(startDir: string): string;
|
|
12
|
+
export declare function ensureStateDir(paths: ProjectPaths): void;
|
|
13
|
+
export declare function loadOrCreateSalt(paths: ProjectPaths): Buffer;
|
|
14
|
+
//# sourceMappingURL=paths.d.ts.map
|
package/dist/paths.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync, } from 'node:fs';
|
|
3
|
+
import { dirname, join, resolve } from 'node:path';
|
|
4
|
+
export function projectPaths(root) {
|
|
5
|
+
const normalizedRoot = resolve(root);
|
|
6
|
+
return {
|
|
7
|
+
root: normalizedRoot,
|
|
8
|
+
manifest: join(normalizedRoot, 'env.schema.jsonc'),
|
|
9
|
+
dotenv: join(normalizedRoot, '.env'),
|
|
10
|
+
stateDir: join(normalizedRoot, '.envseal'),
|
|
11
|
+
salt: join(normalizedRoot, '.envseal', 'salt'),
|
|
12
|
+
approvals: join(normalizedRoot, '.envseal', 'approvals.json'),
|
|
13
|
+
audit: join(normalizedRoot, '.envseal', 'audit.jsonl'),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
export function findProjectRoot(startDir) {
|
|
17
|
+
let dir = resolve(startDir);
|
|
18
|
+
const original = dir;
|
|
19
|
+
for (;;) {
|
|
20
|
+
if (existsSync(join(dir, 'env.schema.jsonc')))
|
|
21
|
+
return dir;
|
|
22
|
+
if (existsSync(join(dir, '.git')))
|
|
23
|
+
return dir;
|
|
24
|
+
if (existsSync(join(dir, 'package.json')))
|
|
25
|
+
return dir;
|
|
26
|
+
const parent = dirname(dir);
|
|
27
|
+
if (parent === dir)
|
|
28
|
+
return original;
|
|
29
|
+
dir = parent;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const isPosix = process.platform !== 'win32';
|
|
33
|
+
/**
|
|
34
|
+
* A nested .gitignore of `*` makes everything under `.envseal/` invisible to
|
|
35
|
+
* git regardless of what the project's own .gitignore says. The dotenv sink
|
|
36
|
+
* stages its plaintext temp file here (F-W7-3), and a project .gitignore
|
|
37
|
+
* containing `.env` does not cover `.envseal/` — nor did it cover the old
|
|
38
|
+
* sibling temp name `..env.<hex>.tmp`.
|
|
39
|
+
*/
|
|
40
|
+
const STATE_GITIGNORE = '# Written by envseal: nothing in here belongs in version control.\n*\n';
|
|
41
|
+
export function ensureStateDir(paths) {
|
|
42
|
+
mkdirSync(paths.stateDir, { recursive: true, mode: 0o700 });
|
|
43
|
+
if (isPosix)
|
|
44
|
+
chmodSync(paths.stateDir, 0o700);
|
|
45
|
+
const guard = join(paths.stateDir, '.gitignore');
|
|
46
|
+
if (!existsSync(guard)) {
|
|
47
|
+
writeFileSync(guard, STATE_GITIGNORE, { mode: 0o600 });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* F-W7-5: a truncated salt file used to be replaced in complete silence. The
|
|
52
|
+
* salt keys every `fp_*` fingerprint in `describe()` output and in the audit
|
|
53
|
+
* log, so a silent swap makes every previously recorded fingerprint mean
|
|
54
|
+
* something different. Regenerating is still the right default — refusing to
|
|
55
|
+
* start over a file the user can simply delete would be worse — but it must be
|
|
56
|
+
* announced.
|
|
57
|
+
*
|
|
58
|
+
* stderr ONLY: stdout is a machine-readable JSON channel for several bindings
|
|
59
|
+
* and the MCP server speaks JSON-RPC on it.
|
|
60
|
+
*/
|
|
61
|
+
function warnSaltReplaced(actualLength) {
|
|
62
|
+
process.stderr.write(`envseal: .envseal/salt is ${actualLength} bytes, expected 32 — generating a new salt. ` +
|
|
63
|
+
'Every fp_* fingerprint recorded before now was derived from the old salt ' +
|
|
64
|
+
'and will no longer match.\n');
|
|
65
|
+
}
|
|
66
|
+
export function loadOrCreateSalt(paths) {
|
|
67
|
+
ensureStateDir(paths);
|
|
68
|
+
let truncatedLength = null;
|
|
69
|
+
try {
|
|
70
|
+
const existing = readFileSync(paths.salt);
|
|
71
|
+
if (existing.length === 32)
|
|
72
|
+
return existing;
|
|
73
|
+
truncatedLength = existing.length;
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
if (error.code !== 'ENOENT')
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
if (truncatedLength !== null)
|
|
80
|
+
warnSaltReplaced(truncatedLength);
|
|
81
|
+
const salt = randomBytes(32);
|
|
82
|
+
writeFileSync(paths.salt, salt, { mode: 0o600 });
|
|
83
|
+
if (isPosix)
|
|
84
|
+
chmodSync(paths.salt, 0o600);
|
|
85
|
+
return salt;
|
|
86
|
+
}
|
|
87
|
+
//# sourceMappingURL=paths.js.map
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { SecretValue } from '@envseal/protocol';
|
|
2
|
+
import type { ProjectPaths } from './paths.js';
|
|
3
|
+
export type PresenceSource = 'process-env' | 'dotenv' | 'sink' | 'absent';
|
|
4
|
+
export interface Presence {
|
|
5
|
+
key: string;
|
|
6
|
+
present: boolean;
|
|
7
|
+
source: PresenceSource;
|
|
8
|
+
value: SecretValue | null;
|
|
9
|
+
}
|
|
10
|
+
export interface ResolvePresenceOptions {
|
|
11
|
+
/**
|
|
12
|
+
* Declared sink per key, taken from the manifest (`entry.sink ?? 'dotenv'`).
|
|
13
|
+
* A key mapped to a sink other than 'dotenv' is resolved through that sink,
|
|
14
|
+
* because that is where use()/verify() will actually read it; keys absent
|
|
15
|
+
* from the map keep the process-env/.env check exactly as before.
|
|
16
|
+
*/
|
|
17
|
+
sinks?: ReadonlyMap<string, string>;
|
|
18
|
+
}
|
|
19
|
+
export declare function resolvePresence(paths: ProjectPaths, keys: string[], options?: ResolvePresenceOptions): Promise<Map<string, Presence>>;
|
|
20
|
+
//# sourceMappingURL=presence.d.ts.map
|
package/dist/presence.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { asSecret } from '@envseal/protocol';
|
|
2
|
+
import { readDotenv } from './sinks/dotenv.js';
|
|
3
|
+
import { getSink } from './sinks/registry.js';
|
|
4
|
+
export async function resolvePresence(paths, keys, options) {
|
|
5
|
+
const env = process.env;
|
|
6
|
+
const dotenv = readDotenv(paths);
|
|
7
|
+
const out = new Map();
|
|
8
|
+
for (const key of keys) {
|
|
9
|
+
// process.env first for every key: runWithSecrets spawns the child with
|
|
10
|
+
// {...process.env}, so an exported value genuinely reaches the process.
|
|
11
|
+
const envValue = env[key];
|
|
12
|
+
if (envValue !== undefined) {
|
|
13
|
+
out.set(key, {
|
|
14
|
+
key,
|
|
15
|
+
present: true,
|
|
16
|
+
source: 'process-env',
|
|
17
|
+
value: asSecret(Buffer.from(envValue, 'utf8')),
|
|
18
|
+
});
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
const sinkId = options?.sinks?.get(key);
|
|
22
|
+
if (sinkId !== undefined && sinkId !== 'dotenv') {
|
|
23
|
+
let value = null;
|
|
24
|
+
try {
|
|
25
|
+
value = await getSink(sinkId).read(paths, key);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
// A credential-store hiccup (locked keychain, DPAPI failure) must
|
|
29
|
+
// degrade to absent rather than crash describe/status — those are
|
|
30
|
+
// read-only reports that must answer even when a store is unhappy.
|
|
31
|
+
// The error still surfaces where it matters, in use()/verify().
|
|
32
|
+
value = null;
|
|
33
|
+
}
|
|
34
|
+
if (value !== null) {
|
|
35
|
+
out.set(key, { key, present: true, source: 'sink', value });
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
// No dotenv fallback here: a keychain-declared key is only resolvable
|
|
39
|
+
// through its declared sink, so a hand-written .env line must not make
|
|
40
|
+
// status claim present.
|
|
41
|
+
out.set(key, { key, present: false, source: 'absent', value: null });
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
const dotenvValue = dotenv[key];
|
|
45
|
+
if (dotenvValue !== undefined) {
|
|
46
|
+
out.set(key, {
|
|
47
|
+
key,
|
|
48
|
+
present: true,
|
|
49
|
+
source: 'dotenv',
|
|
50
|
+
value: asSecret(Buffer.from(dotenvValue, 'utf8')),
|
|
51
|
+
});
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
out.set(key, { key, present: false, source: 'absent', value: null });
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
//# sourceMappingURL=presence.js.map
|
package/dist/redact.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { SecretValue } from '@envseal/protocol';
|
|
2
|
+
export interface RedactResult {
|
|
3
|
+
text: string;
|
|
4
|
+
count: number;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Replace every occurrence of a stored value — and of the encodings in
|
|
8
|
+
* `variantsOf` — with an opaque token. Cost is O(text) in time and O(value) in
|
|
9
|
+
* memory, both bounded; nothing here compiles a pattern, so no input length can
|
|
10
|
+
* abort the process.
|
|
11
|
+
*
|
|
12
|
+
* When the text contains whitespace, a second scan runs over a
|
|
13
|
+
* whitespace-stripped dense copy with a dense→original index map (W2-F5: a
|
|
14
|
+
* value split across a line break emerges as a short head plus a suffix, and
|
|
15
|
+
* neither fragment alone may reach the detection window). Matches found dense
|
|
16
|
+
* are mapped back to original offsets and merged with the first pass's spans,
|
|
17
|
+
* so the joined fragments redact as one region including the separator.
|
|
18
|
+
*/
|
|
19
|
+
export declare function redact(text: string, secrets: Iterable<SecretValue>, labels?: Map<SecretValue, string>): RedactResult;
|
|
20
|
+
//# sourceMappingURL=redact.d.ts.map
|