@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.
- package/CHANGELOG.md +131 -0
- package/README.md +414 -64
- package/package.json +16 -4
- package/src/ai/provider.js +48 -45
- package/src/cli.js +117 -9
- package/src/core/config-schema.js +43 -1
- package/src/core/config.js +20 -3
- package/src/core/scan.js +184 -3
- package/src/init.js +46 -4
- package/src/integrations/discord.js +261 -0
- package/src/migrate.js +7 -0
- package/src/publishers/confluence.js +428 -0
- package/src/publishers/index.js +112 -4
- package/src/publishers/notion.js +20 -16
- package/src/publishers/publish.js +1 -1
- package/src/renderers/render.js +32 -2
- package/src/utils/branch.js +32 -0
- package/src/utils/logger.js +21 -4
- package/src/utils/metrics.js +361 -0
- package/src/utils/rate-limit.js +289 -0
- package/src/utils/secrets.js +240 -0
- package/src/utils/telemetry.js +375 -0
- package/src/utils/validate.js +382 -0
|
@@ -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
|
+
}
|