@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.
Files changed (39) hide show
  1. package/README.md +74 -48
  2. package/SECURITY.md +1 -1
  3. package/SKILL.md +27 -94
  4. package/dist/bin/vibe-shield.d.ts +1 -1
  5. package/dist/bin/vibe-shield.js +572 -541
  6. package/dist/src/init/scaffold.d.ts +29 -31
  7. package/dist/src/init/scaffold.js +69 -153
  8. package/dist/src/mcp/server.js +6 -2
  9. package/dist/src/mcp-guard/skill-auditor.js +11 -3
  10. package/dist/src/reporter/console-reporter.js +17 -3
  11. package/dist/src/reporter/executive.d.ts +4 -0
  12. package/dist/src/reporter/executive.js +58 -0
  13. package/dist/src/reporter/formatters.d.ts +1 -1
  14. package/dist/src/reporter/formatters.js +10 -3
  15. package/dist/src/reporter/patch-prompt-writer.js +5 -5
  16. package/dist/src/scanner/attack-surface.d.ts +4 -0
  17. package/dist/src/scanner/attack-surface.js +50 -0
  18. package/dist/src/scanner/engine.d.ts +2 -3
  19. package/dist/src/scanner/engine.js +21 -35
  20. package/dist/src/scanner/mappings.d.ts +17 -0
  21. package/dist/src/scanner/mappings.js +39 -0
  22. package/dist/src/scanner/redact.d.ts +1 -0
  23. package/dist/src/scanner/redact.js +12 -0
  24. package/dist/src/scanner/rules/database-exposure.js +1 -1
  25. package/dist/src/scanner/rules/hardening.d.ts +5 -0
  26. package/dist/src/scanner/rules/hardening.js +116 -0
  27. package/dist/src/scanner/rules/helpers.js +2 -0
  28. package/dist/src/scanner/rules/index.js +6 -0
  29. package/dist/src/scanner/rules/response-hardening.d.ts +2 -0
  30. package/dist/src/scanner/rules/response-hardening.js +120 -0
  31. package/dist/src/scanner/rules/secret-entropy.js +0 -2
  32. package/dist/src/scanner/rules/shared.d.ts +1 -1
  33. package/dist/src/scanner/rules/shared.js +1 -1
  34. package/dist/src/scanner/types.d.ts +33 -1
  35. package/dist/src/scanner/types.js +6 -2
  36. package/dist/src/scanner/version.d.ts +2 -0
  37. package/dist/src/scanner/version.js +69 -0
  38. package/dist/src/secrets/patterns.js +40 -0
  39. 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 ?? process.env.npm_package_version ?? '1.0.0',
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 github:avivgerz-hue/vibe-shield scan --fix-prompt ${color.dim('# writes SECURITY_PATCH_PROMPT.md for your agent')}`);
220
- lines.push(` ${color.bold('Explain')} npx github:avivgerz-hue/vibe-shield explain ${result.findings[0]?.ruleId ?? 'VSB-AI-001'}`);
221
- lines.push(` ${color.bold('Gate CI')} npx github:avivgerz-hue/vibe-shield scan --fail-on ${worst} --format github`);
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,4 @@
1
+ import { type ScanResult } from '../scanner/types';
2
+ export declare function formatMarkdown(result: ScanResult): string;
3
+ /** All project content is escaped. The only script is a fixed local filter. */
4
+ export declare function formatHtml(result: ScanResult): string;
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
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
- exports.OUTPUT_FORMATS = ['console', 'compact', 'summary', 'json', 'sarif', 'github'];
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: `${result.rootDir.split('\\').join('/')}/` },
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('> Every finding below was detected statically and cites the exact line that must change.');
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
- 'Apply every remediation exactly as written, then re-run the scanner to prove the finding is gone.');
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 github:avivgerz-hue/vibe-shield scan --fail-on high', 'bash'));
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('**Target shape after the fix**');
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 github:avivgerz-hue/vibe-shield scan --fail-on high` exits `0`.');
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 = 1;
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
- /** Package version, resolved once from the nearest package.json. */
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 = 1;
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
- const VERSION = process.env.npm_package_version ?? '1.0.0';
514
- let cachedVersion = null;
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 github:avivgerz-hue/vibe-shield scan` again — the table should disappear from this report even if no policy was added yet (deny-all is safe).',
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;