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.
- package/DOCS.md +352 -0
- package/README.md +192 -15
- package/dist/action/index.js +629 -83
- package/dist/api/dashboard.js +107 -23
- package/dist/api/dashboard.js.map +1 -1
- package/dist/api/server.js +108 -24
- package/dist/api/server.js.map +1 -1
- package/dist/cli/gateway.js +2925 -0
- package/dist/cli/score.js +3015 -237
- package/dist/cli/v2/index.js +133 -49
- package/dist/cli/v2/index.js.map +1 -1
- package/dist/engine/index.d.ts +85 -1
- package/dist/engine/index.js +665 -42
- package/dist/engine/index.js.map +1 -1
- package/dist/gateway/index.d.ts +281 -0
- package/dist/gateway/index.js +2803 -0
- package/dist/gateway/index.js.map +1 -0
- package/dist/govern/index.d.ts +67 -3
- package/dist/govern/index.js +462 -23
- package/dist/govern/index.js.map +1 -1
- package/dist/interact/index.js +108 -24
- package/dist/interact/index.js.map +1 -1
- package/dist/mcp/v2.js +130 -46
- package/dist/mcp/v2.js.map +1 -1
- package/package.json +3 -2
package/dist/govern/index.d.ts
CHANGED
|
@@ -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 };
|
package/dist/govern/index.js
CHANGED
|
@@ -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
|
|
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 {
|
|
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
|
|
182
|
-
|
|
183
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
550
|
-
function
|
|
551
|
-
return
|
|
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
|
|
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 =
|
|
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(
|
|
591
|
-
await scanDir(
|
|
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 =
|
|
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
|
-
|
|
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,
|