@chappibunny/repolens 0.4.3 → 0.6.2

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.
@@ -0,0 +1,382 @@
1
+ /**
2
+ * Configuration Validation & Security
3
+ *
4
+ * Validates .repolens.yml configuration against schema and security best practices.
5
+ * Prevents injection attacks, validates types, and enforces constraints.
6
+ */
7
+
8
+ import { detectSecrets } from "./secrets.js";
9
+
10
+ /**
11
+ * Validate configuration object against schema
12
+ * @param {object} config - Configuration to validate
13
+ * @returns {{valid: boolean, errors: Array<string>, warnings: Array<string>}}
14
+ */
15
+ export function validateConfig(config) {
16
+ const errors = [];
17
+ const warnings = [];
18
+
19
+ // Required: configVersion
20
+ if (!config.configVersion) {
21
+ errors.push("Missing required field: configVersion");
22
+ } else if (config.configVersion !== 1) {
23
+ errors.push(`Unsupported configVersion: ${config.configVersion}. Expected: 1`);
24
+ }
25
+
26
+ // Validate scan configuration
27
+ if (config.scan) {
28
+ const scanErrors = validateScanConfig(config.scan);
29
+ errors.push(...scanErrors);
30
+ } else {
31
+ warnings.push("No scan configuration found. Using defaults.");
32
+ }
33
+
34
+ // Validate publishers
35
+ if (!config.publishers || Object.keys(config.publishers).length === 0) {
36
+ warnings.push("No publishers configured. Documentation will not be published.");
37
+ } else {
38
+ const publisherErrors = validatePublishers(config.publishers);
39
+ errors.push(...publisherErrors);
40
+ }
41
+
42
+ // Validate notion configuration
43
+ if (config.notion) {
44
+ const notionErrors = validateNotionConfig(config.notion);
45
+ errors.push(...notionErrors);
46
+
47
+ const notionWarnings = checkNotionWarnings(config.notion);
48
+ warnings.push(...notionWarnings);
49
+ }
50
+
51
+ // Validate domains (if present)
52
+ if (config.domains) {
53
+ const domainErrors = validateDomains(config.domains);
54
+ errors.push(...domainErrors);
55
+ }
56
+
57
+ // Check for secrets in config
58
+ const secretFindings = scanConfigForSecrets(config);
59
+ if (secretFindings.length > 0) {
60
+ for (const finding of secretFindings) {
61
+ errors.push(`SECRET DETECTED in config: ${finding.type} at ${finding.path}`);
62
+ }
63
+ }
64
+
65
+ // Validate against injection attacks
66
+ const injectionIssues = checkForInjection(config);
67
+ errors.push(...injectionIssues);
68
+
69
+ return {
70
+ valid: errors.length === 0,
71
+ errors,
72
+ warnings,
73
+ };
74
+ }
75
+
76
+ /**
77
+ * Validate scan configuration
78
+ */
79
+ function validateScanConfig(scan) {
80
+ const errors = [];
81
+
82
+ if (scan.include) {
83
+ if (!Array.isArray(scan.include)) {
84
+ errors.push("scan.include must be an array of glob patterns");
85
+ } else {
86
+ // Validate each pattern
87
+ for (const pattern of scan.include) {
88
+ if (typeof pattern !== "string") {
89
+ errors.push(`Invalid pattern in scan.include: ${pattern} (must be string)`);
90
+ continue; // Skip further checks if not a string
91
+ }
92
+
93
+ // Check for dangerous patterns
94
+ if (pattern.includes("..")) {
95
+ errors.push(`Dangerous pattern in scan.include: ${pattern} (contains "..")`);
96
+ }
97
+
98
+ // Warn about overly broad patterns
99
+ if (pattern === "**" || pattern === "**/*") {
100
+ errors.push(`Overly broad pattern: ${pattern}. This may cause performance issues.`);
101
+ }
102
+ }
103
+ }
104
+ }
105
+
106
+ if (scan.exclude) {
107
+ if (!Array.isArray(scan.exclude)) {
108
+ errors.push("scan.exclude must be an array of glob patterns");
109
+ } else {
110
+ for (const pattern of scan.exclude) {
111
+ if (typeof pattern !== "string") {
112
+ errors.push(`Invalid pattern in scan.exclude: ${pattern} (must be string)`);
113
+ continue; // Skip further checks if not a string
114
+ }
115
+ }
116
+ }
117
+ }
118
+
119
+ // Validate root if present
120
+ if (scan.root && typeof scan.root !== "string") {
121
+ errors.push("scan.root must be a string");
122
+ }
123
+
124
+ return errors;
125
+ }
126
+
127
+ /**
128
+ * Validate publishers configuration
129
+ */
130
+ function validatePublishers(publishers) {
131
+ const errors = [];
132
+
133
+ if (!Array.isArray(publishers) && typeof publishers !== "object") {
134
+ errors.push("publishers must be an array or object");
135
+ return errors;
136
+ }
137
+
138
+ const validPublishers = ["notion", "markdown", "confluence"];
139
+
140
+ if (Array.isArray(publishers)) {
141
+ for (const pub of publishers) {
142
+ if (!validPublishers.includes(pub)) {
143
+ errors.push(`Unknown publisher: ${pub}. Valid: ${validPublishers.join(", ")}`);
144
+ }
145
+ }
146
+ } else {
147
+ for (const [key, value] of Object.entries(publishers)) {
148
+ if (!validPublishers.includes(key)) {
149
+ errors.push(`Unknown publisher: ${key}. Valid: ${validPublishers.join(", ")}`);
150
+ }
151
+
152
+ // Validate publisher config
153
+ if (typeof value !== "object" && typeof value !== "boolean") {
154
+ errors.push(`Invalid config for publisher ${key}`);
155
+ }
156
+ }
157
+ }
158
+
159
+ return errors;
160
+ }
161
+
162
+ /**
163
+ * Validate Notion configuration
164
+ */
165
+ function validateNotionConfig(notion) {
166
+ const errors = [];
167
+
168
+ if (notion.workspaceId && typeof notion.workspaceId !== "string") {
169
+ errors.push("notion.workspaceId must be a string");
170
+ }
171
+
172
+ if (notion.branches) {
173
+ if (!Array.isArray(notion.branches)) {
174
+ errors.push("notion.branches must be an array");
175
+ } else {
176
+ for (const branch of notion.branches) {
177
+ if (typeof branch !== "string") {
178
+ errors.push(`Invalid branch name: ${branch} (must be string)`);
179
+ }
180
+
181
+ // Check for dangerous branch names
182
+ if (branch.includes("..") || branch.includes("/")) {
183
+ errors.push(`Invalid branch name: ${branch} (contains dangerous characters)`);
184
+ }
185
+ }
186
+ }
187
+ }
188
+
189
+ return errors;
190
+ }
191
+
192
+ /**
193
+ * Check for Notion configuration warnings
194
+ */
195
+ function checkNotionWarnings(notion) {
196
+ const warnings = [];
197
+
198
+ if (!notion.branches || notion.branches.length === 0) {
199
+ warnings.push("notion.branches not configured. All branches will publish to Notion (may cause conflicts).");
200
+ }
201
+
202
+ if (!notion.workspaceId) {
203
+ warnings.push("notion.workspaceId not set. Will use environment variable NOTION_WORKSPACE_ID.");
204
+ }
205
+
206
+ return warnings;
207
+ }
208
+
209
+ /**
210
+ * Validate domains configuration
211
+ */
212
+ function validateDomains(domains) {
213
+ const errors = [];
214
+
215
+ if (!Array.isArray(domains)) {
216
+ errors.push("domains must be an array");
217
+ return errors;
218
+ }
219
+
220
+ for (const domain of domains) {
221
+ if (!domain.name || typeof domain.name !== "string") {
222
+ errors.push("Each domain must have a 'name' field (string)");
223
+ }
224
+
225
+ if (!domain.patterns || !Array.isArray(domain.patterns)) {
226
+ errors.push(`Domain '${domain.name}' must have 'patterns' array`);
227
+ } else {
228
+ for (const pattern of domain.patterns) {
229
+ if (typeof pattern !== "string") {
230
+ errors.push(`Invalid pattern in domain '${domain.name}': ${pattern}`);
231
+ }
232
+ }
233
+ }
234
+
235
+ if (domain.description && typeof domain.description !== "string") {
236
+ errors.push(`Domain '${domain.name}' description must be a string`);
237
+ }
238
+ }
239
+
240
+ return errors;
241
+ }
242
+
243
+ /**
244
+ * Scan configuration for accidentally included secrets
245
+ */
246
+ function scanConfigForSecrets(config, path = "root", depth = 0) {
247
+ const findings = [];
248
+
249
+ // Prevent infinite recursion
250
+ if (depth > 20) {
251
+ return findings;
252
+ }
253
+
254
+ if (typeof config === "string") {
255
+ const secrets = detectSecrets(config);
256
+ for (const secret of secrets) {
257
+ findings.push({
258
+ ...secret,
259
+ path,
260
+ });
261
+ }
262
+ } else if (Array.isArray(config)) {
263
+ config.forEach((item, index) => {
264
+ findings.push(...scanConfigForSecrets(item, `${path}[${index}]`, depth + 1));
265
+ });
266
+ } else if (typeof config === "object" && config !== null) {
267
+ // Detect circular references by checking if we've seen this object before
268
+ try {
269
+ for (const [key, value] of Object.entries(config)) {
270
+ findings.push(...scanConfigForSecrets(value, `${path}.${key}`, depth + 1));
271
+ }
272
+ } catch (e) {
273
+ // Handle circular references or maximum call stack
274
+ return findings;
275
+ }
276
+ }
277
+
278
+ return findings;
279
+ }
280
+
281
+ /**
282
+ * Check for potential injection attacks in config
283
+ */
284
+ function checkForInjection(config) {
285
+ const errors = [];
286
+
287
+ // Check for shell injection in patterns
288
+ const dangerousChars = [";", "|", "&", "`", "$", "(", ")", "<", ">"];
289
+
290
+ function checkString(str, context) {
291
+ if (typeof str !== "string" || !str) return;
292
+
293
+ for (const char of dangerousChars) {
294
+ if (str.includes(char)) {
295
+ errors.push(`Potentially dangerous character '${char}' found in ${context}: "${str}"`);
296
+ }
297
+ }
298
+
299
+ // Check for command substitution
300
+ if (str.includes("$(") || str.includes("${")) {
301
+ errors.push(`Command substitution detected in ${context}: "${str}"`);
302
+ }
303
+ }
304
+
305
+ // Check scan patterns
306
+ if (config.scan?.include && Array.isArray(config.scan.include)) {
307
+ config.scan.include.forEach((pattern, i) => {
308
+ if (typeof pattern === "string") {
309
+ checkString(pattern, `scan.include[${i}]`);
310
+ }
311
+ });
312
+ }
313
+
314
+ if (config.scan?.exclude && Array.isArray(config.scan.exclude)) {
315
+ config.scan.exclude.forEach((pattern, i) => {
316
+ if (typeof pattern === "string") {
317
+ checkString(pattern, `scan.exclude[${i}]`);
318
+ }
319
+ });
320
+ }
321
+
322
+ // Check domain patterns
323
+ if (config.domains && Array.isArray(config.domains)) {
324
+ config.domains.forEach((domain, i) => {
325
+ if (domain.patterns && Array.isArray(domain.patterns)) {
326
+ domain.patterns.forEach((pattern, j) => {
327
+ if (typeof pattern === "string") {
328
+ checkString(pattern, `domains[${i}].patterns[${j}]`);
329
+ }
330
+ });
331
+ }
332
+ });
333
+ }
334
+
335
+ return errors;
336
+ }
337
+
338
+ /**
339
+ * Sanitize configuration for safe logging
340
+ * Removes or redacts sensitive fields
341
+ */
342
+ export function sanitizeConfigForLogging(config) {
343
+ const sanitized = JSON.parse(JSON.stringify(config));
344
+
345
+ // Redact sensitive fields
346
+ if (sanitized.notion?.workspaceId) {
347
+ const id = sanitized.notion.workspaceId;
348
+ sanitized.notion.workspaceId = id.substring(0, 4) + "***" + id.substring(id.length - 4);
349
+ }
350
+
351
+ // Remove any tokens
352
+ delete sanitized.notion?.token;
353
+ delete sanitized.ai?.apiKey;
354
+
355
+ return sanitized;
356
+ }
357
+
358
+ /**
359
+ * Validate file path is safe (no directory traversal)
360
+ */
361
+ export function validateSafePath(filePath) {
362
+ if (typeof filePath !== "string") {
363
+ return { valid: false, error: "File path must be a string" };
364
+ }
365
+
366
+ // Check for directory traversal
367
+ if (filePath.includes("..")) {
368
+ return { valid: false, error: "Directory traversal not allowed" };
369
+ }
370
+
371
+ // Check for absolute paths (should use relative)
372
+ if (filePath.startsWith("/") || /^[a-zA-Z]:/.test(filePath)) {
373
+ return { valid: false, error: "Absolute paths not allowed in configuration" };
374
+ }
375
+
376
+ // Check for null bytes
377
+ if (filePath.includes("\0")) {
378
+ return { valid: false, error: "Null bytes not allowed in file paths" };
379
+ }
380
+
381
+ return { valid: true };
382
+ }