cto-ai-cli 5.2.0 → 7.1.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.
@@ -1,1101 +0,0 @@
1
- // src/govern/audit.ts
2
- import { randomUUID, createHash } from "crypto";
3
- import { readdir, chmod } from "fs/promises";
4
- import { join } from "path";
5
- import { userInfo } from "os";
6
- import { homedir } from "os";
7
- var CTO_DIR = ".cto-ai";
8
- var AUDIT_DIR = "audit";
9
- var MAX_ENTRIES_PER_FILE = 500;
10
- function getAuditDir() {
11
- return join(homedir(), CTO_DIR, AUDIT_DIR);
12
- }
13
- function getCurrentAuditFile() {
14
- const date = (/* @__PURE__ */ new Date()).toISOString().split("T")[0].replace(/-/g, "");
15
- return join(getAuditDir(), `audit_${date}.json`);
16
- }
17
- function computeIntegrityHash(entry) {
18
- const payload = JSON.stringify({
19
- id: entry.id,
20
- timestamp: entry.timestamp,
21
- action: entry.action,
22
- user: entry.user,
23
- projectPath: entry.projectPath,
24
- details: entry.details
25
- });
26
- return createHash("sha256").update(payload).digest("hex");
27
- }
28
- async function ensureDir(dirPath) {
29
- const { mkdir } = await import("fs/promises");
30
- await mkdir(dirPath, { recursive: true });
31
- }
32
- async function readJSON(filePath) {
33
- const { readFile: readFile4 } = await import("fs/promises");
34
- try {
35
- const content = await readFile4(filePath, "utf-8");
36
- return JSON.parse(content);
37
- } catch {
38
- return null;
39
- }
40
- }
41
- async function writeJSON(filePath, data) {
42
- const { writeFile: writeFile2 } = await import("fs/promises");
43
- await ensureDir(join(filePath, ".."));
44
- await writeFile2(filePath, JSON.stringify(data, null, 2), "utf-8");
45
- }
46
- async function logAudit(action, projectPath, details = {}) {
47
- const auditDir = getAuditDir();
48
- await ensureDir(auditDir);
49
- let currentUser;
50
- try {
51
- currentUser = userInfo().username;
52
- } catch {
53
- currentUser = process.env.USER ?? process.env.USERNAME ?? "unknown";
54
- }
55
- const partialEntry = {
56
- id: randomUUID().substring(0, 12),
57
- timestamp: /* @__PURE__ */ new Date(),
58
- action,
59
- user: currentUser,
60
- projectPath,
61
- details
62
- };
63
- const entry = {
64
- ...partialEntry,
65
- integrityHash: computeIntegrityHash(partialEntry)
66
- };
67
- const auditFile = getCurrentAuditFile();
68
- let entries = await readJSON(auditFile) ?? [];
69
- entries.push(entry);
70
- if (entries.length > MAX_ENTRIES_PER_FILE) {
71
- entries = entries.slice(-MAX_ENTRIES_PER_FILE);
72
- }
73
- await writeJSON(auditFile, entries);
74
- try {
75
- await chmod(auditFile, 384);
76
- } catch {
77
- }
78
- return entry;
79
- }
80
- async function getAuditEntries(options = {}) {
81
- const auditDir = getAuditDir();
82
- let files;
83
- try {
84
- files = await readdir(auditDir);
85
- } catch {
86
- return [];
87
- }
88
- const auditFiles = files.filter((f) => f.startsWith("audit_") && f.endsWith(".json")).sort().reverse();
89
- const allEntries = [];
90
- const limit = options.limit ?? 100;
91
- for (const file of auditFiles) {
92
- if (allEntries.length >= limit) break;
93
- const entries = await readJSON(join(auditDir, file));
94
- if (!entries) continue;
95
- for (const entry of entries.reverse()) {
96
- if (allEntries.length >= limit) break;
97
- if (options.projectPath && entry.projectPath !== options.projectPath) continue;
98
- if (options.action && entry.action !== options.action) continue;
99
- if (options.since && new Date(entry.timestamp) < options.since) continue;
100
- allEntries.push(entry);
101
- }
102
- }
103
- return allEntries;
104
- }
105
- function verifyAuditEntry(entry) {
106
- const { integrityHash, ...rest } = entry;
107
- const expected = computeIntegrityHash(rest);
108
- return expected === integrityHash;
109
- }
110
- async function verifyAuditIntegrity() {
111
- const entries = await getAuditEntries({ limit: 1e4 });
112
- const invalidEntries = [];
113
- for (const entry of entries) {
114
- if (!verifyAuditEntry(entry)) {
115
- invalidEntries.push(entry);
116
- }
117
- }
118
- return {
119
- totalEntries: entries.length,
120
- validEntries: entries.length - invalidEntries.length,
121
- invalidEntries
122
- };
123
- }
124
- async function purgeOldAuditEntries(retentionDays) {
125
- const auditDir = getAuditDir();
126
- let files;
127
- try {
128
- files = await readdir(auditDir);
129
- } catch {
130
- return 0;
131
- }
132
- const cutoff = /* @__PURE__ */ new Date();
133
- cutoff.setDate(cutoff.getDate() - retentionDays);
134
- const cutoffStr = cutoff.toISOString().split("T")[0].replace(/-/g, "");
135
- let purged = 0;
136
- const { unlink } = await import("fs/promises");
137
- for (const file of files) {
138
- if (!file.startsWith("audit_") || !file.endsWith(".json")) continue;
139
- const dateStr = file.replace("audit_", "").replace(".json", "");
140
- if (dateStr < cutoffStr) {
141
- try {
142
- await unlink(join(auditDir, file));
143
- purged++;
144
- } catch {
145
- }
146
- }
147
- }
148
- return purged;
149
- }
150
-
151
- // src/govern/secrets.ts
152
- import { readFile } from "fs/promises";
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";
156
- var BUILTIN_PATTERNS = [
157
- // API Keys
158
- { type: "api-key", source: `(?:api[_-]?key|apikey)\\s*[:=]\\s*['"]?([a-zA-Z0-9_\\-]{20,})['"]?`, flags: "gi", severity: "critical", description: "API Key" },
159
- { type: "api-key", source: "sk-[a-zA-Z0-9]{20,}", flags: "g", severity: "critical", description: "OpenAI/Anthropic API Key" },
160
- { type: "api-key", source: "sk-ant-[a-zA-Z0-9\\-]{20,}", flags: "g", severity: "critical", description: "Anthropic API Key" },
161
- // AWS
162
- { type: "aws-key", source: "AKIA[0-9A-Z]{16}", flags: "g", severity: "critical", description: "AWS Access Key ID" },
163
- { type: "aws-key", source: `(?:aws_secret_access_key|aws_secret)\\s*[:=]\\s*['"]?([a-zA-Z0-9/+=]{40})['"]?`, flags: "gi", severity: "critical", description: "AWS Secret Key" },
164
- // Private Keys
165
- { type: "private-key", source: "-----BEGIN (?:RSA |EC |DSA )?PRIVATE KEY-----", flags: "g", severity: "critical", description: "Private Key" },
166
- { type: "private-key", source: "-----BEGIN OPENSSH PRIVATE KEY-----", flags: "g", severity: "critical", description: "SSH Private Key" },
167
- // Passwords
168
- { type: "password", source: `(?:password|passwd|pwd)\\s*[:=]\\s*['"]([^'"]{8,})['"](?!\\s*\\{)`, flags: "gi", severity: "high", description: "Hardcoded Password" },
169
- { type: "password", source: `(?:DB_PASSWORD|DATABASE_PASSWORD|MYSQL_PASSWORD|POSTGRES_PASSWORD)\\s*[:=]\\s*['"]?([^'"{}\\s]{4,})['"]?`, flags: "gi", severity: "high", description: "Database Password" },
170
- // Tokens
171
- { type: "token", source: `(?:bearer|token|auth_token|access_token|refresh_token)\\s*[:=]\\s*['"]([a-zA-Z0-9_\\-.]{20,})['"](?!\\s*\\{)`, flags: "gi", severity: "high", description: "Auth Token" },
172
- { type: "token", source: "ghp_[a-zA-Z0-9]{36}", flags: "g", severity: "critical", description: "GitHub Personal Access Token" },
173
- { type: "token", source: "gho_[a-zA-Z0-9]{36}", flags: "g", severity: "critical", description: "GitHub OAuth Token" },
174
- { type: "token", source: "glpat-[a-zA-Z0-9\\-]{20,}", flags: "g", severity: "critical", description: "GitLab Personal Access Token" },
175
- { type: "token", source: "npm_[a-zA-Z0-9]{36}", flags: "g", severity: "high", description: "npm Token" },
176
- // Connection strings
177
- { type: "connection-string", source: `(?:mongodb(?:\\+srv)?|postgres(?:ql)?|mysql|redis|amqp):\\/\\/[^\\s'"]+:[^\\s'"]+@[^\\s'"]+`, flags: "gi", severity: "critical", description: "Database Connection String" },
178
- { type: "connection-string", source: `(?:DATABASE_URL|REDIS_URL|MONGODB_URI)\\s*[:=]\\s*['"]?([^\\s'"]{10,})['"]?`, flags: "gi", severity: "high", description: "Database URL" },
179
- // Environment variables with secrets
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)" }
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
- }
236
- function buildPatterns(customPatterns = []) {
237
- const builtins = getBuiltinPatterns();
238
- if (customPatterns.length === 0) return builtins;
239
- const patterns = [...builtins];
240
- for (const custom of customPatterns) {
241
- try {
242
- patterns.push({
243
- type: "custom",
244
- pattern: new RegExp(custom, "gi"),
245
- severity: "medium",
246
- description: `Custom pattern: ${custom}`
247
- });
248
- } catch {
249
- }
250
- }
251
- return patterns;
252
- }
253
- function scanContentForSecrets(content, filePath, customPatterns = [], extraPiiSafeDomains) {
254
- const findings = [];
255
- const lines = content.split("\n");
256
- const allPatterns = buildPatterns(customPatterns);
257
- for (const secretPattern of allPatterns) {
258
- for (let i = 0; i < lines.length; i++) {
259
- const line = lines[i];
260
- secretPattern.pattern.lastIndex = 0;
261
- let match;
262
- while ((match = secretPattern.pattern.exec(line)) !== null) {
263
- const matchText = match[0];
264
- if (isTemplateOrPlaceholder(matchText)) continue;
265
- if (secretPattern.type === "pii" && isSafeEmail(matchText, extraPiiSafeDomains)) continue;
266
- findings.push({
267
- type: secretPattern.type,
268
- file: filePath,
269
- line: i + 1,
270
- match: matchText,
271
- redacted: redactSecret(matchText),
272
- severity: secretPattern.severity
273
- });
274
- }
275
- }
276
- }
277
- return deduplicateFindings(findings);
278
- }
279
- async function scanFileForSecrets(filePath, projectPath, customPatterns = []) {
280
- try {
281
- const content = await readFile(filePath, "utf-8");
282
- const relPath = relative(resolve(projectPath), resolve(filePath));
283
- return scanContentForSecrets(content, relPath, customPatterns);
284
- } catch {
285
- return [];
286
- }
287
- }
288
- async function scanProjectForSecrets(projectPath, filePaths, customPatterns = []) {
289
- const allFindings = [];
290
- for (const fp of filePaths) {
291
- const findings = await scanFileForSecrets(fp, projectPath, customPatterns);
292
- allFindings.push(...findings);
293
- }
294
- return allFindings.sort((a, b) => {
295
- const severityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
296
- return severityOrder[a.severity] - severityOrder[b.severity];
297
- });
298
- }
299
- function sanitizeContent(content, customPatterns = []) {
300
- let sanitized = content;
301
- const allPatterns = buildPatterns(customPatterns);
302
- for (const secretPattern of allPatterns) {
303
- sanitized = sanitized.replace(secretPattern.pattern, (match) => {
304
- if (isTemplateOrPlaceholder(match)) return match;
305
- return redactSecret(match);
306
- });
307
- }
308
- return sanitized;
309
- }
310
- function redactSecret(value) {
311
- if (value.length <= 8) return "***REDACTED***";
312
- const prefix = value.substring(0, 4);
313
- const suffix = value.substring(value.length - 2);
314
- return `${prefix}${"*".repeat(Math.min(value.length - 6, 20))}${suffix}`;
315
- }
316
- function isTemplateOrPlaceholder(value) {
317
- const placeholders = [
318
- /\$\{.*\}/,
319
- /\{\{.*\}\}/,
320
- /%[sd]/,
321
- /<[A-Z_]+>/,
322
- /YOUR_.*_HERE/i,
323
- /\bCHANGE_ME\b/i,
324
- /\bPLACEHOLDER\b/i,
325
- /\bexample\b/i,
326
- /\bTODO\b/i,
327
- /xxx+/i,
328
- /\breplace.?me\b/i,
329
- /\bdummy\b/i,
330
- /\btest_?key\b/i,
331
- /\bsample\b/i
332
- ];
333
- return placeholders.some((p) => p.test(value));
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
- }
365
- function deduplicateFindings(findings) {
366
- const seen = /* @__PURE__ */ new Set();
367
- return findings.filter((f) => {
368
- const key = `${f.file}:${f.line}:${f.type}:${f.match}`;
369
- if (seen.has(key)) return false;
370
- seen.add(key);
371
- return true;
372
- });
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
- }
718
-
719
- // src/engine/graph-utils.ts
720
- function matchGlob(path, pattern) {
721
- const regexStr = pattern.replace(/\./g, "\\.").replace(/\*\*/g, "\xA7\xA7").replace(/\*/g, "[^/]*").replace(/§§/g, ".*").replace(/\?/g, ".");
722
- try {
723
- return new RegExp(`^${regexStr}$`).test(path);
724
- } catch {
725
- return false;
726
- }
727
- }
728
-
729
- // src/govern/policy.ts
730
- var DEFAULT_POLICY = {
731
- version: "1.0",
732
- name: "default",
733
- rules: [
734
- {
735
- id: "no-env",
736
- type: "exclude-always",
737
- pattern: "**/*.env*",
738
- reason: "Environment files must never be sent to AI",
739
- enabled: true
740
- },
741
- {
742
- id: "no-secrets",
743
- type: "secret-block",
744
- reason: "Files with detected secrets are blocked",
745
- enabled: true
746
- },
747
- {
748
- id: "min-coverage",
749
- type: "coverage-minimum",
750
- threshold: 70,
751
- reason: "Warn if context coverage drops below 70%",
752
- enabled: true
753
- }
754
- ]
755
- };
756
- function validateSelection(selection, policies, allFiles) {
757
- const violations = [];
758
- const warnings = [];
759
- const includedPaths = new Set(selection.files.map((f) => f.relativePath));
760
- for (const rule of policies.rules) {
761
- if (!rule.enabled) continue;
762
- switch (rule.type) {
763
- case "exclude-always": {
764
- if (!rule.pattern) break;
765
- const violatingFiles = selection.files.filter(
766
- (f) => matchGlob(f.relativePath, rule.pattern)
767
- );
768
- for (const f of violatingFiles) {
769
- violations.push({
770
- rule,
771
- message: `File "${f.relativePath}" is included but matches exclude-always pattern "${rule.pattern}"`,
772
- severity: "error"
773
- });
774
- }
775
- break;
776
- }
777
- case "include-always": {
778
- if (!rule.pattern || !allFiles) break;
779
- const requiredFiles = allFiles.filter(
780
- (f) => matchGlob(f.relativePath, rule.pattern)
781
- );
782
- for (const f of requiredFiles) {
783
- if (!includedPaths.has(f.relativePath)) {
784
- violations.push({
785
- rule,
786
- message: `File "${f.relativePath}" matches include-always pattern "${rule.pattern}" but is not included`,
787
- severity: "warning"
788
- });
789
- }
790
- }
791
- break;
792
- }
793
- case "coverage-minimum": {
794
- const threshold = rule.threshold ?? 70;
795
- if (selection.coverage.score < threshold) {
796
- warnings.push({
797
- rule,
798
- message: `Coverage ${selection.coverage.score}% is below minimum ${threshold}%`,
799
- currentValue: selection.coverage.score,
800
- threshold
801
- });
802
- }
803
- break;
804
- }
805
- case "risk-maximum": {
806
- const threshold = rule.threshold ?? 50;
807
- if (selection.riskScore > threshold) {
808
- warnings.push({
809
- rule,
810
- message: `Exclusion risk ${selection.riskScore}/100 exceeds maximum ${threshold}`,
811
- currentValue: selection.riskScore,
812
- threshold
813
- });
814
- }
815
- break;
816
- }
817
- case "budget-limit": {
818
- if (!rule.category || !rule.threshold) break;
819
- const categoryFiles = selection.files.filter(
820
- (f) => fileMatchesCategory(f.relativePath, rule.category)
821
- );
822
- const categoryTokens = categoryFiles.reduce((s, f) => s + f.tokens, 0);
823
- const categoryPercent = selection.totalTokens > 0 ? categoryTokens / selection.totalTokens * 100 : 0;
824
- if (categoryPercent > rule.threshold) {
825
- warnings.push({
826
- rule,
827
- message: `Category "${rule.category}" uses ${Math.round(categoryPercent)}% of budget (max: ${rule.threshold}%)`,
828
- currentValue: Math.round(categoryPercent),
829
- threshold: rule.threshold
830
- });
831
- }
832
- break;
833
- }
834
- }
835
- }
836
- return {
837
- passed: violations.filter((v) => v.severity === "error").length === 0,
838
- violations,
839
- warnings
840
- };
841
- }
842
- function addRule(policies, rule) {
843
- return {
844
- ...policies,
845
- rules: [...policies.rules, rule]
846
- };
847
- }
848
- function removeRule(policies, ruleId) {
849
- return {
850
- ...policies,
851
- rules: policies.rules.filter((r) => r.id !== ruleId)
852
- };
853
- }
854
- function toggleRule(policies, ruleId, enabled) {
855
- return {
856
- ...policies,
857
- rules: policies.rules.map(
858
- (r) => r.id === ruleId ? { ...r, enabled } : r
859
- )
860
- };
861
- }
862
- function fileMatchesCategory(path, category) {
863
- switch (category) {
864
- case "test":
865
- return /\.(test|spec)\.[jt]sx?$/.test(path) || /\/__tests__\//.test(path) || /\/tests?\//.test(path);
866
- case "config":
867
- return /\.(config|rc)\.[jt]s$/.test(path) || /\.json$/.test(path) || /\.ya?ml$/.test(path);
868
- case "docs":
869
- return /\.(md|txt|rst)$/.test(path);
870
- case "types":
871
- return /types?\//i.test(path) || /\.d\.ts$/.test(path);
872
- default:
873
- return path.includes(category);
874
- }
875
- }
876
-
877
- // src/govern/snapshot.ts
878
- import { randomUUID as randomUUID2, createHash as createHash3 } from "crypto";
879
- import "fs/promises";
880
- function createSnapshot(name, analysis, selection, metadata = {}) {
881
- const files = selection.files.map((f) => ({
882
- relativePath: f.relativePath,
883
- hash: hashString(`${f.relativePath}:${f.tokens}:${f.pruneLevel}`),
884
- tokens: f.tokens,
885
- pruneLevel: f.pruneLevel
886
- }));
887
- const snapshotData = files.map((f) => `${f.relativePath}:${f.hash}:${f.pruneLevel}`).sort().join("|");
888
- return {
889
- id: randomUUID2().substring(0, 8),
890
- name,
891
- createdAt: /* @__PURE__ */ new Date(),
892
- hash: hashString(snapshotData),
893
- projectHash: analysis.hash,
894
- analysisHash: analysis.hash,
895
- selectionHash: selection.hash,
896
- files,
897
- totalTokens: selection.totalTokens,
898
- coverageScore: selection.coverage.score,
899
- riskScore: selection.riskScore,
900
- metadata
901
- };
902
- }
903
- async function verifySnapshot(snapshot, currentAnalysis, currentSelection) {
904
- const currentFiles = new Map(
905
- currentSelection.files.map((f) => [f.relativePath, f])
906
- );
907
- let filesMatched = 0;
908
- const filesMissing = [];
909
- const filesChanged = [];
910
- for (const snapFile of snapshot.files) {
911
- const current = currentFiles.get(snapFile.relativePath);
912
- if (!current) {
913
- filesMissing.push(snapFile.relativePath);
914
- continue;
915
- }
916
- const currentHash2 = hashString(
917
- `${current.relativePath}:${current.tokens}:${current.pruneLevel}`
918
- );
919
- if (currentHash2 === snapFile.hash) {
920
- filesMatched++;
921
- } else {
922
- filesChanged.push(snapFile.relativePath);
923
- }
924
- }
925
- const currentSnapshotData = snapshot.files.map((f) => {
926
- const current = currentFiles.get(f.relativePath);
927
- if (!current) return `${f.relativePath}:MISSING:MISSING`;
928
- return `${current.relativePath}:${hashString(`${current.relativePath}:${current.tokens}:${current.pruneLevel}`)}:${current.pruneLevel}`;
929
- }).sort().join("|");
930
- const currentHash = hashString(currentSnapshotData);
931
- const integrityOk = currentHash === snapshot.hash && filesMissing.length === 0 && filesChanged.length === 0;
932
- return {
933
- valid: integrityOk,
934
- snapshotId: snapshot.id,
935
- filesChecked: snapshot.files.length,
936
- filesMatched,
937
- filesMissing,
938
- filesChanged,
939
- integrityOk
940
- };
941
- }
942
- function compareSnapshots(older, newer) {
943
- const olderFiles = new Map(older.files.map((f) => [f.relativePath, f]));
944
- const newerFiles = new Map(newer.files.map((f) => [f.relativePath, f]));
945
- const added = [];
946
- const removed = [];
947
- const changed = [];
948
- for (const [path, file] of newerFiles) {
949
- const old = olderFiles.get(path);
950
- if (!old) {
951
- added.push(path);
952
- } else if (old.hash !== file.hash) {
953
- changed.push(path);
954
- }
955
- }
956
- for (const path of olderFiles.keys()) {
957
- if (!newerFiles.has(path)) {
958
- removed.push(path);
959
- }
960
- }
961
- return {
962
- added,
963
- removed,
964
- changed,
965
- tokenDelta: newer.totalTokens - older.totalTokens,
966
- coverageDelta: newer.coverageScore - older.coverageScore,
967
- riskDelta: newer.riskScore - older.riskScore
968
- };
969
- }
970
- function hashString(input) {
971
- return createHash3("sha256").update(input).digest("hex").substring(0, 16);
972
- }
973
-
974
- // src/govern/integrity.ts
975
- import { createHash as createHash4 } from "crypto";
976
- import { readFile as readFile3, chmod as chmod2, readdir as readdir2, stat } from "fs/promises";
977
- import { join as join3 } from "path";
978
- function hashContent2(content) {
979
- return createHash4("sha256").update(content).digest("hex");
980
- }
981
- async function hashFile(filePath) {
982
- try {
983
- const content = await readFile3(filePath);
984
- return hashContent2(content);
985
- } catch {
986
- return null;
987
- }
988
- }
989
- async function buildManifest(projectDir) {
990
- const entries = [];
991
- async function scanDir(dir, type) {
992
- let files;
993
- try {
994
- files = await readdir2(dir);
995
- } catch {
996
- return;
997
- }
998
- for (const file of files) {
999
- const fullPath = join3(dir, file);
1000
- try {
1001
- const fileStat = await stat(fullPath);
1002
- if (fileStat.isFile()) {
1003
- const hash = await hashFile(fullPath);
1004
- if (hash) {
1005
- entries.push({
1006
- filePath: fullPath,
1007
- hash,
1008
- size: fileStat.size,
1009
- createdAt: fileStat.mtime,
1010
- type
1011
- });
1012
- }
1013
- }
1014
- } catch {
1015
- }
1016
- }
1017
- }
1018
- await scanDir(join3(projectDir, "snapshots"), "snapshot");
1019
- await scanDir(join3(projectDir, "audit"), "audit");
1020
- await scanDir(projectDir, "config");
1021
- return {
1022
- version: "2.0",
1023
- createdAt: /* @__PURE__ */ new Date(),
1024
- entries
1025
- };
1026
- }
1027
- async function verifyManifest(manifest) {
1028
- const invalidFiles = [];
1029
- const missingFiles = [];
1030
- for (const entry of manifest.entries) {
1031
- const currentHash = await hashFile(entry.filePath);
1032
- if (currentHash === null) {
1033
- missingFiles.push(entry.filePath);
1034
- } else if (currentHash !== entry.hash) {
1035
- invalidFiles.push(entry.filePath);
1036
- }
1037
- }
1038
- return {
1039
- totalFiles: manifest.entries.length,
1040
- validFiles: manifest.entries.length - invalidFiles.length - missingFiles.length,
1041
- invalidFiles,
1042
- missingFiles
1043
- };
1044
- }
1045
- async function securePermissions(dirPath) {
1046
- let count = 0;
1047
- try {
1048
- await chmod2(dirPath, 448);
1049
- count++;
1050
- const files = await readdir2(dirPath);
1051
- for (const file of files) {
1052
- try {
1053
- const fullPath = join3(dirPath, file);
1054
- const fileStat = await stat(fullPath);
1055
- if (fileStat.isFile()) {
1056
- await chmod2(fullPath, 384);
1057
- count++;
1058
- }
1059
- } catch {
1060
- }
1061
- }
1062
- } catch {
1063
- }
1064
- return count;
1065
- }
1066
- export {
1067
- DEFAULT_AUDIT_CONFIG,
1068
- DEFAULT_POLICY,
1069
- addRule,
1070
- addToAllowlist,
1071
- auditProject,
1072
- buildManifest,
1073
- compareSnapshots,
1074
- createSnapshot,
1075
- filterByAllowlist,
1076
- generatePreCommitHook,
1077
- getAuditEntries,
1078
- getChangedFiles,
1079
- hashContent2 as hashContent,
1080
- hashFile,
1081
- loadAllowlist,
1082
- loadAuditConfig,
1083
- logAudit,
1084
- purgeOldAuditEntries,
1085
- removeRule,
1086
- sanitizeContent,
1087
- saveAllowlist,
1088
- saveAuditConfig,
1089
- scanContentForHighEntropy,
1090
- scanContentForSecrets,
1091
- scanFileForSecrets,
1092
- scanProjectForSecrets,
1093
- securePermissions,
1094
- toggleRule,
1095
- validateSelection,
1096
- verifyAuditEntry,
1097
- verifyAuditIntegrity,
1098
- verifyManifest,
1099
- verifySnapshot
1100
- };
1101
- //# sourceMappingURL=index.js.map