@vibefoundry/vibe-shield 1.1.0 → 2.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/README.md +74 -48
- package/SECURITY.md +1 -1
- package/SKILL.md +27 -94
- package/dist/bin/vibe-shield.d.ts +1 -1
- package/dist/bin/vibe-shield.js +572 -541
- package/dist/src/init/scaffold.d.ts +29 -31
- package/dist/src/init/scaffold.js +69 -153
- package/dist/src/mcp/server.js +6 -2
- package/dist/src/mcp-guard/skill-auditor.js +11 -3
- package/dist/src/reporter/console-reporter.js +17 -3
- package/dist/src/reporter/executive.d.ts +4 -0
- package/dist/src/reporter/executive.js +58 -0
- package/dist/src/reporter/formatters.d.ts +1 -1
- package/dist/src/reporter/formatters.js +10 -3
- package/dist/src/reporter/patch-prompt-writer.js +5 -5
- package/dist/src/scanner/attack-surface.d.ts +4 -0
- package/dist/src/scanner/attack-surface.js +50 -0
- package/dist/src/scanner/engine.d.ts +2 -3
- package/dist/src/scanner/engine.js +21 -35
- package/dist/src/scanner/mappings.d.ts +17 -0
- package/dist/src/scanner/mappings.js +39 -0
- package/dist/src/scanner/redact.d.ts +1 -0
- package/dist/src/scanner/redact.js +12 -0
- package/dist/src/scanner/rules/database-exposure.js +1 -1
- package/dist/src/scanner/rules/hardening.d.ts +5 -0
- package/dist/src/scanner/rules/hardening.js +116 -0
- package/dist/src/scanner/rules/helpers.js +2 -0
- package/dist/src/scanner/rules/index.js +6 -0
- package/dist/src/scanner/rules/response-hardening.d.ts +2 -0
- package/dist/src/scanner/rules/response-hardening.js +120 -0
- package/dist/src/scanner/rules/secret-entropy.js +0 -2
- package/dist/src/scanner/rules/shared.d.ts +1 -1
- package/dist/src/scanner/rules/shared.js +1 -1
- package/dist/src/scanner/types.d.ts +33 -1
- package/dist/src/scanner/types.js +6 -2
- package/dist/src/scanner/version.d.ts +2 -0
- package/dist/src/scanner/version.js +69 -0
- package/dist/src/secrets/patterns.js +40 -0
- package/package.json +99 -96
|
@@ -59,6 +59,9 @@ const path = __importStar(require("node:path"));
|
|
|
59
59
|
const patterns_1 = require("../secrets/patterns");
|
|
60
60
|
const context_1 = require("../scanner/context");
|
|
61
61
|
const gitignore_1 = require("../scanner/gitignore");
|
|
62
|
+
const attack_surface_1 = require("../scanner/attack-surface");
|
|
63
|
+
const version_1 = require("../scanner/version");
|
|
64
|
+
const redact_1 = require("../scanner/redact");
|
|
62
65
|
const helpers_1 = require("../scanner/rules/helpers");
|
|
63
66
|
const shared_1 = require("../scanner/rules/shared");
|
|
64
67
|
const types_1 = require("../scanner/types");
|
|
@@ -1031,6 +1034,7 @@ async function auditSkills(rootDir, options = {}) {
|
|
|
1031
1034
|
// auditor's own discovery rules so `audit-skills .claude` works as expected.
|
|
1032
1035
|
const files = options.paths?.length ? await resolveAuditPaths(rootDir, options.paths) : await discoverAgentFiles(rootDir);
|
|
1033
1036
|
const findings = [];
|
|
1037
|
+
const attackSurface = (0, attack_surface_1.emptyAttackSurface)();
|
|
1034
1038
|
const scanned = [];
|
|
1035
1039
|
let bytesScanned = 0;
|
|
1036
1040
|
for (const absolutePath of files) {
|
|
@@ -1042,6 +1046,7 @@ async function auditSkills(rootDir, options = {}) {
|
|
|
1042
1046
|
continue;
|
|
1043
1047
|
}
|
|
1044
1048
|
const ctx = (0, context_1.createFileContext)(absolutePath, rootDir, content, project);
|
|
1049
|
+
(0, attack_surface_1.inventoryFile)(ctx, attackSurface);
|
|
1045
1050
|
if (ctx.flags.isLockfile || ctx.flags.isGenerated)
|
|
1046
1051
|
continue;
|
|
1047
1052
|
scanned.push(ctx.relativePath);
|
|
@@ -1069,12 +1074,14 @@ async function auditSkills(rootDir, options = {}) {
|
|
|
1069
1074
|
severity: hit.severity ?? rule.severity,
|
|
1070
1075
|
confidence: hit.confidence ?? rule.confidence,
|
|
1071
1076
|
cwe: rule.cwe,
|
|
1077
|
+
owasp: rule.owasp,
|
|
1072
1078
|
filePath: ctx.relativePath,
|
|
1079
|
+
file: ctx.relativePath,
|
|
1073
1080
|
absolutePath: ctx.absolutePath,
|
|
1074
1081
|
line: located.line,
|
|
1075
1082
|
column: located.column,
|
|
1076
|
-
snippet: ctx.lineText(located.line).trim().slice(0, 200),
|
|
1077
|
-
matched: hit.matched ?? ctx.lineText(located.line).trim().slice(0, 200),
|
|
1083
|
+
snippet: (0, redact_1.redactReportText)(ctx.lineText(located.line).trim()).slice(0, 200),
|
|
1084
|
+
matched: (0, redact_1.redactReportText)(hit.matched ?? ctx.lineText(located.line).trim()).slice(0, 200),
|
|
1078
1085
|
message: hit.message ?? rule.description,
|
|
1079
1086
|
remediation: rule.remediate ? rule.remediate(ctx, hit) : rule.remediation,
|
|
1080
1087
|
insecureExample: rule.insecureExample,
|
|
@@ -1101,6 +1108,7 @@ async function auditSkills(rootDir, options = {}) {
|
|
|
1101
1108
|
return {
|
|
1102
1109
|
rootDir,
|
|
1103
1110
|
command: 'audit-skills',
|
|
1111
|
+
attackSurface,
|
|
1104
1112
|
findings: unique,
|
|
1105
1113
|
score: scoreFindings(unique),
|
|
1106
1114
|
stats: {
|
|
@@ -1118,7 +1126,7 @@ async function auditSkills(rootDir, options = {}) {
|
|
|
1118
1126
|
scannedPaths: scanned,
|
|
1119
1127
|
startedAt,
|
|
1120
1128
|
finishedAt: new Date(finished).toISOString(),
|
|
1121
|
-
version: options.version ??
|
|
1129
|
+
version: options.version ?? await (0, version_1.packageVersion)(),
|
|
1122
1130
|
ruleCount: rules.length,
|
|
1123
1131
|
suppressedCount: 0,
|
|
1124
1132
|
notices: scanned.length ? [] : ['No agent instruction or MCP configuration files were found.'],
|
|
@@ -216,9 +216,9 @@ function renderFooter(result, color) {
|
|
|
216
216
|
if (critical > 0) {
|
|
217
217
|
lines.push(color.red(` ⚠ ${critical} critical finding${critical === 1 ? '' : 's'} — rotate any exposed credential before anything else.`));
|
|
218
218
|
}
|
|
219
|
-
lines.push(` ${color.bold('Fix it')} npx
|
|
220
|
-
lines.push(` ${color.bold('Explain')} npx
|
|
221
|
-
lines.push(` ${color.bold('Gate CI')} npx
|
|
219
|
+
lines.push(` ${color.bold('Fix it')} npx -y @vibefoundry/vibe-shield scan --fix-prompt ${color.dim('# writes SECURITY_PATCH_PROMPT.md for your agent')}`);
|
|
220
|
+
lines.push(` ${color.bold('Explain')} npx -y @vibefoundry/vibe-shield explain ${result.findings[0]?.ruleId ?? 'VSB-AI-001'}`);
|
|
221
|
+
lines.push(` ${color.bold('Gate CI')} npx -y @vibefoundry/vibe-shield scan --fail-on ${worst} --format github`);
|
|
222
222
|
for (const notice of result.notices)
|
|
223
223
|
lines.push(color.dim(` ${notice}`));
|
|
224
224
|
return lines;
|
|
@@ -235,6 +235,20 @@ function renderConsoleReport(result, options = {}) {
|
|
|
235
235
|
lines.push(...bars);
|
|
236
236
|
}
|
|
237
237
|
lines.push(...renderCategories(result, color));
|
|
238
|
+
if (result.attackSurface) {
|
|
239
|
+
const surface = result.attackSurface;
|
|
240
|
+
lines.push('', color.bold('Attack surface (static signals; review required)'));
|
|
241
|
+
for (const route of surface.routes)
|
|
242
|
+
lines.push(` route ${route.method} ${route.path} — ${route.file}:${route.line} (${route.auth})`);
|
|
243
|
+
for (const table of surface.tables)
|
|
244
|
+
lines.push(` table ${table.table} — ${table.file}:${table.line} (${table.rls})`);
|
|
245
|
+
for (const ai of surface.aiEntrypoints)
|
|
246
|
+
lines.push(` AI entrypoint ${ai.file}:${ai.line}`);
|
|
247
|
+
for (const agent of surface.agentFiles)
|
|
248
|
+
lines.push(` ${agent.kind} ${agent.name ?? ''} — ${agent.file}`);
|
|
249
|
+
if (!surface.routes.length && !surface.tables.length && !surface.aiEntrypoints.length && !surface.agentFiles.length)
|
|
250
|
+
lines.push(' No inventory signals observed.');
|
|
251
|
+
}
|
|
238
252
|
if (result.findings.length) {
|
|
239
253
|
lines.push('');
|
|
240
254
|
lines.push(color.bold('Findings'));
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.formatMarkdown = formatMarkdown;
|
|
4
|
+
exports.formatHtml = formatHtml;
|
|
5
|
+
const mappings_1 = require("../scanner/mappings");
|
|
6
|
+
const engine_1 = require("../scanner/engine");
|
|
7
|
+
const types_1 = require("../scanner/types");
|
|
8
|
+
const clean = (s) => s.replace(/[\r\n]+/g, ' ').replace(/\|/g, '\\|');
|
|
9
|
+
const fence = (s) => {
|
|
10
|
+
const marker = '`'.repeat(Math.max(3, ...Array.from(s.matchAll(/`+/g), m => m[0].length + 1)));
|
|
11
|
+
return `${marker}\n${s}\n${marker}`;
|
|
12
|
+
};
|
|
13
|
+
// The CWE mapping is based on OWASP's published 2025 category lists.
|
|
14
|
+
function mappings(ruleId, cwe) {
|
|
15
|
+
const mapping = (0, mappings_1.owaspMappings)(ruleId, cwe);
|
|
16
|
+
const web = mapping.web ? `${mapping.web.id} ${mapping.web.name} (${mapping.web.basis})` : 'not mapped';
|
|
17
|
+
const llm = mapping.llm.length ? mapping.llm.map(m => `${m.id} ${m.name} (contextual)`).join(', ') : 'not applicable or not assigned';
|
|
18
|
+
return `OWASP Top 10 2025: ${web}; OWASP LLM Top 10 2025: ${llm}. Validate applicability in context.`;
|
|
19
|
+
}
|
|
20
|
+
function formatMarkdown(result) {
|
|
21
|
+
const lines = ['# Executive Security Report', '', `**Score: ${result.score.score}/100 (${result.score.grade})** · ${result.stats.filesScanned} files scanned · ${result.command}`, '',
|
|
22
|
+
'Static signals require manual validation. A clean score is not proof of security. Ignored, unavailable, and unscanned files are outside this report.', '',
|
|
23
|
+
'## Category scorecard', '', '| Vector | Score | Findings |', '| --- | --- | ---: |'];
|
|
24
|
+
for (const category of types_1.RULE_CATEGORIES) {
|
|
25
|
+
const subset = result.findings.filter(f => f.category === category);
|
|
26
|
+
const applicable = result.command === 'audit-skills' ? category === 'agent-integrity' : category !== 'agent-integrity';
|
|
27
|
+
lines.push(`| ${types_1.CATEGORY_TITLES[category]} | ${applicable ? `${(0, engine_1.scoreFindings)(subset).score}/100` : 'Not assessed'} | ${subset.length} |`);
|
|
28
|
+
}
|
|
29
|
+
lines.push('', '## Attack-surface inventory', '', 'Observed signals only; guard and RLS signals do not prove enforcement. Absence of a guard signal is not proof a route is unguarded.', '', '| Type | Location | Detail | Signal |', '| --- | --- | --- | --- |');
|
|
30
|
+
const surface = result.attackSurface;
|
|
31
|
+
if (surface) {
|
|
32
|
+
for (const r of surface.routes)
|
|
33
|
+
lines.push(`| Route | ${clean(r.file)}:${r.line} | ${clean(r.method)} ${clean(r.path)} | ${r.auth} |`);
|
|
34
|
+
for (const t of surface.tables)
|
|
35
|
+
lines.push(`| Table | ${clean(t.file)}:${t.line} | ${clean(t.table)} | ${t.rls} |`);
|
|
36
|
+
for (const a of surface.aiEntrypoints)
|
|
37
|
+
lines.push(`| AI entrypoint | ${clean(a.file)}:${a.line} | LLM reference | review |`);
|
|
38
|
+
for (const a of surface.agentFiles)
|
|
39
|
+
lines.push(`| Agent | ${clean(a.file)} | ${a.kind}${a.name ? ': ' + clean(a.name) : ''} | review |`);
|
|
40
|
+
}
|
|
41
|
+
else
|
|
42
|
+
lines.push('| — | — | Inventory not available for this command | — |');
|
|
43
|
+
lines.push('', '## Prioritized findings', '');
|
|
44
|
+
const rank = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
|
|
45
|
+
for (const f of [...result.findings].sort((a, b) => (rank[a.severity] ?? 5) - (rank[b.severity] ?? 5))) {
|
|
46
|
+
lines.push(`### ${clean(f.severity.toUpperCase())}: ${clean(f.title)}`, '', `**${f.ruleId} · ${clean(f.filePath)}:${f.line}:${f.column} · ${f.cwe.id} · confidence ${f.confidence}**`, '', f.message, '', mappings(f.ruleId, f.cwe.id), '', '**Observed code**', '', fence(f.snippet), '', '**Suggested remediation**', '', ...f.remediation.map(x => `- ${x}`), '', '**Illustrative before / after (adapt and validate; not an automatic patch)**', '', fence(f.insecureExample), '', fence(f.secureExample), '');
|
|
47
|
+
}
|
|
48
|
+
if (!result.findings.length)
|
|
49
|
+
lines.push('No findings reported for the selected rules and files.', '');
|
|
50
|
+
return lines.join('\n') + '\n';
|
|
51
|
+
}
|
|
52
|
+
const escapeHtml = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
53
|
+
/** All project content is escaped. The only script is a fixed local filter. */
|
|
54
|
+
function formatHtml(result) {
|
|
55
|
+
const md = formatMarkdown(result);
|
|
56
|
+
const entries = result.findings.map(f => `<article data-severity="${escapeHtml(f.severity)}"><h3>${escapeHtml(f.severity.toUpperCase() + ': ' + f.title)}</h3><p>${escapeHtml(`${f.filePath}:${f.line}:${f.column} · ${f.ruleId} · ${f.cwe.id}`)}</p><pre>${escapeHtml(f.snippet)}</pre><p>${escapeHtml(f.message)}</p><ul>${f.remediation.map(s => `<li>${escapeHtml(s)}</li>`).join('')}</ul></article>`).join('\n');
|
|
57
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Executive Security Report</title><style>body{background:#101722;color:#e8effa;font:16px system-ui;max-width:1000px;margin:auto;padding:2rem}pre{white-space:pre-wrap;overflow-wrap:anywhere;background:#1b2738;padding:1rem;border-radius:6px}article{border:1px solid #34455b;padding:1rem;margin:1rem 0;border-radius:8px}select{background:#1b2738;color:white;padding:.5rem}</style></head><body><h1>Executive Security Report</h1><label>Filter findings by severity <select id="severity"><option value="all">All</option><option>critical</option><option>high</option><option>medium</option><option>low</option><option>info</option></select></label><section>${entries}</section><details open><summary>Full report</summary><pre>${escapeHtml(md)}</pre></details><script>document.getElementById('severity').addEventListener('change',function(){document.querySelectorAll('article[data-severity]').forEach(function(e){e.hidden=this.value!=='all'&&e.dataset.severity!==this.value},this)})</script></body></html>\n`;
|
|
58
|
+
}
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* - `github` GitHub Actions workflow commands (annotations in the diff)
|
|
12
12
|
*/
|
|
13
13
|
import type { Finding, ScanResult } from '../scanner/types';
|
|
14
|
-
export declare const OUTPUT_FORMATS: readonly ["console", "compact", "summary", "json", "sarif", "github"];
|
|
14
|
+
export declare const OUTPUT_FORMATS: readonly ["console", "compact", "summary", "json", "sarif", "github", "markdown", "html"];
|
|
15
15
|
export type OutputFormat = (typeof OUTPUT_FORMATS)[number];
|
|
16
16
|
export declare const isOutputFormat: (value: unknown) => value is OutputFormat;
|
|
17
17
|
/** Bump when the JSON shape changes in a breaking way. */
|
|
@@ -20,7 +20,9 @@ exports.formatGithub = formatGithub;
|
|
|
20
20
|
exports.formatSarif = formatSarif;
|
|
21
21
|
exports.formatResult = formatResult;
|
|
22
22
|
exports.groupByFile = groupByFile;
|
|
23
|
-
|
|
23
|
+
const node_url_1 = require("node:url");
|
|
24
|
+
const executive_1 = require("./executive");
|
|
25
|
+
exports.OUTPUT_FORMATS = ['console', 'compact', 'summary', 'json', 'sarif', 'github', 'markdown', 'html'];
|
|
24
26
|
const isOutputFormat = (value) => typeof value === 'string' && exports.OUTPUT_FORMATS.includes(value);
|
|
25
27
|
exports.isOutputFormat = isOutputFormat;
|
|
26
28
|
/** Bump when the JSON shape changes in a breaking way. */
|
|
@@ -193,7 +195,7 @@ function formatSarif(result) {
|
|
|
193
195
|
},
|
|
194
196
|
],
|
|
195
197
|
originalUriBaseIds: {
|
|
196
|
-
'%SRCROOT%': { uri:
|
|
198
|
+
'%SRCROOT%': { uri: (0, node_url_1.pathToFileURL)(result.rootDir.endsWith('/') ? result.rootDir : result.rootDir + '/').href },
|
|
197
199
|
},
|
|
198
200
|
results: result.findings.map((finding) => ({
|
|
199
201
|
ruleId: finding.ruleId,
|
|
@@ -203,7 +205,7 @@ function formatSarif(result) {
|
|
|
203
205
|
locations: [
|
|
204
206
|
{
|
|
205
207
|
physicalLocation: {
|
|
206
|
-
artifactLocation: { uri: finding.filePath, uriBaseId: '%SRCROOT%' },
|
|
208
|
+
artifactLocation: { uri: finding.filePath.split('/').map(encodeURIComponent).join('/'), uriBaseId: '%SRCROOT%' },
|
|
207
209
|
region: {
|
|
208
210
|
startLine: finding.line,
|
|
209
211
|
startColumn: finding.column,
|
|
@@ -221,6 +223,7 @@ function formatSarif(result) {
|
|
|
221
223
|
severity: finding.severity,
|
|
222
224
|
confidence: finding.confidence,
|
|
223
225
|
cwe: finding.cwe.id,
|
|
226
|
+
owasp: finding.owasp,
|
|
224
227
|
remediation: finding.remediation,
|
|
225
228
|
secureExample: finding.secureExample,
|
|
226
229
|
},
|
|
@@ -236,6 +239,10 @@ function formatSarif(result) {
|
|
|
236
239
|
/** Render `result` in the requested format. */
|
|
237
240
|
function formatResult(result, format) {
|
|
238
241
|
switch (format) {
|
|
242
|
+
case 'markdown':
|
|
243
|
+
return (0, executive_1.formatMarkdown)(result);
|
|
244
|
+
case 'html':
|
|
245
|
+
return (0, executive_1.formatHtml)(result);
|
|
239
246
|
case 'json':
|
|
240
247
|
return formatJson(result);
|
|
241
248
|
case 'summary':
|
|
@@ -87,12 +87,12 @@ function buildPatchPrompt(result, options = {}) {
|
|
|
87
87
|
lines.push('# Security patch prompt');
|
|
88
88
|
lines.push('');
|
|
89
89
|
lines.push('> Generated by [vibe-shield](https://github.com/avivgerz-hue/vibe-shield). Hand this file to your coding agent (Claude Code, Codex, Cursor, Windsurf).');
|
|
90
|
-
lines.push('>
|
|
90
|
+
lines.push('> Findings are static signals with source locations; validate each in context before making changes.');
|
|
91
91
|
lines.push('');
|
|
92
92
|
lines.push('## Mission');
|
|
93
93
|
lines.push('');
|
|
94
94
|
lines.push(`Fix the ${selected.length} security finding${selected.length === 1 ? '' : 's'} listed below in the repository at \`${result.rootDir}\`. ` +
|
|
95
|
-
'
|
|
95
|
+
'Review each finding, adapt the guidance to the project, then re-run the scanner and relevant tests.');
|
|
96
96
|
lines.push('');
|
|
97
97
|
lines.push('## Scanner verdict');
|
|
98
98
|
lines.push('');
|
|
@@ -119,7 +119,7 @@ function buildPatchPrompt(result, options = {}) {
|
|
|
119
119
|
lines.push('');
|
|
120
120
|
lines.push('After each group of fixes run:');
|
|
121
121
|
lines.push('');
|
|
122
|
-
lines.push(...fence('npx
|
|
122
|
+
lines.push(...fence('npx -y @vibefoundry/vibe-shield scan --fail-on high', 'bash'));
|
|
123
123
|
lines.push('');
|
|
124
124
|
lines.push('The task is complete only when this command exits `0` and every finding below is gone.');
|
|
125
125
|
lines.push('');
|
|
@@ -175,7 +175,7 @@ function buildPatchPrompt(result, options = {}) {
|
|
|
175
175
|
lines.push(`${stepIndex + 1}. ${step}`);
|
|
176
176
|
});
|
|
177
177
|
lines.push('');
|
|
178
|
-
lines.push('**
|
|
178
|
+
lines.push('**Illustrative remediation example (adapt to the project)**');
|
|
179
179
|
lines.push('');
|
|
180
180
|
lines.push(...fence(finding.secureExample, languageFor(filePath)));
|
|
181
181
|
lines.push('');
|
|
@@ -195,7 +195,7 @@ function buildPatchPrompt(result, options = {}) {
|
|
|
195
195
|
lines.push('## Final checklist');
|
|
196
196
|
lines.push('');
|
|
197
197
|
lines.push('- [ ] Every credential that appeared in this report has been rotated by the user.');
|
|
198
|
-
lines.push('- [ ] `npx
|
|
198
|
+
lines.push('- [ ] `npx -y @vibefoundry/vibe-shield scan --fail-on high` exits `0`.');
|
|
199
199
|
lines.push('- [ ] No `vibe-shield-disable` comments were added.');
|
|
200
200
|
lines.push('- [ ] The application still builds and its tests still pass.');
|
|
201
201
|
lines.push('');
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { AttackSurface, FileContext, ProjectContext } from './types';
|
|
2
|
+
export declare const emptyAttackSurface: () => AttackSurface;
|
|
3
|
+
export declare function inventoryFile(ctx: FileContext, surface: AttackSurface): void;
|
|
4
|
+
export declare function inventoryTables(surface: AttackSurface, project: ProjectContext, scannedPaths: Set<string>): void;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.emptyAttackSurface = void 0;
|
|
4
|
+
exports.inventoryFile = inventoryFile;
|
|
5
|
+
exports.inventoryTables = inventoryTables;
|
|
6
|
+
/** Heuristic inventory of observed surface, not evidence of effective protection. */
|
|
7
|
+
const lexicon_1 = require("./lexicon");
|
|
8
|
+
const emptyAttackSurface = () => ({ routes: [], tables: [], aiEntrypoints: [], agentFiles: [] });
|
|
9
|
+
exports.emptyAttackSurface = emptyAttackSurface;
|
|
10
|
+
function inventoryFile(ctx, surface) {
|
|
11
|
+
const { relativePath: file, code } = ctx;
|
|
12
|
+
if (ctx.flags.isMcpConfig) {
|
|
13
|
+
try {
|
|
14
|
+
const config = JSON.parse(ctx.content);
|
|
15
|
+
const servers = Object.keys(config.mcpServers ?? {});
|
|
16
|
+
if (servers.length)
|
|
17
|
+
for (const name of servers)
|
|
18
|
+
surface.agentFiles.push({ file, kind: 'mcp', name });
|
|
19
|
+
else
|
|
20
|
+
surface.agentFiles.push({ file, kind: 'mcp' });
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
surface.agentFiles.push({ file, kind: 'mcp' });
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
else if (ctx.flags.isAgentInstructionFile)
|
|
27
|
+
surface.agentFiles.push({ file, kind: 'skill' });
|
|
28
|
+
if (ctx.flags.mentionsLlm && (ctx.flags.isAppRouterRoute || ctx.flags.isPagesApiRoute || ctx.flags.isServerFrameworkFile)) {
|
|
29
|
+
surface.aiEntrypoints.push({ file, line: 1 });
|
|
30
|
+
}
|
|
31
|
+
const auth = lexicon_1.AUTH_VERIFICATION_REGEX.test(code) ? 'guard-signal' : 'unknown';
|
|
32
|
+
const add = (method, route, offset) => {
|
|
33
|
+
surface.routes.push({ file, line: ctx.locate(offset).line, method: method.toUpperCase(), path: route, auth });
|
|
34
|
+
};
|
|
35
|
+
if (ctx.flags.isAppRouterRoute) {
|
|
36
|
+
const route = '/' + file.replace(/^.*?app\//, '').replace(/\/route\.[^.]+$/, '').replace(/\([^/]+\)\/?/g, '');
|
|
37
|
+
for (const match of code.matchAll(/\bexport\s+(?:async\s+function|const|function)\s+(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\b/g))
|
|
38
|
+
add(match[1], route.replace(/\/$/, '') || '/', match.index);
|
|
39
|
+
}
|
|
40
|
+
else if (ctx.flags.isPagesApiRoute) {
|
|
41
|
+
add('ANY', '/' + file.replace(/^.*?pages\//, '').replace(/\.[^.]+$/, '').replace(/\/index$/, ''), 0);
|
|
42
|
+
}
|
|
43
|
+
for (const match of code.matchAll(/\b(?:app|router|server|fastify)\.(get|post|put|patch|delete|all)\s*\(\s*['"]([^'"]+)['"]/gi))
|
|
44
|
+
add(match[1], match[2], match.index);
|
|
45
|
+
}
|
|
46
|
+
function inventoryTables(surface, project, scannedPaths) {
|
|
47
|
+
surface.tables = project.sqlTables.filter(t => scannedPaths.has(t.file) && !t.internal).map(t => ({
|
|
48
|
+
file: t.file, line: t.line, table: t.table, rls: t.rlsEnabled ? 'enabled-signal' : 'not-observed',
|
|
49
|
+
}));
|
|
50
|
+
}
|
|
@@ -107,7 +107,7 @@ export declare function applySuppressions(findings: readonly Finding[], parsed:
|
|
|
107
107
|
/** Turn a finding list into a 0-100 score with an A+ → F grade. */
|
|
108
108
|
export declare function scoreFindings(findings: readonly Finding[]): SecurityScore;
|
|
109
109
|
export declare const CACHE_FILE_NAME = ".vibe-shield-cache.json";
|
|
110
|
-
export declare const CACHE_VERSION =
|
|
110
|
+
export declare const CACHE_VERSION = 2;
|
|
111
111
|
/** Content hash used as the cache key for one file. */
|
|
112
112
|
export declare const contentHash: (content: string) => string;
|
|
113
113
|
/**
|
|
@@ -115,8 +115,7 @@ export declare const contentHash: (content: string) => string;
|
|
|
115
115
|
* Exported because the MCP auditor reuses it: two scanners, one rule contract.
|
|
116
116
|
*/
|
|
117
117
|
export declare function runRulesOnFile(ctx: FileContext, rules: readonly Rule[]): RuleHit[];
|
|
118
|
-
|
|
119
|
-
export declare function packageVersion(): Promise<string>;
|
|
118
|
+
export { packageVersion } from './version';
|
|
120
119
|
/**
|
|
121
120
|
* Scan a project. This is the one function the CLI, the tests and any embedding
|
|
122
121
|
* application call; everything else in this module is an implementation detail.
|
|
@@ -47,14 +47,13 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
47
47
|
};
|
|
48
48
|
})();
|
|
49
49
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
50
|
-
exports.hasFindingsAtOrAbove = exports.contentHash = exports.CACHE_VERSION = exports.CACHE_FILE_NAME = exports.isDocPath = exports.isTestPath = exports.DEFAULT_CONCURRENCY = exports.MAX_FILE_SIZE_BYTES = exports.BINARY_EXTENSIONS = exports.DEFAULT_IGNORED_DIRS = void 0;
|
|
50
|
+
exports.hasFindingsAtOrAbove = exports.packageVersion = exports.contentHash = exports.CACHE_VERSION = exports.CACHE_FILE_NAME = exports.isDocPath = exports.isTestPath = exports.DEFAULT_CONCURRENCY = exports.MAX_FILE_SIZE_BYTES = exports.BINARY_EXTENSIONS = exports.DEFAULT_IGNORED_DIRS = void 0;
|
|
51
51
|
exports.discoverFiles = discoverFiles;
|
|
52
52
|
exports.parseSuppressions = parseSuppressions;
|
|
53
53
|
exports.suppressionFor = suppressionFor;
|
|
54
54
|
exports.applySuppressions = applySuppressions;
|
|
55
55
|
exports.scoreFindings = scoreFindings;
|
|
56
56
|
exports.runRulesOnFile = runRulesOnFile;
|
|
57
|
-
exports.packageVersion = packageVersion;
|
|
58
57
|
exports.scanProject = scanProject;
|
|
59
58
|
exports.dedupe = dedupe;
|
|
60
59
|
exports.gitTrackedPaths = gitTrackedPaths;
|
|
@@ -63,6 +62,9 @@ const node_crypto_1 = require("node:crypto");
|
|
|
63
62
|
const node_child_process_1 = require("node:child_process");
|
|
64
63
|
const path = __importStar(require("node:path"));
|
|
65
64
|
const context_1 = require("./context");
|
|
65
|
+
const version_1 = require("./version");
|
|
66
|
+
const redact_1 = require("./redact");
|
|
67
|
+
const attack_surface_1 = require("./attack-surface");
|
|
66
68
|
const gitignore_1 = require("./gitignore");
|
|
67
69
|
const rules_1 = require("./rules");
|
|
68
70
|
const types_1 = require("./types");
|
|
@@ -410,7 +412,7 @@ function scoreFindings(findings) {
|
|
|
410
412
|
/* Cache */
|
|
411
413
|
/* -------------------------------------------------------------------------- */
|
|
412
414
|
exports.CACHE_FILE_NAME = '.vibe-shield-cache.json';
|
|
413
|
-
exports.CACHE_VERSION =
|
|
415
|
+
exports.CACHE_VERSION = 2;
|
|
414
416
|
/** Content hash used as the cache key for one file. */
|
|
415
417
|
const contentHash = (content) => (0, node_crypto_1.createHash)('sha1').update(content).digest('hex').slice(0, 20);
|
|
416
418
|
exports.contentHash = contentHash;
|
|
@@ -482,7 +484,7 @@ function toFindings(ctx, rule, hits) {
|
|
|
482
484
|
const findings = [];
|
|
483
485
|
for (const hit of hits) {
|
|
484
486
|
const located = hit.offset !== undefined ? ctx.locate(hit.offset) : { line: hit.line ?? 1, column: hit.column ?? 1 };
|
|
485
|
-
const snippet = ctx.lineText(located.line).trim().slice(0, 200);
|
|
487
|
+
const snippet = ['VSB-INFRA-003', 'VSB-AUTH-012'].includes(rule.id) ? '[secret value redacted]' : (0, redact_1.redactReportText)(ctx.lineText(located.line).trim()).slice(0, 200);
|
|
486
488
|
findings.push({
|
|
487
489
|
ruleId: rule.id,
|
|
488
490
|
slug: rule.slug,
|
|
@@ -491,12 +493,14 @@ function toFindings(ctx, rule, hits) {
|
|
|
491
493
|
severity: hit.severity ?? rule.severity,
|
|
492
494
|
confidence: hit.confidence ?? rule.confidence,
|
|
493
495
|
cwe: rule.cwe,
|
|
496
|
+
owasp: rule.owasp,
|
|
494
497
|
filePath: ctx.relativePath,
|
|
498
|
+
file: ctx.relativePath,
|
|
495
499
|
absolutePath: ctx.absolutePath,
|
|
496
500
|
line: located.line,
|
|
497
501
|
column: located.column,
|
|
498
502
|
snippet,
|
|
499
|
-
matched: hit.matched ?? snippet,
|
|
503
|
+
matched: ['VSB-INFRA-003', 'VSB-AUTH-012'].includes(rule.id) ? '[secret value redacted]' : (0, redact_1.redactReportText)(hit.matched ?? snippet),
|
|
500
504
|
message: hit.message ?? rule.description,
|
|
501
505
|
remediation: rule.remediate ? rule.remediate(ctx, hit) : rule.remediation,
|
|
502
506
|
insecureExample: rule.insecureExample,
|
|
@@ -510,33 +514,8 @@ function toFindings(ctx, rule, hits) {
|
|
|
510
514
|
/* -------------------------------------------------------------------------- */
|
|
511
515
|
/* Main scan */
|
|
512
516
|
/* -------------------------------------------------------------------------- */
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
/** Package version, resolved once from the nearest package.json. */
|
|
516
|
-
async function packageVersion() {
|
|
517
|
-
if (cachedVersion)
|
|
518
|
-
return cachedVersion;
|
|
519
|
-
let dir = __dirname;
|
|
520
|
-
for (let depth = 0; depth < 6; depth += 1) {
|
|
521
|
-
try {
|
|
522
|
-
const raw = await node_fs_1.promises.readFile(path.join(dir, 'package.json'), 'utf8');
|
|
523
|
-
const parsed = JSON.parse(raw);
|
|
524
|
-
if (parsed.name === 'vibe-shield' && parsed.version) {
|
|
525
|
-
cachedVersion = parsed.version;
|
|
526
|
-
return cachedVersion;
|
|
527
|
-
}
|
|
528
|
-
}
|
|
529
|
-
catch {
|
|
530
|
-
// Keep walking up.
|
|
531
|
-
}
|
|
532
|
-
const parent = path.dirname(dir);
|
|
533
|
-
if (parent === dir)
|
|
534
|
-
break;
|
|
535
|
-
dir = parent;
|
|
536
|
-
}
|
|
537
|
-
cachedVersion = VERSION;
|
|
538
|
-
return cachedVersion;
|
|
539
|
-
}
|
|
517
|
+
var version_2 = require("./version");
|
|
518
|
+
Object.defineProperty(exports, "packageVersion", { enumerable: true, get: function () { return version_2.packageVersion; } });
|
|
540
519
|
/**
|
|
541
520
|
* Scan a project. This is the one function the CLI, the tests and any embedding
|
|
542
521
|
* application call; everything else in this module is an implementation detail.
|
|
@@ -577,6 +556,7 @@ async function scanProject(options) {
|
|
|
577
556
|
};
|
|
578
557
|
const concurrency = Math.max(1, Math.min(256, options.concurrency ?? exports.DEFAULT_CONCURRENCY));
|
|
579
558
|
const findings = [];
|
|
559
|
+
const attackSurface = (0, attack_surface_1.emptyAttackSurface)();
|
|
580
560
|
let suppressedCount = 0;
|
|
581
561
|
let cacheHits = 0;
|
|
582
562
|
let bytesScanned = 0;
|
|
@@ -595,9 +575,11 @@ async function scanProject(options) {
|
|
|
595
575
|
catch {
|
|
596
576
|
return;
|
|
597
577
|
}
|
|
578
|
+
const ctx = (0, context_1.createFileContext)(file.absolutePath, rootDir, content, project);
|
|
579
|
+
(0, attack_surface_1.inventoryFile)(ctx, attackSurface);
|
|
598
580
|
const hash = (0, exports.contentHash)(content);
|
|
599
581
|
const cached = existing?.files[file.relativePath];
|
|
600
|
-
if (cached && cached.hash === hash && existing?.project === cache.project && existing?.rules === rules_1.RULES_FINGERPRINT) {
|
|
582
|
+
if (file.relativePath !== 'package.json' && cached && cached.hash === hash && existing?.project === cache.project && existing?.rules === rules_1.RULES_FINGERPRINT) {
|
|
601
583
|
cacheHits += 1;
|
|
602
584
|
filesScanned += 1;
|
|
603
585
|
bytesScanned += file.bytes;
|
|
@@ -606,7 +588,6 @@ async function scanProject(options) {
|
|
|
606
588
|
options.onFile?.(file.relativePath, (index += 1), discovered.files.length);
|
|
607
589
|
return;
|
|
608
590
|
}
|
|
609
|
-
const ctx = (0, context_1.createFileContext)(file.absolutePath, rootDir, content, project);
|
|
610
591
|
const applicable = rules.filter((rule) => (!rule.appliesTo || rule.appliesTo(ctx)) && !rule.skipFlags?.some((flag) => ctx.flags[flag] === true));
|
|
611
592
|
ruleInvocations += applicable.length;
|
|
612
593
|
const fileFindings = [];
|
|
@@ -646,6 +627,10 @@ async function scanProject(options) {
|
|
|
646
627
|
}
|
|
647
628
|
});
|
|
648
629
|
await Promise.all(workers);
|
|
630
|
+
(0, attack_surface_1.inventoryTables)(attackSurface, project, new Set(discovered.files.map(f => f.relativePath)));
|
|
631
|
+
attackSurface.routes.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.method.localeCompare(b.method));
|
|
632
|
+
attackSurface.aiEntrypoints.sort((a, b) => a.file.localeCompare(b.file));
|
|
633
|
+
attackSurface.agentFiles.sort((a, b) => a.file.localeCompare(b.file));
|
|
649
634
|
const unique = dedupe(findings);
|
|
650
635
|
unique.sort((a, b) => types_1.SEVERITY_WEIGHT[b.severity] - types_1.SEVERITY_WEIGHT[a.severity] ||
|
|
651
636
|
a.filePath.localeCompare(b.filePath) ||
|
|
@@ -666,6 +651,7 @@ async function scanProject(options) {
|
|
|
666
651
|
return {
|
|
667
652
|
rootDir,
|
|
668
653
|
command: 'scan',
|
|
654
|
+
attackSurface,
|
|
669
655
|
findings: unique,
|
|
670
656
|
score,
|
|
671
657
|
stats: {
|
|
@@ -683,7 +669,7 @@ async function scanProject(options) {
|
|
|
683
669
|
scannedPaths: discovered.files.map((file) => file.relativePath),
|
|
684
670
|
startedAt: new Date(started).toISOString(),
|
|
685
671
|
finishedAt: new Date(finished).toISOString(),
|
|
686
|
-
version: await packageVersion(),
|
|
672
|
+
version: await (0, version_1.packageVersion)(),
|
|
687
673
|
ruleCount: rules.length,
|
|
688
674
|
suppressedCount,
|
|
689
675
|
notices,
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OWASP Top 10:2025 associations. CWE-based entries follow the "List of
|
|
3
|
+
* Mapped CWEs" in the official 2025 category pages; contextual associations
|
|
4
|
+
* are explicitly labelled and require review. LLM associations are rule-level
|
|
5
|
+
* judgements, not a claim that every finding involves an LLM.
|
|
6
|
+
*/
|
|
7
|
+
export interface OwaspMapping {
|
|
8
|
+
id: string;
|
|
9
|
+
name: string;
|
|
10
|
+
basis: 'owasp-cwe' | 'contextual';
|
|
11
|
+
}
|
|
12
|
+
export interface OwaspMappings {
|
|
13
|
+
web: OwaspMapping | null;
|
|
14
|
+
llm: OwaspMapping[];
|
|
15
|
+
}
|
|
16
|
+
/** A null web mapping or empty LLM list means no applicable association was assigned. */
|
|
17
|
+
export declare function owaspMappings(ruleId: string, cweId: string): OwaspMappings;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.owaspMappings = owaspMappings;
|
|
4
|
+
const names = {
|
|
5
|
+
A01: 'Broken Access Control', A02: 'Security Misconfiguration', A03: 'Software Supply Chain Failures',
|
|
6
|
+
A04: 'Cryptographic Failures', A05: 'Injection', A06: 'Insecure Design',
|
|
7
|
+
A07: 'Authentication Failures', A08: 'Software or Data Integrity Failures',
|
|
8
|
+
};
|
|
9
|
+
const webByCwe = {
|
|
10
|
+
'CWE-22': 'A01', 'CWE-200': 'A01', 'CWE-284': 'A01', 'CWE-352': 'A01', 'CWE-540': 'A01', 'CWE-601': 'A01',
|
|
11
|
+
'CWE-639': 'A01', 'CWE-732': 'A01', 'CWE-749': 'A01', 'CWE-862': 'A01', 'CWE-863': 'A01', 'CWE-918': 'A01', 'CWE-1275': 'A01',
|
|
12
|
+
'CWE-16': 'A02', 'CWE-526': 'A02', 'CWE-614': 'A02', 'CWE-942': 'A02', 'CWE-1004': 'A02',
|
|
13
|
+
'CWE-1104': 'A03', 'CWE-321': 'A04', 'CWE-327': 'A04', 'CWE-347': 'A04',
|
|
14
|
+
'CWE-74': 'A05', 'CWE-77': 'A05', 'CWE-78': 'A05', 'CWE-79': 'A05', 'CWE-89': 'A05', 'CWE-94': 'A05',
|
|
15
|
+
'CWE-434': 'A06', 'CWE-693': 'A06', 'CWE-287': 'A07', 'CWE-306': 'A07', 'CWE-798': 'A07', 'CWE-1392': 'A07',
|
|
16
|
+
'CWE-345': 'A08', 'CWE-502': 'A08', 'CWE-829': 'A08',
|
|
17
|
+
};
|
|
18
|
+
const contextualWeb = { 'CWE-250': 'A01', 'CWE-770': 'A06' };
|
|
19
|
+
const llmNames = {
|
|
20
|
+
LLM01: 'Prompt Injection', LLM02: 'Sensitive Information Disclosure', LLM03: 'Supply Chain',
|
|
21
|
+
LLM05: 'Improper Output Handling', LLM06: 'Excessive Agency', LLM10: 'Unbounded Consumption',
|
|
22
|
+
};
|
|
23
|
+
const llmByRule = {
|
|
24
|
+
'VSB-AI-002': ['LLM05', 'LLM06'], 'VSB-AI-003': ['LLM05'], 'VSB-AI-004': ['LLM01'],
|
|
25
|
+
'VSB-AI-008': ['LLM05'], 'VSB-AI-014': ['LLM10'],
|
|
26
|
+
'VSB-AGENT-001': ['LLM01'], 'VSB-AGENT-002': ['LLM01', 'LLM02'], 'VSB-AGENT-003': ['LLM01', 'LLM02'],
|
|
27
|
+
'VSB-AGENT-004': ['LLM03'], 'VSB-AGENT-005': ['LLM02'], 'VSB-AGENT-006': ['LLM01'],
|
|
28
|
+
'VSB-AGENT-007': ['LLM06'], 'VSB-AGENT-008': ['LLM05', 'LLM06'], 'VSB-AGENT-009': ['LLM01', 'LLM03'],
|
|
29
|
+
'VSB-AGENT-010': ['LLM06'], 'VSB-AGENT-011': ['LLM02'], 'VSB-AGENT-012': ['LLM06'],
|
|
30
|
+
};
|
|
31
|
+
/** A null web mapping or empty LLM list means no applicable association was assigned. */
|
|
32
|
+
function owaspMappings(ruleId, cweId) {
|
|
33
|
+
const official = webByCwe[cweId];
|
|
34
|
+
const webId = official ?? contextualWeb[cweId];
|
|
35
|
+
return {
|
|
36
|
+
web: webId ? { id: `${webId}:2025`, name: names[webId], basis: official ? 'owasp-cwe' : 'contextual' } : null,
|
|
37
|
+
llm: (llmByRule[ruleId] ?? []).map(id => ({ id: `${id}:2025`, name: llmNames[id], basis: 'contextual' })),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function redactReportText(text: string): string;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.redactReportText = redactReportText;
|
|
4
|
+
/** Best-effort removal of recognized credentials from report text. */
|
|
5
|
+
const patterns_1 = require("../secrets/patterns");
|
|
6
|
+
function redactReportText(text) {
|
|
7
|
+
let output = text;
|
|
8
|
+
for (const match of (0, patterns_1.findSecrets)(text)) {
|
|
9
|
+
output = output.split(match.value).join(match.display);
|
|
10
|
+
}
|
|
11
|
+
return output;
|
|
12
|
+
}
|
|
@@ -60,7 +60,7 @@ const tableWithoutRls = (0, helpers_1.defineRule)({
|
|
|
60
60
|
'Add `alter table public.<table> enable row level security;` to the migration that creates the table (never a later one).',
|
|
61
61
|
'Add a policy that scopes rows to the caller, e.g. `using (auth.uid() = user_id)` for each of select/insert/update/delete.',
|
|
62
62
|
'Use the service-role key only in server code, and only where bypassing RLS is genuinely required.',
|
|
63
|
-
'Run `npx
|
|
63
|
+
'Run `npx -y @vibefoundry/vibe-shield scan` again — the table should disappear from this report even if no policy was added yet (deny-all is safe).',
|
|
64
64
|
],
|
|
65
65
|
references: [
|
|
66
66
|
'https://supabase.com/docs/guides/database/postgres/row-level-security',
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { Rule } from '../types';
|
|
2
|
+
export declare const HARDENING_RULES: readonly Rule[];
|
|
3
|
+
export declare const MISSING_LOCKFILE_RULE: Rule;
|
|
4
|
+
/** Docker defaults to root when the final stage never selects a runtime user. */
|
|
5
|
+
export declare const DOCKER_DEFAULT_ROOT_RULE: Rule;
|