@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,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rate Limiting for External APIs
|
|
3
|
+
*
|
|
4
|
+
* Implements rate limiting and request throttling for Notion API and other services
|
|
5
|
+
* to prevent abuse and respect API limits.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Rate limiter class using token bucket algorithm
|
|
10
|
+
*/
|
|
11
|
+
class RateLimiter {
|
|
12
|
+
constructor(options = {}) {
|
|
13
|
+
this.maxRequests = options.maxRequests || 3; // Notion: 3 requests per second
|
|
14
|
+
this.timeWindow = options.timeWindow || 1000; // 1 second
|
|
15
|
+
this.tokens = this.maxRequests;
|
|
16
|
+
this.lastRefill = Date.now();
|
|
17
|
+
this.queue = [];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Refill tokens based on elapsed time
|
|
22
|
+
*/
|
|
23
|
+
refill() {
|
|
24
|
+
const now = Date.now();
|
|
25
|
+
const elapsed = now - this.lastRefill;
|
|
26
|
+
|
|
27
|
+
if (elapsed >= this.timeWindow) {
|
|
28
|
+
const tokensToAdd = Math.floor(elapsed / this.timeWindow) * this.maxRequests;
|
|
29
|
+
this.tokens = Math.min(this.maxRequests, this.tokens + tokensToAdd);
|
|
30
|
+
this.lastRefill = now;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Attempt to consume a token
|
|
36
|
+
* @returns {boolean} True if token available, false otherwise
|
|
37
|
+
*/
|
|
38
|
+
tryConsume() {
|
|
39
|
+
this.refill();
|
|
40
|
+
|
|
41
|
+
if (this.tokens > 0) {
|
|
42
|
+
this.tokens--;
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Wait for a token to become available
|
|
51
|
+
* @returns {Promise<void>}
|
|
52
|
+
*/
|
|
53
|
+
async waitForToken() {
|
|
54
|
+
return new Promise((resolve) => {
|
|
55
|
+
const attemptConsume = () => {
|
|
56
|
+
if (this.tryConsume()) {
|
|
57
|
+
resolve();
|
|
58
|
+
} else {
|
|
59
|
+
// Wait a bit and try again
|
|
60
|
+
const waitTime = Math.max(50, (this.timeWindow / this.maxRequests) / 2);
|
|
61
|
+
setTimeout(attemptConsume, waitTime);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
attemptConsume();
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Execute a function with rate limiting
|
|
71
|
+
* @param {Function} fn - Async function to execute
|
|
72
|
+
* @returns {Promise<any>}
|
|
73
|
+
*/
|
|
74
|
+
async execute(fn) {
|
|
75
|
+
await this.waitForToken();
|
|
76
|
+
return await fn();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Retry logic with exponential backoff
|
|
82
|
+
*/
|
|
83
|
+
class RetryPolicy {
|
|
84
|
+
constructor(options = {}) {
|
|
85
|
+
this.maxRetries = options.maxRetries || 3;
|
|
86
|
+
this.initialDelay = options.initialDelay || 1000; // 1 second
|
|
87
|
+
this.maxDelay = options.maxDelay || 30000; // 30 seconds
|
|
88
|
+
this.backoffMultiplier = options.backoffMultiplier || 2;
|
|
89
|
+
this.retryableStatuses = options.retryableStatuses || [429, 500, 502, 503, 504];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Execute function with retry logic
|
|
94
|
+
* @param {Function} fn - Async function to execute
|
|
95
|
+
* @param {object} context - Context for logging
|
|
96
|
+
* @returns {Promise<any>}
|
|
97
|
+
*/
|
|
98
|
+
async execute(fn, context = {}) {
|
|
99
|
+
let lastError;
|
|
100
|
+
let delay = this.initialDelay;
|
|
101
|
+
|
|
102
|
+
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
|
|
103
|
+
try {
|
|
104
|
+
return await fn();
|
|
105
|
+
} catch (error) {
|
|
106
|
+
lastError = error;
|
|
107
|
+
|
|
108
|
+
// Check if error is retryable
|
|
109
|
+
if (!this.shouldRetry(error, attempt)) {
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Log retry attempt
|
|
114
|
+
if (context.onRetry) {
|
|
115
|
+
context.onRetry(attempt + 1, this.maxRetries, delay, error);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Wait before retrying
|
|
119
|
+
await this.sleep(delay);
|
|
120
|
+
|
|
121
|
+
// Increase delay for next retry (exponential backoff)
|
|
122
|
+
delay = Math.min(delay * this.backoffMultiplier, this.maxDelay);
|
|
123
|
+
|
|
124
|
+
// Add jitter to prevent thundering herd
|
|
125
|
+
delay += Math.random() * 1000;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
throw lastError;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Check if error should trigger a retry
|
|
134
|
+
*/
|
|
135
|
+
shouldRetry(error, attempt) {
|
|
136
|
+
// Don't retry if max attempts reached
|
|
137
|
+
if (attempt >= this.maxRetries) {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Retry on rate limit errors
|
|
142
|
+
if (error.status === 429 || error.code === "rate_limited") {
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Retry on retryable HTTP statuses
|
|
147
|
+
if (error.status && this.retryableStatuses.includes(error.status)) {
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Retry on network errors
|
|
152
|
+
if (error.code === "ECONNRESET" || error.code === "ETIMEDOUT") {
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Sleep for specified milliseconds
|
|
161
|
+
*/
|
|
162
|
+
sleep(ms) {
|
|
163
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Global rate limiters for different services
|
|
168
|
+
const notionRateLimiter = new RateLimiter({
|
|
169
|
+
maxRequests: 3, // Notion: 3 requests per second
|
|
170
|
+
timeWindow: 1000,
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const openAIRateLimiter = new RateLimiter({
|
|
174
|
+
maxRequests: 3, // Conservative: 3 requests per second
|
|
175
|
+
timeWindow: 1000,
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Execute Notion API request with rate limiting and retries
|
|
180
|
+
* @param {Function} fn - Async function that makes Notion API call
|
|
181
|
+
* @param {object} options - Options for rate limiting and retries
|
|
182
|
+
* @returns {Promise<any>}
|
|
183
|
+
*/
|
|
184
|
+
export async function executeNotionRequest(fn, options = {}) {
|
|
185
|
+
const retryPolicy = new RetryPolicy({
|
|
186
|
+
maxRetries: options.maxRetries || 3,
|
|
187
|
+
initialDelay: options.initialDelay || 1000,
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
return await notionRateLimiter.execute(async () => {
|
|
191
|
+
return await retryPolicy.execute(fn, {
|
|
192
|
+
onRetry: (attempt, maxRetries, delay, error) => {
|
|
193
|
+
if (options.onRetry) {
|
|
194
|
+
options.onRetry(attempt, maxRetries, delay, error);
|
|
195
|
+
} else {
|
|
196
|
+
console.warn(
|
|
197
|
+
`Notion API retry ${attempt}/${maxRetries} after ${Math.round(delay)}ms (${error.message})`
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Execute OpenAI/AI API request with rate limiting and retries
|
|
207
|
+
* @param {Function} fn - Async function that makes AI API call
|
|
208
|
+
* @param {object} options - Options for rate limiting and retries
|
|
209
|
+
* @returns {Promise<any>}
|
|
210
|
+
*/
|
|
211
|
+
export async function executeAIRequest(fn, options = {}) {
|
|
212
|
+
const retryPolicy = new RetryPolicy({
|
|
213
|
+
maxRetries: options.maxRetries || 2, // Fewer retries for AI (can be expensive)
|
|
214
|
+
initialDelay: options.initialDelay || 2000,
|
|
215
|
+
maxDelay: options.maxDelay || 60000, // AI can take longer
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
return await openAIRateLimiter.execute(async () => {
|
|
219
|
+
return await retryPolicy.execute(fn, {
|
|
220
|
+
onRetry: (attempt, maxRetries, delay, error) => {
|
|
221
|
+
if (options.onRetry) {
|
|
222
|
+
options.onRetry(attempt, maxRetries, delay, error);
|
|
223
|
+
} else {
|
|
224
|
+
console.warn(
|
|
225
|
+
`AI API retry ${attempt}/${maxRetries} after ${Math.round(delay)}ms (${error.message})`
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Batch API requests to respect rate limits
|
|
235
|
+
* @param {Array<Function>} requests - Array of async functions
|
|
236
|
+
* @param {object} options - Batching options
|
|
237
|
+
* @returns {Promise<Array<any>>}
|
|
238
|
+
*/
|
|
239
|
+
export async function batchRequests(requests, options = {}) {
|
|
240
|
+
const batchSize = options.batchSize || 3;
|
|
241
|
+
const results = [];
|
|
242
|
+
|
|
243
|
+
for (let i = 0; i < requests.length; i += batchSize) {
|
|
244
|
+
const batch = requests.slice(i, i + batchSize);
|
|
245
|
+
const batchResults = await Promise.all(
|
|
246
|
+
batch.map(fn => fn().catch(err => ({ error: err })))
|
|
247
|
+
);
|
|
248
|
+
results.push(...batchResults);
|
|
249
|
+
|
|
250
|
+
// Progress callback
|
|
251
|
+
if (options.onProgress) {
|
|
252
|
+
options.onProgress(Math.min(i + batchSize, requests.length), requests.length);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return results;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Create a rate-limited version of a function
|
|
261
|
+
* @param {Function} fn - Function to rate limit
|
|
262
|
+
* @param {object} options - Rate limiter options
|
|
263
|
+
* @returns {Function} Rate-limited function
|
|
264
|
+
*/
|
|
265
|
+
export function rateLimit(fn, options = {}) {
|
|
266
|
+
const limiter = new RateLimiter(options);
|
|
267
|
+
|
|
268
|
+
return async function(...args) {
|
|
269
|
+
return await limiter.execute(() => fn(...args));
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Get rate limiter stats for monitoring
|
|
275
|
+
*/
|
|
276
|
+
export function getRateLimiterStats() {
|
|
277
|
+
return {
|
|
278
|
+
notion: {
|
|
279
|
+
availableTokens: notionRateLimiter.tokens,
|
|
280
|
+
maxTokens: notionRateLimiter.maxRequests,
|
|
281
|
+
queueLength: notionRateLimiter.queue.length,
|
|
282
|
+
},
|
|
283
|
+
openai: {
|
|
284
|
+
availableTokens: openAIRateLimiter.tokens,
|
|
285
|
+
maxTokens: openAIRateLimiter.maxRequests,
|
|
286
|
+
queueLength: openAIRateLimiter.queue.length,
|
|
287
|
+
},
|
|
288
|
+
};
|
|
289
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secrets Detection & Sanitization
|
|
3
|
+
*
|
|
4
|
+
* Prevents accidental exposure of sensitive information in documentation,
|
|
5
|
+
* logs, and telemetry.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Patterns for detecting common secret formats
|
|
10
|
+
*/
|
|
11
|
+
const SECRET_PATTERNS = [
|
|
12
|
+
// API Keys
|
|
13
|
+
{ name: "OpenAI API Key", pattern: /sk-[a-zA-Z0-9]{20,}/, severity: "high" },
|
|
14
|
+
{ name: "Anthropic API Key", pattern: /sk-ant-[a-zA-Z0-9_-]{95,}/, severity: "high" },
|
|
15
|
+
{ name: "GitHub Token", pattern: /gh[ps]_[a-zA-Z0-9]{36,}/, severity: "critical" },
|
|
16
|
+
{ name: "Generic API Key", pattern: /api[_-]?key[_-]?[a-zA-Z0-9]{20,}/i, severity: "high" },
|
|
17
|
+
|
|
18
|
+
// OAuth Tokens
|
|
19
|
+
{ name: "Slack Token", pattern: /xox[baprs]-[a-zA-Z0-9-]{10,}/, severity: "critical" },
|
|
20
|
+
{ name: "Bearer Token", pattern: /Bearer\s+[a-zA-Z0-9_-]{20,}/, severity: "high" },
|
|
21
|
+
|
|
22
|
+
// Cloud Provider Keys
|
|
23
|
+
{ name: "AWS Access Key", pattern: /AKIA[0-9A-Z]{16}/, severity: "critical" },
|
|
24
|
+
{ name: "Google API Key", pattern: /AIzaSy[a-zA-Z0-9_-]{33}/, severity: "critical" },
|
|
25
|
+
|
|
26
|
+
// Database Connection Strings
|
|
27
|
+
{ name: "MongoDB URI", pattern: /mongodb(\+srv)?:\/\/[^\s]+/, severity: "high" },
|
|
28
|
+
{ name: "PostgreSQL URI", pattern: /postgres(ql)?:\/\/[^\s]+/, severity: "high" },
|
|
29
|
+
|
|
30
|
+
// Generic Secrets
|
|
31
|
+
{ name: "Private Key", pattern: /-----BEGIN (RSA|OPENSSH|DSA|EC|PGP) PRIVATE KEY-----/, severity: "critical" },
|
|
32
|
+
{ name: "Password in URL", pattern: /[a-zA-Z]{3,10}:\/\/[^:\/]+:[^@\/]+@[^\s]+/, severity: "high" },
|
|
33
|
+
|
|
34
|
+
// Notion Specific
|
|
35
|
+
{ name: "Notion Token", pattern: /secret_[a-zA-Z0-9]{30,}/, severity: "high" },
|
|
36
|
+
{ name: "Notion Integration Token", pattern: /ntn_[a-zA-Z0-9]{50,}/, severity: "high" },
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Scan text for potential secrets
|
|
41
|
+
* @param {string} text - Text to scan
|
|
42
|
+
* @returns {Array<{type: string, severity: string, match: string, position: number}>}
|
|
43
|
+
*/
|
|
44
|
+
export function detectSecrets(text) {
|
|
45
|
+
if (!text || typeof text !== "string") return [];
|
|
46
|
+
|
|
47
|
+
const findings = [];
|
|
48
|
+
|
|
49
|
+
for (const { name, pattern, severity } of SECRET_PATTERNS) {
|
|
50
|
+
const matches = text.matchAll(new RegExp(pattern, "g"));
|
|
51
|
+
|
|
52
|
+
for (const match of matches) {
|
|
53
|
+
findings.push({
|
|
54
|
+
type: name,
|
|
55
|
+
severity,
|
|
56
|
+
match: match[0],
|
|
57
|
+
position: match.index,
|
|
58
|
+
// Redacted version for safe logging
|
|
59
|
+
redacted: redactSecret(match[0]),
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return findings;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Redact a secret for safe display
|
|
69
|
+
* @param {string} secret - Secret to redact
|
|
70
|
+
* @returns {string} Redacted version
|
|
71
|
+
*/
|
|
72
|
+
function redactSecret(secret) {
|
|
73
|
+
if (secret.length <= 8) {
|
|
74
|
+
return "***";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const visibleChars = 4;
|
|
78
|
+
const start = secret.substring(0, visibleChars);
|
|
79
|
+
const end = secret.substring(secret.length - visibleChars);
|
|
80
|
+
|
|
81
|
+
return `${start}***${end}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Sanitize text by replacing secrets with redacted versions
|
|
86
|
+
* @param {string} text - Text to sanitize
|
|
87
|
+
* @returns {string} Sanitized text
|
|
88
|
+
*/
|
|
89
|
+
export function sanitizeSecrets(text) {
|
|
90
|
+
if (!text || typeof text !== "string") return text;
|
|
91
|
+
|
|
92
|
+
let sanitized = text;
|
|
93
|
+
|
|
94
|
+
for (const { pattern } of SECRET_PATTERNS) {
|
|
95
|
+
sanitized = sanitized.replace(new RegExp(pattern, "g"), (match) => {
|
|
96
|
+
return redactSecret(match);
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return sanitized;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Check if a string looks like a secret
|
|
105
|
+
* @param {string} value - Value to check
|
|
106
|
+
* @returns {boolean}
|
|
107
|
+
*/
|
|
108
|
+
export function isLikelySecret(value) {
|
|
109
|
+
if (!value || typeof value !== "string") return false;
|
|
110
|
+
|
|
111
|
+
// Check against known patterns
|
|
112
|
+
for (const { pattern } of SECRET_PATTERNS) {
|
|
113
|
+
if (pattern.test(value)) {
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Heuristic: high entropy strings might be secrets
|
|
119
|
+
const entropy = calculateEntropy(value);
|
|
120
|
+
const hasHighEntropy = entropy > 4.5 && value.length >= 20;
|
|
121
|
+
|
|
122
|
+
// Heuristic: looks like base64-encoded data
|
|
123
|
+
const isBase64Like = /^[A-Za-z0-9+/]{20,}={0,2}$/.test(value);
|
|
124
|
+
|
|
125
|
+
return hasHighEntropy || isBase64Like;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Calculate Shannon entropy of a string
|
|
130
|
+
* @param {string} str - String to analyze
|
|
131
|
+
* @returns {number} Entropy value
|
|
132
|
+
*/
|
|
133
|
+
function calculateEntropy(str) {
|
|
134
|
+
const freq = {};
|
|
135
|
+
for (const char of str) {
|
|
136
|
+
freq[char] = (freq[char] || 0) + 1;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
let entropy = 0;
|
|
140
|
+
const len = str.length;
|
|
141
|
+
|
|
142
|
+
for (const count of Object.values(freq)) {
|
|
143
|
+
const p = count / len;
|
|
144
|
+
entropy -= p * Math.log2(p);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return entropy;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Validate environment variables don't contain unexpected secrets
|
|
152
|
+
* @param {object} env - Environment variables object
|
|
153
|
+
* @returns {Array<{key: string, issue: string}>}
|
|
154
|
+
*/
|
|
155
|
+
export function validateEnvironment(env = process.env) {
|
|
156
|
+
const issues = [];
|
|
157
|
+
|
|
158
|
+
// List of keys that SHOULD contain secrets (expected)
|
|
159
|
+
const expectedSecretKeys = [
|
|
160
|
+
"NOTION_TOKEN",
|
|
161
|
+
"REPOLENS_AI_API_KEY",
|
|
162
|
+
"OPENAI_API_KEY",
|
|
163
|
+
"ANTHROPIC_API_KEY",
|
|
164
|
+
"AZURE_OPENAI_API_KEY",
|
|
165
|
+
];
|
|
166
|
+
|
|
167
|
+
// Check each environment variable
|
|
168
|
+
for (const [key, value] of Object.entries(env)) {
|
|
169
|
+
if (!value || typeof value !== "string") continue;
|
|
170
|
+
|
|
171
|
+
// Skip expected secret keys
|
|
172
|
+
if (expectedSecretKeys.includes(key)) continue;
|
|
173
|
+
|
|
174
|
+
// Check if value looks like a secret but key doesn't indicate it should be
|
|
175
|
+
if (isLikelySecret(value)) {
|
|
176
|
+
issues.push({
|
|
177
|
+
key,
|
|
178
|
+
issue: `Environment variable "${key}" contains what looks like a secret`,
|
|
179
|
+
severity: "warning",
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Check for secrets in the value
|
|
184
|
+
const findings = detectSecrets(value);
|
|
185
|
+
if (findings.length > 0) {
|
|
186
|
+
issues.push({
|
|
187
|
+
key,
|
|
188
|
+
issue: `Environment variable "${key}" contains detected secret: ${findings[0].type}`,
|
|
189
|
+
severity: findings[0].severity,
|
|
190
|
+
redacted: findings[0].redacted,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return issues;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Sanitize an object for logging/telemetry
|
|
200
|
+
* Recursively redacts any values that look like secrets
|
|
201
|
+
* @param {any} obj - Object to sanitize
|
|
202
|
+
* @param {number} depth - Current recursion depth
|
|
203
|
+
* @returns {any} Sanitized copy
|
|
204
|
+
*/
|
|
205
|
+
export function sanitizeObject(obj, depth = 0) {
|
|
206
|
+
if (depth > 10) return "[Max depth exceeded]";
|
|
207
|
+
if (obj === null || obj === undefined) return obj;
|
|
208
|
+
|
|
209
|
+
// Handle strings
|
|
210
|
+
if (typeof obj === "string") {
|
|
211
|
+
return sanitizeSecrets(obj);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Handle arrays
|
|
215
|
+
if (Array.isArray(obj)) {
|
|
216
|
+
return obj.map(item => sanitizeObject(item, depth + 1));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Handle objects
|
|
220
|
+
if (typeof obj === "object") {
|
|
221
|
+
const sanitized = {};
|
|
222
|
+
|
|
223
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
224
|
+
// Known secret keys - always redact
|
|
225
|
+
const secretKeys = ["token", "key", "secret", "password", "auth", "apikey"];
|
|
226
|
+
const isSecretKey = secretKeys.some(sk => key.toLowerCase().includes(sk));
|
|
227
|
+
|
|
228
|
+
if (isSecretKey && typeof value === "string") {
|
|
229
|
+
sanitized[key] = redactSecret(value);
|
|
230
|
+
} else {
|
|
231
|
+
sanitized[key] = sanitizeObject(value, depth + 1);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return sanitized;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Return primitives as-is
|
|
239
|
+
return obj;
|
|
240
|
+
}
|