@ant.sh/colony 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 +21 -0
- package/README.md +172 -0
- package/dist/cjs/cli.js +281 -0
- package/dist/cjs/cli.js.map +7 -0
- package/dist/cjs/index.js +383 -0
- package/dist/cjs/index.js.map +7 -0
- package/dist/cjs/package.json +3 -0
- package/dist/cjs/parser.js +319 -0
- package/dist/cjs/parser.js.map +7 -0
- package/dist/cjs/providers/aws.js +115 -0
- package/dist/cjs/providers/aws.js.map +7 -0
- package/dist/cjs/providers/openbao.js +49 -0
- package/dist/cjs/providers/openbao.js.map +7 -0
- package/dist/cjs/providers/vault-base.js +98 -0
- package/dist/cjs/providers/vault-base.js.map +7 -0
- package/dist/cjs/providers/vault.js +49 -0
- package/dist/cjs/providers/vault.js.map +7 -0
- package/dist/cjs/resolver.js +247 -0
- package/dist/cjs/resolver.js.map +7 -0
- package/dist/cjs/secrets.js +238 -0
- package/dist/cjs/secrets.js.map +7 -0
- package/dist/cjs/strings.js +99 -0
- package/dist/cjs/strings.js.map +7 -0
- package/dist/cjs/util.js +74 -0
- package/dist/cjs/util.js.map +7 -0
- package/dist/esm/cli.js +281 -0
- package/dist/esm/cli.js.map +7 -0
- package/dist/esm/index.d.ts +342 -0
- package/dist/esm/index.js +347 -0
- package/dist/esm/index.js.map +7 -0
- package/dist/esm/package.json +3 -0
- package/dist/esm/parser.js +286 -0
- package/dist/esm/parser.js.map +7 -0
- package/dist/esm/providers/aws.js +82 -0
- package/dist/esm/providers/aws.js.map +7 -0
- package/dist/esm/providers/openbao.js +26 -0
- package/dist/esm/providers/openbao.js.map +7 -0
- package/dist/esm/providers/vault-base.js +75 -0
- package/dist/esm/providers/vault-base.js.map +7 -0
- package/dist/esm/providers/vault.js +26 -0
- package/dist/esm/providers/vault.js.map +7 -0
- package/dist/esm/resolver.js +224 -0
- package/dist/esm/resolver.js.map +7 -0
- package/dist/esm/secrets.js +209 -0
- package/dist/esm/secrets.js.map +7 -0
- package/dist/esm/strings.js +75 -0
- package/dist/esm/strings.js.map +7 -0
- package/dist/esm/util.js +47 -0
- package/dist/esm/util.js.map +7 -0
- package/package.json +66 -0
- package/src/cli.js +353 -0
- package/src/index.d.ts +342 -0
- package/src/index.js +473 -0
- package/src/parser.js +381 -0
- package/src/providers/aws.js +112 -0
- package/src/providers/openbao.js +32 -0
- package/src/providers/vault-base.js +92 -0
- package/src/providers/vault.js +31 -0
- package/src/resolver.js +286 -0
- package/src/secrets.js +313 -0
- package/src/strings.js +84 -0
- package/src/util.js +49 -0
package/src/resolver.js
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { applyInterpolationDeep } from "./strings.js";
|
|
2
|
+
import { getDeep, setDeep, deepMerge, isPlainObject } from "./util.js";
|
|
3
|
+
|
|
4
|
+
export function resolveRules({ rules, dims, ctx, vars, allowedEnvVars = null, allowedVars = null, warnings = [] }) {
|
|
5
|
+
const indexed = [];
|
|
6
|
+
for (const r of rules) {
|
|
7
|
+
const scope = r.keySegments.slice(0, dims.length);
|
|
8
|
+
const keyPath = r.keySegments.slice(dims.length);
|
|
9
|
+
|
|
10
|
+
if (scope.length !== dims.length || keyPath.length === 0) {
|
|
11
|
+
throw new Error(
|
|
12
|
+
`${r.filePath}:${r.line}: Key must have ${dims.length} scope segments + at least one key segment: ${r.keyRaw}`
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
indexed.push({
|
|
17
|
+
...r,
|
|
18
|
+
scope,
|
|
19
|
+
keyPath,
|
|
20
|
+
keyPathStr: keyPath.join("."),
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const ctxScope = dims.map((d) => String(ctx[d] ?? ""));
|
|
25
|
+
|
|
26
|
+
const candidatesByKey = new Map();
|
|
27
|
+
const postOps = [];
|
|
28
|
+
|
|
29
|
+
for (const r of indexed) {
|
|
30
|
+
if (!matches(r.scope, ctxScope)) continue;
|
|
31
|
+
|
|
32
|
+
if (r.op === "+=" || r.op === "-=") postOps.push(r);
|
|
33
|
+
else {
|
|
34
|
+
if (!candidatesByKey.has(r.keyPathStr)) candidatesByKey.set(r.keyPathStr, []);
|
|
35
|
+
candidatesByKey.get(r.keyPathStr).push(r);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const out = {};
|
|
40
|
+
const trace = new Map();
|
|
41
|
+
|
|
42
|
+
for (const [key, cand] of candidatesByKey.entries()) {
|
|
43
|
+
let winner = cand[0];
|
|
44
|
+
let best = specificity(winner.scope);
|
|
45
|
+
for (let i = 1; i < cand.length; i++) {
|
|
46
|
+
const s = specificity(cand[i].scope);
|
|
47
|
+
if (s > best) {
|
|
48
|
+
best = s;
|
|
49
|
+
winner = cand[i];
|
|
50
|
+
} else if (s === best) {
|
|
51
|
+
winner = cand[i];
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const existing = getDeep(out, winner.keyPath);
|
|
56
|
+
|
|
57
|
+
if (winner.op === ":=") {
|
|
58
|
+
if (existing === undefined) {
|
|
59
|
+
setDeep(out, winner.keyPath, clone(winner.value));
|
|
60
|
+
trace.set(key, packTrace(winner, best));
|
|
61
|
+
}
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (winner.op === "|=") {
|
|
66
|
+
if (existing === undefined) {
|
|
67
|
+
setDeep(out, winner.keyPath, clone(winner.value));
|
|
68
|
+
} else if (isPlainObject(existing) && isPlainObject(winner.value)) {
|
|
69
|
+
setDeep(out, winner.keyPath, deepMerge(existing, winner.value));
|
|
70
|
+
} else {
|
|
71
|
+
setDeep(out, winner.keyPath, clone(winner.value));
|
|
72
|
+
}
|
|
73
|
+
trace.set(key, packTrace(winner, best));
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
setDeep(out, winner.keyPath, clone(winner.value));
|
|
78
|
+
trace.set(key, packTrace(winner, best));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
postOps.sort((a, b) => specificity(a.scope) - specificity(b.scope));
|
|
82
|
+
|
|
83
|
+
for (const r of postOps) {
|
|
84
|
+
const key = r.keyPathStr;
|
|
85
|
+
const best = specificity(r.scope);
|
|
86
|
+
|
|
87
|
+
const existing = getDeep(out, r.keyPath);
|
|
88
|
+
const val = clone(r.value);
|
|
89
|
+
|
|
90
|
+
if (r.op === "+=") {
|
|
91
|
+
const add = Array.isArray(val) ? val : [val];
|
|
92
|
+
if (existing === undefined) setDeep(out, r.keyPath, add);
|
|
93
|
+
else if (Array.isArray(existing)) setDeep(out, r.keyPath, existing.concat(add));
|
|
94
|
+
else setDeep(out, r.keyPath, [existing].concat(add));
|
|
95
|
+
trace.set(key, packTrace(r, best));
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (r.op === "-=") {
|
|
100
|
+
const remove = new Set(Array.isArray(val) ? val : [val]);
|
|
101
|
+
if (Array.isArray(existing)) {
|
|
102
|
+
setDeep(out, r.keyPath, existing.filter((x) => !remove.has(x)));
|
|
103
|
+
trace.set(key, packTrace(r, best));
|
|
104
|
+
}
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const finalCfg = applyInterpolationDeep(out, { ctx, vars, allowedEnvVars, allowedVars, warnings });
|
|
110
|
+
|
|
111
|
+
Object.defineProperties(finalCfg, {
|
|
112
|
+
// Core methods
|
|
113
|
+
get: { enumerable: false, value: (p) => getByPath(finalCfg, p) },
|
|
114
|
+
explain: { enumerable: false, value: (p) => explainByPath(trace, p) },
|
|
115
|
+
|
|
116
|
+
// Serialization - returns a plain object copy without non-enumerable methods
|
|
117
|
+
toJSON: {
|
|
118
|
+
enumerable: false,
|
|
119
|
+
value: () => {
|
|
120
|
+
const plain = {};
|
|
121
|
+
for (const [k, v] of Object.entries(finalCfg)) {
|
|
122
|
+
plain[k] = clone(v);
|
|
123
|
+
}
|
|
124
|
+
return plain;
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
// List all keys (dot-notation paths)
|
|
129
|
+
keys: {
|
|
130
|
+
enumerable: false,
|
|
131
|
+
value: () => collectKeys(finalCfg),
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
// Diff against another config
|
|
135
|
+
diff: {
|
|
136
|
+
enumerable: false,
|
|
137
|
+
value: (other) => diffConfigs(finalCfg, other),
|
|
138
|
+
},
|
|
139
|
+
|
|
140
|
+
// Internal trace data
|
|
141
|
+
_trace: { enumerable: false, value: trace },
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
return finalCfg;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Collect all leaf keys in dot notation
|
|
149
|
+
* @param {object} obj
|
|
150
|
+
* @param {string} prefix
|
|
151
|
+
* @returns {string[]}
|
|
152
|
+
*/
|
|
153
|
+
function collectKeys(obj, prefix = "") {
|
|
154
|
+
const keys = [];
|
|
155
|
+
|
|
156
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
157
|
+
const path = prefix ? `${prefix}.${k}` : k;
|
|
158
|
+
|
|
159
|
+
if (isPlainObject(v)) {
|
|
160
|
+
keys.push(...collectKeys(v, path));
|
|
161
|
+
} else {
|
|
162
|
+
keys.push(path);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return keys.sort();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Diff two configs, returning added, removed, and changed keys
|
|
171
|
+
* @param {object} a - First config
|
|
172
|
+
* @param {object} b - Second config
|
|
173
|
+
* @returns {{ added: string[], removed: string[], changed: Array<{key: string, from: any, to: any}> }}
|
|
174
|
+
*/
|
|
175
|
+
function diffConfigs(a, b) {
|
|
176
|
+
const aKeys = new Set(collectKeys(a));
|
|
177
|
+
const bKeys = new Set(collectKeys(b));
|
|
178
|
+
|
|
179
|
+
const added = [];
|
|
180
|
+
const removed = [];
|
|
181
|
+
const changed = [];
|
|
182
|
+
|
|
183
|
+
// Keys in b but not in a
|
|
184
|
+
for (const key of bKeys) {
|
|
185
|
+
if (!aKeys.has(key)) {
|
|
186
|
+
added.push(key);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Keys in a but not in b
|
|
191
|
+
for (const key of aKeys) {
|
|
192
|
+
if (!bKeys.has(key)) {
|
|
193
|
+
removed.push(key);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Keys in both - check for changes
|
|
198
|
+
for (const key of aKeys) {
|
|
199
|
+
if (bKeys.has(key)) {
|
|
200
|
+
const aVal = getByPath(a, key);
|
|
201
|
+
const bVal = getByPath(b, key);
|
|
202
|
+
|
|
203
|
+
if (!deepEqual(aVal, bVal)) {
|
|
204
|
+
changed.push({ key, from: aVal, to: bVal });
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
added: added.sort(),
|
|
211
|
+
removed: removed.sort(),
|
|
212
|
+
changed: changed.sort((x, y) => x.key.localeCompare(y.key)),
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Deep equality check for config values.
|
|
218
|
+
* Note: Does not handle circular references (will stack overflow).
|
|
219
|
+
* Config values should never be circular in practice.
|
|
220
|
+
*/
|
|
221
|
+
function deepEqual(a, b) {
|
|
222
|
+
if (a === b) return true;
|
|
223
|
+
if (typeof a !== typeof b) return false;
|
|
224
|
+
if (a === null || b === null) return a === b;
|
|
225
|
+
|
|
226
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
227
|
+
if (a.length !== b.length) return false;
|
|
228
|
+
return a.every((v, i) => deepEqual(v, b[i]));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (typeof a === "object" && typeof b === "object") {
|
|
232
|
+
const aKeys = Object.keys(a);
|
|
233
|
+
const bKeys = Object.keys(b);
|
|
234
|
+
if (aKeys.length !== bKeys.length) return false;
|
|
235
|
+
return aKeys.every((k) => deepEqual(a[k], b[k]));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function getByPath(obj, p) {
|
|
242
|
+
const segs = String(p).split(".").filter(Boolean);
|
|
243
|
+
return getDeep(obj, segs);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function explainByPath(trace, p) {
|
|
247
|
+
const key = String(p);
|
|
248
|
+
return trace.get(key) ?? null;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function matches(ruleScope, ctxScope) {
|
|
252
|
+
for (let i = 0; i < ruleScope.length; i++) {
|
|
253
|
+
const r = String(ruleScope[i]);
|
|
254
|
+
const c = String(ctxScope[i]);
|
|
255
|
+
if (r === "*") continue;
|
|
256
|
+
if (r !== c) return false;
|
|
257
|
+
}
|
|
258
|
+
return true;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function specificity(ruleScope) {
|
|
262
|
+
let s = 0;
|
|
263
|
+
for (const seg of ruleScope) if (seg !== "*") s++;
|
|
264
|
+
return s;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function packTrace(rule, spec) {
|
|
268
|
+
return {
|
|
269
|
+
op: rule.op,
|
|
270
|
+
scope: rule.scope.map(String),
|
|
271
|
+
specificity: spec,
|
|
272
|
+
filePath: rule.filePath,
|
|
273
|
+
line: rule.line,
|
|
274
|
+
col: rule.col ?? 0,
|
|
275
|
+
keyRaw: rule.keyRaw,
|
|
276
|
+
// Source map style location
|
|
277
|
+
source: `${rule.filePath}:${rule.line}:${rule.col ?? 0}`,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function clone(v) {
|
|
282
|
+
if (v === null || v === undefined) return v;
|
|
283
|
+
if (Array.isArray(v)) return v.map(clone);
|
|
284
|
+
if (typeof v === "object") return structuredClone(v);
|
|
285
|
+
return v;
|
|
286
|
+
}
|
package/src/secrets.js
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secrets provider system for colony
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { isPlainObject } from "./util.js";
|
|
6
|
+
|
|
7
|
+
// Global provider registry
|
|
8
|
+
const globalRegistry = new Map();
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Register a secret provider globally
|
|
12
|
+
* @param {object} provider - Provider with prefix and fetch()
|
|
13
|
+
*/
|
|
14
|
+
export function registerSecretProvider(provider) {
|
|
15
|
+
if (!provider.prefix || typeof provider.fetch !== "function") {
|
|
16
|
+
throw new Error("Invalid provider: must have prefix and fetch()");
|
|
17
|
+
}
|
|
18
|
+
globalRegistry.set(provider.prefix.toUpperCase(), provider);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Unregister a provider by prefix
|
|
23
|
+
* @param {string} prefix - Provider prefix to remove
|
|
24
|
+
* @returns {boolean} True if provider was removed
|
|
25
|
+
*/
|
|
26
|
+
export function unregisterSecretProvider(prefix) {
|
|
27
|
+
return globalRegistry.delete(prefix.toUpperCase());
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Clear all registered providers
|
|
32
|
+
*/
|
|
33
|
+
export function clearSecretProviders() {
|
|
34
|
+
globalRegistry.clear();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Check if any global providers are registered
|
|
39
|
+
* @returns {boolean}
|
|
40
|
+
*/
|
|
41
|
+
export function hasGlobalProviders() {
|
|
42
|
+
return globalRegistry.size > 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Simple LRU cache for secrets
|
|
47
|
+
*/
|
|
48
|
+
export class SecretCache {
|
|
49
|
+
constructor(maxSize = 100) {
|
|
50
|
+
this.maxSize = maxSize;
|
|
51
|
+
this.cache = new Map();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
get(key) {
|
|
55
|
+
const entry = this.cache.get(key);
|
|
56
|
+
if (!entry) return undefined;
|
|
57
|
+
if (Date.now() > entry.expires) {
|
|
58
|
+
this.cache.delete(key);
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
// Move to end for LRU behavior
|
|
62
|
+
this.cache.delete(key);
|
|
63
|
+
this.cache.set(key, entry);
|
|
64
|
+
return entry.value;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
set(key, value, ttl) {
|
|
68
|
+
if (this.cache.size >= this.maxSize) {
|
|
69
|
+
// Remove oldest (first) entry
|
|
70
|
+
const firstKey = this.cache.keys().next().value;
|
|
71
|
+
this.cache.delete(firstKey);
|
|
72
|
+
}
|
|
73
|
+
this.cache.set(key, { value, expires: Date.now() + ttl });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
invalidate(pattern) {
|
|
77
|
+
if (!pattern) {
|
|
78
|
+
this.cache.clear();
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const regex = new RegExp("^" + globToRegex(pattern) + "$");
|
|
82
|
+
for (const key of this.cache.keys()) {
|
|
83
|
+
if (regex.test(key)) this.cache.delete(key);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Convert a glob pattern to regex, escaping special chars except *
|
|
90
|
+
* @param {string} pattern - Glob pattern (e.g., "myapp/*")
|
|
91
|
+
* @returns {string} Regex pattern string
|
|
92
|
+
*/
|
|
93
|
+
function globToRegex(pattern) {
|
|
94
|
+
return pattern
|
|
95
|
+
.replace(/[.+?^${}()|[\]\\]/g, "\\$&") // escape regex special chars
|
|
96
|
+
.replace(/\*/g, ".*"); // convert glob * to regex .*
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Regex to match secret interpolations: ${PROVIDER:key}
|
|
100
|
+
// Provider must start with uppercase letter, followed by uppercase letters, digits, or underscores
|
|
101
|
+
const RX_SECRET = /\$\{([A-Z][A-Z0-9_]*):([^}]+)\}/g;
|
|
102
|
+
|
|
103
|
+
// Reserved prefixes that are not secrets
|
|
104
|
+
const RESERVED = new Set(["ENV", "VAR"]);
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Collect all secret references from a value tree
|
|
108
|
+
* @param {any} value - Value to scan
|
|
109
|
+
* @param {Map} refs - Map to collect refs into
|
|
110
|
+
* @returns {Map} Map of fullKey -> { provider, key }
|
|
111
|
+
*/
|
|
112
|
+
export function collectSecretRefs(value, refs = new Map()) {
|
|
113
|
+
if (typeof value === "string") {
|
|
114
|
+
let match;
|
|
115
|
+
RX_SECRET.lastIndex = 0;
|
|
116
|
+
while ((match = RX_SECRET.exec(value)) !== null) {
|
|
117
|
+
const [, provider, key] = match;
|
|
118
|
+
if (RESERVED.has(provider)) continue;
|
|
119
|
+
|
|
120
|
+
const fullKey = `${provider}:${key.trim()}`;
|
|
121
|
+
if (!refs.has(fullKey)) {
|
|
122
|
+
refs.set(fullKey, { provider, key: key.trim() });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return refs;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (Array.isArray(value)) {
|
|
129
|
+
for (const item of value) {
|
|
130
|
+
collectSecretRefs(item, refs);
|
|
131
|
+
}
|
|
132
|
+
return refs;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (isPlainObject(value)) {
|
|
136
|
+
for (const v of Object.values(value)) {
|
|
137
|
+
collectSecretRefs(v, refs);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return refs;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Check if a secret key matches any allowed pattern
|
|
146
|
+
* @param {string} fullKey - Full key like "AWS:myapp/db"
|
|
147
|
+
* @param {string[]|null} allowedSecrets - Allowed patterns
|
|
148
|
+
* @returns {boolean}
|
|
149
|
+
*/
|
|
150
|
+
function isAllowed(fullKey, allowedSecrets) {
|
|
151
|
+
if (allowedSecrets === null || allowedSecrets === undefined) return true;
|
|
152
|
+
|
|
153
|
+
for (const pattern of allowedSecrets) {
|
|
154
|
+
// Exact match
|
|
155
|
+
if (pattern === fullKey) return true;
|
|
156
|
+
|
|
157
|
+
// Glob pattern match on full key
|
|
158
|
+
const regex = new RegExp("^" + globToRegex(pattern) + "$");
|
|
159
|
+
if (regex.test(fullKey)) return true;
|
|
160
|
+
|
|
161
|
+
// Pattern without provider matches any provider
|
|
162
|
+
if (!pattern.includes(":")) {
|
|
163
|
+
const keyOnly = fullKey.split(":")[1];
|
|
164
|
+
const keyRegex = new RegExp("^" + globToRegex(pattern) + "$");
|
|
165
|
+
if (keyRegex.test(keyOnly)) return true;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Fetch all secrets and apply to value tree
|
|
174
|
+
* @param {any} value - Config value tree
|
|
175
|
+
* @param {object} options - Options
|
|
176
|
+
* @returns {Promise<any>} Value tree with secrets replaced
|
|
177
|
+
*/
|
|
178
|
+
export async function applySecretsDeep(value, options = {}) {
|
|
179
|
+
const {
|
|
180
|
+
providers = [],
|
|
181
|
+
allowedSecrets = null,
|
|
182
|
+
cache = null,
|
|
183
|
+
cacheTtl = 300000,
|
|
184
|
+
onNotFound = "warn",
|
|
185
|
+
warnings = [],
|
|
186
|
+
} = options;
|
|
187
|
+
|
|
188
|
+
// Merge local providers with global registry
|
|
189
|
+
const registry = new Map(globalRegistry);
|
|
190
|
+
for (const p of providers) {
|
|
191
|
+
registry.set(p.prefix.toUpperCase(), p);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Collect all secret references
|
|
195
|
+
const refs = collectSecretRefs(value);
|
|
196
|
+
if (refs.size === 0) return value;
|
|
197
|
+
|
|
198
|
+
// Fetch all secrets in parallel
|
|
199
|
+
const resolved = new Map();
|
|
200
|
+
const fetchPromises = [];
|
|
201
|
+
|
|
202
|
+
for (const [fullKey, { provider, key }] of refs) {
|
|
203
|
+
// Check allowlist
|
|
204
|
+
if (!isAllowed(fullKey, allowedSecrets)) {
|
|
205
|
+
warnings.push({
|
|
206
|
+
type: "blocked_secret",
|
|
207
|
+
provider,
|
|
208
|
+
key,
|
|
209
|
+
message: `Access to secret "${fullKey}" blocked by allowedSecrets`,
|
|
210
|
+
});
|
|
211
|
+
resolved.set(fullKey, "");
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Check cache
|
|
216
|
+
if (cache) {
|
|
217
|
+
const cached = cache.get(fullKey);
|
|
218
|
+
if (cached !== undefined) {
|
|
219
|
+
resolved.set(fullKey, cached);
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Check provider exists
|
|
225
|
+
const providerInstance = registry.get(provider);
|
|
226
|
+
if (!providerInstance) {
|
|
227
|
+
warnings.push({
|
|
228
|
+
type: "unknown_provider",
|
|
229
|
+
provider,
|
|
230
|
+
key,
|
|
231
|
+
message: `No provider registered for "${provider}"`,
|
|
232
|
+
});
|
|
233
|
+
resolved.set(fullKey, "");
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Queue fetch
|
|
238
|
+
fetchPromises.push(
|
|
239
|
+
providerInstance
|
|
240
|
+
.fetch(key)
|
|
241
|
+
.then((val) => {
|
|
242
|
+
const strVal = val ?? "";
|
|
243
|
+
if (cache) cache.set(fullKey, strVal, cacheTtl);
|
|
244
|
+
resolved.set(fullKey, strVal);
|
|
245
|
+
})
|
|
246
|
+
.catch((err) => {
|
|
247
|
+
const isNotFound =
|
|
248
|
+
err.code === "NOT_FOUND" ||
|
|
249
|
+
err.name === "ResourceNotFoundException" ||
|
|
250
|
+
err.message?.includes("not found");
|
|
251
|
+
|
|
252
|
+
if (isNotFound) {
|
|
253
|
+
if (onNotFound === "error") {
|
|
254
|
+
throw new Error(`COLONY: Secret not found: ${fullKey}`);
|
|
255
|
+
}
|
|
256
|
+
warnings.push({
|
|
257
|
+
type: "secret_not_found",
|
|
258
|
+
provider,
|
|
259
|
+
key,
|
|
260
|
+
message: `Secret "${fullKey}" not found`,
|
|
261
|
+
});
|
|
262
|
+
resolved.set(fullKey, "");
|
|
263
|
+
} else {
|
|
264
|
+
if (onNotFound === "error") {
|
|
265
|
+
throw err;
|
|
266
|
+
}
|
|
267
|
+
warnings.push({
|
|
268
|
+
type: "secret_fetch_error",
|
|
269
|
+
provider,
|
|
270
|
+
key,
|
|
271
|
+
message: `Failed to fetch "${fullKey}": ${err.message}`,
|
|
272
|
+
});
|
|
273
|
+
resolved.set(fullKey, "");
|
|
274
|
+
}
|
|
275
|
+
})
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
await Promise.all(fetchPromises);
|
|
280
|
+
|
|
281
|
+
// Apply resolved secrets to value tree
|
|
282
|
+
return replaceSecrets(value, resolved);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Replace secret placeholders with resolved values
|
|
287
|
+
* @param {any} value - Value to process
|
|
288
|
+
* @param {Map} resolved - Map of fullKey -> resolved value
|
|
289
|
+
* @returns {any} Value with secrets replaced
|
|
290
|
+
*/
|
|
291
|
+
function replaceSecrets(value, resolved) {
|
|
292
|
+
if (typeof value === "string") {
|
|
293
|
+
return value.replace(RX_SECRET, (match, provider, key) => {
|
|
294
|
+
if (RESERVED.has(provider)) return match;
|
|
295
|
+
const fullKey = `${provider}:${key.trim()}`;
|
|
296
|
+
return resolved.get(fullKey) ?? "";
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (Array.isArray(value)) {
|
|
301
|
+
return value.map((v) => replaceSecrets(v, resolved));
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (isPlainObject(value)) {
|
|
305
|
+
const out = {};
|
|
306
|
+
for (const [k, v] of Object.entries(value)) {
|
|
307
|
+
out[k] = replaceSecrets(v, resolved);
|
|
308
|
+
}
|
|
309
|
+
return out;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return value;
|
|
313
|
+
}
|
package/src/strings.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { isPlainObject } from "./util.js";
|
|
2
|
+
|
|
3
|
+
// Regex to detect secret provider patterns: ${PROVIDER:key}
|
|
4
|
+
// Provider must start with uppercase letter, followed by uppercase letters, digits, or underscores
|
|
5
|
+
const RX_SECRET_PROVIDER = /^[A-Z][A-Z0-9_]*:/;
|
|
6
|
+
|
|
7
|
+
export function applyInterpolationDeep(value, { ctx, vars, allowedEnvVars = null, allowedVars = null, warnings = [] }) {
|
|
8
|
+
if (typeof value === "string") return interpolate(value, { ctx, vars, allowedEnvVars, allowedVars, warnings });
|
|
9
|
+
if (Array.isArray(value)) return value.map((v) => applyInterpolationDeep(v, { ctx, vars, allowedEnvVars, allowedVars, warnings }));
|
|
10
|
+
if (isPlainObject(value)) {
|
|
11
|
+
const out = {};
|
|
12
|
+
for (const [k, v] of Object.entries(value)) {
|
|
13
|
+
out[k] = applyInterpolationDeep(v, { ctx, vars, allowedEnvVars, allowedVars, warnings });
|
|
14
|
+
}
|
|
15
|
+
return out;
|
|
16
|
+
}
|
|
17
|
+
return value;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function interpolate(s, { ctx, vars, allowedEnvVars = null, allowedVars = null, warnings = [] }) {
|
|
21
|
+
return s.replace(/\$\{([^}]+)\}/g, (match, exprRaw) => {
|
|
22
|
+
const expr = exprRaw.trim();
|
|
23
|
+
|
|
24
|
+
if (expr.startsWith("ENV:")) {
|
|
25
|
+
const k = expr.slice(4).trim();
|
|
26
|
+
// Security: check if env var is allowed
|
|
27
|
+
if (allowedEnvVars !== null && !allowedEnvVars.includes(k)) {
|
|
28
|
+
warnings.push({
|
|
29
|
+
type: "blocked_env_var",
|
|
30
|
+
var: k,
|
|
31
|
+
message: `Access to environment variable "${k}" blocked by allowedEnvVars whitelist`,
|
|
32
|
+
});
|
|
33
|
+
return "";
|
|
34
|
+
}
|
|
35
|
+
return process.env[k] ?? "";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (expr.startsWith("VAR:")) {
|
|
39
|
+
const k = expr.slice(4).trim();
|
|
40
|
+
// Security: check if custom var is allowed
|
|
41
|
+
if (allowedVars !== null && !allowedVars.includes(k)) {
|
|
42
|
+
warnings.push({
|
|
43
|
+
type: "blocked_var",
|
|
44
|
+
var: k,
|
|
45
|
+
message: `Access to custom variable "${k}" blocked by allowedVars whitelist`,
|
|
46
|
+
});
|
|
47
|
+
return "";
|
|
48
|
+
}
|
|
49
|
+
if (vars?.[k] === undefined) {
|
|
50
|
+
warnings.push({
|
|
51
|
+
type: "unknown_var",
|
|
52
|
+
var: k,
|
|
53
|
+
message: `Unknown VAR "${k}" in interpolation ${match}`,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
return String(vars?.[k] ?? "");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (expr.startsWith("ctx.")) {
|
|
60
|
+
const k = expr.slice(4).trim();
|
|
61
|
+
if (ctx?.[k] === undefined) {
|
|
62
|
+
warnings.push({
|
|
63
|
+
type: "unknown_ctx",
|
|
64
|
+
var: k,
|
|
65
|
+
message: `Unknown ctx dimension "${k}" in interpolation ${match}`,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
return String(ctx?.[k] ?? "");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Secret provider patterns (e.g., ${AWS:...}, ${OPENBAO:...}) - leave for secrets.js
|
|
72
|
+
if (RX_SECRET_PROVIDER.test(expr)) {
|
|
73
|
+
return match; // Keep the pattern intact for later secret processing
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Unknown interpolation pattern
|
|
77
|
+
warnings.push({
|
|
78
|
+
type: "unknown_interpolation",
|
|
79
|
+
pattern: match,
|
|
80
|
+
message: `Unknown interpolation pattern: ${match}`,
|
|
81
|
+
});
|
|
82
|
+
return "";
|
|
83
|
+
});
|
|
84
|
+
}
|