@soulbatical/tetra-dev-toolkit 1.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.
Files changed (39) hide show
  1. package/README.md +312 -0
  2. package/bin/vca-audit.js +90 -0
  3. package/bin/vca-dev-token.js +39 -0
  4. package/bin/vca-setup.js +227 -0
  5. package/lib/checks/codeQuality/api-response-format.js +268 -0
  6. package/lib/checks/health/claude-md.js +114 -0
  7. package/lib/checks/health/doppler-compliance.js +174 -0
  8. package/lib/checks/health/git.js +61 -0
  9. package/lib/checks/health/gitignore.js +83 -0
  10. package/lib/checks/health/index.js +26 -0
  11. package/lib/checks/health/infrastructure-yml.js +87 -0
  12. package/lib/checks/health/mcps.js +57 -0
  13. package/lib/checks/health/naming-conventions.js +302 -0
  14. package/lib/checks/health/plugins.js +38 -0
  15. package/lib/checks/health/quality-toolkit.js +97 -0
  16. package/lib/checks/health/repo-visibility.js +70 -0
  17. package/lib/checks/health/rls-audit.js +130 -0
  18. package/lib/checks/health/scanner.js +68 -0
  19. package/lib/checks/health/secrets.js +80 -0
  20. package/lib/checks/health/stella-integration.js +124 -0
  21. package/lib/checks/health/tests.js +140 -0
  22. package/lib/checks/health/types.js +77 -0
  23. package/lib/checks/health/vincifox-widget.js +47 -0
  24. package/lib/checks/index.js +17 -0
  25. package/lib/checks/security/deprecated-supabase-admin.js +96 -0
  26. package/lib/checks/security/gitignore-validation.js +211 -0
  27. package/lib/checks/security/hardcoded-secrets.js +95 -0
  28. package/lib/checks/security/service-key-exposure.js +107 -0
  29. package/lib/checks/security/systemdb-whitelist.js +138 -0
  30. package/lib/checks/stability/ci-pipeline.js +143 -0
  31. package/lib/checks/stability/husky-hooks.js +117 -0
  32. package/lib/checks/stability/npm-audit.js +140 -0
  33. package/lib/checks/supabase/rls-policy-audit.js +261 -0
  34. package/lib/commands/dev-token.js +342 -0
  35. package/lib/config.js +213 -0
  36. package/lib/index.js +17 -0
  37. package/lib/reporters/terminal.js +134 -0
  38. package/lib/runner.js +179 -0
  39. package/package.json +72 -0
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Health Check: Test Pyramid
3
+ *
4
+ * Counts test files by level (unit/integration/e2e), detects frameworks.
5
+ * Score: up to 5 points for framework + pyramid coverage
6
+ */
7
+
8
+ import { existsSync, readFileSync, readdirSync } from 'fs'
9
+ import { join } from 'path'
10
+ import { createCheck } from './types.js'
11
+
12
+ export async function check(projectPath) {
13
+ const result = createCheck('tests', 5, {
14
+ framework: null,
15
+ hasTestDir: false,
16
+ pyramid: { unit: 0, integration: 0, e2e: 0, total: 0 },
17
+ testFiles: { unit: [], integration: [], e2e: [] }
18
+ })
19
+
20
+ // Check for test directories
21
+ for (const dir of ['__tests__', 'tests', 'test', 'spec', 'e2e']) {
22
+ if (existsSync(join(projectPath, dir))) {
23
+ result.details.hasTestDir = true
24
+ result.details.testDir = dir
25
+ break
26
+ }
27
+ }
28
+
29
+ // Scan test files by level
30
+ const scanDir = (dir, level) => {
31
+ const dirPath = join(projectPath, dir)
32
+ if (!existsSync(dirPath)) return
33
+ try {
34
+ const walk = (path) => {
35
+ for (const entry of readdirSync(path, { withFileTypes: true })) {
36
+ const fullPath = join(path, entry.name)
37
+ if (entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules') {
38
+ walk(fullPath)
39
+ } else if (entry.isFile() && /\.(test|spec)\.(ts|tsx|js|jsx)$/.test(entry.name)) {
40
+ result.details.testFiles[level].push(fullPath.replace(projectPath + '/', ''))
41
+ }
42
+ }
43
+ }
44
+ walk(dirPath)
45
+ } catch { /* ignore */ }
46
+ }
47
+
48
+ // Unit: src dirs
49
+ for (const dir of ['src', 'backend/src', 'frontend/src', 'lib', 'mcp/src']) scanDir(dir, 'unit')
50
+ // Integration
51
+ for (const dir of ['tests/integration', 'backend/tests/integration', 'test/integration', '__tests__/integration']) scanDir(dir, 'integration')
52
+ // E2E
53
+ for (const dir of ['e2e', 'tests/e2e', 'test/e2e', 'cypress/e2e', 'playwright']) scanDir(dir, 'e2e')
54
+
55
+ // Also check top-level test dirs for non-categorized tests
56
+ for (const dir of ['tests', 'test', '__tests__', 'mcp/tests']) {
57
+ const dirPath = join(projectPath, dir)
58
+ if (!existsSync(dirPath)) continue
59
+ try {
60
+ for (const entry of readdirSync(dirPath, { withFileTypes: true })) {
61
+ if (entry.isFile() && /\.(test|spec)\.(ts|tsx|js|jsx)$/.test(entry.name)) {
62
+ const relPath = `${dir}/${entry.name}`
63
+ if (!result.details.testFiles.unit.includes(relPath) &&
64
+ !result.details.testFiles.integration.includes(relPath)) {
65
+ result.details.testFiles.unit.push(relPath)
66
+ }
67
+ }
68
+ }
69
+ } catch { /* ignore */ }
70
+ }
71
+
72
+ // Playwright spec files in e2e/
73
+ const playwrightPath = join(projectPath, 'e2e')
74
+ if (existsSync(playwrightPath)) {
75
+ try {
76
+ for (const file of readdirSync(playwrightPath)) {
77
+ if (file.endsWith('.spec.ts') || file.endsWith('.spec.js')) {
78
+ const fullPath = `e2e/${file}`
79
+ if (!result.details.testFiles.e2e.includes(fullPath)) {
80
+ result.details.testFiles.e2e.push(fullPath)
81
+ }
82
+ }
83
+ }
84
+ } catch { /* ignore */ }
85
+ }
86
+
87
+ // Update pyramid counts
88
+ const p = result.details.pyramid
89
+ p.unit = result.details.testFiles.unit.length
90
+ p.integration = result.details.testFiles.integration.length
91
+ p.e2e = result.details.testFiles.e2e.length
92
+ p.total = p.unit + p.integration + p.e2e
93
+
94
+ // Detect test frameworks (check root + sub-packages)
95
+ const packageJsonPaths = [
96
+ join(projectPath, 'package.json'),
97
+ join(projectPath, 'backend', 'package.json'),
98
+ join(projectPath, 'frontend', 'package.json'),
99
+ join(projectPath, 'mcp', 'package.json'),
100
+ ]
101
+ const frameworks = []
102
+ for (const packageJsonPath of packageJsonPaths) {
103
+ if (!existsSync(packageJsonPath)) continue
104
+ try {
105
+ const pkg = JSON.parse(readFileSync(packageJsonPath, 'utf-8'))
106
+ const allDeps = { ...pkg.dependencies, ...pkg.devDependencies }
107
+ for (const fw of ['jest', 'vitest', 'mocha', 'ava', 'playwright', 'cypress', '@playwright/test']) {
108
+ if (allDeps[fw] || allDeps[`@types/${fw}`]) {
109
+ frameworks.push(fw.replace('@playwright/test', 'playwright'))
110
+ }
111
+ }
112
+ if (pkg.scripts?.test && !pkg.scripts.test.includes('no test')) result.details.hasTestScript = true
113
+ if (pkg.scripts?.['test:e2e']) result.details.hasE2EScript = true
114
+ } catch { /* ignore */ }
115
+ }
116
+ result.details.frameworks = [...new Set(frameworks)]
117
+ result.details.framework = result.details.frameworks[0] || null
118
+
119
+ // Scoring
120
+ if (result.details.framework) result.score += 1
121
+ if (p.unit >= 10) result.score += 1
122
+ else if (p.unit >= 1) result.score += 0.5
123
+ if (p.integration >= 5) result.score += 1
124
+ else if (p.integration >= 1) result.score += 0.5
125
+ if (p.e2e >= 3) result.score += 1
126
+ else if (p.e2e >= 1) result.score += 0.5
127
+ if (p.unit > 0 && p.integration > 0 && p.e2e > 0) result.score += 1
128
+
129
+ result.score = Math.min(result.score, result.maxScore)
130
+
131
+ if (result.score === 0) {
132
+ result.status = 'error'
133
+ result.details.message = 'No tests detected'
134
+ } else if (result.score < 3) {
135
+ result.status = 'warning'
136
+ result.details.message = 'Incomplete test coverage'
137
+ }
138
+
139
+ return result
140
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Health Check Types & Helpers
3
+ *
4
+ * Shared types and utility functions for all health checks.
5
+ */
6
+
7
+ /**
8
+ * @typedef {'plugins'|'mcps'|'git'|'tests'|'secrets'|'quality-toolkit'|'naming-conventions'|'rls-audit'|'gitignore'|'repo-visibility'|'vincifox-widget'|'stella-integration'|'claude-md'|'doppler-compliance'|'infrastructure-yml'} HealthCheckType
9
+ *
10
+ * @typedef {'ok'|'warning'|'error'} HealthStatus
11
+ *
12
+ * @typedef {Object} HealthCheck
13
+ * @property {HealthCheckType} type
14
+ * @property {HealthStatus} status
15
+ * @property {number} score
16
+ * @property {number} maxScore
17
+ * @property {Record<string, any>} details
18
+ *
19
+ * @typedef {Object} HealthReport
20
+ * @property {string} projectName
21
+ * @property {string} projectPath
22
+ * @property {number} overallScore
23
+ * @property {number} maxScore
24
+ * @property {number} healthPercent
25
+ * @property {'healthy'|'warning'|'unhealthy'} status
26
+ * @property {HealthCheck[]} checks
27
+ * @property {string} scannedAt
28
+ */
29
+
30
+ /**
31
+ * Create a blank HealthCheck with defaults
32
+ * @param {HealthCheckType} type
33
+ * @param {number} maxScore
34
+ * @param {Record<string, any>} [details]
35
+ * @returns {HealthCheck}
36
+ */
37
+ export function createCheck(type, maxScore, details = {}) {
38
+ return {
39
+ type,
40
+ status: 'ok',
41
+ score: 0,
42
+ maxScore,
43
+ details
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Mask a secret value (show first 4 and last 4 chars)
49
+ * @param {string} value
50
+ * @returns {string}
51
+ */
52
+ export function maskSecret(value) {
53
+ if (value.length <= 12) return '****'
54
+ return `${value.substring(0, 4)}...${value.substring(value.length - 4)}`
55
+ }
56
+
57
+ /**
58
+ * Calculate overall health status from checks
59
+ * @param {HealthCheck[]} checks
60
+ * @returns {'healthy'|'warning'|'unhealthy'}
61
+ */
62
+ export function calculateHealthStatus(checks) {
63
+ const totalScore = checks.reduce((sum, c) => sum + c.score, 0)
64
+ const maxScore = checks.reduce((sum, c) => sum + c.maxScore, 0)
65
+ const percent = maxScore > 0 ? (totalScore / maxScore) * 100 : 0
66
+
67
+ // Critical checks override percentage
68
+ if (checks.some(c =>
69
+ (c.type === 'secrets' || c.type === 'rls-audit' || c.type === 'repo-visibility') && c.status === 'error'
70
+ )) {
71
+ return 'unhealthy'
72
+ }
73
+
74
+ if (percent >= 70) return 'healthy'
75
+ if (percent >= 40) return 'warning'
76
+ return 'unhealthy'
77
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Health Check: VinciFox Feedback Widget
3
+ *
4
+ * Scans HTML/layout files for feedback-widget.js integration.
5
+ * Score: 0 = not found, 2 = installed
6
+ */
7
+
8
+ import { existsSync, readFileSync } from 'fs'
9
+ import { join } from 'path'
10
+ import { createCheck } from './types.js'
11
+
12
+ const CANDIDATE_FILES = [
13
+ 'index.html', 'frontend/index.html', 'src/index.html', 'public/index.html',
14
+ 'src/app/layout.tsx', 'frontend/src/app/layout.tsx',
15
+ 'frontend-user/src/app/layout.tsx', 'app/layout.tsx'
16
+ ]
17
+
18
+ export async function check(projectPath) {
19
+ const result = createCheck('vincifox-widget', 2, { found: false, file: null, apiKey: null })
20
+
21
+ for (const file of CANDIDATE_FILES) {
22
+ const filePath = join(projectPath, file)
23
+ if (!existsSync(filePath)) continue
24
+
25
+ try {
26
+ const content = readFileSync(filePath, 'utf-8')
27
+ if (/feedback-widget\.js/.test(content)) {
28
+ result.details.found = true
29
+ result.details.file = file
30
+ result.score = 2
31
+
32
+ const keyMatch = content.match(/data-api-key="([a-f0-9]+)"/)
33
+ if (keyMatch) result.details.apiKey = keyMatch[1].substring(0, 8) + '...'
34
+
35
+ if (content.includes('api.vincifox.com')) result.details.environment = 'production'
36
+ else if (content.includes('localhost')) result.details.environment = 'development'
37
+
38
+ return result
39
+ }
40
+ } catch { /* ignore */ }
41
+ }
42
+
43
+ result.status = 'warning'
44
+ result.details.message = 'Widget not installed'
45
+ result.details.installUrl = 'https://www.vincifox.com/projects'
46
+ return result
47
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * All available checks
3
+ */
4
+
5
+ // Security checks
6
+ export * as hardcodedSecrets from './security/hardcoded-secrets.js'
7
+ export * as serviceKeyExposure from './security/service-key-exposure.js'
8
+ export * as deprecatedSupabaseAdmin from './security/deprecated-supabase-admin.js'
9
+ export * as systemdbWhitelist from './security/systemdb-whitelist.js'
10
+
11
+ // Stability checks
12
+ export * as huskyHooks from './stability/husky-hooks.js'
13
+ export * as ciPipeline from './stability/ci-pipeline.js'
14
+ export * as npmAudit from './stability/npm-audit.js'
15
+
16
+ // Health checks (project ecosystem)
17
+ export * as health from './health/index.js'
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Check for deprecated supabaseAdmin usage (ralph-manager pattern)
3
+ *
4
+ * Projects should use:
5
+ * - systemDB('context') for system operations
6
+ * - userDB(req) for user-scoped operations
7
+ */
8
+
9
+ import { glob } from 'glob'
10
+ import { readFileSync, existsSync } from 'fs'
11
+
12
+ export const meta = {
13
+ id: 'deprecated-supabase-admin',
14
+ name: 'Deprecated supabaseAdmin Usage',
15
+ category: 'security',
16
+ severity: 'high',
17
+ description: 'Detects direct supabaseAdmin usage - use systemDB(context) or userDB(req) instead'
18
+ }
19
+
20
+ export async function run(config, projectRoot) {
21
+ const results = {
22
+ passed: true,
23
+ findings: [],
24
+ summary: { total: 0, critical: 0, high: 0, medium: 0, low: 0 }
25
+ }
26
+
27
+ // Check if project uses the systemDB/userDB pattern
28
+ const hasSystemDb = existsSync(`${projectRoot}/backend/src/core/systemDb.ts`) ||
29
+ existsSync(`${projectRoot}/src/core/systemDb.ts`)
30
+
31
+ if (!hasSystemDb) {
32
+ // Project doesn't use this pattern, skip check
33
+ results.skipped = true
34
+ results.skipReason = 'Project does not use systemDB/userDB pattern'
35
+ return results
36
+ }
37
+
38
+ // Get all backend TypeScript files
39
+ const files = await glob('**/*.ts', {
40
+ cwd: projectRoot,
41
+ ignore: [
42
+ ...config.ignore,
43
+ '**/supabase.ts', // The deprecated file itself
44
+ '**/*.test.ts',
45
+ '**/*.spec.ts'
46
+ ]
47
+ })
48
+
49
+ for (const file of files) {
50
+ try {
51
+ const content = readFileSync(`${projectRoot}/${file}`, 'utf-8')
52
+ const lines = content.split('\n')
53
+
54
+ // Check for direct supabaseAdmin imports/usage
55
+ const patterns = [
56
+ { pattern: /import.*supabaseAdmin.*from/g, name: 'supabaseAdmin import' },
57
+ { pattern: /supabaseAdmin\./g, name: 'supabaseAdmin usage' },
58
+ { pattern: /supabaseAdmin\s*\(/g, name: 'supabaseAdmin call' }
59
+ ]
60
+
61
+ for (const { pattern, name } of patterns) {
62
+ pattern.lastIndex = 0
63
+
64
+ let match
65
+ while ((match = pattern.exec(content)) !== null) {
66
+ // Find line number
67
+ let lineNumber = 1
68
+ let pos = 0
69
+ for (const line of lines) {
70
+ if (pos + line.length >= match.index) {
71
+ break
72
+ }
73
+ pos += line.length + 1
74
+ lineNumber++
75
+ }
76
+
77
+ results.passed = false
78
+ results.findings.push({
79
+ file,
80
+ line: lineNumber,
81
+ type: name,
82
+ severity: 'high',
83
+ message: `Deprecated ${name} - use systemDB('context') or userDB(req) instead`,
84
+ snippet: lines[lineNumber - 1]?.trim().substring(0, 100)
85
+ })
86
+ results.summary.high++
87
+ results.summary.total++
88
+ }
89
+ }
90
+ } catch (e) {
91
+ // Skip unreadable files
92
+ }
93
+ }
94
+
95
+ return results
96
+ }
@@ -0,0 +1,211 @@
1
+ /**
2
+ * .gitignore Validation
3
+ *
4
+ * Verifies that .gitignore contains critical entries to prevent
5
+ * accidental commits of secrets, dependencies, and build artifacts.
6
+ */
7
+
8
+ import { readFileSync, existsSync } from 'fs'
9
+ import { join } from 'path'
10
+
11
+ export const meta = {
12
+ id: 'gitignore-validation',
13
+ name: 'Gitignore Validation',
14
+ category: 'security',
15
+ severity: 'high',
16
+ description: 'Checks .gitignore for critical entries that prevent secret/credential leaks'
17
+ }
18
+
19
+ // Required entries grouped by category
20
+ const REQUIRED_ENTRIES = [
21
+ {
22
+ category: 'Environment files',
23
+ severity: 'critical',
24
+ patterns: ['.env', '.env.*', '.env.local'],
25
+ description: 'Environment files often contain API keys and secrets'
26
+ },
27
+ {
28
+ category: 'Dependencies',
29
+ severity: 'high',
30
+ patterns: ['node_modules', 'node_modules/'],
31
+ description: 'Dependencies should never be committed'
32
+ },
33
+ {
34
+ category: 'Build artifacts',
35
+ severity: 'medium',
36
+ patterns: ['dist', 'build', '.next'],
37
+ description: 'Build output should not be in version control'
38
+ }
39
+ ]
40
+
41
+ const RECOMMENDED_ENTRIES = [
42
+ {
43
+ category: 'Credential files',
44
+ severity: 'high',
45
+ patterns: ['*.pem', '*.key', '*.p12', '*.pfx', 'credentials.json', 'service-account*.json'],
46
+ description: 'Certificate/key files can contain private keys'
47
+ },
48
+ {
49
+ category: 'Supabase temp files',
50
+ severity: 'medium',
51
+ patterns: ['.supabase', 'supabase/.temp'],
52
+ description: 'Supabase CLI temp files may contain tokens'
53
+ },
54
+ {
55
+ category: 'IDE & OS files',
56
+ severity: 'low',
57
+ patterns: ['.DS_Store', '.idea', '.vscode/settings.json'],
58
+ description: 'IDE settings may leak local paths or preferences'
59
+ },
60
+ {
61
+ category: 'Ralph working files',
62
+ severity: 'medium',
63
+ patterns: ['.ralph'],
64
+ description: 'Ralph session files may contain sensitive project data'
65
+ }
66
+ ]
67
+
68
+ export async function run(config, projectRoot) {
69
+ const results = {
70
+ passed: true,
71
+ findings: [],
72
+ summary: { total: 0, critical: 0, high: 0, medium: 0, low: 0 },
73
+ details: {
74
+ gitignoreExists: false,
75
+ entryCount: 0,
76
+ missingRequired: [],
77
+ missingRecommended: []
78
+ }
79
+ }
80
+
81
+ const gitignorePath = join(projectRoot, '.gitignore')
82
+
83
+ // Check if .gitignore exists
84
+ if (!existsSync(gitignorePath)) {
85
+ results.passed = false
86
+ results.findings.push({
87
+ file: '.gitignore',
88
+ type: 'Missing .gitignore',
89
+ severity: 'critical',
90
+ message: 'No .gitignore file found — secrets and dependencies could be committed'
91
+ })
92
+ results.summary.critical++
93
+ results.summary.total++
94
+ return results
95
+ }
96
+
97
+ results.details.gitignoreExists = true
98
+
99
+ let content
100
+ try {
101
+ content = readFileSync(gitignorePath, 'utf-8')
102
+ } catch {
103
+ results.passed = false
104
+ results.findings.push({
105
+ file: '.gitignore',
106
+ type: 'Unreadable .gitignore',
107
+ severity: 'critical',
108
+ message: 'Could not read .gitignore file'
109
+ })
110
+ results.summary.critical++
111
+ results.summary.total++
112
+ return results
113
+ }
114
+
115
+ // Parse gitignore lines (strip comments and whitespace)
116
+ const lines = content
117
+ .split('\n')
118
+ .map(l => l.trim())
119
+ .filter(l => l && !l.startsWith('#'))
120
+
121
+ results.details.entryCount = lines.length
122
+
123
+ // Helper: check if a pattern is covered by any gitignore line
124
+ const isCovered = (pattern) => {
125
+ const normalizedPattern = pattern.replace(/\/$/, '')
126
+ return lines.some(line => {
127
+ const normalizedLine = line.replace(/\/$/, '')
128
+ // Exact match
129
+ if (normalizedLine === normalizedPattern) return true
130
+ // Wildcard match: .env.* covers .env.local, .env.production etc.
131
+ if (normalizedLine === '.env.*' && pattern.startsWith('.env.')) return true
132
+ if (normalizedLine === '.env*' && pattern.startsWith('.env')) return true
133
+ // Glob match: *.pem covers any .pem file
134
+ if (normalizedLine.startsWith('*') && pattern.endsWith(normalizedLine.slice(1))) return true
135
+ // Pattern with leading slash
136
+ if (normalizedLine === '/' + normalizedPattern) return true
137
+ // Directory glob
138
+ if (normalizedLine === normalizedPattern + '/') return true
139
+ if (normalizedLine === normalizedPattern + '/**') return true
140
+ // Path-prefixed match: /frontend/dist or /backend/dist covers "dist"
141
+ if (normalizedLine.endsWith('/' + normalizedPattern)) return true
142
+ return false
143
+ })
144
+ }
145
+
146
+ // Check required entries
147
+ for (const entry of REQUIRED_ENTRIES) {
148
+ const covered = entry.patterns.some(p => isCovered(p))
149
+ if (!covered) {
150
+ results.passed = false
151
+ results.details.missingRequired.push(entry.category)
152
+ results.findings.push({
153
+ file: '.gitignore',
154
+ type: `Missing: ${entry.category}`,
155
+ severity: entry.severity,
156
+ message: `${entry.category}: none of [${entry.patterns.join(', ')}] found in .gitignore — ${entry.description}`,
157
+ missingPatterns: entry.patterns
158
+ })
159
+ results.summary[entry.severity]++
160
+ results.summary.total++
161
+ }
162
+ }
163
+
164
+ // Check recommended entries
165
+ for (const entry of RECOMMENDED_ENTRIES) {
166
+ const covered = entry.patterns.some(p => isCovered(p))
167
+ if (!covered) {
168
+ results.details.missingRecommended.push(entry.category)
169
+ results.findings.push({
170
+ file: '.gitignore',
171
+ type: `Recommended: ${entry.category}`,
172
+ severity: entry.severity,
173
+ message: `${entry.category}: consider adding [${entry.patterns.join(', ')}] — ${entry.description}`,
174
+ missingPatterns: entry.patterns
175
+ })
176
+ results.summary[entry.severity]++
177
+ results.summary.total++
178
+ }
179
+ }
180
+
181
+ // Check for accidentally tracked .env files (git ls-files)
182
+ // This is informational — the gitignore may be fine but files were added before
183
+ try {
184
+ const { execSync } = await import('child_process')
185
+ const tracked = execSync('git ls-files -- .env .env.* .env.local 2>/dev/null || true', {
186
+ cwd: projectRoot,
187
+ encoding: 'utf-8'
188
+ }).trim()
189
+
190
+ if (tracked) {
191
+ // Exclude .env.example and .env.sample — these are meant to be committed
192
+ const trackedFiles = tracked.split('\n').filter(f => f.trim() && !f.includes('.example') && !f.includes('.sample'))
193
+ if (trackedFiles.length > 0) {
194
+ results.passed = false
195
+ results.findings.push({
196
+ file: '.gitignore',
197
+ type: 'Tracked env files',
198
+ severity: 'critical',
199
+ message: `${trackedFiles.length} .env file(s) are tracked by git despite .gitignore: ${trackedFiles.join(', ')} — run: git rm --cached ${trackedFiles.join(' ')}`,
200
+ trackedFiles
201
+ })
202
+ results.summary.critical++
203
+ results.summary.total++
204
+ }
205
+ }
206
+ } catch {
207
+ // Not a git repo or git not available — skip this check
208
+ }
209
+
210
+ return results
211
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Check for hardcoded secrets in source code
3
+ */
4
+
5
+ import { glob } from 'glob'
6
+ import { readFileSync } from 'fs'
7
+
8
+ export const meta = {
9
+ id: 'hardcoded-secrets',
10
+ name: 'Hardcoded Secrets Detection',
11
+ category: 'security',
12
+ severity: 'critical',
13
+ description: 'Detects API keys, tokens, and other secrets hardcoded in source files'
14
+ }
15
+
16
+ const DEFAULT_PATTERNS = [
17
+ { name: 'OpenAI API Key', pattern: /sk-[a-zA-Z0-9]{20,}/g },
18
+ { name: 'Stripe Live Key', pattern: /sk_live_[a-zA-Z0-9]{20,}/g },
19
+ { name: 'Stripe Test Key', pattern: /sk_test_[a-zA-Z0-9]{20,}/g },
20
+ { name: 'AWS Access Key', pattern: /AKIA[A-Z0-9]{16}/g },
21
+ { name: 'GitHub Token', pattern: /ghp_[a-zA-Z0-9]{36}/g },
22
+ { name: 'GitHub OAuth', pattern: /gho_[a-zA-Z0-9]{36}/g },
23
+ { name: 'Slack Token', pattern: /xox[baprs]-[a-zA-Z0-9-]{10,}/g },
24
+ { name: 'Supabase Service Key', pattern: /eyJ[a-zA-Z0-9_-]{100,}\.[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+/g },
25
+ { name: 'Generic API Key', pattern: /['"]api[_-]?key['"]\s*[:=]\s*['"][a-zA-Z0-9]{20,}['"]/gi },
26
+ { name: 'Generic Secret', pattern: /['"]secret['"]\s*[:=]\s*['"][a-zA-Z0-9]{20,}['"]/gi },
27
+ { name: 'Private Key Header', pattern: /-----BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----/g }
28
+ ]
29
+
30
+ export async function run(config, projectRoot) {
31
+ const results = {
32
+ passed: true,
33
+ findings: [],
34
+ summary: { total: 0, critical: 0, high: 0, medium: 0, low: 0 }
35
+ }
36
+
37
+ // Get all source files
38
+ const files = await glob('**/*.{ts,tsx,js,jsx,json}', {
39
+ cwd: projectRoot,
40
+ ignore: config.ignore
41
+ })
42
+
43
+ for (const file of files) {
44
+ // Skip test files and examples
45
+ if (file.includes('.test.') || file.includes('.spec.') || file.includes('example')) {
46
+ continue
47
+ }
48
+
49
+ try {
50
+ const content = readFileSync(`${projectRoot}/${file}`, 'utf-8')
51
+ const lines = content.split('\n')
52
+
53
+ for (const { name, pattern } of DEFAULT_PATTERNS) {
54
+ // Reset regex state
55
+ pattern.lastIndex = 0
56
+
57
+ let match
58
+ while ((match = pattern.exec(content)) !== null) {
59
+ // Find line number
60
+ let lineNumber = 1
61
+ let pos = 0
62
+ for (const line of lines) {
63
+ if (pos + line.length >= match.index) {
64
+ break
65
+ }
66
+ pos += line.length + 1
67
+ lineNumber++
68
+ }
69
+
70
+ // Check if it's in a comment or placeholder
71
+ const line = lines[lineNumber - 1] || ''
72
+ if (line.includes('// ') || line.includes('example') || line.includes('placeholder')) {
73
+ continue
74
+ }
75
+
76
+ results.passed = false
77
+ results.findings.push({
78
+ file,
79
+ line: lineNumber,
80
+ type: name,
81
+ severity: 'critical',
82
+ message: `Potential ${name} found`,
83
+ snippet: match[0].substring(0, 20) + '...'
84
+ })
85
+ results.summary.critical++
86
+ results.summary.total++
87
+ }
88
+ }
89
+ } catch (e) {
90
+ // Skip unreadable files
91
+ }
92
+ }
93
+
94
+ return results
95
+ }