guardvibe 3.0.0 → 3.0.3
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 +19 -23
- package/build/cli/audit.d.ts +5 -0
- package/build/cli/audit.js +34 -0
- package/build/cli/scan.js +4 -5
- package/build/cli.js +5 -0
- package/build/data/compliance-metadata.js +104 -0
- package/build/data/rules/advanced-security.js +130 -0
- package/build/data/rules/core.js +13 -0
- package/build/data/rules/modern-stack.js +63 -0
- package/build/data/rules/nextjs.js +13 -0
- package/build/index.js +93 -3
- package/build/tools/auth-coverage.d.ts +46 -0
- package/build/tools/auth-coverage.js +261 -0
- package/build/tools/check-code.js +9 -1
- package/build/tools/check-project.js +9 -1
- package/build/tools/cross-file-taint.d.ts +4 -0
- package/build/tools/cross-file-taint.js +146 -3
- package/build/tools/deep-scan.d.ts +33 -0
- package/build/tools/deep-scan.js +169 -0
- package/build/tools/full-audit.d.ts +81 -0
- package/build/tools/full-audit.js +365 -0
- package/build/tools/scan-directory.js +21 -4
- package/build/tools/taint-analysis.js +28 -7
- package/build/utils/walk-directory.d.ts +2 -1
- package/build/utils/walk-directory.js +10 -2
- package/package.json +2 -2
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Full Audit — single source of truth for AI assistants.
|
|
3
|
+
* Orchestrates all security tools in one call, produces:
|
|
4
|
+
* - PASS/FAIL/WARN verdict
|
|
5
|
+
* - Unified report across code, secrets, deps, config, taint, auth
|
|
6
|
+
* - Deterministic result hash (same code = same hash)
|
|
7
|
+
* - Coverage metrics (files scanned, rules applied, %)
|
|
8
|
+
*/
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
import { resolve } from "node:path";
|
|
11
|
+
import { readdirSync, readFileSync, statSync, existsSync } from "node:fs";
|
|
12
|
+
import { scanDirectory } from "./scan-directory.js";
|
|
13
|
+
import { scanSecrets } from "./scan-secrets.js";
|
|
14
|
+
import { scanDependencies } from "./scan-dependencies.js";
|
|
15
|
+
import { auditConfig } from "./audit-config.js";
|
|
16
|
+
import { analyzeCrossFileTaint } from "./cross-file-taint.js";
|
|
17
|
+
import { analyzeAuthCoverage } from "./auth-coverage.js";
|
|
18
|
+
import { getRules } from "../utils/rule-registry.js";
|
|
19
|
+
// --- Core Logic ---
|
|
20
|
+
/**
|
|
21
|
+
* Compute verdict: PASS (0 critical + 0 high), WARN (high > 0), FAIL (critical > 0)
|
|
22
|
+
*/
|
|
23
|
+
export function computeVerdict(critical, high, _medium) {
|
|
24
|
+
if (critical > 0)
|
|
25
|
+
return "FAIL";
|
|
26
|
+
if (high > 0)
|
|
27
|
+
return "WARN";
|
|
28
|
+
return "PASS";
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Compute coverage metrics from scan results.
|
|
32
|
+
*/
|
|
33
|
+
export function computeCoverage(filesScanned, filesSkipped, rulesApplied) {
|
|
34
|
+
const totalFiles = filesScanned + filesSkipped;
|
|
35
|
+
const coveragePercent = totalFiles > 0 ? Math.round((filesScanned / totalFiles) * 100) : 0;
|
|
36
|
+
return { filesScanned, filesSkipped, totalFiles, coveragePercent, rulesApplied };
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Compute deterministic SHA256 hash of findings.
|
|
40
|
+
* Same findings (in any order) = same hash.
|
|
41
|
+
*/
|
|
42
|
+
export function computeResultHash(findings) {
|
|
43
|
+
const normalized = findings
|
|
44
|
+
.map(f => `${f.ruleId}:${f.severity}:${f.file}:${f.line}`)
|
|
45
|
+
.sort()
|
|
46
|
+
.join("|");
|
|
47
|
+
return createHash("sha256").update(normalized).digest("hex").substring(0, 16);
|
|
48
|
+
}
|
|
49
|
+
// --- Orchestrator ---
|
|
50
|
+
function safeJsonParse(str) {
|
|
51
|
+
try {
|
|
52
|
+
return JSON.parse(str);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function parseSectionCounts(parsed) {
|
|
59
|
+
const s = parsed?.summary ?? {};
|
|
60
|
+
return {
|
|
61
|
+
findings: s.total ?? 0,
|
|
62
|
+
critical: s.critical ?? 0,
|
|
63
|
+
high: s.high ?? 0,
|
|
64
|
+
medium: s.medium ?? 0,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function collectJsFiles(dir, maxFiles = 200) {
|
|
68
|
+
const files = [];
|
|
69
|
+
const skip = new Set(["node_modules", ".git", ".next", "build", "dist", ".turbo", "coverage"]);
|
|
70
|
+
function walk(d) {
|
|
71
|
+
if (files.length >= maxFiles)
|
|
72
|
+
return;
|
|
73
|
+
let entries;
|
|
74
|
+
try {
|
|
75
|
+
entries = readdirSync(d);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
for (const entry of entries) {
|
|
81
|
+
if (files.length >= maxFiles)
|
|
82
|
+
return;
|
|
83
|
+
if (skip.has(entry))
|
|
84
|
+
continue;
|
|
85
|
+
const full = resolve(d, entry);
|
|
86
|
+
let stat;
|
|
87
|
+
try {
|
|
88
|
+
stat = statSync(full);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (stat.isDirectory()) {
|
|
94
|
+
walk(full);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (!/\.(ts|tsx|js|jsx|mts|cts)$/.test(entry))
|
|
98
|
+
continue;
|
|
99
|
+
if (stat.size > 100_000)
|
|
100
|
+
continue;
|
|
101
|
+
try {
|
|
102
|
+
const content = readFileSync(full, "utf-8");
|
|
103
|
+
const relPath = full.replace(resolve(dir) + "/", "");
|
|
104
|
+
files.push({ path: relPath, content });
|
|
105
|
+
}
|
|
106
|
+
catch { /* skip unreadable */ }
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
walk(resolve(dir));
|
|
110
|
+
return files;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Run a full security audit — single source of truth.
|
|
114
|
+
* Orchestrates code scan, secret scan, dependency scan, config audit,
|
|
115
|
+
* taint analysis, and auth coverage in one call.
|
|
116
|
+
*/
|
|
117
|
+
export async function runFullAudit(path, options) {
|
|
118
|
+
const projectRoot = resolve(path);
|
|
119
|
+
const rules = getRules();
|
|
120
|
+
const allFindings = [];
|
|
121
|
+
const sections = [];
|
|
122
|
+
let filesScanned = 0;
|
|
123
|
+
let filesSkipped = 0;
|
|
124
|
+
let score = 100;
|
|
125
|
+
let grade = "A";
|
|
126
|
+
// Truncation tracking
|
|
127
|
+
let scanTruncated = false;
|
|
128
|
+
let scanTotalFindings = 0;
|
|
129
|
+
let scanMaxFindings = 50; // MAX_JSON_FINDINGS from scan-directory
|
|
130
|
+
let taintFilesProcessed = 0;
|
|
131
|
+
const taintFileCap = 200;
|
|
132
|
+
// --- Section 1: Code scan ---
|
|
133
|
+
try {
|
|
134
|
+
const codeJson = scanDirectory(projectRoot, true, [], "json", rules.length > 0 ? rules : undefined);
|
|
135
|
+
const parsed = safeJsonParse(codeJson);
|
|
136
|
+
if (parsed) {
|
|
137
|
+
const counts = parseSectionCounts(parsed);
|
|
138
|
+
filesScanned = parsed.metadata?.filesScanned ?? 0;
|
|
139
|
+
filesSkipped = parsed.metadata?.filesSkipped ?? 0;
|
|
140
|
+
score = parsed.summary?.score ?? 100;
|
|
141
|
+
grade = parsed.summary?.grade ?? "A";
|
|
142
|
+
sections.push({ name: "code", status: "ok", ...counts, details: `Grade ${grade} (${score}/100)` });
|
|
143
|
+
for (const f of parsed.findings ?? []) {
|
|
144
|
+
allFindings.push({ ruleId: f.id ?? "unknown", severity: f.severity, file: f.file ?? "", line: f.line ?? 0 });
|
|
145
|
+
}
|
|
146
|
+
if (parsed?.summary?.truncated) {
|
|
147
|
+
scanTruncated = true;
|
|
148
|
+
scanTotalFindings = parsed.summary.total ?? 0;
|
|
149
|
+
scanMaxFindings = parsed.summary.showing ?? 50;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
sections.push({ name: "code", status: "error", findings: 0, critical: 0, high: 0, medium: 0, details: "Scan error" });
|
|
155
|
+
}
|
|
156
|
+
// --- Section 2: Secrets ---
|
|
157
|
+
if (!options?.skipSecrets) {
|
|
158
|
+
try {
|
|
159
|
+
const secretsJson = scanSecrets(projectRoot, true, "json");
|
|
160
|
+
const parsed = safeJsonParse(secretsJson);
|
|
161
|
+
if (parsed) {
|
|
162
|
+
const counts = parseSectionCounts(parsed);
|
|
163
|
+
sections.push({ name: "secrets", status: "ok", ...counts, details: counts.findings === 0 ? "No secrets found" : `${counts.findings} secret(s) detected` });
|
|
164
|
+
for (const f of parsed.findings ?? []) {
|
|
165
|
+
allFindings.push({ ruleId: `SECRET:${f.provider ?? "unknown"}`, severity: f.severity, file: f.file ?? "", line: f.line ?? 0 });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
sections.push({ name: "secrets", status: "error", findings: 0, critical: 0, high: 0, medium: 0, details: "Scan error" });
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
// --- Section 3: Dependencies ---
|
|
174
|
+
if (!options?.skipDeps) {
|
|
175
|
+
const manifestPath = resolve(projectRoot, "package.json");
|
|
176
|
+
if (existsSync(manifestPath)) {
|
|
177
|
+
try {
|
|
178
|
+
const depsJson = await scanDependencies(manifestPath, "json");
|
|
179
|
+
const parsed = safeJsonParse(depsJson);
|
|
180
|
+
if (parsed) {
|
|
181
|
+
const vuln = parsed.summary?.vulnerable ?? 0;
|
|
182
|
+
const counts = { findings: vuln, critical: parsed.summary?.critical ?? 0, high: parsed.summary?.high ?? 0, medium: parsed.summary?.medium ?? 0 };
|
|
183
|
+
sections.push({ name: "dependencies", status: "ok", ...counts, details: vuln === 0 ? "No known CVEs" : `${vuln} vulnerable package(s)` });
|
|
184
|
+
for (const pkg of parsed.packages ?? []) {
|
|
185
|
+
for (const v of pkg.vulnerabilities ?? []) {
|
|
186
|
+
allFindings.push({ ruleId: `DEP:${v.id ?? "CVE"}`, severity: v.severity, file: "package.json", line: 0 });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
sections.push({ name: "dependencies", status: "error", findings: 0, critical: 0, high: 0, medium: 0, details: "Scan error" });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
196
|
+
sections.push({ name: "dependencies", status: "skipped", findings: 0, critical: 0, high: 0, medium: 0, details: "No package.json found" });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
// --- Section 4: Config audit ---
|
|
200
|
+
try {
|
|
201
|
+
const configJson = auditConfig(projectRoot, "json");
|
|
202
|
+
const parsed = safeJsonParse(configJson);
|
|
203
|
+
if (parsed) {
|
|
204
|
+
const counts = parseSectionCounts(parsed);
|
|
205
|
+
sections.push({ name: "config", status: "ok", ...counts, details: counts.findings === 0 ? "Config secure" : `${counts.findings} config issue(s)` });
|
|
206
|
+
for (const f of parsed.findings ?? []) {
|
|
207
|
+
allFindings.push({ ruleId: f.id ?? f.ruleId ?? "CONFIG", severity: f.severity ?? "medium", file: f.file ?? "", line: f.line ?? 0 });
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
sections.push({ name: "config", status: "error", findings: 0, critical: 0, high: 0, medium: 0, details: "No configs found" });
|
|
213
|
+
}
|
|
214
|
+
// --- Section 5: Taint analysis ---
|
|
215
|
+
try {
|
|
216
|
+
const jsFiles = collectJsFiles(projectRoot);
|
|
217
|
+
taintFilesProcessed = jsFiles.length;
|
|
218
|
+
if (jsFiles.length > 0) {
|
|
219
|
+
const { crossFileFindings, perFileFindings } = analyzeCrossFileTaint(jsFiles);
|
|
220
|
+
const perFileCount = Array.from(perFileFindings.values()).reduce((sum, f) => sum + f.length, 0);
|
|
221
|
+
const taintTotal = crossFileFindings.length + perFileCount;
|
|
222
|
+
const taintCritical = crossFileFindings.filter(f => f.severity === "critical").length;
|
|
223
|
+
const taintHigh = crossFileFindings.filter(f => f.severity === "high").length;
|
|
224
|
+
const taintMedium = taintTotal - taintCritical - taintHigh;
|
|
225
|
+
sections.push({ name: "taint", status: "ok", findings: taintTotal, critical: taintCritical, high: taintHigh, medium: taintMedium,
|
|
226
|
+
details: taintTotal === 0 ? "No tainted data flows" : `${taintTotal} tainted flow(s)` });
|
|
227
|
+
for (const f of crossFileFindings) {
|
|
228
|
+
allFindings.push({ ruleId: `TAINT:${f.sink.type}`, severity: f.severity, file: f.source.file, line: f.source.line });
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
sections.push({ name: "taint", status: "error", findings: 0, critical: 0, high: 0, medium: 0, details: "Analysis error" });
|
|
234
|
+
}
|
|
235
|
+
// --- Section 6: Auth coverage ---
|
|
236
|
+
try {
|
|
237
|
+
const jsFiles = collectJsFiles(projectRoot);
|
|
238
|
+
const routeFiles = jsFiles.filter(f => /\/(route|page)\.(ts|tsx|js|jsx)$/.test(f.path));
|
|
239
|
+
const layoutFiles = jsFiles.filter(f => /\/layout\.(ts|tsx|js|jsx)$/.test(f.path));
|
|
240
|
+
if (routeFiles.length > 0) {
|
|
241
|
+
const middlewareFile = jsFiles.find(f => /middleware\.(ts|js)$/.test(f.path));
|
|
242
|
+
const report = analyzeAuthCoverage(routeFiles, middlewareFile?.content ?? "", layoutFiles);
|
|
243
|
+
const unprotected = report.unprotectedRoutes;
|
|
244
|
+
sections.push({ name: "auth-coverage", status: "ok", findings: unprotected, critical: 0, high: unprotected > 0 ? unprotected : 0, medium: 0,
|
|
245
|
+
details: `${report.protectedRoutes}/${report.totalRoutes} routes protected (${report.middlewareCoveragePercent}% middleware)` });
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
catch { /* auth coverage is optional */ }
|
|
249
|
+
// --- Compute totals ---
|
|
250
|
+
const totalCritical = sections.reduce((s, sec) => s + sec.critical, 0);
|
|
251
|
+
const totalHigh = sections.reduce((s, sec) => s + sec.high, 0);
|
|
252
|
+
const totalMedium = sections.reduce((s, sec) => s + sec.medium, 0);
|
|
253
|
+
const totalFindings = sections.reduce((s, sec) => s + sec.findings, 0);
|
|
254
|
+
const rulesApplied = rules.length > 0 ? rules.length : 334;
|
|
255
|
+
const verdict = computeVerdict(totalCritical, totalHigh, totalMedium);
|
|
256
|
+
const coverage = computeCoverage(filesScanned, filesSkipped, rulesApplied);
|
|
257
|
+
const resultHash = computeResultHash(allFindings);
|
|
258
|
+
// Action items
|
|
259
|
+
const actionItems = [];
|
|
260
|
+
if (totalCritical > 0)
|
|
261
|
+
actionItems.push(`Fix ${totalCritical} critical finding(s) immediately`);
|
|
262
|
+
if (totalHigh > 0)
|
|
263
|
+
actionItems.push(`Address ${totalHigh} high severity finding(s)`);
|
|
264
|
+
const secretSection = sections.find(s => s.name === "secrets");
|
|
265
|
+
if (secretSection && secretSection.findings > 0)
|
|
266
|
+
actionItems.push("Rotate exposed secrets and add to .gitignore");
|
|
267
|
+
const depSection = sections.find(s => s.name === "dependencies");
|
|
268
|
+
if (depSection && depSection.findings > 0)
|
|
269
|
+
actionItems.push("Update vulnerable dependencies");
|
|
270
|
+
const authSection = sections.find(s => s.name === "auth-coverage");
|
|
271
|
+
if (authSection && authSection.findings > 0)
|
|
272
|
+
actionItems.push(`Add auth guards to ${authSection.findings} unprotected route(s)`);
|
|
273
|
+
if (verdict === "PASS")
|
|
274
|
+
actionItems.push("No action required — project verified secure");
|
|
275
|
+
return {
|
|
276
|
+
verdict,
|
|
277
|
+
score,
|
|
278
|
+
grade,
|
|
279
|
+
coverage,
|
|
280
|
+
resultHash,
|
|
281
|
+
timestamp: new Date().toISOString(),
|
|
282
|
+
sections,
|
|
283
|
+
truncation: {
|
|
284
|
+
truncated: scanTruncated || taintFilesProcessed >= taintFileCap,
|
|
285
|
+
maxFindings: scanMaxFindings,
|
|
286
|
+
totalFindings: scanTotalFindings || totalFindings,
|
|
287
|
+
taintFileCap,
|
|
288
|
+
taintFilesProcessed,
|
|
289
|
+
},
|
|
290
|
+
summary: { totalFindings, critical: totalCritical, high: totalHigh, medium: totalMedium },
|
|
291
|
+
actionItems,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
// --- Formatter ---
|
|
295
|
+
/**
|
|
296
|
+
* Format audit result as markdown or JSON.
|
|
297
|
+
*/
|
|
298
|
+
export function formatAuditResult(result, format) {
|
|
299
|
+
if (format === "json") {
|
|
300
|
+
return JSON.stringify(result);
|
|
301
|
+
}
|
|
302
|
+
const verdictLabel = {
|
|
303
|
+
PASS: "PASS — Project verified secure",
|
|
304
|
+
WARN: "WARN — High severity issues found",
|
|
305
|
+
FAIL: "FAIL — Critical security issues detected",
|
|
306
|
+
};
|
|
307
|
+
const lines = [
|
|
308
|
+
`# GuardVibe Full Audit Report`,
|
|
309
|
+
``,
|
|
310
|
+
`## Verdict: ${result.verdict}`,
|
|
311
|
+
``,
|
|
312
|
+
`**${verdictLabel[result.verdict]}**`,
|
|
313
|
+
``,
|
|
314
|
+
`| Metric | Value |`,
|
|
315
|
+
`|--------|-------|`,
|
|
316
|
+
`| Score | ${result.grade} (${result.score}/100) |`,
|
|
317
|
+
`| Total findings | ${result.summary.totalFindings} |`,
|
|
318
|
+
`| Critical | ${result.summary.critical} |`,
|
|
319
|
+
`| High | ${result.summary.high} |`,
|
|
320
|
+
`| Medium | ${result.summary.medium} |`,
|
|
321
|
+
`| Result hash | \`${result.resultHash}\` |`,
|
|
322
|
+
``,
|
|
323
|
+
`## Coverage`,
|
|
324
|
+
``,
|
|
325
|
+
`| Metric | Value |`,
|
|
326
|
+
`|--------|-------|`,
|
|
327
|
+
`| Files scanned | ${result.coverage.filesScanned} |`,
|
|
328
|
+
`| Files skipped | ${result.coverage.filesSkipped} |`,
|
|
329
|
+
`| Coverage | ${result.coverage.coveragePercent}% |`,
|
|
330
|
+
`| Rules applied | ${result.coverage.rulesApplied} |`,
|
|
331
|
+
``,
|
|
332
|
+
`## Sections`,
|
|
333
|
+
``,
|
|
334
|
+
`| Section | Status | Findings | Critical | High | Medium | Details |`,
|
|
335
|
+
`|---------|--------|----------|----------|------|--------|---------|`,
|
|
336
|
+
];
|
|
337
|
+
const statusIcon = { ok: "ok", error: "ERROR", skipped: "skipped" };
|
|
338
|
+
for (const s of result.sections) {
|
|
339
|
+
lines.push(`| ${s.name} | ${statusIcon[s.status] ?? s.status} | ${s.findings} | ${s.critical} | ${s.high} | ${s.medium} | ${s.details} |`);
|
|
340
|
+
}
|
|
341
|
+
if (result.truncation.truncated) {
|
|
342
|
+
lines.push(``);
|
|
343
|
+
lines.push(`## Truncation Notice`);
|
|
344
|
+
lines.push(``);
|
|
345
|
+
if (result.truncation.totalFindings > result.truncation.maxFindings) {
|
|
346
|
+
lines.push(`- Code scan: showing ${result.truncation.maxFindings} of ${result.truncation.totalFindings} findings (sorted by severity)`);
|
|
347
|
+
}
|
|
348
|
+
if (result.truncation.taintFilesProcessed >= result.truncation.taintFileCap) {
|
|
349
|
+
lines.push(`- Taint analysis: capped at ${result.truncation.taintFileCap} files (${result.truncation.taintFilesProcessed} found)`);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
if (result.actionItems.length > 0) {
|
|
353
|
+
lines.push(``);
|
|
354
|
+
lines.push(`## Action Items`);
|
|
355
|
+
lines.push(``);
|
|
356
|
+
for (const item of result.actionItems) {
|
|
357
|
+
lines.push(`- ${item}`);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
lines.push(``);
|
|
361
|
+
lines.push(`---`);
|
|
362
|
+
lines.push(`Timestamp: ${result.timestamp}`);
|
|
363
|
+
lines.push(`Result hash: \`${result.resultHash}\` (same code + same GuardVibe version = same hash)`);
|
|
364
|
+
return lines.join("\n");
|
|
365
|
+
}
|
|
@@ -49,11 +49,13 @@ export function scanDirectory(path, recursive = true, exclude = [], format = "ma
|
|
|
49
49
|
const excludes = new Set([...DEFAULT_EXCLUDES, ...exclude, ...config.scan.exclude]);
|
|
50
50
|
const maxSize = config.scan.maxFileSize;
|
|
51
51
|
const filePaths = [];
|
|
52
|
-
|
|
52
|
+
const unsupportedFiles = [];
|
|
53
|
+
walkDirectory(scanRoot, recursive, excludes, filePaths, unsupportedFiles);
|
|
53
54
|
const scanResults = [];
|
|
54
55
|
const skippedFiles = [];
|
|
55
56
|
const fileHashes = {};
|
|
56
57
|
const effectiveRules = rules ?? [];
|
|
58
|
+
const unsupportedTypeCount = unsupportedFiles.length;
|
|
57
59
|
for (const filePath of filePaths) {
|
|
58
60
|
try {
|
|
59
61
|
const stat = statSync(filePath);
|
|
@@ -145,7 +147,12 @@ export function scanDirectory(path, recursive = true, exclude = [], format = "ma
|
|
|
145
147
|
low: allFindings.filter(f => f.rule.severity === "low").length,
|
|
146
148
|
blocked: totalCritical > 0 || totalHigh > 0,
|
|
147
149
|
grade, score,
|
|
148
|
-
...(truncated ? { truncated: true, showing: MAX_JSON_FINDINGS, message: `Showing top ${MAX_JSON_FINDINGS} of ${allFindings.length} findings (sorted by severity). Use scan_file on individual files for full details.` } : {}),
|
|
150
|
+
...(truncated ? { truncated: true, showing: MAX_JSON_FINDINGS, totalBeforeTruncation: allFindings.length, message: `Showing top ${MAX_JSON_FINDINGS} of ${allFindings.length} findings (sorted by severity). Use scan_file on individual files for full details.` } : {}),
|
|
151
|
+
filesSkippedReasons: {
|
|
152
|
+
tooLarge: skippedFiles.filter(r => r.includes("too large")).length,
|
|
153
|
+
readError: skippedFiles.filter(r => r.includes("read error")).length,
|
|
154
|
+
unsupportedType: unsupportedTypeCount,
|
|
155
|
+
},
|
|
149
156
|
},
|
|
150
157
|
metadata,
|
|
151
158
|
findings: limitedFindings.map(f => ({
|
|
@@ -255,8 +262,18 @@ export function scanDirectory(path, recursive = true, exclude = [], format = "ma
|
|
|
255
262
|
else {
|
|
256
263
|
lines.push(`## No Issues Found`, ``, `All files passed security checks.`);
|
|
257
264
|
}
|
|
258
|
-
|
|
259
|
-
|
|
265
|
+
const totalSkipped = skippedFiles.length + unsupportedTypeCount;
|
|
266
|
+
if (totalSkipped > 0) {
|
|
267
|
+
const parts = [];
|
|
268
|
+
const tooLargeCount = skippedFiles.filter(r => r.includes("too large")).length;
|
|
269
|
+
const readErrorCount = skippedFiles.filter(r => r.includes("read error")).length;
|
|
270
|
+
if (tooLargeCount > 0)
|
|
271
|
+
parts.push(`${tooLargeCount} too large (>${Math.round(maxSize / 1024)}KB)`);
|
|
272
|
+
if (readErrorCount > 0)
|
|
273
|
+
parts.push(`${readErrorCount} read error`);
|
|
274
|
+
if (unsupportedTypeCount > 0)
|
|
275
|
+
parts.push(`${unsupportedTypeCount} unsupported type`);
|
|
276
|
+
lines.push(``, `**${totalSkipped} files skipped:** ${parts.join(", ")}`);
|
|
260
277
|
}
|
|
261
278
|
// ── Priority Summary Table (always at the end, visible in terminal) ──
|
|
262
279
|
if (totalIssues > 0) {
|
|
@@ -42,6 +42,19 @@ const TAINT_SINKS = [
|
|
|
42
42
|
description: "User input flows into file read path, enabling directory traversal and sensitive file access.",
|
|
43
43
|
fix: "Validate file paths against an allowlist. Use path.resolve() and check prefix." },
|
|
44
44
|
];
|
|
45
|
+
// Known sanitizers that neutralize taint
|
|
46
|
+
const SANITIZERS = [
|
|
47
|
+
/DOMPurify\.sanitize\s*\(/,
|
|
48
|
+
/escapeHtml\s*\(/,
|
|
49
|
+
/encodeURIComponent\s*\(/,
|
|
50
|
+
/encodeURI\s*\(/,
|
|
51
|
+
/parseInt\s*\(/,
|
|
52
|
+
/Number\s*\(/,
|
|
53
|
+
/parseFloat\s*\(/,
|
|
54
|
+
/validator\.escape\s*\(/,
|
|
55
|
+
/sanitizeHtml\s*\(/,
|
|
56
|
+
/xss\s*\(/,
|
|
57
|
+
];
|
|
45
58
|
function extractAssignments(lines) {
|
|
46
59
|
const assignments = [];
|
|
47
60
|
const assignPattern = /(?:const|let|var)\s+([\w]+)\s*=\s*(.*)/;
|
|
@@ -51,14 +64,18 @@ function extractAssignments(lines) {
|
|
|
51
64
|
continue;
|
|
52
65
|
const varName = match[1];
|
|
53
66
|
const value = match[2];
|
|
67
|
+
// Check if value is wrapped in a known sanitizer — if so, it's not tainted
|
|
68
|
+
const isSanitized = SANITIZERS.some(s => s.test(value));
|
|
54
69
|
let tainted = false;
|
|
55
70
|
let sourceType;
|
|
56
|
-
|
|
57
|
-
source
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
71
|
+
if (!isSanitized) {
|
|
72
|
+
for (const source of TAINT_SOURCES) {
|
|
73
|
+
source.pattern.lastIndex = 0;
|
|
74
|
+
if (source.pattern.test(value)) {
|
|
75
|
+
tainted = true;
|
|
76
|
+
sourceType = source.type;
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
62
79
|
}
|
|
63
80
|
}
|
|
64
81
|
assignments.push({ name: varName, line: i + 1, tainted, sourceType });
|
|
@@ -68,7 +85,7 @@ function extractAssignments(lines) {
|
|
|
68
85
|
function propagateTaint(assignments, lines) {
|
|
69
86
|
let changed = true;
|
|
70
87
|
let iterations = 0;
|
|
71
|
-
while (changed && iterations <
|
|
88
|
+
while (changed && iterations < 25) {
|
|
72
89
|
changed = false;
|
|
73
90
|
iterations++;
|
|
74
91
|
const taintedNames = new Set(assignments.filter(a => a.tainted).map(a => a.name));
|
|
@@ -76,6 +93,10 @@ function propagateTaint(assignments, lines) {
|
|
|
76
93
|
if (assignment.tainted)
|
|
77
94
|
continue;
|
|
78
95
|
const lineContent = lines[assignment.line - 1] ?? "";
|
|
96
|
+
// Skip propagation if the value is wrapped in a sanitizer
|
|
97
|
+
const isSanitized = SANITIZERS.some(s => s.test(lineContent));
|
|
98
|
+
if (isSanitized)
|
|
99
|
+
continue;
|
|
79
100
|
for (const name of taintedNames) {
|
|
80
101
|
if (lineContent.includes(name) && name !== assignment.name) {
|
|
81
102
|
assignment.tainted = true;
|
|
@@ -10,5 +10,6 @@
|
|
|
10
10
|
* @param recursive - Whether to descend into subdirectories
|
|
11
11
|
* @param excludes - Set of directory names to skip
|
|
12
12
|
* @param results - Accumulator array (mutated in place)
|
|
13
|
+
* @param unsupportedResults - Optional accumulator for files with unsupported types
|
|
13
14
|
*/
|
|
14
|
-
export declare function walkDirectory(dir: string, recursive: boolean, excludes: Set<string>, results: string[]): void;
|
|
15
|
+
export declare function walkDirectory(dir: string, recursive: boolean, excludes: Set<string>, results: string[], unsupportedResults?: string[]): void;
|
|
@@ -13,8 +13,9 @@ import { EXTENSION_MAP, CONFIG_FILE_MAP } from "./constants.js";
|
|
|
13
13
|
* @param recursive - Whether to descend into subdirectories
|
|
14
14
|
* @param excludes - Set of directory names to skip
|
|
15
15
|
* @param results - Accumulator array (mutated in place)
|
|
16
|
+
* @param unsupportedResults - Optional accumulator for files with unsupported types
|
|
16
17
|
*/
|
|
17
|
-
export function walkDirectory(dir, recursive, excludes, results) {
|
|
18
|
+
export function walkDirectory(dir, recursive, excludes, results, unsupportedResults) {
|
|
18
19
|
let entries;
|
|
19
20
|
try {
|
|
20
21
|
entries = readdirSync(dir, { withFileTypes: true });
|
|
@@ -27,19 +28,26 @@ export function walkDirectory(dir, recursive, excludes, results) {
|
|
|
27
28
|
continue;
|
|
28
29
|
const fullPath = join(dir, entry.name);
|
|
29
30
|
if (entry.isDirectory() && recursive) {
|
|
30
|
-
walkDirectory(fullPath, recursive, excludes, results);
|
|
31
|
+
walkDirectory(fullPath, recursive, excludes, results, unsupportedResults);
|
|
31
32
|
}
|
|
32
33
|
else if (entry.isFile()) {
|
|
33
34
|
const ext = extname(entry.name).toLowerCase();
|
|
35
|
+
let matched = false;
|
|
34
36
|
if (EXTENSION_MAP[ext]) {
|
|
35
37
|
results.push(fullPath);
|
|
38
|
+
matched = true;
|
|
36
39
|
}
|
|
37
40
|
if (entry.name.startsWith("Dockerfile") || entry.name.endsWith(".dockerfile")) {
|
|
38
41
|
if (!results.includes(fullPath))
|
|
39
42
|
results.push(fullPath);
|
|
43
|
+
matched = true;
|
|
40
44
|
}
|
|
41
45
|
if (CONFIG_FILE_MAP[entry.name] && !results.includes(fullPath)) {
|
|
42
46
|
results.push(fullPath);
|
|
47
|
+
matched = true;
|
|
48
|
+
}
|
|
49
|
+
if (!matched && unsupportedResults) {
|
|
50
|
+
unsupportedResults.push(fullPath);
|
|
43
51
|
}
|
|
44
52
|
}
|
|
45
53
|
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "guardvibe",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.3",
|
|
4
4
|
"mcpName": "io.github.goklab/guardvibe",
|
|
5
|
-
"description": "Security MCP for vibe coding. 334 rules,
|
|
5
|
+
"description": "Security MCP for vibe coding. 334 rules, 34 tools, CLI + doctor. Host security, auth coverage mapping, LLM-powered deep scan (IDOR/business logic), taint analysis. Plus Next.js, Supabase, Clerk, Stripe, Prisma, tRPC, Hono, GraphQL, Convex, Turso, Uploadthing, AI SDK, and the full AI-generated stack.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
8
8
|
"guardvibe": "build/cli.js",
|