buildflow-dev 1.0.1 → 1.0.2
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 +483 -157
- package/bin/buildflow.js +10 -0
- package/package.json +1 -1
- package/src/commands/fix.js +368 -0
- package/src/commands/init.js +390 -17
- package/src/commands/install.js +69 -38
- package/src/index.js +1 -0
package/bin/buildflow.js
CHANGED
|
@@ -11,6 +11,7 @@ const pkg = JSON.parse(readFileSync(join(__dirname, '../package.json'), 'utf8'))
|
|
|
11
11
|
const loadInit = () => import('../src/commands/init.js')
|
|
12
12
|
const loadInstall = () => import('../src/commands/install.js')
|
|
13
13
|
const loadAudit = () => import('../src/commands/audit.js')
|
|
14
|
+
const loadFix = () => import('../src/commands/fix.js')
|
|
14
15
|
const loadStatus = () => import('../src/commands/status.js')
|
|
15
16
|
const loadUpdate = () => import('../src/commands/update.js')
|
|
16
17
|
|
|
@@ -54,6 +55,15 @@ program
|
|
|
54
55
|
await run(opts)
|
|
55
56
|
})
|
|
56
57
|
|
|
58
|
+
program
|
|
59
|
+
.command('fix')
|
|
60
|
+
.description('Scan for issues and fix them interactively')
|
|
61
|
+
.option('-t, --target <path>', 'Scan specific file or directory')
|
|
62
|
+
.action(async (opts) => {
|
|
63
|
+
const { run } = await loadFix()
|
|
64
|
+
await run(opts)
|
|
65
|
+
})
|
|
66
|
+
|
|
57
67
|
program
|
|
58
68
|
.command('status')
|
|
59
69
|
.description('Show BuildFlow status for current project')
|
package/package.json
CHANGED
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
import chalk from 'chalk'
|
|
2
|
+
import ora from 'ora'
|
|
3
|
+
import enquirer from 'enquirer'
|
|
4
|
+
import { existsSync, readFileSync, writeFileSync, readdirSync, statSync, mkdirSync } from 'fs'
|
|
5
|
+
import { join, relative, resolve } from 'path'
|
|
6
|
+
import { execSync } from 'child_process'
|
|
7
|
+
|
|
8
|
+
const { prompt } = enquirer
|
|
9
|
+
|
|
10
|
+
// ─── Scan patterns (same source as audit.js) ─────────────────────────────────
|
|
11
|
+
|
|
12
|
+
const SECRET_PATTERNS = [
|
|
13
|
+
{ pattern: /(?<![a-zA-Z])(sk|pk|rk)[-_][a-zA-Z0-9]{20,}/g, label: 'API Key (sk/pk/rk)' },
|
|
14
|
+
{ pattern: /AKIA[0-9A-Z]{16}/g, label: 'AWS Access Key' },
|
|
15
|
+
{ pattern: /-----BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY/g, label: 'Private Key' },
|
|
16
|
+
{ pattern: /['"]\s*(password|passwd|pwd|secret|api.?key|auth.?token)\s*['"]?\s*[:=]\s*['"][^'"]{4,}/gi, label: 'Hardcoded credential' },
|
|
17
|
+
{ pattern: /postgres:\/\/[^@]+:[^@]+@/g, label: 'DB URL with credentials' },
|
|
18
|
+
{ pattern: /mongodb(\+srv)?:\/\/[^@]+:[^@]+@/g, label: 'MongoDB URL with credentials' },
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
const VULN_PATTERNS = [
|
|
22
|
+
{
|
|
23
|
+
pattern: /\.query\s*\(\s*[`'"]\s*SELECT.*?\$\{|\.query\s*\(\s*["'`].*?\+\s*\w/gs,
|
|
24
|
+
label: 'Possible SQL Injection',
|
|
25
|
+
severity: 'CRITICAL',
|
|
26
|
+
owasp: 'A03',
|
|
27
|
+
autoFix: null,
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
pattern: /eval\s*\(/g,
|
|
31
|
+
label: 'eval() usage',
|
|
32
|
+
severity: 'HIGH',
|
|
33
|
+
owasp: 'A03',
|
|
34
|
+
autoFix: null,
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
pattern: /exec\s*\(\s*[`'"]\s*.*?\$\{|execSync\s*\(\s*[`'"]\s*.*?\$\{/g,
|
|
38
|
+
label: 'Command injection risk',
|
|
39
|
+
severity: 'CRITICAL',
|
|
40
|
+
owasp: 'A03',
|
|
41
|
+
autoFix: null,
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
pattern: /Math\.random\s*\(\)/g,
|
|
45
|
+
label: 'Math.random() used for tokens (not cryptographically secure)',
|
|
46
|
+
severity: 'HIGH',
|
|
47
|
+
owasp: 'A07',
|
|
48
|
+
autoFix: {
|
|
49
|
+
description: 'Replace Math.random() with crypto.randomUUID() or crypto.getRandomValues()',
|
|
50
|
+
apply: (content) => content.replace(/Math\.random\s*\(\)/g, 'crypto.randomUUID()'),
|
|
51
|
+
note: 'Replaced Math.random() with crypto.randomUUID(). Review usage — randomUUID() returns a string, not a float.',
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
pattern: /console\.log\s*\([^)]*(?:password|token|secret|key|user)[^)]*\)/gi,
|
|
56
|
+
label: 'Sensitive data may be logged',
|
|
57
|
+
severity: 'MEDIUM',
|
|
58
|
+
owasp: 'A09',
|
|
59
|
+
autoFix: null,
|
|
60
|
+
},
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
const CODE_EXTENSIONS = new Set(['.js', '.ts', '.jsx', '.tsx', '.mjs', '.cjs', '.py', '.go', '.rs', '.java'])
|
|
64
|
+
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next', '__pycache__', 'coverage'])
|
|
65
|
+
|
|
66
|
+
function* walkFiles(dir) {
|
|
67
|
+
for (const entry of readdirSync(dir)) {
|
|
68
|
+
if (SKIP_DIRS.has(entry)) continue
|
|
69
|
+
const full = join(dir, entry)
|
|
70
|
+
if (statSync(full).isDirectory()) {
|
|
71
|
+
yield* walkFiles(full)
|
|
72
|
+
} else if (CODE_EXTENSIONS.has(entry.slice(entry.lastIndexOf('.')))) {
|
|
73
|
+
yield full
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function scanFile(filePath) {
|
|
79
|
+
let content
|
|
80
|
+
try { content = readFileSync(filePath, 'utf8') } catch { return [] }
|
|
81
|
+
|
|
82
|
+
const isTestFile = /\.(test|spec)\.[jt]sx?$/.test(filePath) ||
|
|
83
|
+
filePath.includes('__tests__') ||
|
|
84
|
+
filePath.includes('fixtures')
|
|
85
|
+
|
|
86
|
+
const findings = []
|
|
87
|
+
const lines = content.split('\n')
|
|
88
|
+
|
|
89
|
+
if (!isTestFile) {
|
|
90
|
+
for (const { pattern, label } of SECRET_PATTERNS) {
|
|
91
|
+
for (let i = 0; i < lines.length; i++) {
|
|
92
|
+
pattern.lastIndex = 0
|
|
93
|
+
if (pattern.test(lines[i])) {
|
|
94
|
+
findings.push({
|
|
95
|
+
type: 'SECRET',
|
|
96
|
+
severity: 'CRITICAL',
|
|
97
|
+
label,
|
|
98
|
+
file: filePath,
|
|
99
|
+
line: i + 1,
|
|
100
|
+
snippet: lines[i].trim().slice(0, 80),
|
|
101
|
+
autoFix: null,
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
for (const { pattern, label, severity, owasp, autoFix } of VULN_PATTERNS) {
|
|
109
|
+
for (let i = 0; i < lines.length; i++) {
|
|
110
|
+
pattern.lastIndex = 0
|
|
111
|
+
if (pattern.test(lines[i])) {
|
|
112
|
+
findings.push({
|
|
113
|
+
type: 'VULN',
|
|
114
|
+
severity,
|
|
115
|
+
label,
|
|
116
|
+
owasp,
|
|
117
|
+
file: filePath,
|
|
118
|
+
line: i + 1,
|
|
119
|
+
snippet: lines[i].trim().slice(0, 80),
|
|
120
|
+
autoFix,
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return findings
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ─── Config / structural checks ───────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
function checkConfigIssues(cwd) {
|
|
132
|
+
const issues = []
|
|
133
|
+
|
|
134
|
+
// .env not in .gitignore
|
|
135
|
+
if (existsSync(join(cwd, '.env'))) {
|
|
136
|
+
const gitignore = existsSync(join(cwd, '.gitignore'))
|
|
137
|
+
? readFileSync(join(cwd, '.gitignore'), 'utf8')
|
|
138
|
+
: ''
|
|
139
|
+
if (!gitignore.includes('.env')) {
|
|
140
|
+
issues.push({
|
|
141
|
+
type: 'CONFIG',
|
|
142
|
+
severity: 'CRITICAL',
|
|
143
|
+
label: '.env file is not in .gitignore — secrets could be committed',
|
|
144
|
+
file: join(cwd, '.gitignore'),
|
|
145
|
+
autoFix: {
|
|
146
|
+
description: 'Add .env to .gitignore',
|
|
147
|
+
apply: () => {
|
|
148
|
+
const existing = existsSync(join(cwd, '.gitignore'))
|
|
149
|
+
? readFileSync(join(cwd, '.gitignore'), 'utf8')
|
|
150
|
+
: ''
|
|
151
|
+
writeFileSync(join(cwd, '.gitignore'), existing + '\n.env\n.env.local\n.env.*.local\n')
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
})
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Missing package-lock.json in a Node project
|
|
159
|
+
if (existsSync(join(cwd, 'package.json')) && !existsSync(join(cwd, 'package-lock.json')) && !existsSync(join(cwd, 'yarn.lock')) && !existsSync(join(cwd, 'pnpm-lock.yaml'))) {
|
|
160
|
+
issues.push({
|
|
161
|
+
type: 'CONFIG',
|
|
162
|
+
severity: 'MEDIUM',
|
|
163
|
+
label: 'No lockfile found (package-lock.json / yarn.lock / pnpm-lock.yaml) — dependency versions not pinned',
|
|
164
|
+
file: join(cwd, 'package.json'),
|
|
165
|
+
autoFix: {
|
|
166
|
+
description: 'Run npm install to generate package-lock.json',
|
|
167
|
+
apply: () => execSync('npm install', { cwd, stdio: 'ignore' }),
|
|
168
|
+
},
|
|
169
|
+
})
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return issues
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ─── Fix application ──────────────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
function applyFileFix(filePath, autoFix) {
|
|
178
|
+
const content = readFileSync(filePath, 'utf8')
|
|
179
|
+
const fixed = autoFix.apply(content)
|
|
180
|
+
writeFileSync(filePath, fixed)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ─── Severity colour helper ───────────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
function severityColor(s) {
|
|
186
|
+
if (s === 'CRITICAL') return chalk.red
|
|
187
|
+
if (s === 'HIGH') return chalk.yellow
|
|
188
|
+
return chalk.hex('#FFA500')
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function severityIcon(s) {
|
|
192
|
+
if (s === 'CRITICAL') return '🔴'
|
|
193
|
+
if (s === 'HIGH') return '🟡'
|
|
194
|
+
return '🟠'
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ─── Main ─────────────────────────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
export async function run(opts = {}) {
|
|
200
|
+
const cwd = process.cwd()
|
|
201
|
+
|
|
202
|
+
console.log('\n' + chalk.bold.white(' BuildFlow — Fix Mode') + '\n')
|
|
203
|
+
console.log(chalk.dim(' Scans for issues, auto-fixes safe ones, asks about the rest.\n'))
|
|
204
|
+
|
|
205
|
+
// 1. Scan
|
|
206
|
+
const spinner = ora('Scanning project...').start()
|
|
207
|
+
const target = opts.target ? resolve(opts.target) : cwd
|
|
208
|
+
|
|
209
|
+
const allFindings = []
|
|
210
|
+
let fileCount = 0
|
|
211
|
+
|
|
212
|
+
for (const filePath of walkFiles(target)) {
|
|
213
|
+
fileCount++
|
|
214
|
+
allFindings.push(...scanFile(filePath))
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
allFindings.push(...checkConfigIssues(cwd))
|
|
218
|
+
spinner.stop()
|
|
219
|
+
|
|
220
|
+
if (allFindings.length === 0) {
|
|
221
|
+
console.log(chalk.green(' ✓ No issues found. Nothing to fix.\n'))
|
|
222
|
+
return
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Sort: CRITICAL first, then HIGH, then MEDIUM
|
|
226
|
+
const order = { CRITICAL: 0, HIGH: 1, MEDIUM: 2 }
|
|
227
|
+
allFindings.sort((a, b) => (order[a.severity] ?? 3) - (order[b.severity] ?? 3))
|
|
228
|
+
|
|
229
|
+
console.log(chalk.dim(` Found ${allFindings.length} issue(s) across ${fileCount} files.\n`))
|
|
230
|
+
|
|
231
|
+
// 2. Separate auto-fixable from prompt-required
|
|
232
|
+
const autoFixable = allFindings.filter(f => f.autoFix !== null)
|
|
233
|
+
const needsPrompt = allFindings.filter(f => f.autoFix === null)
|
|
234
|
+
|
|
235
|
+
// ── Auto-fixes ──────────────────────────────────────────────────────────────
|
|
236
|
+
if (autoFixable.length > 0) {
|
|
237
|
+
console.log(chalk.bold(' Auto-fixable issues:\n'))
|
|
238
|
+
|
|
239
|
+
for (const f of autoFixable) {
|
|
240
|
+
const color = severityColor(f.severity)
|
|
241
|
+
const icon = severityIcon(f.severity)
|
|
242
|
+
const rel = f.file ? relative(cwd, f.file) : ''
|
|
243
|
+
|
|
244
|
+
console.log(color(` ${icon} [${f.severity}] ${f.label}`))
|
|
245
|
+
if (rel) console.log(chalk.dim(` File: ${rel}${f.line ? `:${f.line}` : ''}`))
|
|
246
|
+
console.log(chalk.dim(` Fix: ${f.autoFix.description}`))
|
|
247
|
+
console.log('')
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const { confirmAuto } = await prompt({
|
|
251
|
+
type: 'confirm',
|
|
252
|
+
name: 'confirmAuto',
|
|
253
|
+
message: `Apply ${autoFixable.length} auto-fix(es) now?`,
|
|
254
|
+
initial: true,
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
if (confirmAuto) {
|
|
258
|
+
for (const f of autoFixable) {
|
|
259
|
+
const sp = ora(chalk.dim(` Fixing: ${f.label}...`)).start()
|
|
260
|
+
try {
|
|
261
|
+
if (f.type === 'VULN' && f.file && f.autoFix.apply.length === 1) {
|
|
262
|
+
// File-content fix
|
|
263
|
+
applyFileFix(f.file, f.autoFix)
|
|
264
|
+
} else {
|
|
265
|
+
// Side-effect fix (gitignore, npm install, etc.)
|
|
266
|
+
f.autoFix.apply()
|
|
267
|
+
}
|
|
268
|
+
sp.succeed(chalk.green(` ✓ Fixed: ${f.label}`))
|
|
269
|
+
if (f.autoFix.note) console.log(chalk.dim(` Note: ${f.autoFix.note}`))
|
|
270
|
+
} catch (err) {
|
|
271
|
+
sp.fail(chalk.red(` ✗ Failed: ${f.label} — ${err.message}`))
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
console.log('')
|
|
275
|
+
} else {
|
|
276
|
+
console.log(chalk.dim(' Skipped auto-fixes.\n'))
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ── Prompt-required issues ───────────────────────────────────────────────────
|
|
281
|
+
if (needsPrompt.length > 0) {
|
|
282
|
+
console.log(chalk.bold(` ${needsPrompt.length} issue(s) require your decision:\n`))
|
|
283
|
+
|
|
284
|
+
let fixed = 0
|
|
285
|
+
let skipped = 0
|
|
286
|
+
|
|
287
|
+
for (let i = 0; i < needsPrompt.length; i++) {
|
|
288
|
+
const f = needsPrompt[i]
|
|
289
|
+
const color = severityColor(f.severity)
|
|
290
|
+
const icon = severityIcon(f.severity)
|
|
291
|
+
const rel = f.file ? relative(cwd, f.file) : ''
|
|
292
|
+
|
|
293
|
+
console.log(chalk.dim(` [${i + 1}/${needsPrompt.length}]`))
|
|
294
|
+
console.log(color(` ${icon} [${f.severity}] ${f.label}`))
|
|
295
|
+
if (rel) console.log(chalk.dim(` File: ${rel}${f.line ? `:${f.line}` : ''}`))
|
|
296
|
+
if (f.snippet) console.log(chalk.dim(` Code: ${f.snippet}`))
|
|
297
|
+
if (f.owasp) console.log(chalk.dim(` OWASP: ${f.owasp}`))
|
|
298
|
+
console.log('')
|
|
299
|
+
|
|
300
|
+
const { action } = await prompt({
|
|
301
|
+
type: 'select',
|
|
302
|
+
name: 'action',
|
|
303
|
+
message: 'What do you want to do?',
|
|
304
|
+
choices: [
|
|
305
|
+
{ name: 'skip', message: 'Skip — leave as-is for now' },
|
|
306
|
+
{ name: 'debt', message: 'Log to security debt — track but do not fix now' },
|
|
307
|
+
{ name: 'open', message: 'Open in editor — I will fix it manually' },
|
|
308
|
+
{ name: 'stop', message: 'Stop — exit fix mode' },
|
|
309
|
+
],
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
if (action === 'stop') {
|
|
313
|
+
console.log(chalk.dim(`\n Stopped at issue ${i + 1}/${needsPrompt.length}.\n`))
|
|
314
|
+
break
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (action === 'debt') {
|
|
318
|
+
logSecurityDebt(cwd, f)
|
|
319
|
+
console.log(chalk.dim(' Logged to .buildflow/security/DEBT.md\n'))
|
|
320
|
+
fixed++
|
|
321
|
+
} else if (action === 'open') {
|
|
322
|
+
openInEditor(f.file, f.line)
|
|
323
|
+
console.log(chalk.dim(' Opened in editor. Fix and save, then re-run: buildflow fix\n'))
|
|
324
|
+
skipped++
|
|
325
|
+
} else {
|
|
326
|
+
skipped++
|
|
327
|
+
console.log('')
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
console.log(chalk.bold('\n Summary:'))
|
|
332
|
+
console.log(chalk.dim(` Logged to debt: ${fixed}`))
|
|
333
|
+
console.log(chalk.dim(` Skipped/manual: ${skipped}`))
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
console.log(chalk.dim('\n Re-run `buildflow fix` anytime to re-check.\n'))
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
340
|
+
|
|
341
|
+
function logSecurityDebt(cwd, finding) {
|
|
342
|
+
const debtPath = join(cwd, '.buildflow', 'security', 'DEBT.md')
|
|
343
|
+
if (!existsSync(debtPath)) return
|
|
344
|
+
|
|
345
|
+
const existing = readFileSync(debtPath, 'utf8')
|
|
346
|
+
const today = new Date().toISOString().split('T')[0]
|
|
347
|
+
const rel = finding.file ? relative(cwd, finding.file) : 'unknown'
|
|
348
|
+
const entry = `\n### ${today} — [${finding.severity}] ${finding.label}\n- File: ${rel}${finding.line ? `:${finding.line}` : ''}\n- OWASP: ${finding.owasp ?? 'N/A'}\n- Status: Deferred\n`
|
|
349
|
+
|
|
350
|
+
// Insert under "## Active" section
|
|
351
|
+
if (existing.includes('## Active')) {
|
|
352
|
+
writeFileSync(debtPath, existing.replace('## Active', `## Active\n${entry}`))
|
|
353
|
+
} else {
|
|
354
|
+
writeFileSync(debtPath, existing + '\n' + entry)
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function openInEditor(filePath, line) {
|
|
359
|
+
if (!filePath) return
|
|
360
|
+
const editor = process.env.EDITOR || process.env.VISUAL || 'code'
|
|
361
|
+
try {
|
|
362
|
+
const target = line ? `${filePath}:${line}` : filePath
|
|
363
|
+
// VS Code and many editors support file:line syntax
|
|
364
|
+
execSync(`${editor} "${target}"`, { stdio: 'ignore' })
|
|
365
|
+
} catch {
|
|
366
|
+
console.log(chalk.dim(` Path: ${filePath}${line ? `:${line}` : ''}`))
|
|
367
|
+
}
|
|
368
|
+
}
|