@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.
- package/README.md +312 -0
- package/bin/vca-audit.js +90 -0
- package/bin/vca-dev-token.js +39 -0
- package/bin/vca-setup.js +227 -0
- package/lib/checks/codeQuality/api-response-format.js +268 -0
- package/lib/checks/health/claude-md.js +114 -0
- package/lib/checks/health/doppler-compliance.js +174 -0
- package/lib/checks/health/git.js +61 -0
- package/lib/checks/health/gitignore.js +83 -0
- package/lib/checks/health/index.js +26 -0
- package/lib/checks/health/infrastructure-yml.js +87 -0
- package/lib/checks/health/mcps.js +57 -0
- package/lib/checks/health/naming-conventions.js +302 -0
- package/lib/checks/health/plugins.js +38 -0
- package/lib/checks/health/quality-toolkit.js +97 -0
- package/lib/checks/health/repo-visibility.js +70 -0
- package/lib/checks/health/rls-audit.js +130 -0
- package/lib/checks/health/scanner.js +68 -0
- package/lib/checks/health/secrets.js +80 -0
- package/lib/checks/health/stella-integration.js +124 -0
- package/lib/checks/health/tests.js +140 -0
- package/lib/checks/health/types.js +77 -0
- package/lib/checks/health/vincifox-widget.js +47 -0
- package/lib/checks/index.js +17 -0
- package/lib/checks/security/deprecated-supabase-admin.js +96 -0
- package/lib/checks/security/gitignore-validation.js +211 -0
- package/lib/checks/security/hardcoded-secrets.js +95 -0
- package/lib/checks/security/service-key-exposure.js +107 -0
- package/lib/checks/security/systemdb-whitelist.js +138 -0
- package/lib/checks/stability/ci-pipeline.js +143 -0
- package/lib/checks/stability/husky-hooks.js +117 -0
- package/lib/checks/stability/npm-audit.js +140 -0
- package/lib/checks/supabase/rls-policy-audit.js +261 -0
- package/lib/commands/dev-token.js +342 -0
- package/lib/config.js +213 -0
- package/lib/index.js +17 -0
- package/lib/reporters/terminal.js +134 -0
- package/lib/runner.js +179 -0
- 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
|
+
}
|