@tamyla/clodo-framework 4.5.0 → 4.6.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.
@@ -0,0 +1,398 @@
1
+ /**
2
+ * Clodo Framework - Secrets Manager
3
+ *
4
+ * Provides scanning, baseline management, and validation for secret leakage prevention.
5
+ * Extracted from ValidationHandler and enhanced with baseline update/show/validate flows.
6
+ *
7
+ * Usage:
8
+ * import { SecretsManager } from './SecretsManager.js';
9
+ * const mgr = new SecretsManager();
10
+ * const results = await mgr.scan('/path/to/service');
11
+ * const validation = await mgr.validate('/path/to/service');
12
+ * await mgr.baselineUpdate('/path/to/service');
13
+ */
14
+
15
+ import fs from 'fs/promises';
16
+ import path from 'path';
17
+ import { SecretGenerator } from './SecretGenerator.js';
18
+
19
+ /**
20
+ * Default secret detection patterns
21
+ */
22
+ const SECRET_PATTERNS = [{
23
+ name: 'api_key',
24
+ pattern: /(api[_-]?key|apikey)\s*[=:]\s*['"]([^'"]{20,})['"]/gi,
25
+ severity: 'high'
26
+ }, {
27
+ name: 'secret',
28
+ pattern: /(secret|token)\s*[=:]\s*['"]([^'"]{20,})['"]/gi,
29
+ severity: 'high'
30
+ }, {
31
+ name: 'password',
32
+ pattern: /(password|passwd|pwd)\s*[=:]\s*['"]([^'"]{8,})['"]/gi,
33
+ severity: 'critical'
34
+ }, {
35
+ name: 'private_key',
36
+ pattern: /-----BEGIN\s+(?:RSA\s+)?PRIVATE\s+KEY-----/gi,
37
+ severity: 'critical'
38
+ }, {
39
+ name: 'aws_access_key',
40
+ pattern: /AKIA[0-9A-Z]{16}/g,
41
+ severity: 'critical'
42
+ }, {
43
+ name: 'cloudflare_token',
44
+ pattern: /(?:cloudflare|cf)[_-]?(?:api[_-]?)?token\s*[=:]\s*['"]([^'"]{40,})['"]/gi,
45
+ severity: 'high'
46
+ }, {
47
+ name: 'jwt_secret',
48
+ pattern: /(?:jwt|json[_-]?web[_-]?token)[_-]?secret\s*[=:]\s*['"]([^'"]{20,})['"]/gi,
49
+ severity: 'high'
50
+ }, {
51
+ name: 'database_url',
52
+ pattern: /(?:database|db)[_-]?url\s*[=:]\s*['"]([^'"]*password[^'"]*)['"]/gi,
53
+ severity: 'high'
54
+ }, {
55
+ name: 'stripe_key',
56
+ pattern: /(sk|pk)_(?:test|live)_[0-9a-zA-Z]{20,}/g,
57
+ severity: 'critical'
58
+ }, {
59
+ name: 'github_token',
60
+ pattern: /gh[ps]_[A-Za-z0-9_]{36,}/g,
61
+ severity: 'critical'
62
+ }, {
63
+ name: 'slack_token',
64
+ pattern: /xox[baprs]-[0-9A-Za-z-]{10,}/g,
65
+ severity: 'high'
66
+ }, {
67
+ name: 'generic_secret',
68
+ pattern: /(?:auth|bearer)\s*[=:]\s*['"]([^'"]{30,})['"]/gi,
69
+ severity: 'medium'
70
+ }];
71
+
72
+ /**
73
+ * File extensions to scan
74
+ */
75
+ const SCAN_EXTENSIONS = ['.js', '.ts', '.json', '.toml', '.env', '.md', '.txt', '.yaml', '.yml', '.cfg', '.ini'];
76
+
77
+ /**
78
+ * Directories to skip during scanning
79
+ */
80
+ const SKIP_DIRECTORIES = ['node_modules', '.git', 'dist', 'build', 'coverage', 'logs', '.wrangler', '.cache'];
81
+
82
+ /**
83
+ * Words that indicate a value is clearly a test/example (case-insensitive)
84
+ */
85
+ const FALSE_POSITIVE_INDICATORS = ['example', 'test', 'fake', 'placeholder', 'dummy', 'sample', 'your-', 'xxx', 'changeme', 'todo', 'fixme'];
86
+ export class SecretsManager {
87
+ /**
88
+ * @param {Object} options
89
+ * @param {Array} options.patterns - Custom patterns (defaults to SECRET_PATTERNS)
90
+ * @param {Array} options.extensions - File extensions to scan (defaults to SCAN_EXTENSIONS)
91
+ * @param {Array} options.skipDirs - Directories to skip (defaults to SKIP_DIRECTORIES)
92
+ * @param {boolean} options.includeTests - Include test/example content (default: false)
93
+ */
94
+ constructor(options = {}) {
95
+ this.patterns = options.patterns || SECRET_PATTERNS;
96
+ this.extensions = options.extensions || SCAN_EXTENSIONS;
97
+ this.skipDirs = options.skipDirs || SKIP_DIRECTORIES;
98
+ this.includeTests = options.includeTests || false;
99
+ }
100
+
101
+ /**
102
+ * Scan a directory for potential secrets
103
+ * @param {string} servicePath - Root path to scan
104
+ * @returns {Promise<Array<SecretFinding>>} Array of findings
105
+ */
106
+ async scan(servicePath) {
107
+ const resolvedPath = path.resolve(servicePath);
108
+ const files = await this.getFilesToScan(resolvedPath);
109
+ const findings = [];
110
+ for (const file of files) {
111
+ try {
112
+ const content = await fs.readFile(file, 'utf8');
113
+ const lines = content.split('\n');
114
+ const relativePath = path.relative(resolvedPath, file);
115
+ lines.forEach((line, index) => {
116
+ for (const {
117
+ name,
118
+ pattern,
119
+ severity
120
+ } of this.patterns) {
121
+ // Reset regex lastIndex for global patterns
122
+ pattern.lastIndex = 0;
123
+ let match;
124
+ while ((match = pattern.exec(line)) !== null) {
125
+ // Skip false positives unless includeTests is set
126
+ if (!this.includeTests && this._isFalsePositive(line)) {
127
+ continue;
128
+ }
129
+ findings.push({
130
+ file: relativePath,
131
+ line: index + 1,
132
+ pattern: name,
133
+ severity,
134
+ match: this._truncateMatch(match[0]),
135
+ column: match.index + 1
136
+ });
137
+ }
138
+ }
139
+ });
140
+ } catch {
141
+ // Skip files that can't be read
142
+ continue;
143
+ }
144
+ }
145
+ return findings;
146
+ }
147
+
148
+ /**
149
+ * Validate: scan and compare against baseline, return pass/fail
150
+ * @param {string} servicePath - Root path to scan
151
+ * @returns {Promise<ValidationResult>}
152
+ */
153
+ async validate(servicePath) {
154
+ const resolvedPath = path.resolve(servicePath);
155
+ const findings = await this.scan(resolvedPath);
156
+ const baseline = await this.loadBaseline(resolvedPath);
157
+ const newFindings = findings.filter(f => !this._isInBaseline(f, baseline));
158
+ const removedFromBaseline = baseline.filter(b => !findings.some(f => f.file === b.file && f.line === b.line && f.pattern === b.pattern));
159
+ const passed = newFindings.length === 0;
160
+ return {
161
+ passed,
162
+ totalFindings: findings.length,
163
+ baselineCount: baseline.length,
164
+ newFindings,
165
+ removedFromBaseline,
166
+ message: passed ? findings.length === 0 ? 'No potential secrets found' : `All ${findings.length} findings are in the baseline` : `Found ${newFindings.length} new potential secret(s) not in baseline`
167
+ };
168
+ }
169
+
170
+ /**
171
+ * Update the baseline file with current findings
172
+ * @param {string} servicePath - Root path to scan
173
+ * @param {Object} options
174
+ * @param {boolean} options.addAll - Add all new findings without prompting
175
+ * @param {boolean} options.prune - Remove stale entries no longer detected
176
+ * @param {string} options.reason - Reason for adding (for audit trail)
177
+ * @returns {Promise<BaselineUpdateResult>}
178
+ */
179
+ async baselineUpdate(servicePath, options = {}) {
180
+ const resolvedPath = path.resolve(servicePath);
181
+ const findings = await this.scan(resolvedPath);
182
+ const baseline = await this.loadBaseline(resolvedPath);
183
+ const newFindings = findings.filter(f => !this._isInBaseline(f, baseline));
184
+ const staleEntries = baseline.filter(b => !findings.some(f => f.file === b.file && f.line === b.line && f.pattern === b.pattern));
185
+ let updatedBaseline = [...baseline];
186
+ let added = 0;
187
+ let pruned = 0;
188
+
189
+ // Add new findings
190
+ if (options.addAll && newFindings.length > 0) {
191
+ const timestamp = new Date().toISOString();
192
+ const newEntries = newFindings.map(f => ({
193
+ file: f.file,
194
+ line: f.line,
195
+ pattern: f.pattern,
196
+ match: f.match,
197
+ severity: f.severity,
198
+ addedAt: timestamp,
199
+ reason: options.reason || 'bulk-update'
200
+ }));
201
+ updatedBaseline = [...updatedBaseline, ...newEntries];
202
+ added = newEntries.length;
203
+ }
204
+
205
+ // Prune stale entries
206
+ if (options.prune && staleEntries.length > 0) {
207
+ updatedBaseline = updatedBaseline.filter(b => !staleEntries.some(s => s.file === b.file && s.line === b.line && s.pattern === b.pattern));
208
+ pruned = staleEntries.length;
209
+ }
210
+
211
+ // Sort by file then line
212
+ updatedBaseline.sort((a, b) => {
213
+ const fileCompare = a.file.localeCompare(b.file);
214
+ return fileCompare !== 0 ? fileCompare : a.line - b.line;
215
+ });
216
+
217
+ // Write baseline
218
+ await this.saveBaseline(resolvedPath, updatedBaseline);
219
+ return {
220
+ added,
221
+ pruned,
222
+ total: updatedBaseline.length,
223
+ staleEntries: staleEntries.length,
224
+ newFindings: newFindings.length,
225
+ baselinePath: this._getBaselinePath(resolvedPath)
226
+ };
227
+ }
228
+
229
+ /**
230
+ * Get current baseline contents
231
+ * @param {string} servicePath - Root path
232
+ * @returns {Promise<Array>} Current baseline entries
233
+ */
234
+ async baselineShow(servicePath) {
235
+ const resolvedPath = path.resolve(servicePath);
236
+ return await this.loadBaseline(resolvedPath);
237
+ }
238
+
239
+ /**
240
+ * Load the .secrets.baseline file
241
+ * @param {string} servicePath
242
+ * @returns {Promise<Array>}
243
+ */
244
+ async loadBaseline(servicePath) {
245
+ const baselinePath = this._getBaselinePath(servicePath);
246
+ try {
247
+ const content = await fs.readFile(baselinePath, 'utf8');
248
+ return JSON.parse(content);
249
+ } catch {
250
+ return [];
251
+ }
252
+ }
253
+
254
+ /**
255
+ * Save the baseline file
256
+ * @param {string} servicePath
257
+ * @param {Array} baseline
258
+ */
259
+ async saveBaseline(servicePath, baseline) {
260
+ const baselinePath = this._getBaselinePath(servicePath);
261
+ await fs.writeFile(baselinePath, JSON.stringify(baseline, null, 2) + '\n', 'utf8');
262
+ }
263
+
264
+ /**
265
+ * Get all files to scan in a directory tree
266
+ * @param {string} dir - Root directory
267
+ * @returns {Promise<string[]>}
268
+ */
269
+ async getFilesToScan(dir) {
270
+ const files = [];
271
+ const scanDir = async currentDir => {
272
+ let entries;
273
+ try {
274
+ entries = await fs.readdir(currentDir, {
275
+ withFileTypes: true
276
+ });
277
+ } catch {
278
+ return; // Skip unreadable directories
279
+ }
280
+ for (const entry of entries) {
281
+ const fullPath = path.join(currentDir, entry.name);
282
+ if (entry.isDirectory()) {
283
+ if (!this.skipDirs.includes(entry.name)) {
284
+ await scanDir(fullPath);
285
+ }
286
+ } else if (entry.isFile()) {
287
+ const ext = path.extname(entry.name);
288
+ if (this.extensions.includes(ext) || entry.name.startsWith('.env')) {
289
+ files.push(fullPath);
290
+ }
291
+ }
292
+ }
293
+ };
294
+ await scanDir(dir);
295
+ return files;
296
+ }
297
+
298
+ /**
299
+ * Get the configured secret patterns
300
+ * @returns {Array} Pattern definitions
301
+ */
302
+ getPatterns() {
303
+ return this.patterns.map(({
304
+ name,
305
+ severity
306
+ }) => ({
307
+ name,
308
+ severity
309
+ }));
310
+ }
311
+
312
+ /**
313
+ * Generate a secure replacement value for a detected secret.
314
+ * Delegates to SecretGenerator for cryptographically safe key generation.
315
+ * @param {string} patternName - The detected pattern type (api_key, jwt_secret, password, etc.)
316
+ * @param {Object} options
317
+ * @param {number} options.length - Key length in bytes (default: 32)
318
+ * @param {string} options.prefix - Optional key prefix
319
+ * @returns {Object} Generated key with metadata
320
+ */
321
+ generateReplacement(patternName, options = {}) {
322
+ const {
323
+ length = 32,
324
+ prefix = ''
325
+ } = options;
326
+ switch (patternName) {
327
+ case 'jwt_secret':
328
+ return SecretGenerator.generateKeyWithMetadata('jwt', length || 64);
329
+ case 'api_key':
330
+ case 'cloudflare_token':
331
+ case 'github_token':
332
+ case 'slack_token':
333
+ return SecretGenerator.generateKeyWithMetadata(prefix || patternName, length);
334
+ default:
335
+ return SecretGenerator.generateKeyWithMetadata(prefix, length);
336
+ }
337
+ }
338
+
339
+ /**
340
+ * Validate the strength of a secret value using SecretGenerator's entropy analysis
341
+ * @param {string} key - The secret value to validate
342
+ * @param {Object} requirements - Strength requirements
343
+ * @returns {Object} Validation result with { valid, issues }
344
+ */
345
+ validateKeyStrength(key, requirements = {}) {
346
+ return SecretGenerator.validateKeyStrength(key, requirements);
347
+ }
348
+
349
+ // ─── Private helpers ──────────────────────────────────────────
350
+
351
+ _getBaselinePath(servicePath) {
352
+ return path.join(servicePath, '.secrets.baseline');
353
+ }
354
+ _isInBaseline(finding, baseline) {
355
+ return baseline.some(b => b.file === finding.file && b.line === finding.line && b.pattern === finding.pattern);
356
+ }
357
+ _isFalsePositive(line) {
358
+ const lower = line.toLowerCase();
359
+ return FALSE_POSITIVE_INDICATORS.some(indicator => lower.includes(indicator));
360
+ }
361
+ _truncateMatch(matchStr) {
362
+ const maxLen = 50;
363
+ if (matchStr.length <= maxLen) {
364
+ return matchStr + '...';
365
+ }
366
+ return matchStr.substring(0, maxLen) + '...';
367
+ }
368
+ }
369
+
370
+ /**
371
+ * @typedef {Object} SecretFinding
372
+ * @property {string} file - Relative file path
373
+ * @property {number} line - Line number (1-based)
374
+ * @property {string} pattern - Pattern name that matched
375
+ * @property {string} severity - critical|high|medium
376
+ * @property {string} match - Truncated match string
377
+ * @property {number} column - Column position
378
+ */
379
+
380
+ /**
381
+ * @typedef {Object} ValidationResult
382
+ * @property {boolean} passed - Whether validation passed
383
+ * @property {number} totalFindings - Total secrets found
384
+ * @property {number} baselineCount - Baseline entries count
385
+ * @property {Array<SecretFinding>} newFindings - Secrets not in baseline
386
+ * @property {Array} removedFromBaseline - Stale baseline entries
387
+ * @property {string} message - Human-readable summary
388
+ */
389
+
390
+ /**
391
+ * @typedef {Object} BaselineUpdateResult
392
+ * @property {number} added - New entries added
393
+ * @property {number} pruned - Stale entries removed
394
+ * @property {number} total - Total baseline entries
395
+ * @property {number} staleEntries - Stale entries found
396
+ * @property {number} newFindings - New findings found
397
+ * @property {string} baselinePath - Path to baseline file
398
+ */
@@ -6,12 +6,14 @@
6
6
  import { ConfigurationValidator } from './ConfigurationValidator.js';
7
7
  // DeploymentManager removed - replaced by MultiDomainOrchestrator + WranglerConfigManager
8
8
  import { SecretGenerator } from './SecretGenerator.js';
9
+ import { SecretsManager } from './SecretsManager.js';
9
10
  import { SecurityCLI } from './SecurityCLI.js';
10
11
  // InteractiveDeploymentConfigurator removed - replaced by InputCollector
11
12
 
12
13
  export { ConfigurationValidator } from './ConfigurationValidator.js';
13
14
  // export { DeploymentManager} - DEPRECATED: Use MultiDomainOrchestrator instead
14
15
  export { SecretGenerator } from './SecretGenerator.js';
16
+ export { SecretsManager } from './SecretsManager.js';
15
17
  export { SecurityCLI } from './SecurityCLI.js';
16
18
  // export { InteractiveDeploymentConfigurator } - DEPRECATED: Use InputCollector instead
17
19
 
@@ -37,7 +37,7 @@ import { validateServiceName, validateDomainName } from '../utils/validation.js'
37
37
  import { NameFormatters, UrlFormatters, ResourceFormatters } from '../utils/formatters.js';
38
38
  export class ConfirmationEngine {
39
39
  constructor(options = {}) {
40
- this.interactive = options.interactive !== false;
40
+ this.interactive = !!options.interactive;
41
41
  this.rl = this.interactive ? createInterface({
42
42
  input: process.stdin,
43
43
  output: process.stdout
@@ -48,6 +48,7 @@ export class ConfirmationEngine {
48
48
  * Generate and confirm all derived values from core inputs
49
49
  */
50
50
  async generateAndConfirm(coreInputs) {
51
+ console.error('DEBUG: ConfirmationEngine.generateAndConfirm called');
51
52
  console.log(chalk.cyan('\n🔍 Tier 2: Smart Confirmations'));
52
53
  console.log(chalk.white('Reviewing and confirming 15 derived configuration values...\n'));
53
54
 
@@ -341,6 +342,7 @@ export class ConfirmationEngine {
341
342
  * Generate features based on service type
342
343
  */
343
344
  generateFeaturesForType(serviceType) {
345
+ console.error(`DEBUG: generateFeaturesForType called with serviceType: ${serviceType}`);
344
346
  const baseFeatures = {
345
347
  logging: true,
346
348
  monitoring: true,
@@ -353,6 +355,7 @@ export class ConfirmationEngine {
353
355
  authentication: true,
354
356
  authorization: true,
355
357
  database: true,
358
+ d1: true,
356
359
  search: true,
357
360
  filtering: true,
358
361
  pagination: true,
@@ -13,14 +13,18 @@ export class ServiceManifestGenerator {
13
13
  * @returns {Object} Service manifest
14
14
  */
15
15
  createManifest(coreInputs, confirmedValues, generatedFiles) {
16
+ console.error('DEBUG: ServiceManifestGenerator.createManifest called');
17
+ console.error('DEBUG: coreInputs.serviceType:', coreInputs.serviceType);
16
18
  // Derive explicit top-level feature booleans for quick manifest checks (e.g., D1/KV/R2)
17
19
  const features = confirmedValues.features || {};
20
+ console.error('DEBUG: features:', JSON.stringify(features));
21
+ console.error('DEBUG: features.d1:', features.d1);
18
22
  return {
19
23
  manifestVersion: '1.0.0',
20
24
  frameworkVersion: '3.0.0',
21
25
  generatedAt: new Date().toISOString(),
22
26
  // Top-level feature flags for ConfigurationValidator compatibility
23
- d1: !!features.d1,
27
+ d1: !!features.d1 || coreInputs.serviceType === 'data-service',
24
28
  // kv may be represented via a provider flag (e.g., upstash) - accept either
25
29
  kv: !!(features.kv || features.upstash),
26
30
  r2: !!features.r2,
@@ -7,7 +7,7 @@ import { ConfirmationEngine } from '../ConfirmationEngine.js';
7
7
  import { createPromptHandler } from '../../utils/prompt-handler.js';
8
8
  export class ConfirmationHandler {
9
9
  constructor(options = {}) {
10
- this.interactive = options.interactive !== false;
10
+ this.interactive = !!options.interactive;
11
11
  this.promptHandler = createPromptHandler({
12
12
  interactive: this.interactive
13
13
  });