create-qa-architect 5.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/.editorconfig +12 -0
- package/.github/CLAUDE_MD_AUTOMATION.md +248 -0
- package/.github/PROGRESSIVE_QUALITY_IMPLEMENTATION.md +408 -0
- package/.github/PROGRESSIVE_QUALITY_PROPOSAL.md +443 -0
- package/.github/RELEASE_CHECKLIST.md +100 -0
- package/.github/dependabot.yml +50 -0
- package/.github/git-sync.sh +48 -0
- package/.github/workflows/claude-md-validation.yml +82 -0
- package/.github/workflows/nightly-gitleaks-verification.yml +176 -0
- package/.github/workflows/pnpm-ci.yml.example +53 -0
- package/.github/workflows/python-ci.yml.example +69 -0
- package/.github/workflows/quality-legacy.yml.backup +165 -0
- package/.github/workflows/quality-progressive.yml.example +291 -0
- package/.github/workflows/quality.yml +436 -0
- package/.github/workflows/release.yml +53 -0
- package/.nvmrc +1 -0
- package/.prettierignore +14 -0
- package/.prettierrc +9 -0
- package/.stylelintrc.json +5 -0
- package/README.md +212 -0
- package/config/.lighthouserc.js +45 -0
- package/config/.pre-commit-config.yaml +66 -0
- package/config/constants.js +128 -0
- package/config/defaults.js +124 -0
- package/config/pyproject.toml +124 -0
- package/config/quality-config.schema.json +97 -0
- package/config/quality-python.yml +89 -0
- package/config/requirements-dev.txt +15 -0
- package/create-saas-monetization.js +1465 -0
- package/eslint.config.cjs +117 -0
- package/eslint.config.ts.cjs +99 -0
- package/legal/README.md +106 -0
- package/legal/copyright.md +76 -0
- package/legal/disclaimer.md +146 -0
- package/legal/privacy-policy.html +324 -0
- package/legal/privacy-policy.md +196 -0
- package/legal/terms-of-service.md +224 -0
- package/lib/billing-dashboard.html +645 -0
- package/lib/config-validator.js +163 -0
- package/lib/dependency-monitoring-basic.js +185 -0
- package/lib/dependency-monitoring-premium.js +1490 -0
- package/lib/error-reporter.js +444 -0
- package/lib/interactive/prompt.js +128 -0
- package/lib/interactive/questions.js +146 -0
- package/lib/license-validator.js +403 -0
- package/lib/licensing.js +989 -0
- package/lib/package-utils.js +187 -0
- package/lib/project-maturity.js +516 -0
- package/lib/security-enhancements.js +340 -0
- package/lib/setup-enhancements.js +317 -0
- package/lib/smart-strategy-generator.js +344 -0
- package/lib/telemetry.js +323 -0
- package/lib/template-loader.js +252 -0
- package/lib/typescript-config-generator.js +210 -0
- package/lib/ui-helpers.js +74 -0
- package/lib/validation/base-validator.js +174 -0
- package/lib/validation/cache-manager.js +158 -0
- package/lib/validation/config-security.js +741 -0
- package/lib/validation/documentation.js +326 -0
- package/lib/validation/index.js +186 -0
- package/lib/validation/validation-factory.js +153 -0
- package/lib/validation/workflow-validation.js +172 -0
- package/lib/yaml-utils.js +120 -0
- package/marketing/beta-user-email-campaign.md +372 -0
- package/marketing/landing-page.html +721 -0
- package/package.json +165 -0
- package/setup.js +2076 -0
|
@@ -0,0 +1,741 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const fs = require('fs')
|
|
4
|
+
const path = require('path')
|
|
5
|
+
const os = require('os')
|
|
6
|
+
const crypto = require('crypto')
|
|
7
|
+
const https = require('https')
|
|
8
|
+
const { execSync } = require('child_process')
|
|
9
|
+
const { showProgress } = require('../ui-helpers')
|
|
10
|
+
|
|
11
|
+
// Pinned gitleaks version for reproducible security scanning
|
|
12
|
+
const GITLEAKS_VERSION = '8.28.0'
|
|
13
|
+
// Real SHA256 checksums from https://github.com/gitleaks/gitleaks/releases/tag/v8.28.0
|
|
14
|
+
const GITLEAKS_CHECKSUMS = {
|
|
15
|
+
'linux-x64':
|
|
16
|
+
'a65b5253807a68ac0cafa4414031fd740aeb55f54fb7e55f386acb52e6a840eb',
|
|
17
|
+
'darwin-x64':
|
|
18
|
+
'edf5a507008b0d2ef4959575772772770586409c1f6f74dabf19cbe7ec341ced',
|
|
19
|
+
'darwin-arm64':
|
|
20
|
+
'5588b5d942dffa048720f7e6e1d274283219fb5722a2c7564d22e83ba39087d7',
|
|
21
|
+
'win32-x64':
|
|
22
|
+
'da6458e8864af553807de1c46a7a8eac0880bd6b99ba56288e87e86a45af884f',
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Configuration Security Scanner
|
|
27
|
+
* Uses mature security tools instead of custom regex heuristics
|
|
28
|
+
*/
|
|
29
|
+
class ConfigSecurityScanner {
|
|
30
|
+
constructor(options = {}) {
|
|
31
|
+
this.issues = []
|
|
32
|
+
this.options = options
|
|
33
|
+
|
|
34
|
+
// checksumMap dependency injection - FOR TESTING ONLY
|
|
35
|
+
// WARNING: Do not use in production CLI - this bypasses security verification!
|
|
36
|
+
if (options.checksumMap) {
|
|
37
|
+
if (process.env.NODE_ENV === 'production') {
|
|
38
|
+
throw new Error(
|
|
39
|
+
'checksumMap override not allowed in production environment'
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
if (!options.quiet) {
|
|
43
|
+
console.warn(
|
|
44
|
+
'⚠️ WARNING: Using custom checksum map - FOR TESTING ONLY!'
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
this.checksumMap = options.checksumMap
|
|
48
|
+
} else {
|
|
49
|
+
this.checksumMap = GITLEAKS_CHECKSUMS
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Resolve gitleaks binary with fallback chain for reproducible security
|
|
55
|
+
* 1. Process.env.GITLEAKS_PATH (user override)
|
|
56
|
+
* 2. Global gitleaks binary (brew, choco, etc.)
|
|
57
|
+
* 3. Cached pinned version in ~/.cache/create-qa-architect/
|
|
58
|
+
* 4. npx fallback with loud warning
|
|
59
|
+
*/
|
|
60
|
+
async resolveGitleaksBinary() {
|
|
61
|
+
// 1. Check environment override
|
|
62
|
+
if (process.env.GITLEAKS_PATH) {
|
|
63
|
+
if (fs.existsSync(process.env.GITLEAKS_PATH)) {
|
|
64
|
+
return process.env.GITLEAKS_PATH
|
|
65
|
+
}
|
|
66
|
+
console.warn(
|
|
67
|
+
`⚠️ GITLEAKS_PATH set but binary not found: ${process.env.GITLEAKS_PATH}`
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// 2. Check global installation
|
|
72
|
+
try {
|
|
73
|
+
const globalPath = execSync('which gitleaks', {
|
|
74
|
+
encoding: 'utf8',
|
|
75
|
+
stdio: 'pipe',
|
|
76
|
+
}).trim()
|
|
77
|
+
if (globalPath && fs.existsSync(globalPath)) {
|
|
78
|
+
return globalPath
|
|
79
|
+
}
|
|
80
|
+
} catch {
|
|
81
|
+
// which command failed, global binary not available
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 3. Use cached pinned version or download if missing
|
|
85
|
+
const cacheDir = path.join(os.homedir(), '.cache', 'create-qa-architect')
|
|
86
|
+
const binaryName =
|
|
87
|
+
process.platform === 'win32' ? 'gitleaks.exe' : 'gitleaks'
|
|
88
|
+
const cachedBinary = path.join(
|
|
89
|
+
cacheDir,
|
|
90
|
+
'gitleaks',
|
|
91
|
+
GITLEAKS_VERSION,
|
|
92
|
+
binaryName
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
if (fs.existsSync(cachedBinary)) {
|
|
96
|
+
// Verify cached binary integrity
|
|
97
|
+
if (await this.verifyBinaryChecksum(cachedBinary)) {
|
|
98
|
+
return cachedBinary
|
|
99
|
+
} else {
|
|
100
|
+
console.warn(
|
|
101
|
+
'⚠️ Cached gitleaks binary failed checksum verification, re-downloading...'
|
|
102
|
+
)
|
|
103
|
+
fs.rmSync(path.dirname(cachedBinary), { recursive: true, force: true })
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Download and cache pinned version
|
|
108
|
+
try {
|
|
109
|
+
await this.downloadGitleaksBinary(cachedBinary)
|
|
110
|
+
return cachedBinary
|
|
111
|
+
} catch (error) {
|
|
112
|
+
if (!this.options.quiet) {
|
|
113
|
+
console.error(
|
|
114
|
+
`❌ Failed to download gitleaks v${GITLEAKS_VERSION}: ${error.message}`
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Check if fallback to unpinned gitleaks is explicitly allowed
|
|
119
|
+
if (this.options.allowLatestGitleaks) {
|
|
120
|
+
console.warn(
|
|
121
|
+
'🚨 WARNING: Using npx gitleaks (supply chain risk - downloads latest version)'
|
|
122
|
+
)
|
|
123
|
+
console.warn(
|
|
124
|
+
'📌 Consider: brew install gitleaks (macOS) or choco install gitleaks (Windows)'
|
|
125
|
+
)
|
|
126
|
+
return 'npx gitleaks'
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Security-first: fail hard instead of silent fallback
|
|
130
|
+
throw new Error(
|
|
131
|
+
`Cannot resolve secure gitleaks binary. Options:\n` +
|
|
132
|
+
`1. Install globally: brew install gitleaks (macOS) or choco install gitleaks (Windows)\n` +
|
|
133
|
+
`2. Set GITLEAKS_PATH to your preferred binary\n` +
|
|
134
|
+
`3. Use --allow-latest-gitleaks flag (NOT RECOMMENDED - supply chain risk)`
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Download and verify gitleaks binary for current platform
|
|
141
|
+
*/
|
|
142
|
+
async downloadGitleaksBinary(targetPath) {
|
|
143
|
+
const platform = this.detectPlatform()
|
|
144
|
+
if (!platform) {
|
|
145
|
+
throw new Error(
|
|
146
|
+
`Unsupported platform: ${process.platform}-${process.arch}`
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const tarballName = `gitleaks_${GITLEAKS_VERSION}_${platform}.tar.gz`
|
|
151
|
+
const downloadUrl = `https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/${tarballName}`
|
|
152
|
+
|
|
153
|
+
console.log(
|
|
154
|
+
`📥 Downloading gitleaks v${GITLEAKS_VERSION} for ${platform}...`
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
// Ensure cache directory exists
|
|
158
|
+
const cacheDir = path.dirname(targetPath)
|
|
159
|
+
fs.mkdirSync(cacheDir, { recursive: true })
|
|
160
|
+
|
|
161
|
+
// Download and extract
|
|
162
|
+
const tarballPath = path.join(cacheDir, tarballName)
|
|
163
|
+
await this.downloadFile(downloadUrl, tarballPath)
|
|
164
|
+
|
|
165
|
+
// Extract binary (tar.gz contains just the gitleaks executable)
|
|
166
|
+
const tar = require('tar')
|
|
167
|
+
await tar.extract({
|
|
168
|
+
file: tarballPath,
|
|
169
|
+
cwd: cacheDir,
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
// Move extracted binary to final location and make executable
|
|
173
|
+
const extractedBinary = path.join(
|
|
174
|
+
cacheDir,
|
|
175
|
+
process.platform === 'win32' ? 'gitleaks.exe' : 'gitleaks'
|
|
176
|
+
)
|
|
177
|
+
fs.renameSync(extractedBinary, targetPath)
|
|
178
|
+
if (process.platform !== 'win32') {
|
|
179
|
+
fs.chmodSync(targetPath, 0o755)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Verify checksum
|
|
183
|
+
if (!(await this.verifyBinaryChecksum(targetPath))) {
|
|
184
|
+
fs.rmSync(targetPath, { force: true })
|
|
185
|
+
throw new Error('Downloaded binary failed checksum verification')
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Cleanup tarball
|
|
189
|
+
fs.rmSync(tarballPath, { force: true })
|
|
190
|
+
|
|
191
|
+
console.log(`✅ gitleaks v${GITLEAKS_VERSION} cached and verified`)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Detect current platform for gitleaks release naming
|
|
196
|
+
*/
|
|
197
|
+
detectPlatform() {
|
|
198
|
+
const platforms = {
|
|
199
|
+
'darwin-x64': 'darwin_x64',
|
|
200
|
+
'darwin-arm64': 'darwin_arm64',
|
|
201
|
+
'linux-x64': 'linux_x64',
|
|
202
|
+
'win32-x64': 'windows_x64',
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const key = `${process.platform}-${process.arch}`
|
|
206
|
+
return platforms[key] || null
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Download file from URL to target path
|
|
211
|
+
*/
|
|
212
|
+
downloadFile(url, targetPath) {
|
|
213
|
+
return new Promise((resolve, reject) => {
|
|
214
|
+
const file = fs.createWriteStream(targetPath)
|
|
215
|
+
|
|
216
|
+
https
|
|
217
|
+
.get(url, response => {
|
|
218
|
+
if (response.statusCode === 302 || response.statusCode === 301) {
|
|
219
|
+
// Follow redirect
|
|
220
|
+
return this.downloadFile(response.headers.location, targetPath)
|
|
221
|
+
.then(resolve)
|
|
222
|
+
.catch(reject)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (response.statusCode !== 200) {
|
|
226
|
+
reject(
|
|
227
|
+
new Error(
|
|
228
|
+
`HTTP ${response.statusCode}: ${response.statusMessage}`
|
|
229
|
+
)
|
|
230
|
+
)
|
|
231
|
+
return
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
response.pipe(file)
|
|
235
|
+
|
|
236
|
+
file.on('finish', () => {
|
|
237
|
+
file.close()
|
|
238
|
+
resolve()
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
file.on('error', err => {
|
|
242
|
+
fs.unlink(targetPath, () => {}) // Delete partial file
|
|
243
|
+
reject(err)
|
|
244
|
+
})
|
|
245
|
+
})
|
|
246
|
+
.on('error', reject)
|
|
247
|
+
})
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Verify binary checksum against known good hash
|
|
252
|
+
* CRITICAL SECURITY: Fails hard on missing checksums - no silent bypass
|
|
253
|
+
*/
|
|
254
|
+
async verifyBinaryChecksum(binaryPath) {
|
|
255
|
+
const platformKey = `${process.platform}-${process.arch}`
|
|
256
|
+
const expectedChecksum = this.checksumMap[platformKey]
|
|
257
|
+
|
|
258
|
+
if (!expectedChecksum) {
|
|
259
|
+
throw new Error(
|
|
260
|
+
`No checksum available for platform ${platformKey} - refusing to execute unverified binary`
|
|
261
|
+
)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
try {
|
|
265
|
+
const binaryData = fs.readFileSync(binaryPath)
|
|
266
|
+
const actualChecksum = crypto
|
|
267
|
+
.createHash('sha256')
|
|
268
|
+
.update(binaryData)
|
|
269
|
+
.digest('hex')
|
|
270
|
+
|
|
271
|
+
if (actualChecksum !== expectedChecksum) {
|
|
272
|
+
throw new Error(
|
|
273
|
+
`Checksum mismatch for ${binaryPath}: expected ${expectedChecksum}, got ${actualChecksum}`
|
|
274
|
+
)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return true
|
|
278
|
+
} catch (error) {
|
|
279
|
+
if (
|
|
280
|
+
error.message.includes('Checksum mismatch') ||
|
|
281
|
+
error.message.includes('No checksum available')
|
|
282
|
+
) {
|
|
283
|
+
throw error // Re-throw security-critical errors
|
|
284
|
+
}
|
|
285
|
+
throw new Error(`Checksum verification failed: ${error.message}`)
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Scan all configuration files for security issues
|
|
291
|
+
*/
|
|
292
|
+
async scanAll() {
|
|
293
|
+
console.log('🔍 Running security scans with mature tools...')
|
|
294
|
+
|
|
295
|
+
this.issues = []
|
|
296
|
+
|
|
297
|
+
if (!this.options.disableNpmAudit) {
|
|
298
|
+
await this.runNpmAudit()
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (!this.options.disableEslintSecurity) {
|
|
302
|
+
await this.runESLintSecurity()
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (!this.options.disableGitleaks) {
|
|
306
|
+
await this.runGitleaks()
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
await this.scanClientSideSecrets()
|
|
310
|
+
await this.scanDockerSecrets()
|
|
311
|
+
await this.scanEnvironmentFiles()
|
|
312
|
+
await this.checkGitignore()
|
|
313
|
+
|
|
314
|
+
if (this.issues.length > 0) {
|
|
315
|
+
console.error(`❌ Found ${this.issues.length} security issue(s):`)
|
|
316
|
+
this.issues.forEach(issue => console.error(` ${issue}`))
|
|
317
|
+
throw new Error('Security violations detected')
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
console.log('✅ Security checks passed')
|
|
321
|
+
return { issues: this.issues, passed: this.issues.length === 0 }
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Run package manager audit for dependency vulnerabilities
|
|
326
|
+
* Only checks production dependencies (dev vulnerabilities are acceptable)
|
|
327
|
+
*/
|
|
328
|
+
async runNpmAudit() {
|
|
329
|
+
if (!fs.existsSync('package.json')) return
|
|
330
|
+
|
|
331
|
+
const spinner = showProgress(
|
|
332
|
+
'Running npm audit for dependency vulnerabilities...'
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
// Detect package manager
|
|
336
|
+
const {
|
|
337
|
+
detectPackageManager,
|
|
338
|
+
getAuditCommand,
|
|
339
|
+
} = require('../package-utils')
|
|
340
|
+
const packageManager = detectPackageManager(process.cwd())
|
|
341
|
+
const baseAuditCmd = getAuditCommand(packageManager)
|
|
342
|
+
|
|
343
|
+
try {
|
|
344
|
+
// Run audit and capture high/critical vulnerabilities
|
|
345
|
+
// Use --omit=dev to only check production dependencies
|
|
346
|
+
const auditCmd = `${baseAuditCmd} --audit-level high --omit=dev --json`
|
|
347
|
+
execSync(auditCmd, {
|
|
348
|
+
stdio: 'pipe',
|
|
349
|
+
timeout: 60000, // 60 second timeout for audit operations
|
|
350
|
+
encoding: 'utf8',
|
|
351
|
+
})
|
|
352
|
+
} catch (error) {
|
|
353
|
+
if (error.signal === 'SIGTERM') {
|
|
354
|
+
// Timeout occurred
|
|
355
|
+
this.issues.push(
|
|
356
|
+
`${packageManager} audit: Scan timed out after 60 seconds. Check for network issues or consider running audit manually.`
|
|
357
|
+
)
|
|
358
|
+
return
|
|
359
|
+
}
|
|
360
|
+
// Audit exits with code 1 when vulnerabilities are found
|
|
361
|
+
if (error.stdout) {
|
|
362
|
+
try {
|
|
363
|
+
const auditResult = JSON.parse(error.stdout.toString())
|
|
364
|
+
if (auditResult.metadata && auditResult.metadata.vulnerabilities) {
|
|
365
|
+
const vulns = auditResult.metadata.vulnerabilities
|
|
366
|
+
const total = vulns.high + vulns.critical + vulns.moderate
|
|
367
|
+
if (total > 0) {
|
|
368
|
+
spinner.fail(`npm audit found ${total} vulnerabilities`)
|
|
369
|
+
this.issues.push(
|
|
370
|
+
`${packageManager} audit: ${total} vulnerabilities found (${vulns.high} high, ${vulns.critical} critical). Run '${packageManager} audit fix' to resolve.`
|
|
371
|
+
)
|
|
372
|
+
} else {
|
|
373
|
+
spinner.succeed(
|
|
374
|
+
'npm audit completed - no high/critical vulnerabilities'
|
|
375
|
+
)
|
|
376
|
+
}
|
|
377
|
+
} else {
|
|
378
|
+
spinner.succeed('npm audit completed')
|
|
379
|
+
}
|
|
380
|
+
} catch {
|
|
381
|
+
spinner.warn(`Could not parse ${packageManager} audit output`)
|
|
382
|
+
console.warn(`Could not parse ${packageManager} audit output`)
|
|
383
|
+
}
|
|
384
|
+
} else {
|
|
385
|
+
spinner.succeed('npm audit completed')
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Run gitleaks for comprehensive secret scanning with timeout and redaction
|
|
392
|
+
* Uses pinned binary for reproducible security scanning
|
|
393
|
+
*/
|
|
394
|
+
async runGitleaks() {
|
|
395
|
+
const spinner = showProgress('Scanning for secrets with gitleaks...')
|
|
396
|
+
|
|
397
|
+
try {
|
|
398
|
+
// Resolve gitleaks binary with security-focused fallback chain
|
|
399
|
+
const gitleaksBinary = await this.resolveGitleaksBinary()
|
|
400
|
+
|
|
401
|
+
// Build command - handle npx vs direct binary execution
|
|
402
|
+
const isNpxCommand = gitleaksBinary.startsWith('npx ')
|
|
403
|
+
const command = isNpxCommand
|
|
404
|
+
? `${gitleaksBinary} detect --source . --redact`
|
|
405
|
+
: `"${gitleaksBinary}" detect --source . --redact`
|
|
406
|
+
|
|
407
|
+
// Run gitleaks with --redact to prevent secret exposure and timeout for safety
|
|
408
|
+
execSync(command, {
|
|
409
|
+
stdio: 'pipe',
|
|
410
|
+
timeout: 30000, // 30 second timeout to prevent hangs
|
|
411
|
+
encoding: 'utf8',
|
|
412
|
+
})
|
|
413
|
+
spinner.succeed('gitleaks scan completed - no secrets detected')
|
|
414
|
+
} catch (error) {
|
|
415
|
+
if (error.signal === 'SIGTERM') {
|
|
416
|
+
// Timeout occurred
|
|
417
|
+
this.issues.push(
|
|
418
|
+
'gitleaks: Scan timed out after 30 seconds. Repository may be too large for comprehensive scanning.'
|
|
419
|
+
)
|
|
420
|
+
return
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (error.status === 1) {
|
|
424
|
+
// Gitleaks found secrets (exit code 1)
|
|
425
|
+
const output = error.stdout
|
|
426
|
+
? error.stdout.toString()
|
|
427
|
+
: error.stderr
|
|
428
|
+
? error.stderr.toString()
|
|
429
|
+
: ''
|
|
430
|
+
if (output.includes('leaks found') || output.includes('Finding:')) {
|
|
431
|
+
// Extract just the count, not the actual findings
|
|
432
|
+
const leakMatches = output.match(/(\d+)\s+leaks?\s+found/i)
|
|
433
|
+
const leakCount = leakMatches ? leakMatches[1] : 'some'
|
|
434
|
+
spinner.fail('gitleaks found potential secrets')
|
|
435
|
+
this.issues.push(
|
|
436
|
+
`gitleaks: ${leakCount} potential secret(s) detected in repository. Run gitleaks with --redact for details.`
|
|
437
|
+
)
|
|
438
|
+
} else {
|
|
439
|
+
spinner.succeed('gitleaks scan completed')
|
|
440
|
+
}
|
|
441
|
+
} else {
|
|
442
|
+
// Other errors (missing binary, permission issues, etc.)
|
|
443
|
+
const stderr = error.stderr ? error.stderr.toString() : ''
|
|
444
|
+
const stdout = error.stdout ? error.stdout.toString() : ''
|
|
445
|
+
const output = stderr || stdout || error.message
|
|
446
|
+
|
|
447
|
+
if (
|
|
448
|
+
output.includes('not found') ||
|
|
449
|
+
output.includes('command not found') ||
|
|
450
|
+
output.includes('ENOENT') ||
|
|
451
|
+
error?.code === 'ENOENT'
|
|
452
|
+
) {
|
|
453
|
+
// Missing gitleaks should block security validation, not silently pass
|
|
454
|
+
spinner.fail('gitleaks tool not found')
|
|
455
|
+
this.issues.push(
|
|
456
|
+
`gitleaks: Tool not found. Install gitleaks v${GITLEAKS_VERSION}+ for comprehensive secret scanning or use --no-gitleaks to skip.`
|
|
457
|
+
)
|
|
458
|
+
} else {
|
|
459
|
+
// Log the actual error so users know gitleaks failed to run (redact any potential sensitive info)
|
|
460
|
+
const sanitizedError = output
|
|
461
|
+
.split('\n')[0]
|
|
462
|
+
.replace(/[A-Za-z0-9+/=]{20,}/g, '[REDACTED]')
|
|
463
|
+
spinner.fail('gitleaks failed to run')
|
|
464
|
+
console.warn(`⚠️ gitleaks failed to run: ${sanitizedError}`)
|
|
465
|
+
this.issues.push(
|
|
466
|
+
`gitleaks: Failed to run - ${sanitizedError}. Install gitleaks for secret scanning.`
|
|
467
|
+
)
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Run ESLint with security rules using programmatic API
|
|
475
|
+
*/
|
|
476
|
+
async runESLintSecurity() {
|
|
477
|
+
// Detect which ESLint config file exists (check all variants)
|
|
478
|
+
let eslintConfigPath = null
|
|
479
|
+
const configVariants = [
|
|
480
|
+
'eslint.config.cjs',
|
|
481
|
+
'eslint.config.js',
|
|
482
|
+
'eslint.config.mjs',
|
|
483
|
+
'eslint.config.ts.cjs', // TypeScript projects
|
|
484
|
+
]
|
|
485
|
+
|
|
486
|
+
for (const configFile of configVariants) {
|
|
487
|
+
if (fs.existsSync(configFile)) {
|
|
488
|
+
eslintConfigPath = path.resolve(configFile)
|
|
489
|
+
break
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
if (!eslintConfigPath) {
|
|
494
|
+
return // No ESLint config found
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const spinner = showProgress('Running ESLint security checks...')
|
|
498
|
+
|
|
499
|
+
// Step 1: Load ESLint module
|
|
500
|
+
let ESLint
|
|
501
|
+
try {
|
|
502
|
+
const eslintModule = require('eslint')
|
|
503
|
+
ESLint = eslintModule.ESLint
|
|
504
|
+
} catch (error) {
|
|
505
|
+
if (
|
|
506
|
+
error?.code === 'MODULE_NOT_FOUND' ||
|
|
507
|
+
error?.message?.includes('Cannot find module')
|
|
508
|
+
) {
|
|
509
|
+
spinner.fail('ESLint not found')
|
|
510
|
+
this.issues.push(
|
|
511
|
+
'ESLint Security: ESLint is not installed or cannot be loaded. ' +
|
|
512
|
+
'Install eslint and eslint-plugin-security to enable security validation, or use --no-eslint-security to skip.'
|
|
513
|
+
)
|
|
514
|
+
return
|
|
515
|
+
}
|
|
516
|
+
throw error // Re-throw unexpected errors
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// Step 2: Create ESLint instance with detected config
|
|
520
|
+
let eslint
|
|
521
|
+
try {
|
|
522
|
+
eslint = new ESLint({
|
|
523
|
+
overrideConfigFile: eslintConfigPath,
|
|
524
|
+
})
|
|
525
|
+
} catch (error) {
|
|
526
|
+
if (error.name === 'SyntaxError' || error.message?.includes('parse')) {
|
|
527
|
+
spinner.fail('ESLint configuration error')
|
|
528
|
+
this.issues.push(
|
|
529
|
+
`ESLint Security: ESLint configuration file has errors: ${error.message}. ` +
|
|
530
|
+
'Fix the configuration or use --no-eslint-security to skip.'
|
|
531
|
+
)
|
|
532
|
+
return
|
|
533
|
+
}
|
|
534
|
+
spinner.fail('ESLint initialization failed')
|
|
535
|
+
this.issues.push(
|
|
536
|
+
`ESLint Security: Failed to initialize ESLint: ${error.message}. ` +
|
|
537
|
+
'Review the error and fix the issue, or use --no-eslint-security to skip.'
|
|
538
|
+
)
|
|
539
|
+
return
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// Step 3: Lint files in current directory
|
|
543
|
+
let results
|
|
544
|
+
try {
|
|
545
|
+
results = await eslint.lintFiles(['.'])
|
|
546
|
+
} catch (error) {
|
|
547
|
+
spinner.fail('ESLint linting failed')
|
|
548
|
+
this.issues.push(
|
|
549
|
+
`ESLint Security: Linting failed: ${error.message}. ` +
|
|
550
|
+
'Review the error and fix the issue, or use --no-eslint-security to skip.'
|
|
551
|
+
)
|
|
552
|
+
return
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// Step 4: Process results (no try-catch needed - safe operations)
|
|
556
|
+
const securityIssues = []
|
|
557
|
+
for (const result of results) {
|
|
558
|
+
for (const message of result.messages) {
|
|
559
|
+
if (message.ruleId && message.ruleId.startsWith('security/')) {
|
|
560
|
+
securityIssues.push({
|
|
561
|
+
file: result.filePath,
|
|
562
|
+
line: message.line,
|
|
563
|
+
column: message.column,
|
|
564
|
+
rule: message.ruleId,
|
|
565
|
+
message: message.message,
|
|
566
|
+
severity: message.severity,
|
|
567
|
+
})
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// Report security issues
|
|
573
|
+
if (securityIssues.length > 0) {
|
|
574
|
+
spinner.fail(`ESLint security found ${securityIssues.length} issue(s)`)
|
|
575
|
+
securityIssues.forEach(issue => {
|
|
576
|
+
const relativePath = path.relative(process.cwd(), issue.file)
|
|
577
|
+
this.issues.push(
|
|
578
|
+
`ESLint Security: ${relativePath}:${issue.line}:${issue.column} - ${issue.message} (${issue.rule})`
|
|
579
|
+
)
|
|
580
|
+
})
|
|
581
|
+
} else {
|
|
582
|
+
spinner.succeed('ESLint security checks passed')
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Scan for client-side secret exposure in Next.js and Vite
|
|
588
|
+
*/
|
|
589
|
+
async scanClientSideSecrets() {
|
|
590
|
+
await this.scanNextjsConfig()
|
|
591
|
+
await this.scanViteConfig()
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* Scan Next.js configuration for client-side secret exposure
|
|
596
|
+
*/
|
|
597
|
+
async scanNextjsConfig() {
|
|
598
|
+
const configFiles = ['next.config.js', 'next.config.mjs', 'next.config.ts']
|
|
599
|
+
|
|
600
|
+
for (const configFile of configFiles) {
|
|
601
|
+
if (fs.existsSync(configFile)) {
|
|
602
|
+
const content = fs.readFileSync(configFile, 'utf8')
|
|
603
|
+
|
|
604
|
+
// Check for secrets in env block (client-side exposure risk)
|
|
605
|
+
const envBlockRegex = /env:\s*\{([^}]+)\}/gi
|
|
606
|
+
let match
|
|
607
|
+
|
|
608
|
+
while ((match = envBlockRegex.exec(content)) !== null) {
|
|
609
|
+
const envBlock = match[1]
|
|
610
|
+
|
|
611
|
+
const secretPatterns = [
|
|
612
|
+
{ pattern: /\b\w*SECRET\w*\b/gi, type: 'SECRET' },
|
|
613
|
+
{ pattern: /\b\w*PASSWORD\w*\b/gi, type: 'PASSWORD' },
|
|
614
|
+
{ pattern: /\b\w*PRIVATE\w*\b/gi, type: 'PRIVATE' },
|
|
615
|
+
{ pattern: /\b\w*API_KEY\w*\b/gi, type: 'API_KEY' },
|
|
616
|
+
{ pattern: /\b\w*_KEY\b/gi, type: 'KEY' },
|
|
617
|
+
{ pattern: /\b\w*TOKEN\w*\b/gi, type: 'TOKEN' },
|
|
618
|
+
{ pattern: /\b\w*WEBHOOK\w*\b/gi, type: 'WEBHOOK' },
|
|
619
|
+
]
|
|
620
|
+
|
|
621
|
+
for (const { pattern, type } of secretPatterns) {
|
|
622
|
+
if (pattern.test(envBlock)) {
|
|
623
|
+
this.issues.push(
|
|
624
|
+
`${configFile}: Potential ${type} exposure in env block. ` +
|
|
625
|
+
`Variables in 'env' are sent to client bundle. ` +
|
|
626
|
+
`Use process.env.${type} server-side instead.`
|
|
627
|
+
)
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* Scan Vite configuration for client-side secret exposure
|
|
637
|
+
*/
|
|
638
|
+
async scanViteConfig() {
|
|
639
|
+
const configFiles = ['vite.config.js', 'vite.config.ts', 'vite.config.mjs']
|
|
640
|
+
|
|
641
|
+
for (const configFile of configFiles) {
|
|
642
|
+
if (fs.existsSync(configFile)) {
|
|
643
|
+
const content = fs.readFileSync(configFile, 'utf8')
|
|
644
|
+
|
|
645
|
+
// VITE_ prefixed variables are automatically exposed to client
|
|
646
|
+
const viteSecretPattern =
|
|
647
|
+
/VITE_[^=]*(?:SECRET|PASSWORD|PRIVATE|KEY|TOKEN)/gi
|
|
648
|
+
const matches = content.match(viteSecretPattern)
|
|
649
|
+
|
|
650
|
+
if (matches && matches.length > 0) {
|
|
651
|
+
this.issues.push(
|
|
652
|
+
`${configFile}: VITE_ prefixed secrets detected: ${matches.join(', ')}. ` +
|
|
653
|
+
`All VITE_ variables are exposed to client bundle!`
|
|
654
|
+
)
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* Scan Dockerfile for hardcoded secrets
|
|
662
|
+
*/
|
|
663
|
+
async scanDockerSecrets() {
|
|
664
|
+
if (fs.existsSync('Dockerfile')) {
|
|
665
|
+
const content = fs.readFileSync('Dockerfile', 'utf8')
|
|
666
|
+
|
|
667
|
+
// Check for hardcoded secrets in ENV statements
|
|
668
|
+
const envStatements = content.match(/^ENV\s+.+$/gim) || []
|
|
669
|
+
|
|
670
|
+
for (const envStatement of envStatements) {
|
|
671
|
+
const secretPattern =
|
|
672
|
+
/(?:SECRET|PASSWORD|KEY|TOKEN)\s*=\s*["']?[^"\s']+/gi
|
|
673
|
+
if (secretPattern.test(envStatement)) {
|
|
674
|
+
this.issues.push(
|
|
675
|
+
`Dockerfile: Hardcoded secret in ENV statement: ${envStatement.trim()}`
|
|
676
|
+
)
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* Check .gitignore for security-sensitive files
|
|
684
|
+
*/
|
|
685
|
+
async checkGitignore() {
|
|
686
|
+
if (!fs.existsSync('.gitignore')) {
|
|
687
|
+
this.issues.push(
|
|
688
|
+
'No .gitignore found. Create one to prevent committing sensitive files.'
|
|
689
|
+
)
|
|
690
|
+
return
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
const gitignore = fs.readFileSync('.gitignore', 'utf8')
|
|
694
|
+
const requiredIgnores = ['.env*', 'node_modules', '*.log']
|
|
695
|
+
|
|
696
|
+
for (const pattern of requiredIgnores) {
|
|
697
|
+
if (!gitignore.includes(pattern)) {
|
|
698
|
+
this.issues.push(`Missing '${pattern}' in .gitignore`)
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* Scan environment files for common issues
|
|
705
|
+
*/
|
|
706
|
+
async scanEnvironmentFiles() {
|
|
707
|
+
// Check that .env files are properly ignored
|
|
708
|
+
const envFiles = [
|
|
709
|
+
'.env',
|
|
710
|
+
'.env.local',
|
|
711
|
+
'.env.production',
|
|
712
|
+
'.env.development',
|
|
713
|
+
]
|
|
714
|
+
|
|
715
|
+
const existingEnvFiles = envFiles.filter(file => fs.existsSync(file))
|
|
716
|
+
|
|
717
|
+
if (existingEnvFiles.length > 0) {
|
|
718
|
+
if (!fs.existsSync('.gitignore')) {
|
|
719
|
+
this.issues.push('Environment files found but no .gitignore exists')
|
|
720
|
+
} else {
|
|
721
|
+
const gitignore = fs.readFileSync('.gitignore', 'utf8')
|
|
722
|
+
for (const envFile of existingEnvFiles) {
|
|
723
|
+
if (!gitignore.includes(envFile) && !gitignore.includes('.env*')) {
|
|
724
|
+
this.issues.push(
|
|
725
|
+
`${envFile} exists but not in .gitignore. Add it to prevent secret exposure.`
|
|
726
|
+
)
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// Check for .env.example without corresponding documentation
|
|
733
|
+
if (fs.existsSync('.env.example') && !fs.existsSync('README.md')) {
|
|
734
|
+
this.issues.push(
|
|
735
|
+
'.env.example exists but no README.md to document required variables'
|
|
736
|
+
)
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
module.exports = { ConfigSecurityScanner }
|