@vibefoundry/vibe-shield 0.0.0-stage → 1.1.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/LICENSE +21 -0
- package/README.md +571 -3
- package/SECURITY.md +110 -0
- package/SKILL.md +124 -0
- package/dist/bin/vibe-shield.d.ts +59 -0
- package/dist/bin/vibe-shield.js +541 -0
- package/dist/src/index.d.ts +25 -0
- package/dist/src/index.js +108 -0
- package/dist/src/init/scaffold.d.ts +122 -0
- package/dist/src/init/scaffold.js +573 -0
- package/dist/src/mcp/server.d.ts +127 -0
- package/dist/src/mcp/server.js +479 -0
- package/dist/src/mcp-guard/skill-auditor.d.ts +105 -0
- package/dist/src/mcp-guard/skill-auditor.js +1129 -0
- package/dist/src/reporter/console-reporter.d.ts +58 -0
- package/dist/src/reporter/console-reporter.js +254 -0
- package/dist/src/reporter/formatters.d.ts +38 -0
- package/dist/src/reporter/formatters.js +262 -0
- package/dist/src/reporter/patch-prompt-writer.d.ts +35 -0
- package/dist/src/reporter/patch-prompt-writer.js +238 -0
- package/dist/src/scanner/context.d.ts +51 -0
- package/dist/src/scanner/context.js +568 -0
- package/dist/src/scanner/engine.d.ts +130 -0
- package/dist/src/scanner/engine.js +722 -0
- package/dist/src/scanner/gitignore.d.ts +49 -0
- package/dist/src/scanner/gitignore.js +195 -0
- package/dist/src/scanner/lexicon.d.ts +56 -0
- package/dist/src/scanner/lexicon.js +357 -0
- package/dist/src/scanner/rules/ai-vulnerabilities.d.ts +15 -0
- package/dist/src/scanner/rules/ai-vulnerabilities.js +859 -0
- package/dist/src/scanner/rules/client-exposure.d.ts +18 -0
- package/dist/src/scanner/rules/client-exposure.js +410 -0
- package/dist/src/scanner/rules/database-exposure.d.ts +15 -0
- package/dist/src/scanner/rules/database-exposure.js +626 -0
- package/dist/src/scanner/rules/helpers.d.ts +31 -0
- package/dist/src/scanner/rules/helpers.js +85 -0
- package/dist/src/scanner/rules/index.d.ts +30 -0
- package/dist/src/scanner/rules/index.js +95 -0
- package/dist/src/scanner/rules/secret-entropy.d.ts +17 -0
- package/dist/src/scanner/rules/secret-entropy.js +338 -0
- package/dist/src/scanner/rules/shared.d.ts +100 -0
- package/dist/src/scanner/rules/shared.js +161 -0
- package/dist/src/scanner/rules/unguarded-routes.d.ts +16 -0
- package/dist/src/scanner/rules/unguarded-routes.js +526 -0
- package/dist/src/scanner/text.d.ts +90 -0
- package/dist/src/scanner/text.js +472 -0
- package/dist/src/scanner/types.d.ts +340 -0
- package/dist/src/scanner/types.js +195 -0
- package/dist/src/secrets/patterns.d.ts +63 -0
- package/dist/src/secrets/patterns.js +392 -0
- package/package.json +94 -4
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* vibe-shield :: terminal reporter
|
|
3
|
+
* --------------------------------
|
|
4
|
+
* The scorecard is the product. It has one job: make a developer *feel* the
|
|
5
|
+
* number of critical findings in under a second, then hand them exact
|
|
6
|
+
* `file:line` citations they can click in a terminal.
|
|
7
|
+
*
|
|
8
|
+
* No colour is required for meaning — every status is also spelled out — so the
|
|
9
|
+
* output survives CI logs, `--no-color`, and screen readers.
|
|
10
|
+
*/
|
|
11
|
+
import type { ScanResult, Severity } from '../scanner/types';
|
|
12
|
+
export interface Colorizer {
|
|
13
|
+
enabled: boolean;
|
|
14
|
+
red(text: string): string;
|
|
15
|
+
green(text: string): string;
|
|
16
|
+
yellow(text: string): string;
|
|
17
|
+
blue(text: string): string;
|
|
18
|
+
magenta(text: string): string;
|
|
19
|
+
cyan(text: string): string;
|
|
20
|
+
gray(text: string): string;
|
|
21
|
+
bold(text: string): string;
|
|
22
|
+
dim(text: string): string;
|
|
23
|
+
underline(text: string): string;
|
|
24
|
+
}
|
|
25
|
+
/** Build a colouriser honouring `NO_COLOR`, `FORCE_COLOR` and TTY detection. */
|
|
26
|
+
export declare function createColorizer(stream?: {
|
|
27
|
+
isTTY?: boolean;
|
|
28
|
+
}): Colorizer;
|
|
29
|
+
declare const severityPaint: (color: Colorizer, severity: Severity) => ((text: string) => string);
|
|
30
|
+
/** Strip ANSI codes to measure true display width. */
|
|
31
|
+
declare const visibleLength: (text: string) => number;
|
|
32
|
+
/** Pad `text` to `width` visible characters. */
|
|
33
|
+
export declare const pad: (text: string, width: number) => string;
|
|
34
|
+
declare const GRADE_ICON: Record<string, string>;
|
|
35
|
+
export interface ReportOptions {
|
|
36
|
+
color?: Colorizer;
|
|
37
|
+
/** Terminal width. Defaults to `process.stdout.columns` or 100. */
|
|
38
|
+
width?: number;
|
|
39
|
+
/** Cap the per-finding list (0 = all). Default 50. */
|
|
40
|
+
maxFindings?: number;
|
|
41
|
+
/** Show timing / throughput statistics. */
|
|
42
|
+
showStats?: boolean;
|
|
43
|
+
/** Absolute root used to shorten paths (defaults to `result.rootDir`). */
|
|
44
|
+
cwd?: string;
|
|
45
|
+
}
|
|
46
|
+
/** Shown instead of a grade when discovery matched nothing: silence is not a pass. */
|
|
47
|
+
export declare const NOTHING_SCANNED = "\u26A0 NO FILES SCANNED \u2014 this is not a pass";
|
|
48
|
+
/** The one-line verdict, e.g. `💀 GRADE F — 0/100 · 6 critical`. */
|
|
49
|
+
export declare function verdictLine(result: ScanResult, color: Colorizer): string;
|
|
50
|
+
/** Full scorecard as an array of lines (no trailing newline). */
|
|
51
|
+
export declare function renderConsoleReport(result: ScanResult, options?: ReportOptions): string[];
|
|
52
|
+
/** Print the scorecard to a stream. */
|
|
53
|
+
export declare function reportConsole(result: ScanResult, stream?: {
|
|
54
|
+
write(chunk: string): unknown;
|
|
55
|
+
isTTY?: boolean;
|
|
56
|
+
columns?: number;
|
|
57
|
+
}, options?: ReportOptions): void;
|
|
58
|
+
export { GRADE_ICON, severityPaint, visibleLength };
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* vibe-shield :: terminal reporter
|
|
4
|
+
* --------------------------------
|
|
5
|
+
* The scorecard is the product. It has one job: make a developer *feel* the
|
|
6
|
+
* number of critical findings in under a second, then hand them exact
|
|
7
|
+
* `file:line` citations they can click in a terminal.
|
|
8
|
+
*
|
|
9
|
+
* No colour is required for meaning — every status is also spelled out — so the
|
|
10
|
+
* output survives CI logs, `--no-color`, and screen readers.
|
|
11
|
+
*/
|
|
12
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
+
exports.visibleLength = exports.severityPaint = exports.GRADE_ICON = exports.NOTHING_SCANNED = exports.pad = void 0;
|
|
14
|
+
exports.createColorizer = createColorizer;
|
|
15
|
+
exports.verdictLine = verdictLine;
|
|
16
|
+
exports.renderConsoleReport = renderConsoleReport;
|
|
17
|
+
exports.reportConsole = reportConsole;
|
|
18
|
+
const types_1 = require("../scanner/types");
|
|
19
|
+
const ANSI = {
|
|
20
|
+
red: [31, 39],
|
|
21
|
+
green: [32, 39],
|
|
22
|
+
yellow: [33, 39],
|
|
23
|
+
blue: [34, 39],
|
|
24
|
+
magenta: [35, 39],
|
|
25
|
+
cyan: [36, 39],
|
|
26
|
+
gray: [90, 39],
|
|
27
|
+
bold: [1, 22],
|
|
28
|
+
dim: [2, 22],
|
|
29
|
+
underline: [4, 24],
|
|
30
|
+
};
|
|
31
|
+
/** Build a colouriser honouring `NO_COLOR`, `FORCE_COLOR` and TTY detection. */
|
|
32
|
+
function createColorizer(stream = process.stdout) {
|
|
33
|
+
const env = process.env;
|
|
34
|
+
const forced = env.FORCE_COLOR !== undefined && env.FORCE_COLOR !== '0';
|
|
35
|
+
const disabled = env.NO_COLOR !== undefined || env.TERM === 'dumb';
|
|
36
|
+
const enabled = forced || (!disabled && stream.isTTY === true);
|
|
37
|
+
const wrap = (code, close) => (text) => (enabled ? `\u001B[${code}m${text}\u001B[${close}m` : text);
|
|
38
|
+
return {
|
|
39
|
+
enabled,
|
|
40
|
+
red: wrap(...ANSI.red),
|
|
41
|
+
green: wrap(...ANSI.green),
|
|
42
|
+
yellow: wrap(...ANSI.yellow),
|
|
43
|
+
blue: wrap(...ANSI.blue),
|
|
44
|
+
magenta: wrap(...ANSI.magenta),
|
|
45
|
+
cyan: wrap(...ANSI.cyan),
|
|
46
|
+
gray: wrap(...ANSI.gray),
|
|
47
|
+
bold: wrap(...ANSI.bold),
|
|
48
|
+
dim: wrap(...ANSI.dim),
|
|
49
|
+
underline: wrap(...ANSI.underline),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
const SEVERITY_COLORS = {
|
|
53
|
+
critical: 'red',
|
|
54
|
+
high: 'magenta',
|
|
55
|
+
medium: 'yellow',
|
|
56
|
+
low: 'blue',
|
|
57
|
+
info: 'gray',
|
|
58
|
+
};
|
|
59
|
+
const severityPaint = (color, severity) => color[SEVERITY_COLORS[severity]];
|
|
60
|
+
exports.severityPaint = severityPaint;
|
|
61
|
+
/* -------------------------------------------------------------------------- */
|
|
62
|
+
/* Layout helpers */
|
|
63
|
+
/* -------------------------------------------------------------------------- */
|
|
64
|
+
/** Strip ANSI codes to measure true display width. */
|
|
65
|
+
const visibleLength = (text) => text.replace(/\u001B\[[0-9;]*m/g, '').length;
|
|
66
|
+
exports.visibleLength = visibleLength;
|
|
67
|
+
/** Pad `text` to `width` visible characters. */
|
|
68
|
+
const pad = (text, width) => text + ' '.repeat(Math.max(0, width - visibleLength(text)));
|
|
69
|
+
exports.pad = pad;
|
|
70
|
+
const truncate = (text, width) => text.length <= width ? text : `${text.slice(0, Math.max(0, width - 1))}…`;
|
|
71
|
+
const GRADE_ICON = {
|
|
72
|
+
'A+': '🏆',
|
|
73
|
+
A: '✅',
|
|
74
|
+
'A-': '✅',
|
|
75
|
+
'B+': '🟢',
|
|
76
|
+
B: '🟢',
|
|
77
|
+
'B-': '🟡',
|
|
78
|
+
'C+': '🟡',
|
|
79
|
+
C: '🟠',
|
|
80
|
+
'C-': '🟠',
|
|
81
|
+
'D+': '🔴',
|
|
82
|
+
D: '🔴',
|
|
83
|
+
'D-': '🔴',
|
|
84
|
+
F: '💀',
|
|
85
|
+
};
|
|
86
|
+
exports.GRADE_ICON = GRADE_ICON;
|
|
87
|
+
const DEFAULT_WIDTH = 100;
|
|
88
|
+
/** Shown instead of a grade when discovery matched nothing: silence is not a pass. */
|
|
89
|
+
exports.NOTHING_SCANNED = '⚠ NO FILES SCANNED — this is not a pass';
|
|
90
|
+
/** The one-line verdict, e.g. `💀 GRADE F — 0/100 · 6 critical`. */
|
|
91
|
+
function verdictLine(result, color) {
|
|
92
|
+
if (result.stats.filesScanned === 0)
|
|
93
|
+
return color.yellow(exports.NOTHING_SCANNED);
|
|
94
|
+
const icon = GRADE_ICON[result.score.grade] ?? '•';
|
|
95
|
+
const grade = color.bold(severityPaint(color, result.score.counts.critical > 0 ? 'critical' : result.score.counts.high > 0 ? 'high' : result.score.counts.medium > 0 ? 'medium' : 'low')(result.score.grade));
|
|
96
|
+
const counts = ['critical', 'high', 'medium', 'low']
|
|
97
|
+
.filter((severity) => result.score.counts[severity] > 0)
|
|
98
|
+
.map((severity) => `${result.score.counts[severity]} ${severity}`)
|
|
99
|
+
.join(' · ');
|
|
100
|
+
return `${icon} GRADE ${grade} — ${result.score.score}/100${counts ? ` · ${counts}` : ''}`;
|
|
101
|
+
}
|
|
102
|
+
/** Render the header block: brand, version, target and verdict. */
|
|
103
|
+
function renderHeader(result, color, width) {
|
|
104
|
+
const lines = [];
|
|
105
|
+
const title = color.bold(color.cyan('🛡️ vibe-shield'));
|
|
106
|
+
const version = color.dim(`v${result.version}`);
|
|
107
|
+
const mode = result.command === 'audit-skills' ? 'agent surface audit' : 'security scan';
|
|
108
|
+
lines.push(`${title} ${version} ${color.dim(`· ${mode}`)}`);
|
|
109
|
+
lines.push(color.dim(`target ${truncate(result.rootDir, width - 10)}`));
|
|
110
|
+
lines.push('');
|
|
111
|
+
lines.push(verdictLine(result, color));
|
|
112
|
+
return lines;
|
|
113
|
+
}
|
|
114
|
+
/** A horizontal bar for the severity histogram. */
|
|
115
|
+
function histogram(result, color) {
|
|
116
|
+
const counts = result.score.counts;
|
|
117
|
+
const order = ['critical', 'high', 'medium', 'low', 'info'];
|
|
118
|
+
const lines = [];
|
|
119
|
+
for (const severity of order) {
|
|
120
|
+
const count = counts[severity];
|
|
121
|
+
if (!count)
|
|
122
|
+
continue;
|
|
123
|
+
const paint = severityPaint(color, severity);
|
|
124
|
+
const bar = paint('█'.repeat(Math.min(40, count)));
|
|
125
|
+
lines.push(` ${(0, exports.pad)(paint(severity.toUpperCase()), 8)} ${(0, exports.pad)(String(count), 4)} ${bar}`);
|
|
126
|
+
}
|
|
127
|
+
return lines;
|
|
128
|
+
}
|
|
129
|
+
/** All findings grouped by file, with exact citations. */
|
|
130
|
+
function renderFindings(findings, color, options) {
|
|
131
|
+
const lines = [];
|
|
132
|
+
const maxFindings = options.maxFindings ?? 50;
|
|
133
|
+
// Severity is the outer sort key (what to fix first); file is the inner one so
|
|
134
|
+
// each file's findings stay together and the list reads like a patch.
|
|
135
|
+
const ordered = [...findings].sort((a, b) => types_1.SEVERITY_WEIGHT[b.severity] - types_1.SEVERITY_WEIGHT[a.severity] ||
|
|
136
|
+
a.filePath.localeCompare(b.filePath) ||
|
|
137
|
+
a.line - b.line);
|
|
138
|
+
const shown = maxFindings > 0 ? ordered.slice(0, maxFindings) : ordered;
|
|
139
|
+
let currentFile = '';
|
|
140
|
+
for (const finding of shown) {
|
|
141
|
+
if (finding.filePath !== currentFile) {
|
|
142
|
+
currentFile = finding.filePath;
|
|
143
|
+
lines.push('');
|
|
144
|
+
lines.push(color.bold(color.underline(currentFile)));
|
|
145
|
+
}
|
|
146
|
+
const paint = severityPaint(color, finding.severity);
|
|
147
|
+
const location = color.cyan(`${finding.line}:${finding.column}`);
|
|
148
|
+
lines.push(` ${paint(finding.severity.toUpperCase().padEnd(8))} ${location.padEnd(color.enabled ? 22 : 8)} ${color.dim(finding.ruleId)} ${finding.title}`);
|
|
149
|
+
if (finding.snippet)
|
|
150
|
+
lines.push(` ${color.gray(truncate(finding.snippet, 110))}`);
|
|
151
|
+
}
|
|
152
|
+
if (maxFindings > 0 && findings.length > maxFindings) {
|
|
153
|
+
lines.push('');
|
|
154
|
+
lines.push(color.dim(` … and ${findings.length - maxFindings} more. Run with --format json or --max-findings 0 to see every finding.`));
|
|
155
|
+
}
|
|
156
|
+
return lines;
|
|
157
|
+
}
|
|
158
|
+
/** Category breakdown, useful when the list is long. */
|
|
159
|
+
function renderCategories(result, color) {
|
|
160
|
+
const byCategory = new Map();
|
|
161
|
+
for (const finding of result.findings) {
|
|
162
|
+
const bucket = byCategory.get(finding.category) ?? [];
|
|
163
|
+
bucket.push(finding);
|
|
164
|
+
byCategory.set(finding.category, bucket);
|
|
165
|
+
}
|
|
166
|
+
if (byCategory.size <= 1)
|
|
167
|
+
return [];
|
|
168
|
+
const lines = ['', color.bold('By category')];
|
|
169
|
+
const sorted = [...byCategory.entries()].sort((a, b) => b[1].length - a[1].length);
|
|
170
|
+
for (const [category, findings] of sorted) {
|
|
171
|
+
const worst = findings.reduce((acc, finding) => (types_1.SEVERITY_WEIGHT[finding.severity] > types_1.SEVERITY_WEIGHT[acc] ? finding.severity : acc), 'info');
|
|
172
|
+
const paint = severityPaint(color, worst);
|
|
173
|
+
lines.push(` ${paint('●')} ${(0, exports.pad)(types_1.CATEGORY_TITLES[category] ?? category, 44)} ${findings.length}`);
|
|
174
|
+
}
|
|
175
|
+
return lines;
|
|
176
|
+
}
|
|
177
|
+
/** Timing / throughput footer. */
|
|
178
|
+
function renderStats(result, color) {
|
|
179
|
+
const { stats } = result;
|
|
180
|
+
const parts = [
|
|
181
|
+
`${stats.filesScanned} ${stats.filesScanned === 1 ? 'file' : 'files'}`,
|
|
182
|
+
];
|
|
183
|
+
parts.push(stats.bytesScanned >= 1024
|
|
184
|
+
? `${(stats.bytesScanned / 1024).toFixed(0)} KiB`
|
|
185
|
+
: `${stats.bytesScanned} B`);
|
|
186
|
+
parts.push(`${stats.filesPerSecond} files/s`, `${stats.durationMs} ms`, `${result.ruleCount} rules`);
|
|
187
|
+
if (stats.cacheHits > 0)
|
|
188
|
+
parts.push(`${stats.cacheHits} cached`);
|
|
189
|
+
if (result.suppressedCount > 0)
|
|
190
|
+
parts.push(`${result.suppressedCount} suppressed`);
|
|
191
|
+
const line = color.dim(` ${parts.join(' · ')}`);
|
|
192
|
+
return ['', line];
|
|
193
|
+
}
|
|
194
|
+
/** Next steps, tailored to what was found. */
|
|
195
|
+
function renderFooter(result, color) {
|
|
196
|
+
const lines = [''];
|
|
197
|
+
if (result.stats.filesScanned === 0) {
|
|
198
|
+
for (const notice of result.notices)
|
|
199
|
+
lines.push(color.dim(` ${notice}`));
|
|
200
|
+
lines.push(color.dim(' No grade is given for an empty scan — check the path, --include-docs/--include-tests, and .vibe-shieldignore.'));
|
|
201
|
+
return lines;
|
|
202
|
+
}
|
|
203
|
+
if (result.findings.length === 0) {
|
|
204
|
+
lines.push(color.green(' Nothing to fix. Keep the shield up. 🛡️'));
|
|
205
|
+
// The engine's "no vulnerabilities found" notice says the same thing in
|
|
206
|
+
// different words; drop it so a clean run stays one line.
|
|
207
|
+
for (const notice of result.notices) {
|
|
208
|
+
if (/^no vulnerabilities found/i.test(notice))
|
|
209
|
+
continue;
|
|
210
|
+
lines.push(color.dim(` ${notice}`));
|
|
211
|
+
}
|
|
212
|
+
return lines;
|
|
213
|
+
}
|
|
214
|
+
const worst = result.findings[0]?.severity ?? 'info';
|
|
215
|
+
const critical = result.score.counts.critical;
|
|
216
|
+
if (critical > 0) {
|
|
217
|
+
lines.push(color.red(` ⚠ ${critical} critical finding${critical === 1 ? '' : 's'} — rotate any exposed credential before anything else.`));
|
|
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`);
|
|
222
|
+
for (const notice of result.notices)
|
|
223
|
+
lines.push(color.dim(` ${notice}`));
|
|
224
|
+
return lines;
|
|
225
|
+
}
|
|
226
|
+
/** Full scorecard as an array of lines (no trailing newline). */
|
|
227
|
+
function renderConsoleReport(result, options = {}) {
|
|
228
|
+
const color = options.color ?? createColorizer();
|
|
229
|
+
const width = options.width ?? process.stdout.columns ?? DEFAULT_WIDTH;
|
|
230
|
+
const lines = [];
|
|
231
|
+
lines.push(...renderHeader(result, color, width));
|
|
232
|
+
const bars = histogram(result, color);
|
|
233
|
+
if (bars.length) {
|
|
234
|
+
lines.push('');
|
|
235
|
+
lines.push(...bars);
|
|
236
|
+
}
|
|
237
|
+
lines.push(...renderCategories(result, color));
|
|
238
|
+
if (result.findings.length) {
|
|
239
|
+
lines.push('');
|
|
240
|
+
lines.push(color.bold('Findings'));
|
|
241
|
+
lines.push(...renderFindings(result.findings, color, options));
|
|
242
|
+
}
|
|
243
|
+
if (options.showStats !== false)
|
|
244
|
+
lines.push(...renderStats(result, color));
|
|
245
|
+
lines.push(...renderFooter(result, color));
|
|
246
|
+
return lines;
|
|
247
|
+
}
|
|
248
|
+
/** Print the scorecard to a stream. */
|
|
249
|
+
function reportConsole(result, stream = process.stdout, options = {}) {
|
|
250
|
+
const color = options.color ?? createColorizer(stream);
|
|
251
|
+
const width = options.width ?? stream.columns ?? DEFAULT_WIDTH;
|
|
252
|
+
const lines = renderConsoleReport(result, { ...options, color, width });
|
|
253
|
+
stream.write(`${lines.join('\n')}\n`);
|
|
254
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* vibe-shield :: machine-readable output
|
|
3
|
+
* --------------------------------------
|
|
4
|
+
* Five formats, one contract: every field the console shows is available
|
|
5
|
+
* structured, so nothing is lost when the report goes to a machine.
|
|
6
|
+
*
|
|
7
|
+
* - `json` the full `ScanResult` (stable, versioned by `schemaVersion`)
|
|
8
|
+
* - `compact` one line per finding — what CI actually needs
|
|
9
|
+
* - `summary` counts + grade only — for dashboards and PR comments
|
|
10
|
+
* - `sarif` SARIF 2.1.0 for GitHub code scanning (`--format sarif`)
|
|
11
|
+
* - `github` GitHub Actions workflow commands (annotations in the diff)
|
|
12
|
+
*/
|
|
13
|
+
import type { Finding, ScanResult } from '../scanner/types';
|
|
14
|
+
export declare const OUTPUT_FORMATS: readonly ["console", "compact", "summary", "json", "sarif", "github"];
|
|
15
|
+
export type OutputFormat = (typeof OUTPUT_FORMATS)[number];
|
|
16
|
+
export declare const isOutputFormat: (value: unknown) => value is OutputFormat;
|
|
17
|
+
/** Bump when the JSON shape changes in a breaking way. */
|
|
18
|
+
export declare const SCHEMA_VERSION = 1;
|
|
19
|
+
/** Full machine-readable report. */
|
|
20
|
+
export declare function formatJson(result: ScanResult): string;
|
|
21
|
+
/** Counts and grade only. */
|
|
22
|
+
export declare function formatSummary(result: ScanResult): string;
|
|
23
|
+
/** One line per finding: `path:line:col severity ruleId message`. */
|
|
24
|
+
export declare function formatCompact(result: ScanResult): string;
|
|
25
|
+
/**
|
|
26
|
+
* GitHub Actions annotations. Each finding becomes an inline comment on the
|
|
27
|
+
* diff when the workflow runs on a pull request.
|
|
28
|
+
*/
|
|
29
|
+
export declare function formatGithub(result: ScanResult): string;
|
|
30
|
+
/**
|
|
31
|
+
* SARIF 2.1.0 document accepted by GitHub code scanning, VS Code SARIF viewer,
|
|
32
|
+
* and every other consumer of the format.
|
|
33
|
+
*/
|
|
34
|
+
export declare function formatSarif(result: ScanResult): string;
|
|
35
|
+
/** Render `result` in the requested format. */
|
|
36
|
+
export declare function formatResult(result: ScanResult, format: OutputFormat): string;
|
|
37
|
+
/** Group findings by file for reporters that render trees. */
|
|
38
|
+
export declare function groupByFile(findings: readonly Finding[]): Map<string, Finding[]>;
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* vibe-shield :: machine-readable output
|
|
4
|
+
* --------------------------------------
|
|
5
|
+
* Five formats, one contract: every field the console shows is available
|
|
6
|
+
* structured, so nothing is lost when the report goes to a machine.
|
|
7
|
+
*
|
|
8
|
+
* - `json` the full `ScanResult` (stable, versioned by `schemaVersion`)
|
|
9
|
+
* - `compact` one line per finding — what CI actually needs
|
|
10
|
+
* - `summary` counts + grade only — for dashboards and PR comments
|
|
11
|
+
* - `sarif` SARIF 2.1.0 for GitHub code scanning (`--format sarif`)
|
|
12
|
+
* - `github` GitHub Actions workflow commands (annotations in the diff)
|
|
13
|
+
*/
|
|
14
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
exports.SCHEMA_VERSION = exports.isOutputFormat = exports.OUTPUT_FORMATS = void 0;
|
|
16
|
+
exports.formatJson = formatJson;
|
|
17
|
+
exports.formatSummary = formatSummary;
|
|
18
|
+
exports.formatCompact = formatCompact;
|
|
19
|
+
exports.formatGithub = formatGithub;
|
|
20
|
+
exports.formatSarif = formatSarif;
|
|
21
|
+
exports.formatResult = formatResult;
|
|
22
|
+
exports.groupByFile = groupByFile;
|
|
23
|
+
exports.OUTPUT_FORMATS = ['console', 'compact', 'summary', 'json', 'sarif', 'github'];
|
|
24
|
+
const isOutputFormat = (value) => typeof value === 'string' && exports.OUTPUT_FORMATS.includes(value);
|
|
25
|
+
exports.isOutputFormat = isOutputFormat;
|
|
26
|
+
/** Bump when the JSON shape changes in a breaking way. */
|
|
27
|
+
exports.SCHEMA_VERSION = 1;
|
|
28
|
+
const SEVERITY_TO_SARIF = {
|
|
29
|
+
critical: 'error',
|
|
30
|
+
high: 'error',
|
|
31
|
+
medium: 'warning',
|
|
32
|
+
low: 'note',
|
|
33
|
+
info: 'note',
|
|
34
|
+
};
|
|
35
|
+
const SEVERITY_TO_GITHUB = {
|
|
36
|
+
critical: 'error',
|
|
37
|
+
high: 'error',
|
|
38
|
+
medium: 'warning',
|
|
39
|
+
low: 'notice',
|
|
40
|
+
info: 'notice',
|
|
41
|
+
};
|
|
42
|
+
/* -------------------------------------------------------------------------- */
|
|
43
|
+
/* json / summary */
|
|
44
|
+
/* -------------------------------------------------------------------------- */
|
|
45
|
+
/** Full machine-readable report. */
|
|
46
|
+
function formatJson(result) {
|
|
47
|
+
return `${JSON.stringify({ schemaVersion: exports.SCHEMA_VERSION, ...result }, null, 2)}\n`;
|
|
48
|
+
}
|
|
49
|
+
/** Counts and grade only. */
|
|
50
|
+
function formatSummary(result) {
|
|
51
|
+
const payload = {
|
|
52
|
+
schemaVersion: exports.SCHEMA_VERSION,
|
|
53
|
+
command: result.command,
|
|
54
|
+
version: result.version,
|
|
55
|
+
rootDir: result.rootDir,
|
|
56
|
+
score: result.score.score,
|
|
57
|
+
grade: result.score.grade,
|
|
58
|
+
total: result.score.total,
|
|
59
|
+
counts: result.score.counts,
|
|
60
|
+
filesScanned: result.stats.filesScanned,
|
|
61
|
+
durationMs: result.stats.durationMs,
|
|
62
|
+
ruleCount: result.ruleCount,
|
|
63
|
+
suppressed: result.suppressedCount,
|
|
64
|
+
notices: result.notices,
|
|
65
|
+
};
|
|
66
|
+
return `${JSON.stringify(payload, null, 2)}\n`;
|
|
67
|
+
}
|
|
68
|
+
/* -------------------------------------------------------------------------- */
|
|
69
|
+
/* compact */
|
|
70
|
+
/* -------------------------------------------------------------------------- */
|
|
71
|
+
/** One line per finding: `path:line:col severity ruleId message`. */
|
|
72
|
+
function formatCompact(result) {
|
|
73
|
+
const lines = result.findings.map((finding) => `${finding.filePath}:${finding.line}:${finding.column} ${finding.severity} ${finding.ruleId} ${finding.message.replace(/\s+/g, ' ')}`);
|
|
74
|
+
const counts = result.score.counts;
|
|
75
|
+
lines.push(`${result.score.score}/100 (${result.score.grade}) — ${counts.critical} critical, ${counts.high} high, ${counts.medium} medium, ${counts.low} low, ${counts.info} info across ${result.stats.filesScanned} ${result.stats.filesScanned === 1 ? 'file' : 'files'}`);
|
|
76
|
+
return `${lines.join('\n')}\n`;
|
|
77
|
+
}
|
|
78
|
+
/* -------------------------------------------------------------------------- */
|
|
79
|
+
/* github actions annotations */
|
|
80
|
+
/* -------------------------------------------------------------------------- */
|
|
81
|
+
/** Escape a value for a `::error ...::` workflow command. */
|
|
82
|
+
const escapeGithubData = (value) => value.replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A');
|
|
83
|
+
const escapeGithubProperty = (value) => escapeGithubData(value).replace(/:/g, '%3A').replace(/,/g, '%2C');
|
|
84
|
+
/**
|
|
85
|
+
* GitHub Actions annotations. Each finding becomes an inline comment on the
|
|
86
|
+
* diff when the workflow runs on a pull request.
|
|
87
|
+
*/
|
|
88
|
+
function formatGithub(result) {
|
|
89
|
+
const lines = [];
|
|
90
|
+
for (const finding of result.findings) {
|
|
91
|
+
const level = SEVERITY_TO_GITHUB[finding.severity];
|
|
92
|
+
const props = [
|
|
93
|
+
`file=${escapeGithubProperty(finding.filePath)}`,
|
|
94
|
+
`line=${finding.line}`,
|
|
95
|
+
`col=${finding.column}`,
|
|
96
|
+
`title=${escapeGithubProperty(`vibe-shield ${finding.ruleId}: ${finding.title}`)}`,
|
|
97
|
+
].join(',');
|
|
98
|
+
lines.push(`::${level} ${props}::${escapeGithubData(`${finding.message} (${finding.cwe.id})`)}`);
|
|
99
|
+
}
|
|
100
|
+
lines.push(`::notice title=vibe-shield::Scanned ${result.stats.filesScanned} files in ${result.stats.durationMs}ms — score ${result.score.score}/100 (${result.score.grade}), ${result.score.total} finding(s).`);
|
|
101
|
+
return `${lines.join('\n')}\n`;
|
|
102
|
+
}
|
|
103
|
+
/** CVSS-ish score so GitHub can rank the alert. */
|
|
104
|
+
const securitySeverity = (severity) => {
|
|
105
|
+
switch (severity) {
|
|
106
|
+
case 'critical':
|
|
107
|
+
return '9.5';
|
|
108
|
+
case 'high':
|
|
109
|
+
return '8.1';
|
|
110
|
+
case 'medium':
|
|
111
|
+
return '5.5';
|
|
112
|
+
case 'low':
|
|
113
|
+
return '3.1';
|
|
114
|
+
default:
|
|
115
|
+
return '0.0';
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
/**
|
|
119
|
+
* SARIF 2.1.0 document accepted by GitHub code scanning, VS Code SARIF viewer,
|
|
120
|
+
* and every other consumer of the format.
|
|
121
|
+
*/
|
|
122
|
+
function formatSarif(result) {
|
|
123
|
+
const ruleMap = new Map();
|
|
124
|
+
for (const finding of result.findings) {
|
|
125
|
+
if (ruleMap.has(finding.ruleId))
|
|
126
|
+
continue;
|
|
127
|
+
ruleMap.set(finding.ruleId, {
|
|
128
|
+
id: finding.ruleId,
|
|
129
|
+
name: finding.slug.replace(/[^A-Za-z0-9]/g, '_'),
|
|
130
|
+
shortDescription: { text: finding.title },
|
|
131
|
+
fullDescription: { text: finding.message },
|
|
132
|
+
help: {
|
|
133
|
+
text: [
|
|
134
|
+
finding.title,
|
|
135
|
+
'',
|
|
136
|
+
'Vulnerable:',
|
|
137
|
+
finding.insecureExample,
|
|
138
|
+
'',
|
|
139
|
+
'Fixed:',
|
|
140
|
+
finding.secureExample,
|
|
141
|
+
'',
|
|
142
|
+
...finding.remediation.map((step, index) => `${index + 1}. ${step}`),
|
|
143
|
+
].join('\n'),
|
|
144
|
+
markdown: [
|
|
145
|
+
`**${finding.title}**`,
|
|
146
|
+
'',
|
|
147
|
+
'```ts',
|
|
148
|
+
finding.insecureExample,
|
|
149
|
+
'```',
|
|
150
|
+
'',
|
|
151
|
+
'**Fix**',
|
|
152
|
+
'',
|
|
153
|
+
...finding.remediation.map((step) => `- ${step}`),
|
|
154
|
+
].join('\n'),
|
|
155
|
+
},
|
|
156
|
+
defaultConfiguration: { level: SEVERITY_TO_SARIF[finding.severity] },
|
|
157
|
+
properties: {
|
|
158
|
+
tags: ['security', finding.category, finding.cwe.id, 'vibe-shield'],
|
|
159
|
+
'security-severity': securitySeverity(finding.severity),
|
|
160
|
+
precision: finding.confidence,
|
|
161
|
+
category: finding.category,
|
|
162
|
+
cwe: finding.cwe.id,
|
|
163
|
+
},
|
|
164
|
+
relationships: [{ target: { id: finding.cwe.id, toolComponent: { name: 'CWE' } } }],
|
|
165
|
+
helpUri: finding.references[0],
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
const sarif = {
|
|
169
|
+
$schema: 'https://json.schemastore.org/sarif-2.1.0.json',
|
|
170
|
+
version: '2.1.0',
|
|
171
|
+
runs: [
|
|
172
|
+
{
|
|
173
|
+
tool: {
|
|
174
|
+
driver: {
|
|
175
|
+
name: 'vibe-shield',
|
|
176
|
+
version: result.version,
|
|
177
|
+
informationUri: 'https://github.com/avivgerz-hue/vibe-shield',
|
|
178
|
+
semanticVersion: result.version,
|
|
179
|
+
rules: [...ruleMap.values()],
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
invocations: [
|
|
183
|
+
{
|
|
184
|
+
executionSuccessful: true,
|
|
185
|
+
commandLine: `vibe-shield ${result.command}`,
|
|
186
|
+
startTimeUtc: result.startedAt,
|
|
187
|
+
endTimeUtc: result.finishedAt,
|
|
188
|
+
properties: {
|
|
189
|
+
score: result.score.score,
|
|
190
|
+
grade: result.score.grade,
|
|
191
|
+
filesScanned: result.stats.filesScanned,
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
],
|
|
195
|
+
originalUriBaseIds: {
|
|
196
|
+
'%SRCROOT%': { uri: `${result.rootDir.split('\\').join('/')}/` },
|
|
197
|
+
},
|
|
198
|
+
results: result.findings.map((finding) => ({
|
|
199
|
+
ruleId: finding.ruleId,
|
|
200
|
+
ruleIndex: [...ruleMap.keys()].indexOf(finding.ruleId),
|
|
201
|
+
level: SEVERITY_TO_SARIF[finding.severity],
|
|
202
|
+
message: { text: `${finding.message} [${finding.cwe.id}: ${finding.cwe.name}]` },
|
|
203
|
+
locations: [
|
|
204
|
+
{
|
|
205
|
+
physicalLocation: {
|
|
206
|
+
artifactLocation: { uri: finding.filePath, uriBaseId: '%SRCROOT%' },
|
|
207
|
+
region: {
|
|
208
|
+
startLine: finding.line,
|
|
209
|
+
startColumn: finding.column,
|
|
210
|
+
endLine: finding.line,
|
|
211
|
+
endColumn: finding.column + Math.max(1, finding.matched.length),
|
|
212
|
+
snippet: { text: finding.snippet },
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
],
|
|
217
|
+
partialFingerprints: {
|
|
218
|
+
'vibe-shield/v1': `${finding.ruleId}:${finding.filePath}:${finding.line}`,
|
|
219
|
+
},
|
|
220
|
+
properties: {
|
|
221
|
+
severity: finding.severity,
|
|
222
|
+
confidence: finding.confidence,
|
|
223
|
+
cwe: finding.cwe.id,
|
|
224
|
+
remediation: finding.remediation,
|
|
225
|
+
secureExample: finding.secureExample,
|
|
226
|
+
},
|
|
227
|
+
})),
|
|
228
|
+
},
|
|
229
|
+
],
|
|
230
|
+
};
|
|
231
|
+
return `${JSON.stringify(sarif, null, 2)}\n`;
|
|
232
|
+
}
|
|
233
|
+
/* -------------------------------------------------------------------------- */
|
|
234
|
+
/* dispatcher */
|
|
235
|
+
/* -------------------------------------------------------------------------- */
|
|
236
|
+
/** Render `result` in the requested format. */
|
|
237
|
+
function formatResult(result, format) {
|
|
238
|
+
switch (format) {
|
|
239
|
+
case 'json':
|
|
240
|
+
return formatJson(result);
|
|
241
|
+
case 'summary':
|
|
242
|
+
return formatSummary(result);
|
|
243
|
+
case 'compact':
|
|
244
|
+
return formatCompact(result);
|
|
245
|
+
case 'sarif':
|
|
246
|
+
return formatSarif(result);
|
|
247
|
+
case 'github':
|
|
248
|
+
return formatGithub(result);
|
|
249
|
+
default:
|
|
250
|
+
throw new Error(`format "${String(format)}" is not a machine format`);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
/** Group findings by file for reporters that render trees. */
|
|
254
|
+
function groupByFile(findings) {
|
|
255
|
+
const map = new Map();
|
|
256
|
+
for (const finding of findings) {
|
|
257
|
+
const bucket = map.get(finding.filePath) ?? [];
|
|
258
|
+
bucket.push(finding);
|
|
259
|
+
map.set(finding.filePath, bucket);
|
|
260
|
+
}
|
|
261
|
+
return map;
|
|
262
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* vibe-shield :: SECURITY_PATCH_PROMPT.md
|
|
3
|
+
* ---------------------------------------
|
|
4
|
+
* `--fix-prompt` writes the file an autonomous agent can *execute*. That means
|
|
5
|
+
* every finding must be self-contained: exact location, the vulnerable code,
|
|
6
|
+
* why it is exploitable, the exact replacement, and a verification command.
|
|
7
|
+
*
|
|
8
|
+
* The document is ordered by severity and grouped by file so an agent reads one
|
|
9
|
+
* file's problems, patches them, and moves on — instead of thrashing between
|
|
10
|
+
* unrelated modules.
|
|
11
|
+
*/
|
|
12
|
+
import type { ScanResult } from '../scanner/types';
|
|
13
|
+
export declare const PATCH_PROMPT_FILENAME = "SECURITY_PATCH_PROMPT.md";
|
|
14
|
+
export interface PatchPromptOptions {
|
|
15
|
+
/** File name to write (default `SECURITY_PATCH_PROMPT.md`). */
|
|
16
|
+
fileName?: string;
|
|
17
|
+
/** Skip findings below this severity. */
|
|
18
|
+
minSeverity?: 'critical' | 'high' | 'medium' | 'low' | 'info';
|
|
19
|
+
/** Maximum findings to include (0 = all). Default 200. */
|
|
20
|
+
maxFindings?: number;
|
|
21
|
+
/** Include the full rule description for each finding. */
|
|
22
|
+
verbose?: boolean;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Build the patch prompt document.
|
|
26
|
+
*
|
|
27
|
+
* The prompt is written *for an agent*: imperative, unambiguous, and explicit
|
|
28
|
+
* about what must not change. It ends with a self-check the agent is told to run
|
|
29
|
+
* before reporting the task as done — the same discipline `SKILL.md` enforces.
|
|
30
|
+
*/
|
|
31
|
+
export declare function buildPatchPrompt(result: ScanResult, options?: PatchPromptOptions): string;
|
|
32
|
+
/** Write the patch prompt and return the absolute path written. */
|
|
33
|
+
export declare function writePatchPrompt(result: ScanResult, options?: PatchPromptOptions & {
|
|
34
|
+
outDir?: string;
|
|
35
|
+
}): Promise<string>;
|