@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
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type definitions for colony
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export interface SandboxOptions {
|
|
6
|
+
/** Restrict @include paths to this directory */
|
|
7
|
+
basePath?: string;
|
|
8
|
+
/** Whitelist of allowed environment variables for ${ENV:*} (null = allow all) */
|
|
9
|
+
allowedEnvVars?: string[] | null;
|
|
10
|
+
/** Whitelist of allowed custom variables for ${VAR:*} (null = allow all) */
|
|
11
|
+
allowedVars?: string[] | null;
|
|
12
|
+
/** Maximum depth for nested includes (default: 50) */
|
|
13
|
+
maxIncludeDepth?: number;
|
|
14
|
+
/** Maximum file size in bytes for included files */
|
|
15
|
+
maxFileSize?: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Secret provider interface for custom integrations
|
|
20
|
+
*/
|
|
21
|
+
export interface SecretProvider {
|
|
22
|
+
/** Unique prefix for this provider (e.g., "AWS", "VAULT") */
|
|
23
|
+
readonly prefix: string;
|
|
24
|
+
/** Fetch a secret value by key/path */
|
|
25
|
+
fetch(key: string): Promise<string>;
|
|
26
|
+
/** Optional: validate configuration on registration */
|
|
27
|
+
validate?(): Promise<void>;
|
|
28
|
+
/** Optional: cleanup resources */
|
|
29
|
+
dispose?(): Promise<void>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface SecretCacheOptions {
|
|
33
|
+
/** Enable caching (default: true) */
|
|
34
|
+
enabled?: boolean;
|
|
35
|
+
/** Cache TTL in milliseconds (default: 300000 = 5 minutes) */
|
|
36
|
+
ttl?: number;
|
|
37
|
+
/** Maximum number of cached secrets (default: 100) */
|
|
38
|
+
maxSize?: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface SecretsOptions {
|
|
42
|
+
/** Secret providers to use (e.g., AwsSecretsProvider) */
|
|
43
|
+
providers?: SecretProvider[];
|
|
44
|
+
/** Whitelist of allowed secret patterns (glob supported, null = allow all) */
|
|
45
|
+
allowedSecrets?: string[] | null;
|
|
46
|
+
/** Cache settings */
|
|
47
|
+
cache?: SecretCacheOptions;
|
|
48
|
+
/** Behavior when secret not found: 'empty' returns "", 'warn' adds warning, 'error' throws */
|
|
49
|
+
onNotFound?: "empty" | "warn" | "error";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface LoadColonyOptions {
|
|
53
|
+
/** Entry colony file path */
|
|
54
|
+
entry: string;
|
|
55
|
+
/** Dimension names (e.g., ["env", "realm", "region"]) */
|
|
56
|
+
dims?: string[];
|
|
57
|
+
/** Context values for scope matching */
|
|
58
|
+
ctx?: Record<string, string>;
|
|
59
|
+
/** Custom variables for ${VAR:*} interpolation */
|
|
60
|
+
vars?: Record<string, string>;
|
|
61
|
+
/** Schema validation hook (supports sync and async) */
|
|
62
|
+
schema?: (cfg: ColonyConfig) => ColonyConfig | Promise<ColonyConfig>;
|
|
63
|
+
/** Security sandbox options */
|
|
64
|
+
sandbox?: SandboxOptions;
|
|
65
|
+
/** Warn when skipping already-visited includes */
|
|
66
|
+
warnOnSkippedIncludes?: boolean;
|
|
67
|
+
/** Secrets provider options */
|
|
68
|
+
secrets?: SecretsOptions;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface Warning {
|
|
72
|
+
type:
|
|
73
|
+
| "blocked_env_var"
|
|
74
|
+
| "blocked_var"
|
|
75
|
+
| "unknown_var"
|
|
76
|
+
| "unknown_ctx"
|
|
77
|
+
| "unknown_interpolation"
|
|
78
|
+
| "skipped_include"
|
|
79
|
+
| "blocked_secret"
|
|
80
|
+
| "secret_not_found"
|
|
81
|
+
| "secret_fetch_error"
|
|
82
|
+
| "unknown_provider";
|
|
83
|
+
message: string;
|
|
84
|
+
var?: string;
|
|
85
|
+
file?: string;
|
|
86
|
+
pattern?: string;
|
|
87
|
+
/** Provider name (for secret warnings) */
|
|
88
|
+
provider?: string;
|
|
89
|
+
/** Secret key (for secret warnings) */
|
|
90
|
+
key?: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface TraceInfo {
|
|
94
|
+
/** Operator used (=, :=, |=, +=, -=) */
|
|
95
|
+
op: string;
|
|
96
|
+
/** Scope segments that matched */
|
|
97
|
+
scope: string[];
|
|
98
|
+
/** Specificity score (number of non-* segments) */
|
|
99
|
+
specificity: number;
|
|
100
|
+
/** File path where the rule was defined */
|
|
101
|
+
filePath: string;
|
|
102
|
+
/** Line number in the file */
|
|
103
|
+
line: number;
|
|
104
|
+
/** Column number in the file */
|
|
105
|
+
col: number;
|
|
106
|
+
/** Raw key from the rule */
|
|
107
|
+
keyRaw: string;
|
|
108
|
+
/** Source location string (filePath:line:col) */
|
|
109
|
+
source: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface DiffResult {
|
|
113
|
+
/** Keys present in other but not in this config */
|
|
114
|
+
added: string[];
|
|
115
|
+
/** Keys present in this config but not in other */
|
|
116
|
+
removed: string[];
|
|
117
|
+
/** Keys present in both but with different values */
|
|
118
|
+
changed: Array<{
|
|
119
|
+
key: string;
|
|
120
|
+
from: unknown;
|
|
121
|
+
to: unknown;
|
|
122
|
+
}>;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface ColonyConfig {
|
|
126
|
+
/** Get a value by dot-notation path */
|
|
127
|
+
get(path: string): unknown;
|
|
128
|
+
/** Get trace info for how a key was set */
|
|
129
|
+
explain(path: string): TraceInfo | null;
|
|
130
|
+
/** Serialize to plain object */
|
|
131
|
+
toJSON(): Record<string, unknown>;
|
|
132
|
+
/** List all leaf keys in dot notation */
|
|
133
|
+
keys(): string[];
|
|
134
|
+
/** Compare with another config */
|
|
135
|
+
diff(other: ColonyConfig | Record<string, unknown>): DiffResult;
|
|
136
|
+
/** Internal trace data */
|
|
137
|
+
readonly _trace: Map<string, TraceInfo>;
|
|
138
|
+
/** Warnings generated during resolution */
|
|
139
|
+
readonly _warnings: Warning[];
|
|
140
|
+
/** Allow indexing with any string key */
|
|
141
|
+
[key: string]: unknown;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface ValidationResult {
|
|
145
|
+
/** Whether all files are valid */
|
|
146
|
+
valid: boolean;
|
|
147
|
+
/** List of all files that were checked */
|
|
148
|
+
files: string[];
|
|
149
|
+
/** List of errors found */
|
|
150
|
+
errors: Array<{
|
|
151
|
+
file: string;
|
|
152
|
+
error: string;
|
|
153
|
+
}>;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export interface DiffColonyOptions extends Omit<LoadColonyOptions, "ctx"> {
|
|
157
|
+
/** First context to compare */
|
|
158
|
+
ctx1: Record<string, string>;
|
|
159
|
+
/** Second context to compare */
|
|
160
|
+
ctx2: Record<string, string>;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export interface DiffColonyResult {
|
|
164
|
+
/** Config resolved with ctx1 */
|
|
165
|
+
cfg1: ColonyConfig;
|
|
166
|
+
/** Config resolved with ctx2 */
|
|
167
|
+
cfg2: ColonyConfig;
|
|
168
|
+
/** Differences between the two configs */
|
|
169
|
+
diff: DiffResult;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Load and resolve a colony configuration file
|
|
174
|
+
*/
|
|
175
|
+
export function loadColony(options: LoadColonyOptions): Promise<ColonyConfig>;
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Validate syntax of colony files without resolving
|
|
179
|
+
*/
|
|
180
|
+
export function validateColony(entry: string): Promise<ValidationResult>;
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* List all files that would be included (dry run)
|
|
184
|
+
*/
|
|
185
|
+
export function dryRunIncludes(entry: string): Promise<string[]>;
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Compare configs loaded with different contexts
|
|
189
|
+
*/
|
|
190
|
+
export function diffColony(options: DiffColonyOptions): Promise<DiffColonyResult>;
|
|
191
|
+
|
|
192
|
+
export interface LintIssue {
|
|
193
|
+
/** Type of issue found */
|
|
194
|
+
type: "parse_error" | "shadowed_rule" | "overridden_wildcard" | "empty_include";
|
|
195
|
+
/** Severity level */
|
|
196
|
+
severity: "error" | "warning" | "info";
|
|
197
|
+
/** Human-readable message */
|
|
198
|
+
message: string;
|
|
199
|
+
/** File where the issue was found */
|
|
200
|
+
file?: string;
|
|
201
|
+
/** Line number in the file */
|
|
202
|
+
line?: number;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export interface LintColonyOptions {
|
|
206
|
+
/** Entry colony file path */
|
|
207
|
+
entry: string;
|
|
208
|
+
/** Dimension names (e.g., ["env", "realm", "region"]) */
|
|
209
|
+
dims?: string[];
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export interface LintColonyResult {
|
|
213
|
+
/** List of issues found */
|
|
214
|
+
issues: LintIssue[];
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Lint colony files for potential issues
|
|
219
|
+
*/
|
|
220
|
+
export function lintColony(options: LintColonyOptions): Promise<LintColonyResult>;
|
|
221
|
+
|
|
222
|
+
// ============================================================================
|
|
223
|
+
// Secrets Management
|
|
224
|
+
// ============================================================================
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Register a secret provider globally (available to all loadColony calls)
|
|
228
|
+
*/
|
|
229
|
+
export function registerSecretProvider(provider: SecretProvider): void;
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Unregister a secret provider by prefix
|
|
233
|
+
*/
|
|
234
|
+
export function unregisterSecretProvider(prefix: string): boolean;
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Clear all globally registered secret providers
|
|
238
|
+
*/
|
|
239
|
+
export function clearSecretProviders(): void;
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* AWS Secrets Manager provider
|
|
243
|
+
*
|
|
244
|
+
* @example
|
|
245
|
+
* ```ts
|
|
246
|
+
* import { loadColony, AwsSecretsProvider } from "@ant.sh/colony";
|
|
247
|
+
*
|
|
248
|
+
* const cfg = await loadColony({
|
|
249
|
+
* entry: "./config/app.colony",
|
|
250
|
+
* secrets: {
|
|
251
|
+
* providers: [new AwsSecretsProvider({ region: "us-east-1" })],
|
|
252
|
+
* },
|
|
253
|
+
* });
|
|
254
|
+
* ```
|
|
255
|
+
*
|
|
256
|
+
* Config usage:
|
|
257
|
+
* ```
|
|
258
|
+
* *.db.password = "${AWS:myapp/db#password}";
|
|
259
|
+
* ```
|
|
260
|
+
*/
|
|
261
|
+
export class AwsSecretsProvider implements SecretProvider {
|
|
262
|
+
readonly prefix: "AWS";
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* @param options.region - AWS region (default: process.env.AWS_REGION or "us-east-1")
|
|
266
|
+
*/
|
|
267
|
+
constructor(options?: { region?: string });
|
|
268
|
+
|
|
269
|
+
fetch(key: string): Promise<string>;
|
|
270
|
+
validate(): Promise<void>;
|
|
271
|
+
dispose(): Promise<void>;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* HashiCorp Vault provider
|
|
276
|
+
*
|
|
277
|
+
* @example
|
|
278
|
+
* ```ts
|
|
279
|
+
* import { loadColony, VaultProvider } from "@ant.sh/colony";
|
|
280
|
+
*
|
|
281
|
+
* const cfg = await loadColony({
|
|
282
|
+
* entry: "./config/app.colony",
|
|
283
|
+
* secrets: {
|
|
284
|
+
* providers: [new VaultProvider({ addr: "https://vault.example.com" })],
|
|
285
|
+
* },
|
|
286
|
+
* });
|
|
287
|
+
* ```
|
|
288
|
+
*
|
|
289
|
+
* Config usage:
|
|
290
|
+
* ```
|
|
291
|
+
* *.api.key = "${VAULT:secret/data/myapp#api_key}";
|
|
292
|
+
* ```
|
|
293
|
+
*/
|
|
294
|
+
export class VaultProvider implements SecretProvider {
|
|
295
|
+
readonly prefix: "VAULT";
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* @param options.addr - Vault address (default: process.env.VAULT_ADDR or "http://127.0.0.1:8200")
|
|
299
|
+
* @param options.token - Vault token (default: process.env.VAULT_TOKEN)
|
|
300
|
+
* @param options.namespace - Vault namespace (default: process.env.VAULT_NAMESPACE)
|
|
301
|
+
* @param options.timeout - Request timeout in ms (default: 30000)
|
|
302
|
+
*/
|
|
303
|
+
constructor(options?: { addr?: string; token?: string; namespace?: string; timeout?: number });
|
|
304
|
+
|
|
305
|
+
fetch(key: string): Promise<string>;
|
|
306
|
+
validate(): Promise<void>;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* OpenBao provider (API-compatible Vault fork)
|
|
311
|
+
*
|
|
312
|
+
* @example
|
|
313
|
+
* ```ts
|
|
314
|
+
* import { loadColony, OpenBaoProvider } from "@ant.sh/colony";
|
|
315
|
+
*
|
|
316
|
+
* const cfg = await loadColony({
|
|
317
|
+
* entry: "./config/app.colony",
|
|
318
|
+
* secrets: {
|
|
319
|
+
* providers: [new OpenBaoProvider({ addr: "https://bao.example.com" })],
|
|
320
|
+
* },
|
|
321
|
+
* });
|
|
322
|
+
* ```
|
|
323
|
+
*
|
|
324
|
+
* Config usage:
|
|
325
|
+
* ```
|
|
326
|
+
* *.api.key = "${OPENBAO:secret/data/myapp#api_key}";
|
|
327
|
+
* ```
|
|
328
|
+
*/
|
|
329
|
+
export class OpenBaoProvider implements SecretProvider {
|
|
330
|
+
readonly prefix: "OPENBAO";
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* @param options.addr - OpenBao address (default: process.env.BAO_ADDR or "http://127.0.0.1:8200")
|
|
334
|
+
* @param options.token - OpenBao token (default: process.env.BAO_TOKEN)
|
|
335
|
+
* @param options.namespace - OpenBao namespace (default: process.env.BAO_NAMESPACE)
|
|
336
|
+
* @param options.timeout - Request timeout in ms (default: 30000)
|
|
337
|
+
*/
|
|
338
|
+
constructor(options?: { addr?: string; token?: string; namespace?: string; timeout?: number });
|
|
339
|
+
|
|
340
|
+
fetch(key: string): Promise<string>;
|
|
341
|
+
validate(): Promise<void>;
|
|
342
|
+
}
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import fg from "fast-glob";
|
|
4
|
+
import { parseColony } from "./parser.js";
|
|
5
|
+
import { resolveRules } from "./resolver.js";
|
|
6
|
+
import {
|
|
7
|
+
applySecretsDeep,
|
|
8
|
+
SecretCache,
|
|
9
|
+
hasGlobalProviders,
|
|
10
|
+
registerSecretProvider,
|
|
11
|
+
unregisterSecretProvider,
|
|
12
|
+
clearSecretProviders
|
|
13
|
+
} from "./secrets.js";
|
|
14
|
+
import { AwsSecretsProvider } from "./providers/aws.js";
|
|
15
|
+
import { VaultProvider } from "./providers/vault.js";
|
|
16
|
+
import { OpenBaoProvider } from "./providers/openbao.js";
|
|
17
|
+
async function loadColony(opts) {
|
|
18
|
+
const entry = opts?.entry;
|
|
19
|
+
if (!entry) throw new Error("loadColony: opts.entry is required");
|
|
20
|
+
const sandbox = opts.sandbox ?? {};
|
|
21
|
+
const basePath = sandbox.basePath ? path.resolve(sandbox.basePath) : null;
|
|
22
|
+
const maxIncludeDepth = sandbox.maxIncludeDepth ?? 50;
|
|
23
|
+
const maxFileSize = sandbox.maxFileSize ?? null;
|
|
24
|
+
const warnOnSkippedIncludes = opts.warnOnSkippedIncludes ?? false;
|
|
25
|
+
const visited = /* @__PURE__ */ new Set();
|
|
26
|
+
const warnings = [];
|
|
27
|
+
const files = await expandIncludes(entry, visited, {
|
|
28
|
+
basePath,
|
|
29
|
+
maxIncludeDepth,
|
|
30
|
+
maxFileSize,
|
|
31
|
+
warnOnSkippedIncludes,
|
|
32
|
+
warnings
|
|
33
|
+
});
|
|
34
|
+
const parsed = [];
|
|
35
|
+
for (const file of files) {
|
|
36
|
+
const text = await fs.readFile(file, "utf8");
|
|
37
|
+
parsed.push(parseColony(text, { filePath: file }));
|
|
38
|
+
}
|
|
39
|
+
const dims = (Array.isArray(opts.dims) && opts.dims.length ? opts.dims : null) ?? parsed.find((p) => p.dims?.length)?.dims ?? ["env"];
|
|
40
|
+
const envDefaults = mergeEnvDefaults(parsed.map((p) => p.envDefaults ?? {}));
|
|
41
|
+
const ctx = {
|
|
42
|
+
...envDefaults,
|
|
43
|
+
env: process.env.NODE_ENV ?? "dev",
|
|
44
|
+
...opts.ctx
|
|
45
|
+
};
|
|
46
|
+
const vars = { ROOT: process.cwd(), ...opts.vars ?? {} };
|
|
47
|
+
const requires = parsed.flatMap((p) => p.requires ?? []);
|
|
48
|
+
const allRules = parsed.flatMap((p) => p.rules);
|
|
49
|
+
const allowedEnvVars = sandbox.allowedEnvVars ?? null;
|
|
50
|
+
const allowedVars = sandbox.allowedVars ?? null;
|
|
51
|
+
let cfg = resolveRules({ rules: allRules, dims, ctx, vars, allowedEnvVars, allowedVars, warnings });
|
|
52
|
+
const secretsOpts = opts.secrets ?? {};
|
|
53
|
+
if (secretsOpts.providers?.length || hasGlobalProviders()) {
|
|
54
|
+
const cacheOpts = secretsOpts.cache ?? {};
|
|
55
|
+
const cache = cacheOpts.enabled !== false ? new SecretCache(cacheOpts.maxSize ?? 100) : null;
|
|
56
|
+
const secretified = await applySecretsDeep(cfg, {
|
|
57
|
+
providers: secretsOpts.providers ?? [],
|
|
58
|
+
allowedSecrets: secretsOpts.allowedSecrets ?? null,
|
|
59
|
+
cache,
|
|
60
|
+
cacheTtl: cacheOpts.ttl ?? 3e5,
|
|
61
|
+
onNotFound: secretsOpts.onNotFound ?? "warn",
|
|
62
|
+
warnings
|
|
63
|
+
});
|
|
64
|
+
copyConfigMethods(secretified, cfg, warnings);
|
|
65
|
+
cfg = secretified;
|
|
66
|
+
}
|
|
67
|
+
const missing = [];
|
|
68
|
+
for (const reqKey of requires) {
|
|
69
|
+
if (cfg.get(reqKey) === void 0) missing.push(reqKey);
|
|
70
|
+
}
|
|
71
|
+
if (missing.length) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
`COLONY @require failed (missing keys):
|
|
74
|
+
` + missing.map((k) => ` - ${k}`).join("\n")
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
Object.defineProperty(cfg, "_warnings", { enumerable: false, value: warnings });
|
|
78
|
+
if (typeof opts.schema === "function") {
|
|
79
|
+
const result = opts.schema(cfg);
|
|
80
|
+
if (result && typeof result.then === "function") {
|
|
81
|
+
const validated = await result;
|
|
82
|
+
if (validated && validated !== cfg) {
|
|
83
|
+
copyConfigMethods(validated, cfg, warnings);
|
|
84
|
+
return validated;
|
|
85
|
+
}
|
|
86
|
+
} else if (result && result !== cfg) {
|
|
87
|
+
copyConfigMethods(result, cfg, warnings);
|
|
88
|
+
return result;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return cfg;
|
|
92
|
+
}
|
|
93
|
+
function copyConfigMethods(target, source, warnings) {
|
|
94
|
+
Object.defineProperties(target, {
|
|
95
|
+
get: { enumerable: false, value: source.get },
|
|
96
|
+
explain: { enumerable: false, value: source.explain },
|
|
97
|
+
toJSON: { enumerable: false, value: source.toJSON },
|
|
98
|
+
keys: { enumerable: false, value: source.keys },
|
|
99
|
+
diff: { enumerable: false, value: source.diff },
|
|
100
|
+
_trace: { enumerable: false, value: source._trace },
|
|
101
|
+
_warnings: { enumerable: false, value: warnings }
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
function mergeEnvDefaults(list) {
|
|
105
|
+
const out = {};
|
|
106
|
+
for (const m of list) {
|
|
107
|
+
for (const [k, v] of Object.entries(m)) out[k] = v;
|
|
108
|
+
}
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
async function expandIncludes(entry, visited, { basePath, maxIncludeDepth, maxFileSize, warnOnSkippedIncludes, warnings }) {
|
|
112
|
+
const absEntry = path.resolve(entry);
|
|
113
|
+
const out = [];
|
|
114
|
+
await dfs(absEntry, 0);
|
|
115
|
+
return out;
|
|
116
|
+
async function dfs(file, depth) {
|
|
117
|
+
if (depth > maxIncludeDepth) {
|
|
118
|
+
throw new Error(`COLONY: Max include depth (${maxIncludeDepth}) exceeded at: ${file}`);
|
|
119
|
+
}
|
|
120
|
+
const abs = path.resolve(file);
|
|
121
|
+
if (visited.has(abs)) {
|
|
122
|
+
if (warnOnSkippedIncludes) {
|
|
123
|
+
warnings.push({ type: "skipped_include", file: abs, message: `Skipping already-visited include: ${abs}` });
|
|
124
|
+
}
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
visited.add(abs);
|
|
128
|
+
if (maxFileSize !== null) {
|
|
129
|
+
const stat = await fs.stat(abs);
|
|
130
|
+
if (stat.size > maxFileSize) {
|
|
131
|
+
throw new Error(`COLONY: File size (${stat.size} bytes) exceeds maxFileSize (${maxFileSize} bytes): ${abs}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const text = await fs.readFile(abs, "utf8");
|
|
135
|
+
const { includes } = parseColony(text, { filePath: abs, parseOnlyDirectives: true });
|
|
136
|
+
for (const inc of includes) {
|
|
137
|
+
const incAbs = path.resolve(path.dirname(abs), inc);
|
|
138
|
+
if (basePath !== null) {
|
|
139
|
+
const normalizedInc = path.normalize(incAbs);
|
|
140
|
+
if (!normalizedInc.startsWith(basePath + path.sep) && normalizedInc !== basePath) {
|
|
141
|
+
throw new Error(
|
|
142
|
+
`COLONY: Path traversal blocked. Include "${inc}" resolves to "${normalizedInc}" which is outside basePath "${basePath}"`
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const matches = await fg(incAbs.replace(/\\/g, "/"), { dot: true });
|
|
147
|
+
for (const m of matches.sort((a, b) => a.localeCompare(b))) {
|
|
148
|
+
if (basePath !== null) {
|
|
149
|
+
const normalizedMatch = path.normalize(m);
|
|
150
|
+
if (!normalizedMatch.startsWith(basePath + path.sep) && normalizedMatch !== basePath) {
|
|
151
|
+
throw new Error(
|
|
152
|
+
`COLONY: Path traversal blocked. Glob match "${m}" is outside basePath "${basePath}"`
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
await dfs(m, depth + 1);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
out.push(abs);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
async function validateColony(entry) {
|
|
163
|
+
const visited = /* @__PURE__ */ new Set();
|
|
164
|
+
const files = [];
|
|
165
|
+
const errors = [];
|
|
166
|
+
await validateDfs(path.resolve(entry));
|
|
167
|
+
return {
|
|
168
|
+
valid: errors.length === 0,
|
|
169
|
+
files,
|
|
170
|
+
errors
|
|
171
|
+
};
|
|
172
|
+
async function validateDfs(file) {
|
|
173
|
+
const abs = path.resolve(file);
|
|
174
|
+
if (visited.has(abs)) return;
|
|
175
|
+
visited.add(abs);
|
|
176
|
+
try {
|
|
177
|
+
const text = await fs.readFile(abs, "utf8");
|
|
178
|
+
const { includes } = parseColony(text, { filePath: abs });
|
|
179
|
+
files.push(abs);
|
|
180
|
+
for (const inc of includes) {
|
|
181
|
+
const incAbs = path.resolve(path.dirname(abs), inc);
|
|
182
|
+
const matches = await fg(incAbs.replace(/\\/g, "/"), { dot: true });
|
|
183
|
+
for (const m of matches.sort((a, b) => a.localeCompare(b))) {
|
|
184
|
+
await validateDfs(m);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
} catch (e) {
|
|
188
|
+
errors.push({ file: abs, error: e.message });
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
async function dryRunIncludes(entry) {
|
|
193
|
+
const visited = /* @__PURE__ */ new Set();
|
|
194
|
+
const files = [];
|
|
195
|
+
await dryRunDfs(path.resolve(entry));
|
|
196
|
+
return files;
|
|
197
|
+
async function dryRunDfs(file) {
|
|
198
|
+
const abs = path.resolve(file);
|
|
199
|
+
if (visited.has(abs)) return;
|
|
200
|
+
visited.add(abs);
|
|
201
|
+
const text = await fs.readFile(abs, "utf8");
|
|
202
|
+
const { includes } = parseColony(text, { filePath: abs, parseOnlyDirectives: true });
|
|
203
|
+
for (const inc of includes) {
|
|
204
|
+
const incAbs = path.resolve(path.dirname(abs), inc);
|
|
205
|
+
const matches = await fg(incAbs.replace(/\\/g, "/"), { dot: true });
|
|
206
|
+
for (const m of matches.sort((a, b) => a.localeCompare(b))) {
|
|
207
|
+
await dryRunDfs(m);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
files.push(abs);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
async function diffColony(opts) {
|
|
214
|
+
const { ctx1, ctx2, ...baseOpts } = opts;
|
|
215
|
+
if (!ctx1 || !ctx2) {
|
|
216
|
+
throw new Error("diffColony: both ctx1 and ctx2 are required");
|
|
217
|
+
}
|
|
218
|
+
const cfg1 = await loadColony({ ...baseOpts, ctx: ctx1 });
|
|
219
|
+
const cfg2 = await loadColony({ ...baseOpts, ctx: ctx2 });
|
|
220
|
+
return {
|
|
221
|
+
cfg1,
|
|
222
|
+
cfg2,
|
|
223
|
+
diff: cfg1.diff(cfg2)
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
async function lintColony(opts) {
|
|
227
|
+
const entry = opts?.entry;
|
|
228
|
+
if (!entry) throw new Error("lintColony: opts.entry is required");
|
|
229
|
+
const issues = [];
|
|
230
|
+
const visited = /* @__PURE__ */ new Set();
|
|
231
|
+
const allRules = [];
|
|
232
|
+
const allFiles = [];
|
|
233
|
+
let foundDims = null;
|
|
234
|
+
await collectRules(path.resolve(entry));
|
|
235
|
+
async function collectRules(file) {
|
|
236
|
+
const abs = path.resolve(file);
|
|
237
|
+
if (visited.has(abs)) return;
|
|
238
|
+
visited.add(abs);
|
|
239
|
+
try {
|
|
240
|
+
const text = await fs.readFile(abs, "utf8");
|
|
241
|
+
const parsed = parseColony(text, { filePath: abs });
|
|
242
|
+
allFiles.push(abs);
|
|
243
|
+
if (!foundDims && parsed.dims?.length) {
|
|
244
|
+
foundDims = parsed.dims;
|
|
245
|
+
}
|
|
246
|
+
for (const rule of parsed.rules) {
|
|
247
|
+
allRules.push({ ...rule, filePath: abs });
|
|
248
|
+
}
|
|
249
|
+
for (const inc of parsed.includes) {
|
|
250
|
+
const incAbs = path.resolve(path.dirname(abs), inc);
|
|
251
|
+
const matches = await fg(incAbs.replace(/\\/g, "/"), { dot: true });
|
|
252
|
+
for (const m of matches.sort((a, b) => a.localeCompare(b))) {
|
|
253
|
+
await collectRules(m);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
} catch (e) {
|
|
257
|
+
issues.push({
|
|
258
|
+
type: "parse_error",
|
|
259
|
+
severity: "error",
|
|
260
|
+
message: e.message,
|
|
261
|
+
file: abs
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
const dims = opts.dims ?? foundDims ?? ["env"];
|
|
266
|
+
const rulesByKey = /* @__PURE__ */ new Map();
|
|
267
|
+
for (const rule of allRules) {
|
|
268
|
+
const scope = rule.keySegments.slice(0, dims.length).join(".");
|
|
269
|
+
const keyPath = rule.keySegments.slice(dims.length).join(".");
|
|
270
|
+
const key = `${scope}|${keyPath}`;
|
|
271
|
+
if (!rulesByKey.has(key)) {
|
|
272
|
+
rulesByKey.set(key, []);
|
|
273
|
+
}
|
|
274
|
+
rulesByKey.get(key).push(rule);
|
|
275
|
+
}
|
|
276
|
+
for (const [key, rules] of rulesByKey.entries()) {
|
|
277
|
+
if (rules.length > 1) {
|
|
278
|
+
const locations = rules.map((r) => `${r.filePath}:${r.line}`);
|
|
279
|
+
const uniqueLocations = new Set(locations);
|
|
280
|
+
if (uniqueLocations.size > 1) {
|
|
281
|
+
const [scope, keyPath] = key.split("|");
|
|
282
|
+
issues.push({
|
|
283
|
+
type: "shadowed_rule",
|
|
284
|
+
severity: "warning",
|
|
285
|
+
message: `Rule "${scope}.${keyPath}" is defined ${rules.length} times. Later rule wins.`,
|
|
286
|
+
file: rules[rules.length - 1].filePath,
|
|
287
|
+
line: rules[rules.length - 1].line
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
for (const rule of allRules) {
|
|
293
|
+
const scope = rule.keySegments.slice(0, dims.length);
|
|
294
|
+
const keyPath = rule.keySegments.slice(dims.length).join(".");
|
|
295
|
+
if (scope.every((s) => s === "*")) {
|
|
296
|
+
const moreSpecific = allRules.filter((r) => {
|
|
297
|
+
const rKeyPath = r.keySegments.slice(dims.length).join(".");
|
|
298
|
+
if (rKeyPath !== keyPath) return false;
|
|
299
|
+
const rScope = r.keySegments.slice(0, dims.length);
|
|
300
|
+
return rScope.some((s) => s !== "*") && r !== rule;
|
|
301
|
+
});
|
|
302
|
+
if (moreSpecific.length > 0) {
|
|
303
|
+
issues.push({
|
|
304
|
+
type: "overridden_wildcard",
|
|
305
|
+
severity: "info",
|
|
306
|
+
message: `Wildcard rule for "${keyPath}" is overridden by ${moreSpecific.length} more specific rule(s)`,
|
|
307
|
+
file: rule.filePath,
|
|
308
|
+
line: rule.line
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
for (const file of allFiles) {
|
|
314
|
+
try {
|
|
315
|
+
const text = await fs.readFile(file, "utf8");
|
|
316
|
+
const parsed = parseColony(text, { filePath: file });
|
|
317
|
+
for (const inc of parsed.includes) {
|
|
318
|
+
const incAbs = path.resolve(path.dirname(file), inc);
|
|
319
|
+
const matches = await fg(incAbs.replace(/\\/g, "/"), { dot: true });
|
|
320
|
+
if (matches.length === 0) {
|
|
321
|
+
issues.push({
|
|
322
|
+
type: "empty_include",
|
|
323
|
+
severity: "warning",
|
|
324
|
+
message: `Include pattern "${inc}" matches no files`,
|
|
325
|
+
file
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
} catch {
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return { issues };
|
|
333
|
+
}
|
|
334
|
+
export {
|
|
335
|
+
AwsSecretsProvider,
|
|
336
|
+
OpenBaoProvider,
|
|
337
|
+
VaultProvider,
|
|
338
|
+
clearSecretProviders,
|
|
339
|
+
diffColony,
|
|
340
|
+
dryRunIncludes,
|
|
341
|
+
lintColony,
|
|
342
|
+
loadColony,
|
|
343
|
+
registerSecretProvider,
|
|
344
|
+
unregisterSecretProvider,
|
|
345
|
+
validateColony
|
|
346
|
+
};
|
|
347
|
+
//# sourceMappingURL=index.js.map
|