cto-ai-cli 3.1.0 → 4.0.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.
@@ -84,7 +84,7 @@ interface SecretFinding {
84
84
  redacted: string;
85
85
  severity: 'critical' | 'high' | 'medium' | 'low';
86
86
  }
87
- type SecretType = 'api-key' | 'aws-key' | 'private-key' | 'password' | 'token' | 'connection-string' | 'env-variable' | 'custom';
87
+ type SecretType = 'api-key' | 'aws-key' | 'private-key' | 'password' | 'token' | 'connection-string' | 'env-variable' | 'pii' | 'high-entropy' | 'custom';
88
88
  interface IntegrityManifest {
89
89
  version: string;
90
90
  createdAt: Date;
@@ -113,10 +113,74 @@ declare function verifyAuditIntegrity(): Promise<{
113
113
  }>;
114
114
  declare function purgeOldAuditEntries(retentionDays: number): Promise<number>;
115
115
 
116
- declare function scanContentForSecrets(content: string, filePath: string, customPatterns?: string[]): SecretFinding[];
116
+ declare function scanContentForSecrets(content: string, filePath: string, customPatterns?: string[], extraPiiSafeDomains?: Set<string>): SecretFinding[];
117
117
  declare function scanFileForSecrets(filePath: string, projectPath: string, customPatterns?: string[]): Promise<SecretFinding[]>;
118
118
  declare function scanProjectForSecrets(projectPath: string, filePaths: string[], customPatterns?: string[]): Promise<SecretFinding[]>;
119
119
  declare function sanitizeContent(content: string, customPatterns?: string[]): string;
120
+ interface AllowlistEntry {
121
+ fingerprint: string;
122
+ file: string;
123
+ type: string;
124
+ redacted: string;
125
+ reason: string;
126
+ reviewedBy: string;
127
+ reviewedAt: string;
128
+ }
129
+ declare function loadAllowlist(projectPath: string): AllowlistEntry[];
130
+ declare function saveAllowlist(projectPath: string, entries: AllowlistEntry[]): void;
131
+ declare function addToAllowlist(projectPath: string, finding: SecretFinding, reason: string, reviewedBy?: string): AllowlistEntry;
132
+ declare function filterByAllowlist(findings: SecretFinding[], projectPath: string): {
133
+ filtered: SecretFinding[];
134
+ allowed: SecretFinding[];
135
+ };
136
+ interface FileHashMap {
137
+ [relativePath: string]: string;
138
+ }
139
+ declare function getChangedFiles(projectPath: string, filePaths: string[]): {
140
+ changed: string[];
141
+ unchanged: string[];
142
+ cache: FileHashMap;
143
+ };
144
+ interface AuditConfig {
145
+ severityOverrides: Partial<Record<SecretType, SecretFinding['severity']>>;
146
+ piiSafeDomains: string[];
147
+ customPatterns: string[];
148
+ entropyThreshold: number;
149
+ includePII: boolean;
150
+ incrementalScan: boolean;
151
+ }
152
+ declare const DEFAULT_AUDIT_CONFIG: AuditConfig;
153
+ declare function loadAuditConfig(projectPath: string): AuditConfig;
154
+ declare function saveAuditConfig(projectPath: string, config: AuditConfig): void;
155
+ declare function generatePreCommitHook(projectPath: string, hookType?: 'husky' | 'githooks'): string;
156
+ declare function scanContentForHighEntropy(content: string, filePath: string, threshold?: number): SecretFinding[];
157
+ interface AuditResult {
158
+ findings: SecretFinding[];
159
+ summary: {
160
+ totalFiles: number;
161
+ filesScanned: number;
162
+ filesWithSecrets: number;
163
+ totalFindings: number;
164
+ bySeverity: {
165
+ critical: number;
166
+ high: number;
167
+ medium: number;
168
+ low: number;
169
+ };
170
+ byType: Record<string, number>;
171
+ };
172
+ recommendations: string[];
173
+ }
174
+ interface AuditOptions {
175
+ customPatterns?: string[];
176
+ entropyThreshold?: number;
177
+ includePII?: boolean;
178
+ useAllowlist?: boolean;
179
+ incrementalScan?: boolean;
180
+ severityOverrides?: Partial<Record<SecretType, SecretFinding['severity']>>;
181
+ piiSafeDomains?: string[];
182
+ }
183
+ declare function auditProject(projectPath: string, filePaths: string[], options?: AuditOptions): Promise<AuditResult>;
120
184
 
121
185
  interface AnalyzedFile {
122
186
  path: string;
@@ -258,4 +322,4 @@ declare function verifyManifest(manifest: IntegrityManifest): Promise<{
258
322
  }>;
259
323
  declare function securePermissions(dirPath: string): Promise<number>;
260
324
 
261
- export { DEFAULT_POLICY, addRule, buildManifest, compareSnapshots, createSnapshot, getAuditEntries, hashContent, hashFile, logAudit, purgeOldAuditEntries, removeRule, sanitizeContent, scanContentForSecrets, scanFileForSecrets, scanProjectForSecrets, securePermissions, toggleRule, validateSelection, verifyAuditEntry, verifyAuditIntegrity, verifyManifest, verifySnapshot };
325
+ export { type AllowlistEntry, type AuditConfig, type AuditOptions, type AuditResult, DEFAULT_AUDIT_CONFIG, DEFAULT_POLICY, addRule, addToAllowlist, auditProject, buildManifest, compareSnapshots, createSnapshot, filterByAllowlist, generatePreCommitHook, getAuditEntries, getChangedFiles, hashContent, hashFile, loadAllowlist, loadAuditConfig, logAudit, purgeOldAuditEntries, removeRule, sanitizeContent, saveAllowlist, saveAuditConfig, scanContentForHighEntropy, scanContentForSecrets, scanFileForSecrets, scanProjectForSecrets, securePermissions, toggleRule, validateSelection, verifyAuditEntry, verifyAuditIntegrity, verifyManifest, verifySnapshot };
@@ -39,9 +39,9 @@ async function readJSON(filePath) {
39
39
  }
40
40
  }
41
41
  async function writeJSON(filePath, data) {
42
- const { writeFile } = await import("fs/promises");
42
+ const { writeFile: writeFile2 } = await import("fs/promises");
43
43
  await ensureDir(join(filePath, ".."));
44
- await writeFile(filePath, JSON.stringify(data, null, 2), "utf-8");
44
+ await writeFile2(filePath, JSON.stringify(data, null, 2), "utf-8");
45
45
  }
46
46
  async function logAudit(action, projectPath, details = {}) {
47
47
  const auditDir = getAuditDir();
@@ -150,7 +150,9 @@ async function purgeOldAuditEntries(retentionDays) {
150
150
 
151
151
  // src/govern/secrets.ts
152
152
  import { readFile } from "fs/promises";
153
- import { resolve, relative } from "path";
153
+ import { readFileSync, existsSync, mkdirSync, writeFileSync } from "fs";
154
+ import { resolve, relative, join as join2, dirname } from "path";
155
+ import { createHash as createHash2 } from "crypto";
154
156
  var BUILTIN_PATTERNS = [
155
157
  // API Keys
156
158
  { type: "api-key", source: `(?:api[_-]?key|apikey)\\s*[:=]\\s*['"]?([a-zA-Z0-9_\\-]{20,})['"]?`, flags: "gi", severity: "critical", description: "API Key" },
@@ -175,15 +177,66 @@ var BUILTIN_PATTERNS = [
175
177
  { type: "connection-string", source: `(?:mongodb(?:\\+srv)?|postgres(?:ql)?|mysql|redis|amqp):\\/\\/[^\\s'"]+:[^\\s'"]+@[^\\s'"]+`, flags: "gi", severity: "critical", description: "Database Connection String" },
176
178
  { type: "connection-string", source: `(?:DATABASE_URL|REDIS_URL|MONGODB_URI)\\s*[:=]\\s*['"]?([^\\s'"]{10,})['"]?`, flags: "gi", severity: "high", description: "Database URL" },
177
179
  // Environment variables with secrets
178
- { type: "env-variable", source: `(?:SECRET|PRIVATE|ENCRYPTION)[_-]?(?:KEY|TOKEN|PASS)\\s*[:=]\\s*['"]?([^\\s'"]{8,})['"]?`, flags: "gi", severity: "high", description: "Secret Environment Variable" }
180
+ { type: "env-variable", source: `(?:SECRET|PRIVATE|ENCRYPTION)[_-]?(?:KEY|TOKEN|PASS)\\s*[:=]\\s*['"]?([^\\s'"]{8,})['"]?`, flags: "gi", severity: "high", description: "Secret Environment Variable" },
181
+ // Stripe
182
+ { type: "api-key", source: "sk_live_[a-zA-Z0-9]{24,}", flags: "g", severity: "critical", description: "Stripe Live Secret Key" },
183
+ { type: "api-key", source: "pk_live_[a-zA-Z0-9]{24,}", flags: "g", severity: "high", description: "Stripe Live Publishable Key" },
184
+ { type: "api-key", source: "rk_live_[a-zA-Z0-9]{24,}", flags: "g", severity: "critical", description: "Stripe Restricted Key" },
185
+ // Slack
186
+ { type: "token", source: "xoxb-[0-9]{10,}-[0-9]{10,}-[a-zA-Z0-9]{24,}", flags: "g", severity: "critical", description: "Slack Bot Token" },
187
+ { type: "token", source: "xoxp-[0-9]{10,}-[0-9]{10,}-[a-zA-Z0-9]{24,}", flags: "g", severity: "critical", description: "Slack User Token" },
188
+ { type: "api-key", source: "https://hooks\\.slack\\.com/services/T[a-zA-Z0-9_]+/B[a-zA-Z0-9_]+/[a-zA-Z0-9_]+", flags: "g", severity: "high", description: "Slack Webhook URL" },
189
+ // Google
190
+ { type: "api-key", source: "AIza[0-9A-Za-z_-]{35}", flags: "g", severity: "high", description: "Google API Key" },
191
+ { type: "token", source: "ya29\\.[0-9A-Za-z_-]+", flags: "g", severity: "high", description: "Google OAuth Token" },
192
+ // Azure
193
+ { type: "api-key", source: "(?:AccountKey|SharedAccessKey)\\s*=\\s*[a-zA-Z0-9+/=]{40,}", flags: "g", severity: "critical", description: "Azure Storage Key" },
194
+ // Twilio
195
+ { type: "api-key", source: "AC[a-f0-9]{32}", flags: "g", severity: "high", description: "Twilio Account SID" },
196
+ // SendGrid
197
+ { type: "api-key", source: "SG\\.[a-zA-Z0-9_-]{22}\\.[a-zA-Z0-9_-]{43}", flags: "g", severity: "critical", description: "SendGrid API Key" },
198
+ // JWT
199
+ { type: "token", source: "eyJ[a-zA-Z0-9_-]{10,}\\.eyJ[a-zA-Z0-9_-]{10,}\\.[a-zA-Z0-9_-]{10,}", flags: "g", severity: "high", description: "JSON Web Token" },
200
+ // Datadog
201
+ { type: "api-key", source: `(?:DD_API_KEY|DATADOG_API_KEY)\\s*[:=]\\s*['"]?([a-f0-9]{32})['"]?`, flags: "gi", severity: "critical", description: "Datadog API Key" },
202
+ { type: "api-key", source: `(?:DD_APP_KEY|DATADOG_APP_KEY)\\s*[:=]\\s*['"]?([a-f0-9]{40})['"]?`, flags: "gi", severity: "critical", description: "Datadog App Key" },
203
+ // Sentry
204
+ { type: "connection-string", source: "https://[a-f0-9]{32}@[a-z0-9]+\\.ingest\\.sentry\\.io/[0-9]+", flags: "g", severity: "high", description: "Sentry DSN" },
205
+ // Firebase
206
+ { type: "api-key", source: `(?:FIREBASE_API_KEY|FIREBASE_KEY)\\s*[:=]\\s*['"]?([a-zA-Z0-9_\\-]{30,})['"]?`, flags: "gi", severity: "high", description: "Firebase API Key" },
207
+ { type: "connection-string", source: `firebase[a-z]*:\\/\\/[^\\s'"]+`, flags: "gi", severity: "high", description: "Firebase URL" },
208
+ // Supabase
209
+ { type: "api-key", source: "sbp_[a-f0-9]{40}", flags: "g", severity: "critical", description: "Supabase Service Key" },
210
+ { type: "token", source: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\\.[a-zA-Z0-9_-]{20,}\\.[a-zA-Z0-9_-]{20,}", flags: "g", severity: "high", description: "Supabase Anon/Service JWT" },
211
+ // Vercel
212
+ { type: "token", source: `(?:VERCEL_TOKEN|VERCEL_API_TOKEN)\\s*[:=]\\s*['"]?([a-zA-Z0-9]{24,})['"]?`, flags: "gi", severity: "critical", description: "Vercel Token" },
213
+ // Heroku
214
+ { type: "api-key", source: `(?:HEROKU_API_KEY|HEROKU_TOKEN)\\s*[:=]\\s*['"]?([a-f0-9\\-]{36,})['"]?`, flags: "gi", severity: "critical", description: "Heroku API Key" },
215
+ // DigitalOcean
216
+ { type: "token", source: "dop_v1_[a-f0-9]{64}", flags: "g", severity: "critical", description: "DigitalOcean Personal Access Token" },
217
+ { type: "token", source: "doo_v1_[a-f0-9]{64}", flags: "g", severity: "critical", description: "DigitalOcean OAuth Token" },
218
+ // Mailgun
219
+ { type: "api-key", source: "key-[a-zA-Z0-9]{32}", flags: "g", severity: "high", description: "Mailgun API Key" },
220
+ // PII
221
+ { type: "pii", source: "\\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Z|a-z]{2,}\\b", flags: "g", severity: "medium", description: "Email Address (PII)" },
222
+ { type: "pii", source: "\\b(?!000|666|9\\d{2})(\\d{3})[-.]?(?!00)(\\d{2})[-.]?(?!0000)(\\d{4})\\b", flags: "g", severity: "high", description: "Possible SSN (PII)" }
179
223
  ];
224
+ var _cachedBuiltinPatterns = null;
225
+ function getBuiltinPatterns() {
226
+ if (!_cachedBuiltinPatterns) {
227
+ _cachedBuiltinPatterns = BUILTIN_PATTERNS.map((def) => ({
228
+ type: def.type,
229
+ pattern: new RegExp(def.source, def.flags),
230
+ severity: def.severity,
231
+ description: def.description
232
+ }));
233
+ }
234
+ return _cachedBuiltinPatterns;
235
+ }
180
236
  function buildPatterns(customPatterns = []) {
181
- const patterns = BUILTIN_PATTERNS.map((def) => ({
182
- type: def.type,
183
- pattern: new RegExp(def.source, def.flags),
184
- severity: def.severity,
185
- description: def.description
186
- }));
237
+ const builtins = getBuiltinPatterns();
238
+ if (customPatterns.length === 0) return builtins;
239
+ const patterns = [...builtins];
187
240
  for (const custom of customPatterns) {
188
241
  try {
189
242
  patterns.push({
@@ -197,7 +250,7 @@ function buildPatterns(customPatterns = []) {
197
250
  }
198
251
  return patterns;
199
252
  }
200
- function scanContentForSecrets(content, filePath, customPatterns = []) {
253
+ function scanContentForSecrets(content, filePath, customPatterns = [], extraPiiSafeDomains) {
201
254
  const findings = [];
202
255
  const lines = content.split("\n");
203
256
  const allPatterns = buildPatterns(customPatterns);
@@ -209,6 +262,7 @@ function scanContentForSecrets(content, filePath, customPatterns = []) {
209
262
  while ((match = secretPattern.pattern.exec(line)) !== null) {
210
263
  const matchText = match[0];
211
264
  if (isTemplateOrPlaceholder(matchText)) continue;
265
+ if (secretPattern.type === "pii" && isSafeEmail(matchText, extraPiiSafeDomains)) continue;
212
266
  findings.push({
213
267
  type: secretPattern.type,
214
268
  file: filePath,
@@ -278,6 +332,36 @@ function isTemplateOrPlaceholder(value) {
278
332
  ];
279
333
  return placeholders.some((p) => p.test(value));
280
334
  }
335
+ var PII_SAFE_EMAIL_DOMAINS = /* @__PURE__ */ new Set([
336
+ "example.com",
337
+ "example.org",
338
+ "example.net",
339
+ "test.com",
340
+ "test.org",
341
+ "test.net",
342
+ "localhost",
343
+ "localhost.localdomain",
344
+ "email.com",
345
+ "mail.com",
346
+ "foo.com",
347
+ "bar.com",
348
+ "baz.com",
349
+ "acme.com",
350
+ "company.com",
351
+ "corp.com",
352
+ "noreply.com",
353
+ "no-reply.com",
354
+ "users.noreply.github.com",
355
+ "placeholder.com"
356
+ ]);
357
+ function isSafeEmail(value, extraDomains) {
358
+ const match = value.match(/@([a-zA-Z0-9.-]+)$/);
359
+ if (!match) return false;
360
+ const domain = match[1].toLowerCase();
361
+ if (PII_SAFE_EMAIL_DOMAINS.has(domain)) return true;
362
+ if (extraDomains && extraDomains.has(domain)) return true;
363
+ return false;
364
+ }
281
365
  function deduplicateFindings(findings) {
282
366
  const seen = /* @__PURE__ */ new Set();
283
367
  return findings.filter((f) => {
@@ -287,6 +371,350 @@ function deduplicateFindings(findings) {
287
371
  return true;
288
372
  });
289
373
  }
374
+ function fingerprintFinding(f) {
375
+ return createHash2("sha256").update(`${f.file}:${f.type}:${f.match}`).digest("hex").slice(0, 32);
376
+ }
377
+ function getAllowlistPath(projectPath) {
378
+ return join2(projectPath, ".cto", "audit", "allowlist.json");
379
+ }
380
+ function loadAllowlist(projectPath) {
381
+ const filePath = getAllowlistPath(projectPath);
382
+ if (!existsSync(filePath)) return [];
383
+ try {
384
+ return JSON.parse(readFileSync(filePath, "utf-8"));
385
+ } catch {
386
+ return [];
387
+ }
388
+ }
389
+ function saveAllowlist(projectPath, entries) {
390
+ const filePath = getAllowlistPath(projectPath);
391
+ mkdirSync(dirname(filePath), { recursive: true });
392
+ writeFileSync(filePath, JSON.stringify(entries, null, 2) + "\n");
393
+ }
394
+ function addToAllowlist(projectPath, finding, reason, reviewedBy = "manual") {
395
+ const entries = loadAllowlist(projectPath);
396
+ const entry = {
397
+ fingerprint: fingerprintFinding(finding),
398
+ file: finding.file,
399
+ type: finding.type,
400
+ redacted: finding.redacted,
401
+ reason,
402
+ reviewedBy,
403
+ reviewedAt: (/* @__PURE__ */ new Date()).toISOString()
404
+ };
405
+ const existing = entries.findIndex((e) => e.fingerprint === entry.fingerprint);
406
+ if (existing >= 0) {
407
+ entries[existing] = entry;
408
+ } else {
409
+ entries.push(entry);
410
+ }
411
+ saveAllowlist(projectPath, entries);
412
+ return entry;
413
+ }
414
+ function filterByAllowlist(findings, projectPath) {
415
+ const allowlist = loadAllowlist(projectPath);
416
+ if (allowlist.length === 0) return { filtered: findings, allowed: [] };
417
+ const allowedFingerprints = new Set(allowlist.map((e) => e.fingerprint));
418
+ const filtered = [];
419
+ const allowed = [];
420
+ for (const f of findings) {
421
+ if (allowedFingerprints.has(fingerprintFinding(f))) {
422
+ allowed.push(f);
423
+ } else {
424
+ filtered.push(f);
425
+ }
426
+ }
427
+ return { filtered, allowed };
428
+ }
429
+ function getHashCachePath(projectPath) {
430
+ return join2(projectPath, ".cto", "audit", ".hashcache.json");
431
+ }
432
+ function loadHashCache(projectPath) {
433
+ const filePath = getHashCachePath(projectPath);
434
+ if (!existsSync(filePath)) return {};
435
+ try {
436
+ return JSON.parse(readFileSync(filePath, "utf-8"));
437
+ } catch {
438
+ return {};
439
+ }
440
+ }
441
+ function saveHashCache(projectPath, cache) {
442
+ const filePath = getHashCachePath(projectPath);
443
+ mkdirSync(dirname(filePath), { recursive: true });
444
+ writeFileSync(filePath, JSON.stringify(cache));
445
+ }
446
+ function hashContent(content) {
447
+ return createHash2("sha256").update(content).digest("hex").slice(0, 16);
448
+ }
449
+ function getChangedFiles(projectPath, filePaths) {
450
+ const oldCache = loadHashCache(projectPath);
451
+ const newCache = {};
452
+ const changed = [];
453
+ const unchanged = [];
454
+ for (const fp of filePaths) {
455
+ try {
456
+ const content = readFileSync(fp, "utf-8");
457
+ const relPath = relative(resolve(projectPath), resolve(fp));
458
+ const hash = hashContent(content);
459
+ newCache[relPath] = hash;
460
+ if (oldCache[relPath] === hash) {
461
+ unchanged.push(fp);
462
+ } else {
463
+ changed.push(fp);
464
+ }
465
+ } catch {
466
+ changed.push(fp);
467
+ }
468
+ }
469
+ return { changed, unchanged, cache: newCache };
470
+ }
471
+ var DEFAULT_AUDIT_CONFIG = {
472
+ severityOverrides: {},
473
+ piiSafeDomains: [],
474
+ customPatterns: [],
475
+ entropyThreshold: 5,
476
+ includePII: true,
477
+ incrementalScan: true
478
+ };
479
+ function getAuditConfigPath(projectPath) {
480
+ return join2(projectPath, ".cto", "audit", "config.json");
481
+ }
482
+ function loadAuditConfig(projectPath) {
483
+ const filePath = getAuditConfigPath(projectPath);
484
+ if (!existsSync(filePath)) return { ...DEFAULT_AUDIT_CONFIG };
485
+ try {
486
+ const loaded = JSON.parse(readFileSync(filePath, "utf-8"));
487
+ return { ...DEFAULT_AUDIT_CONFIG, ...loaded };
488
+ } catch {
489
+ return { ...DEFAULT_AUDIT_CONFIG };
490
+ }
491
+ }
492
+ function saveAuditConfig(projectPath, config) {
493
+ const filePath = getAuditConfigPath(projectPath);
494
+ mkdirSync(dirname(filePath), { recursive: true });
495
+ writeFileSync(filePath, JSON.stringify(config, null, 2) + "\n");
496
+ }
497
+ function applySeverityOverrides(findings, overrides) {
498
+ if (Object.keys(overrides).length === 0) return findings;
499
+ return findings.map((f) => {
500
+ const override = overrides[f.type];
501
+ if (override) return { ...f, severity: override };
502
+ return f;
503
+ });
504
+ }
505
+ function generatePreCommitHook(projectPath, hookType = "husky") {
506
+ const hookContent = `#!/bin/sh
507
+ # CTO Secret Detection \u2014 Pre-commit hook
508
+ # Auto-generated by: npx cto-ai-cli --audit --init-hook
509
+ # Scans ONLY staged files for secrets before allowing commit.
510
+
511
+ STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM)
512
+
513
+ if [ -z "$STAGED_FILES" ]; then
514
+ exit 0
515
+ fi
516
+
517
+ echo "\u{1F50D} CTO: Scanning $(echo "$STAGED_FILES" | wc -l | tr -d ' ') staged files for secrets..."
518
+
519
+ # Write staged files to temp list
520
+ TMPFILE=$(mktemp)
521
+ echo "$STAGED_FILES" > "$TMPFILE"
522
+
523
+ # Run audit in CI mode on staged files only
524
+ CI=true npx cto-ai-cli --audit --files "$TMPFILE"
525
+ RESULT=$?
526
+
527
+ rm -f "$TMPFILE"
528
+
529
+ if [ $RESULT -ne 0 ]; then
530
+ echo ""
531
+ echo "\u274C Commit blocked: secrets detected in staged files."
532
+ echo " Run 'npx cto-ai-cli --audit' to see details."
533
+ echo " Use allowlist to mark reviewed findings as safe."
534
+ echo ""
535
+ exit 1
536
+ fi
537
+
538
+ echo "\u2705 No secrets detected. Proceeding with commit."
539
+ `;
540
+ let hookPath;
541
+ if (hookType === "husky") {
542
+ hookPath = join2(projectPath, ".husky", "pre-commit");
543
+ } else {
544
+ hookPath = join2(projectPath, ".git", "hooks", "pre-commit");
545
+ }
546
+ mkdirSync(dirname(hookPath), { recursive: true });
547
+ writeFileSync(hookPath, hookContent, { mode: 493 });
548
+ return hookPath;
549
+ }
550
+ function shannonEntropy(str) {
551
+ const freq = /* @__PURE__ */ new Map();
552
+ for (const ch of str) {
553
+ freq.set(ch, (freq.get(ch) || 0) + 1);
554
+ }
555
+ let entropy = 0;
556
+ for (const count of freq.values()) {
557
+ const p = count / str.length;
558
+ if (p > 0) entropy -= p * Math.log2(p);
559
+ }
560
+ return entropy;
561
+ }
562
+ var HIGH_ENTROPY_RE = /['"]([a-zA-Z0-9+/=_\-]{30,})['"]|=\s*['"]?([a-zA-Z0-9+/=_\-]{30,})['"]?/g;
563
+ var ENTROPY_SKIP = [
564
+ /^[a-f0-9]{32,}$/i,
565
+ // hex hashes
566
+ /^[A-Z_]{30,}$/,
567
+ // all-caps constants
568
+ /^[a-z_]{30,}$/,
569
+ // all-lowercase identifiers
570
+ /^[a-zA-Z0-9+/]+=+$/,
571
+ // base64 padding
572
+ /^[a-z]+[A-Z][a-zA-Z]+$/,
573
+ // camelCase identifiers
574
+ /sha\d+-/i
575
+ // integrity hashes (sha256-, sha512-)
576
+ ];
577
+ function scanContentForHighEntropy(content, filePath, threshold = 5) {
578
+ const findings = [];
579
+ const lines = content.split("\n");
580
+ for (let i = 0; i < lines.length; i++) {
581
+ const line = lines[i];
582
+ if (line.trim().startsWith("//") || line.trim().startsWith("#") || line.trim().startsWith("*")) continue;
583
+ HIGH_ENTROPY_RE.lastIndex = 0;
584
+ let match;
585
+ while ((match = HIGH_ENTROPY_RE.exec(line)) !== null) {
586
+ const value = match[1] || match[2];
587
+ if (!value || value.length < 40) continue;
588
+ if (isTemplateOrPlaceholder(value)) continue;
589
+ if (ENTROPY_SKIP.some((p) => p.test(value))) continue;
590
+ const entropy = shannonEntropy(value);
591
+ if (entropy >= threshold) {
592
+ findings.push({
593
+ type: "high-entropy",
594
+ file: filePath,
595
+ line: i + 1,
596
+ match: value,
597
+ redacted: redactSecret(value),
598
+ severity: entropy >= 5 ? "high" : "medium"
599
+ });
600
+ }
601
+ }
602
+ }
603
+ return deduplicateFindings(findings);
604
+ }
605
+ async function auditProject(projectPath, filePaths, options = {}) {
606
+ const savedConfig = loadAuditConfig(projectPath);
607
+ const customPatterns = options.customPatterns ?? savedConfig.customPatterns;
608
+ const entropyThreshold = options.entropyThreshold ?? savedConfig.entropyThreshold;
609
+ const includePII = options.includePII ?? savedConfig.includePII;
610
+ const useAllowlist = options.useAllowlist ?? true;
611
+ const incrementalScan = options.incrementalScan ?? savedConfig.incrementalScan;
612
+ const severityOverrides = options.severityOverrides ?? savedConfig.severityOverrides;
613
+ let extraPiiDomains;
614
+ const allExtraDomains = [...options.piiSafeDomains || [], ...savedConfig.piiSafeDomains];
615
+ if (allExtraDomains.length > 0) {
616
+ extraPiiDomains = new Set(allExtraDomains.map((d) => d.toLowerCase()));
617
+ }
618
+ let filesToScan = filePaths;
619
+ let unchangedCount = 0;
620
+ let newCache = null;
621
+ if (incrementalScan) {
622
+ const { changed, unchanged, cache } = getChangedFiles(projectPath, filePaths);
623
+ newCache = cache;
624
+ if (changed.length < filePaths.length) {
625
+ filesToScan = changed;
626
+ unchangedCount = unchanged.length;
627
+ }
628
+ }
629
+ const allFindings = [];
630
+ const filesWithSecrets = /* @__PURE__ */ new Set();
631
+ for (const fp of filesToScan) {
632
+ try {
633
+ const content = await readFile(fp, "utf-8");
634
+ const relPath = relative(resolve(projectPath), resolve(fp));
635
+ const isTestFile = /\.(test|spec|mock)\.[jt]sx?$/.test(relPath) || relPath.includes("__tests__");
636
+ const isDtsFile = relPath.endsWith(".d.ts");
637
+ let findings = scanContentForSecrets(content, relPath, customPatterns, extraPiiDomains);
638
+ if (!includePII) {
639
+ findings = findings.filter((f) => f.type !== "pii");
640
+ }
641
+ const entropyFindings = isTestFile || isDtsFile ? [] : scanContentForHighEntropy(content, relPath, entropyThreshold);
642
+ const combined = [...findings, ...entropyFindings];
643
+ if (combined.length > 0) {
644
+ filesWithSecrets.add(relPath);
645
+ allFindings.push(...combined);
646
+ }
647
+ } catch {
648
+ }
649
+ }
650
+ let finalFindings = applySeverityOverrides(allFindings, severityOverrides);
651
+ let allowedCount = 0;
652
+ if (useAllowlist) {
653
+ const { filtered, allowed } = filterByAllowlist(finalFindings, projectPath);
654
+ finalFindings = filtered;
655
+ allowedCount = allowed.length;
656
+ }
657
+ finalFindings.sort((a, b) => {
658
+ const order = { critical: 0, high: 1, medium: 2, low: 3 };
659
+ return order[a.severity] - order[b.severity];
660
+ });
661
+ if (newCache) {
662
+ saveHashCache(projectPath, newCache);
663
+ }
664
+ const bySeverity = { critical: 0, high: 0, medium: 0, low: 0 };
665
+ const byType = {};
666
+ for (const f of finalFindings) {
667
+ bySeverity[f.severity]++;
668
+ byType[f.type] = (byType[f.type] || 0) + 1;
669
+ }
670
+ const recommendations = [];
671
+ if (bySeverity.critical > 0) {
672
+ recommendations.push("CRITICAL: Rotate all detected credentials immediately. They may already be compromised.");
673
+ }
674
+ if (byType["password"] > 0) {
675
+ recommendations.push("Move passwords to environment variables or a secrets manager (AWS Secrets Manager, Vault, etc.).");
676
+ }
677
+ if (byType["api-key"] > 0 || byType["aws-key"] > 0) {
678
+ recommendations.push("Use environment variables for API keys. Never commit them to source control.");
679
+ }
680
+ if (byType["connection-string"] > 0) {
681
+ recommendations.push("Database connection strings should use environment variables, not hardcoded values.");
682
+ }
683
+ if (byType["private-key"] > 0) {
684
+ recommendations.push("Private keys should NEVER be in source code. Use a key management service.");
685
+ }
686
+ if (byType["pii"] > 0) {
687
+ recommendations.push("PII detected. Review for GDPR/CCPA compliance. Consider data anonymization.");
688
+ }
689
+ if (byType["high-entropy"] > 0) {
690
+ recommendations.push("High-entropy strings detected that may be secrets. Review manually.");
691
+ }
692
+ if (finalFindings.length > 0) {
693
+ recommendations.push("Add a .gitignore entry for .env files if not already present.");
694
+ recommendations.push("Run `npx cto-ai-cli --audit` regularly or add to CI pipeline.");
695
+ }
696
+ if (finalFindings.length === 0) {
697
+ recommendations.push("No secrets detected. Great job keeping your codebase clean!");
698
+ }
699
+ if (allowedCount > 0) {
700
+ recommendations.push(`${allowedCount} finding(s) skipped via allowlist (.cto/audit/allowlist.json).`);
701
+ }
702
+ if (unchangedCount > 0) {
703
+ recommendations.push(`${unchangedCount} unchanged file(s) skipped (incremental scan).`);
704
+ }
705
+ return {
706
+ findings: finalFindings,
707
+ summary: {
708
+ totalFiles: filePaths.length,
709
+ filesScanned: filesToScan.length,
710
+ filesWithSecrets: filesWithSecrets.size,
711
+ totalFindings: finalFindings.length,
712
+ bySeverity,
713
+ byType
714
+ },
715
+ recommendations
716
+ };
717
+ }
290
718
 
291
719
  // src/engine/graph-utils.ts
292
720
  function matchGlob(path, pattern) {
@@ -447,7 +875,7 @@ function fileMatchesCategory(path, category) {
447
875
  }
448
876
 
449
877
  // src/govern/snapshot.ts
450
- import { randomUUID as randomUUID2, createHash as createHash2 } from "crypto";
878
+ import { randomUUID as randomUUID2, createHash as createHash3 } from "crypto";
451
879
  import "fs/promises";
452
880
  function createSnapshot(name, analysis, selection, metadata = {}) {
453
881
  const files = selection.files.map((f) => ({
@@ -540,20 +968,20 @@ function compareSnapshots(older, newer) {
540
968
  };
541
969
  }
542
970
  function hashString(input) {
543
- return createHash2("sha256").update(input).digest("hex").substring(0, 16);
971
+ return createHash3("sha256").update(input).digest("hex").substring(0, 16);
544
972
  }
545
973
 
546
974
  // src/govern/integrity.ts
547
- import { createHash as createHash3 } from "crypto";
975
+ import { createHash as createHash4 } from "crypto";
548
976
  import { readFile as readFile3, chmod as chmod2, readdir as readdir2, stat } from "fs/promises";
549
- import { join as join2 } from "path";
550
- function hashContent(content) {
551
- return createHash3("sha256").update(content).digest("hex");
977
+ import { join as join3 } from "path";
978
+ function hashContent2(content) {
979
+ return createHash4("sha256").update(content).digest("hex");
552
980
  }
553
981
  async function hashFile(filePath) {
554
982
  try {
555
983
  const content = await readFile3(filePath);
556
- return hashContent(content);
984
+ return hashContent2(content);
557
985
  } catch {
558
986
  return null;
559
987
  }
@@ -568,7 +996,7 @@ async function buildManifest(projectDir) {
568
996
  return;
569
997
  }
570
998
  for (const file of files) {
571
- const fullPath = join2(dir, file);
999
+ const fullPath = join3(dir, file);
572
1000
  try {
573
1001
  const fileStat = await stat(fullPath);
574
1002
  if (fileStat.isFile()) {
@@ -587,8 +1015,8 @@ async function buildManifest(projectDir) {
587
1015
  }
588
1016
  }
589
1017
  }
590
- await scanDir(join2(projectDir, "snapshots"), "snapshot");
591
- await scanDir(join2(projectDir, "audit"), "audit");
1018
+ await scanDir(join3(projectDir, "snapshots"), "snapshot");
1019
+ await scanDir(join3(projectDir, "audit"), "audit");
592
1020
  await scanDir(projectDir, "config");
593
1021
  return {
594
1022
  version: "2.0",
@@ -622,7 +1050,7 @@ async function securePermissions(dirPath) {
622
1050
  const files = await readdir2(dirPath);
623
1051
  for (const file of files) {
624
1052
  try {
625
- const fullPath = join2(dirPath, file);
1053
+ const fullPath = join3(dirPath, file);
626
1054
  const fileStat = await stat(fullPath);
627
1055
  if (fileStat.isFile()) {
628
1056
  await chmod2(fullPath, 384);
@@ -636,18 +1064,29 @@ async function securePermissions(dirPath) {
636
1064
  return count;
637
1065
  }
638
1066
  export {
1067
+ DEFAULT_AUDIT_CONFIG,
639
1068
  DEFAULT_POLICY,
640
1069
  addRule,
1070
+ addToAllowlist,
1071
+ auditProject,
641
1072
  buildManifest,
642
1073
  compareSnapshots,
643
1074
  createSnapshot,
1075
+ filterByAllowlist,
1076
+ generatePreCommitHook,
644
1077
  getAuditEntries,
645
- hashContent,
1078
+ getChangedFiles,
1079
+ hashContent2 as hashContent,
646
1080
  hashFile,
1081
+ loadAllowlist,
1082
+ loadAuditConfig,
647
1083
  logAudit,
648
1084
  purgeOldAuditEntries,
649
1085
  removeRule,
650
1086
  sanitizeContent,
1087
+ saveAllowlist,
1088
+ saveAuditConfig,
1089
+ scanContentForHighEntropy,
651
1090
  scanContentForSecrets,
652
1091
  scanFileForSecrets,
653
1092
  scanProjectForSecrets,