@vibefoundry/vibe-shield 0.0.0-stage → 1.1.1

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 (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +577 -3
  3. package/SECURITY.md +110 -0
  4. package/SKILL.md +124 -0
  5. package/dist/bin/vibe-shield.d.ts +59 -0
  6. package/dist/bin/vibe-shield.js +545 -0
  7. package/dist/src/index.d.ts +25 -0
  8. package/dist/src/index.js +108 -0
  9. package/dist/src/init/scaffold.d.ts +120 -0
  10. package/dist/src/init/scaffold.js +556 -0
  11. package/dist/src/mcp/server.d.ts +127 -0
  12. package/dist/src/mcp/server.js +479 -0
  13. package/dist/src/mcp-guard/skill-auditor.d.ts +105 -0
  14. package/dist/src/mcp-guard/skill-auditor.js +1129 -0
  15. package/dist/src/reporter/console-reporter.d.ts +58 -0
  16. package/dist/src/reporter/console-reporter.js +254 -0
  17. package/dist/src/reporter/formatters.d.ts +38 -0
  18. package/dist/src/reporter/formatters.js +262 -0
  19. package/dist/src/reporter/patch-prompt-writer.d.ts +35 -0
  20. package/dist/src/reporter/patch-prompt-writer.js +238 -0
  21. package/dist/src/scanner/context.d.ts +51 -0
  22. package/dist/src/scanner/context.js +568 -0
  23. package/dist/src/scanner/engine.d.ts +130 -0
  24. package/dist/src/scanner/engine.js +726 -0
  25. package/dist/src/scanner/gitignore.d.ts +49 -0
  26. package/dist/src/scanner/gitignore.js +195 -0
  27. package/dist/src/scanner/lexicon.d.ts +56 -0
  28. package/dist/src/scanner/lexicon.js +357 -0
  29. package/dist/src/scanner/rules/ai-vulnerabilities.d.ts +15 -0
  30. package/dist/src/scanner/rules/ai-vulnerabilities.js +859 -0
  31. package/dist/src/scanner/rules/client-exposure.d.ts +18 -0
  32. package/dist/src/scanner/rules/client-exposure.js +410 -0
  33. package/dist/src/scanner/rules/database-exposure.d.ts +15 -0
  34. package/dist/src/scanner/rules/database-exposure.js +626 -0
  35. package/dist/src/scanner/rules/helpers.d.ts +31 -0
  36. package/dist/src/scanner/rules/helpers.js +85 -0
  37. package/dist/src/scanner/rules/index.d.ts +30 -0
  38. package/dist/src/scanner/rules/index.js +95 -0
  39. package/dist/src/scanner/rules/secret-entropy.d.ts +17 -0
  40. package/dist/src/scanner/rules/secret-entropy.js +338 -0
  41. package/dist/src/scanner/rules/shared.d.ts +100 -0
  42. package/dist/src/scanner/rules/shared.js +161 -0
  43. package/dist/src/scanner/rules/unguarded-routes.d.ts +16 -0
  44. package/dist/src/scanner/rules/unguarded-routes.js +526 -0
  45. package/dist/src/scanner/text.d.ts +90 -0
  46. package/dist/src/scanner/text.js +472 -0
  47. package/dist/src/scanner/types.d.ts +340 -0
  48. package/dist/src/scanner/types.js +195 -0
  49. package/dist/src/secrets/patterns.d.ts +63 -0
  50. package/dist/src/secrets/patterns.js +392 -0
  51. package/package.json +97 -4
@@ -0,0 +1,726 @@
1
+ "use strict";
2
+ /**
3
+ * vibe-shield :: scan engine
4
+ * --------------------------
5
+ * A single-pass, dependency-free static analyser. Design constraints, in order:
6
+ *
7
+ * 1. **Fast.** A 5k-file Next.js repo must finish in well under a second. That
8
+ * means one `readdir` tree walk, one read per file, one comment-masking pass
9
+ * and a cheap `appliesTo` gate before any rule runs.
10
+ * 2. **Honest.** Every finding carries the exact `file:line` and the snippet
11
+ * the developer wrote, and every suppression is counted, not hidden.
12
+ * 3. **Composable.** `scanProject()` is the CLI entry point, `runRulesOnFile()`
13
+ * is reused by the agent auditor, and `discoverFiles()` is exported so tests
14
+ * can assert on file selection independent of the rules.
15
+ */
16
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
17
+ if (k2 === undefined) k2 = k;
18
+ var desc = Object.getOwnPropertyDescriptor(m, k);
19
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
20
+ desc = { enumerable: true, get: function() { return m[k]; } };
21
+ }
22
+ Object.defineProperty(o, k2, desc);
23
+ }) : (function(o, m, k, k2) {
24
+ if (k2 === undefined) k2 = k;
25
+ o[k2] = m[k];
26
+ }));
27
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
28
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
29
+ }) : function(o, v) {
30
+ o["default"] = v;
31
+ });
32
+ var __importStar = (this && this.__importStar) || (function () {
33
+ var ownKeys = function(o) {
34
+ ownKeys = Object.getOwnPropertyNames || function (o) {
35
+ var ar = [];
36
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
37
+ return ar;
38
+ };
39
+ return ownKeys(o);
40
+ };
41
+ return function (mod) {
42
+ if (mod && mod.__esModule) return mod;
43
+ var result = {};
44
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
45
+ __setModuleDefault(result, mod);
46
+ return result;
47
+ };
48
+ })();
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;
51
+ exports.discoverFiles = discoverFiles;
52
+ exports.parseSuppressions = parseSuppressions;
53
+ exports.suppressionFor = suppressionFor;
54
+ exports.applySuppressions = applySuppressions;
55
+ exports.scoreFindings = scoreFindings;
56
+ exports.runRulesOnFile = runRulesOnFile;
57
+ exports.packageVersion = packageVersion;
58
+ exports.scanProject = scanProject;
59
+ exports.dedupe = dedupe;
60
+ exports.gitTrackedPaths = gitTrackedPaths;
61
+ const node_fs_1 = require("node:fs");
62
+ const node_crypto_1 = require("node:crypto");
63
+ const node_child_process_1 = require("node:child_process");
64
+ const path = __importStar(require("node:path"));
65
+ const context_1 = require("./context");
66
+ const gitignore_1 = require("./gitignore");
67
+ const rules_1 = require("./rules");
68
+ const types_1 = require("./types");
69
+ /** Directories that never contain first-party source. */
70
+ exports.DEFAULT_IGNORED_DIRS = new Set([
71
+ 'node_modules',
72
+ '.git',
73
+ 'dist',
74
+ 'build',
75
+ 'out',
76
+ '.next',
77
+ '.nuxt',
78
+ '.output',
79
+ '.svelte-kit',
80
+ 'coverage',
81
+ '.turbo',
82
+ '.vercel',
83
+ '.cache',
84
+ '__pycache__',
85
+ '.venv',
86
+ 'venv',
87
+ 'target',
88
+ '.gradle',
89
+ '.idea',
90
+ '.vscode',
91
+ 'vendor',
92
+ 'bower_components',
93
+ '.terraform',
94
+ '.parcel-cache',
95
+ '.pytest_cache',
96
+ '.mypy_cache',
97
+ '.ruff_cache',
98
+ '.angular',
99
+ 'storybook-static',
100
+ '.playwright',
101
+ 'tmp',
102
+ 'temp',
103
+ '.tmp',
104
+ '.vibe-shield',
105
+ ]);
106
+ /** Extensions that are never source code (archives, media, binaries). */
107
+ exports.BINARY_EXTENSIONS = new Set([
108
+ 'png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'ico', 'bmp', 'tiff', 'psd',
109
+ 'woff', 'woff2', 'ttf', 'otf', 'eot',
110
+ 'mp3', 'mp4', 'm4a', 'wav', 'ogg', 'flac', 'mov', 'avi', 'mkv', 'webm',
111
+ 'zip', 'gz', 'tgz', 'bz2', 'xz', '7z', 'rar', 'tar', 'jar', 'war',
112
+ 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx',
113
+ 'exe', 'dll', 'so', 'dylib', 'bin', 'class', 'o', 'a', 'obj', 'lib',
114
+ 'wasm', 'pyc', 'pyo', 'node', 'db', 'sqlite', 'sqlite3', 'parquet', 'avro',
115
+ 'map', 'lockb', 'DS_Store',
116
+ ]);
117
+ /** Files larger than this are skipped (minified bundles, generated monsters). */
118
+ exports.MAX_FILE_SIZE_BYTES = 1_500_000;
119
+ /** Default concurrency for the read pool. I/O-bound, so higher than CPU count. */
120
+ exports.DEFAULT_CONCURRENCY = 32;
121
+ const IGNORE_FILE_NAMES = ['.gitignore', '.vibe-shieldignore', '.ignore'];
122
+ /** Lockfiles: huge, generated, and never a place a human writes a secret by hand. */
123
+ const LOCKFILE_REGEX = /^(?:package-lock\.json|yarn\.lock|pnpm-lock\.yaml|bun\.lockb?|npm-shrinkwrap\.json|composer\.lock|Gemfile\.lock|poetry\.lock|uv\.lock|Pipfile\.lock|Cargo\.lock|go\.sum|mix\.lock|pubspec\.lock)$/i;
124
+ /* -------------------------------------------------------------------------- */
125
+ /* File discovery */
126
+ /* -------------------------------------------------------------------------- */
127
+ const toPosix = (value) => value.split(path.sep).join('/');
128
+ /** True when `relativePath` looks like a test/fixture/doc path. */
129
+ const isTestPath = (relativePath) => /(?:^|\/)(?:__tests__|__mocks__|tests?|specs?|e2e|cypress|playwright|fixtures?|stories|test-utils|testing)(?:\/|$)|\.(?:test|spec|stories|it)\.[cm]?[jt]sx?$|_test\.go$|_spec\.rb$|(?:^|\/)test_[^/]+\.py$/i.test(relativePath);
130
+ exports.isTestPath = isTestPath;
131
+ /** True when `relativePath` looks like documentation. */
132
+ const isDocPath = (relativePath) => /\.(?:md|mdx|markdown|rst|adoc)$|(?:^|\/)docs?(?:\/|$)/i.test(relativePath) &&
133
+ !/(?:^|\/)(?:AGENTS?\.md|CLAUDE\.md|SKILL\.md|\.cursorrules|\.mdc)$/i.test(relativePath);
134
+ exports.isDocPath = isDocPath;
135
+ /**
136
+ * Walk `rootDir` and return every file that should be scanned.
137
+ * Directory ignores are layered: built-in defaults, then every
138
+ * `.gitignore` / `.vibe-shieldignore` / `.ignore` found on the way down.
139
+ */
140
+ async function discoverFiles(rootDir, options = { rootDir }) {
141
+ const root = path.resolve(rootDir);
142
+ const matcher = new gitignore_1.IgnoreMatcher();
143
+ for (const pattern of options.ignorePatterns ?? [])
144
+ matcher.addRule(pattern, '', '<cli>');
145
+ const files = [];
146
+ let skipped = 0;
147
+ let pruned = 0;
148
+ const maxBytes = options.maxFileSizeBytes ?? exports.MAX_FILE_SIZE_BYTES;
149
+ // Naming a path explicitly is an instruction to scan it: the test/doc
150
+ // heuristics only apply to the implicit whole-tree walk.
151
+ const explicitTargets = Boolean(options.paths?.length);
152
+ const includeTests = options.includeTests ?? explicitTargets;
153
+ const includeDocs = options.includeDocs ?? explicitTargets;
154
+ const loadIgnoreFiles = async (dir) => {
155
+ if (options.useGitignore === false)
156
+ return;
157
+ for (const name of IGNORE_FILE_NAMES) {
158
+ // `--no-shield-ignore` lets CI scan the fixtures this repo excludes.
159
+ if (name === '.vibe-shieldignore' && options.useShieldIgnore === false)
160
+ continue;
161
+ const file = path.join(dir, name);
162
+ try {
163
+ const text = await node_fs_1.promises.readFile(file, 'utf8');
164
+ matcher.addIgnoreFile(text, toPosix(path.relative(root, dir)), name);
165
+ }
166
+ catch {
167
+ // Missing ignore files are the common case.
168
+ }
169
+ }
170
+ };
171
+ const walk = async (dir) => {
172
+ await loadIgnoreFiles(dir);
173
+ let entries;
174
+ try {
175
+ entries = await node_fs_1.promises.readdir(dir, { withFileTypes: true });
176
+ }
177
+ catch {
178
+ return;
179
+ }
180
+ for (const entry of entries) {
181
+ const absolutePath = path.join(dir, entry.name);
182
+ const relativePath = toPosix(path.relative(root, absolutePath));
183
+ if (entry.isSymbolicLink()) {
184
+ skipped += 1;
185
+ continue;
186
+ }
187
+ if (entry.isDirectory()) {
188
+ if (exports.DEFAULT_IGNORED_DIRS.has(entry.name)) {
189
+ pruned += 1;
190
+ continue;
191
+ }
192
+ if (matcher.isIgnored(relativePath, true)) {
193
+ pruned += 1;
194
+ continue;
195
+ }
196
+ await walk(absolutePath);
197
+ continue;
198
+ }
199
+ if (!entry.isFile())
200
+ continue;
201
+ if (matcher.isIgnored(relativePath, false)) {
202
+ skipped += 1;
203
+ continue;
204
+ }
205
+ if (LOCKFILE_REGEX.test(entry.name)) {
206
+ skipped += 1;
207
+ continue;
208
+ }
209
+ const extension = (entry.name.split('.').pop() ?? '').toLowerCase();
210
+ if (exports.BINARY_EXTENSIONS.has(extension)) {
211
+ skipped += 1;
212
+ continue;
213
+ }
214
+ if (!includeTests && (0, exports.isTestPath)(relativePath)) {
215
+ skipped += 1;
216
+ continue;
217
+ }
218
+ if (!includeDocs && (0, exports.isDocPath)(relativePath)) {
219
+ skipped += 1;
220
+ continue;
221
+ }
222
+ let stat;
223
+ try {
224
+ stat = await node_fs_1.promises.stat(absolutePath);
225
+ }
226
+ catch {
227
+ continue;
228
+ }
229
+ if (stat.size > maxBytes) {
230
+ skipped += 1;
231
+ continue;
232
+ }
233
+ files.push({ absolutePath, relativePath, bytes: stat.size });
234
+ }
235
+ };
236
+ if (options.paths?.length) {
237
+ // Ignore files are still loaded from the root so `.vibe-shieldignore`
238
+ // applies to explicit targets too.
239
+ await loadIgnoreFiles(root);
240
+ for (const target of options.paths) {
241
+ const absolute = path.resolve(root, target);
242
+ let stat = null;
243
+ try {
244
+ stat = await node_fs_1.promises.stat(absolute);
245
+ }
246
+ catch {
247
+ stat = null;
248
+ }
249
+ if (!stat) {
250
+ skipped += 1;
251
+ continue;
252
+ }
253
+ if (stat.isDirectory()) {
254
+ await walk(absolute);
255
+ // A target directory that the user named is never pruned by a parent
256
+ // ignore rule, but its own contents still respect ignore files.
257
+ }
258
+ else {
259
+ const relativePath = toPosix(path.relative(root, absolute));
260
+ const extension = (absolute.split('.').pop() ?? '').toLowerCase();
261
+ if (exports.BINARY_EXTENSIONS.has(extension) || stat.size > maxBytes) {
262
+ skipped += 1;
263
+ continue;
264
+ }
265
+ files.push({ absolutePath: absolute, relativePath, bytes: stat.size });
266
+ }
267
+ }
268
+ }
269
+ else {
270
+ await walk(root);
271
+ }
272
+ files.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
273
+ // De-duplicate (a path can be reached twice when `paths` overlap).
274
+ const unique = [];
275
+ const seen = new Set();
276
+ for (const file of files) {
277
+ if (seen.has(file.relativePath))
278
+ continue;
279
+ seen.add(file.relativePath);
280
+ unique.push(file);
281
+ }
282
+ return { files: unique, skipped, pruned };
283
+ }
284
+ /* -------------------------------------------------------------------------- */
285
+ /* Suppressions */
286
+ /* -------------------------------------------------------------------------- */
287
+ const SUPPRESSION_REGEX = /vibe-shield-(disable-next-line|disable-line|disable-file|disable|enable)\b([^\n]*)$/;
288
+ /**
289
+ * Parse `vibe-shield-disable*` directives from a file.
290
+ *
291
+ * // vibe-shield-disable-next-line VSB-DB-001 -- legacy table
292
+ * // vibe-shield-disable VSB-AI-014
293
+ * // vibe-shield-enable
294
+ */
295
+ function parseSuppressions(lines) {
296
+ const fileWide = [];
297
+ const byLine = new Map();
298
+ const ranges = [];
299
+ // Held in an object so TypeScript's closure narrowing cannot collapse it.
300
+ const state = { open: null };
301
+ const closeRange = (to) => {
302
+ const open = state.open;
303
+ if (!open)
304
+ return;
305
+ ranges.push({ from: open.from, to, ruleIds: open.ruleIds, reason: open.reason });
306
+ state.open = null;
307
+ };
308
+ lines.forEach((line, index) => {
309
+ const match = SUPPRESSION_REGEX.exec(line);
310
+ if (!match)
311
+ return;
312
+ const [, directive = '', tail = ''] = match;
313
+ const [idsPart = '', reasonPart = ''] = tail.split('--');
314
+ const ruleIds = idsPart
315
+ .trim()
316
+ .split(/[\s,]+/)
317
+ .map((id) => id.trim())
318
+ .filter((id) => /^VSB-[A-Z]+(?:-[A-Z]+)?-\d+$/i.test(id) || /^[A-Z]+-\d+$/i.test(id));
319
+ const reason = reasonPart.trim();
320
+ const lineNumber = index + 1;
321
+ if (directive === 'disable-file') {
322
+ fileWide.push({ line: lineNumber, ruleIds, reason });
323
+ return;
324
+ }
325
+ if (directive === 'disable-next-line') {
326
+ byLine.set(lineNumber + 1, { line: lineNumber, ruleIds, reason });
327
+ return;
328
+ }
329
+ if (directive === 'disable-line') {
330
+ byLine.set(lineNumber, { line: lineNumber, ruleIds, reason });
331
+ return;
332
+ }
333
+ if (directive === 'disable') {
334
+ closeRange(lineNumber);
335
+ state.open = { from: lineNumber, ruleIds, reason };
336
+ return;
337
+ }
338
+ if (directive === 'enable')
339
+ closeRange(lineNumber);
340
+ });
341
+ if (state.open)
342
+ closeRange(Number.POSITIVE_INFINITY);
343
+ return { fileWide, byLine, ranges };
344
+ }
345
+ /** True when the finding at `line` is suppressed (and why). */
346
+ function suppressionFor(parsed, ruleId, line) {
347
+ const matches = (ruleIds) => ruleIds.length === 0 || ruleIds.some((id) => id.toUpperCase() === ruleId.toUpperCase());
348
+ for (const suppression of parsed.fileWide) {
349
+ if (matches(suppression.ruleIds))
350
+ return suppression;
351
+ }
352
+ const lineSuppression = parsed.byLine.get(line);
353
+ if (lineSuppression && matches(lineSuppression.ruleIds))
354
+ return lineSuppression;
355
+ for (const range of parsed.ranges) {
356
+ if (line >= range.from && line <= range.to && matches(range.ruleIds)) {
357
+ return { line: range.from, ruleIds: range.ruleIds, reason: range.reason };
358
+ }
359
+ }
360
+ return null;
361
+ }
362
+ /**
363
+ * Apply suppressions to a finding list. Returns the kept findings plus the
364
+ * number that were suppressed (the scorecard reports both).
365
+ */
366
+ function applySuppressions(findings, parsed) {
367
+ const kept = [];
368
+ let suppressed = 0;
369
+ for (const finding of findings) {
370
+ if (suppressionFor(parsed, finding.ruleId, finding.line)) {
371
+ suppressed += 1;
372
+ continue;
373
+ }
374
+ kept.push(finding);
375
+ }
376
+ return { findings: kept, suppressed };
377
+ }
378
+ /* -------------------------------------------------------------------------- */
379
+ /* Scoring */
380
+ /* -------------------------------------------------------------------------- */
381
+ /** Turn a finding list into a 0-100 score with an A+ → F grade. */
382
+ function scoreFindings(findings) {
383
+ const counts = (0, types_1.emptySeverityCounts)();
384
+ for (const finding of findings)
385
+ counts[finding.severity] += 1;
386
+ const total = findings.length;
387
+ if (!total) {
388
+ return { score: 100, grade: 'A+', counts, ceiling: null, total };
389
+ }
390
+ let score = 100;
391
+ for (const finding of findings)
392
+ score -= types_1.SEVERITY_SCORE_PENALTY[finding.severity];
393
+ let ceiling = null;
394
+ for (const severity of ['critical', 'high', 'medium', 'low']) {
395
+ if (counts[severity] === 0)
396
+ continue;
397
+ const limit = types_1.SEVERITY_SCORE_CEILING[severity];
398
+ if (limit === undefined)
399
+ continue;
400
+ if (score > limit || ceiling === null)
401
+ ceiling = { severity, limit };
402
+ score = Math.min(score, limit);
403
+ break;
404
+ }
405
+ score = Math.max(0, Math.round(score * 10) / 10);
406
+ const grade = types_1.GRADE_THRESHOLDS.find((entry) => score >= entry.min)?.grade ?? 'F';
407
+ return { score, grade, counts, ceiling, total };
408
+ }
409
+ /* -------------------------------------------------------------------------- */
410
+ /* Cache */
411
+ /* -------------------------------------------------------------------------- */
412
+ exports.CACHE_FILE_NAME = '.vibe-shield-cache.json';
413
+ exports.CACHE_VERSION = 1;
414
+ /** Content hash used as the cache key for one file. */
415
+ const contentHash = (content) => (0, node_crypto_1.createHash)('sha1').update(content).digest('hex').slice(0, 20);
416
+ exports.contentHash = contentHash;
417
+ /** Fingerprint of the whole project (manifests + layout) for cache validity. */
418
+ const projectFingerprint = (project) => {
419
+ const payload = [
420
+ project.frameworks.join(','),
421
+ project.dependencies.sort().join(','),
422
+ project.migrationFiles.join(','),
423
+ project.hasGlobalAuthMiddleware ? 'mw' : '',
424
+ ].join('|');
425
+ return (0, node_crypto_1.createHash)('sha1').update(payload).digest('hex').slice(0, 16);
426
+ };
427
+ async function readCache(rootDir) {
428
+ try {
429
+ const raw = await node_fs_1.promises.readFile(path.join(rootDir, exports.CACHE_FILE_NAME), 'utf8');
430
+ const parsed = JSON.parse(raw);
431
+ if (parsed.version !== exports.CACHE_VERSION)
432
+ return null;
433
+ if (parsed.rules !== rules_1.RULES_FINGERPRINT)
434
+ return null;
435
+ return parsed;
436
+ }
437
+ catch {
438
+ return null;
439
+ }
440
+ }
441
+ async function writeCache(rootDir, cache) {
442
+ try {
443
+ await node_fs_1.promises.writeFile(path.join(rootDir, exports.CACHE_FILE_NAME), `${JSON.stringify(cache)}\n`, 'utf8');
444
+ }
445
+ catch {
446
+ // A read-only checkout is not an error.
447
+ }
448
+ }
449
+ /* -------------------------------------------------------------------------- */
450
+ /* Rule execution */
451
+ /* -------------------------------------------------------------------------- */
452
+ /**
453
+ * Run a rule set against one file context.
454
+ * Exported because the MCP auditor reuses it: two scanners, one rule contract.
455
+ */
456
+ function runRulesOnFile(ctx, rules) {
457
+ const hits = [];
458
+ for (const rule of rules) {
459
+ if (rule.appliesTo && !rule.appliesTo(ctx))
460
+ continue;
461
+ if (rule.skipFlags?.some((flag) => ctx.flags[flag] === true))
462
+ continue;
463
+ if (rule.languages?.length && !rule.languages.includes(ctx.language))
464
+ continue;
465
+ if (rule.contentHints?.length && !rule.contentHints.some((hint) => hint.test(ctx.content)))
466
+ continue;
467
+ let ruleHits;
468
+ try {
469
+ ruleHits = rule.detect(ctx);
470
+ }
471
+ catch {
472
+ // A throwing rule must never abort a scan over an unrelated file.
473
+ continue;
474
+ }
475
+ for (const hit of ruleHits)
476
+ hits.push(hit);
477
+ }
478
+ return hits;
479
+ }
480
+ /** Convert one rule's hits into fully-populated findings. */
481
+ function toFindings(ctx, rule, hits) {
482
+ const findings = [];
483
+ for (const hit of hits) {
484
+ 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);
486
+ findings.push({
487
+ ruleId: rule.id,
488
+ slug: rule.slug,
489
+ title: rule.title,
490
+ category: rule.category,
491
+ severity: hit.severity ?? rule.severity,
492
+ confidence: hit.confidence ?? rule.confidence,
493
+ cwe: rule.cwe,
494
+ filePath: ctx.relativePath,
495
+ absolutePath: ctx.absolutePath,
496
+ line: located.line,
497
+ column: located.column,
498
+ snippet,
499
+ matched: hit.matched ?? snippet,
500
+ message: hit.message ?? rule.description,
501
+ remediation: rule.remediate ? rule.remediate(ctx, hit) : rule.remediation,
502
+ insecureExample: rule.insecureExample,
503
+ secureExample: rule.secureExample,
504
+ references: rule.references,
505
+ data: hit.data,
506
+ });
507
+ }
508
+ return findings;
509
+ }
510
+ /* -------------------------------------------------------------------------- */
511
+ /* Main scan */
512
+ /* -------------------------------------------------------------------------- */
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
+ // Match the scoped name *or* the legacy bare one, so an install made
525
+ // before the package was published under `@vibefoundry/` still resolves
526
+ // its own version instead of reporting the fallback.
527
+ const isThisPackage = parsed.name === '@vibefoundry/vibe-shield' || parsed.name === 'vibe-shield';
528
+ if (isThisPackage && parsed.version) {
529
+ cachedVersion = parsed.version;
530
+ return cachedVersion;
531
+ }
532
+ }
533
+ catch {
534
+ // Keep walking up.
535
+ }
536
+ const parent = path.dirname(dir);
537
+ if (parent === dir)
538
+ break;
539
+ dir = parent;
540
+ }
541
+ cachedVersion = VERSION;
542
+ return cachedVersion;
543
+ }
544
+ /**
545
+ * Scan a project. This is the one function the CLI, the tests and any embedding
546
+ * application call; everything else in this module is an implementation detail.
547
+ */
548
+ async function scanProject(options) {
549
+ const started = Date.now();
550
+ const rootDir = path.resolve(options.rootDir || '.');
551
+ const rules = (0, rules_1.selectRules)([], options.rules ?? rules_1.SCAN_RULES);
552
+ const notices = [];
553
+ let project;
554
+ try {
555
+ project = await (0, context_1.detectProject)(rootDir, {
556
+ skip: (relativePath, isDirectory) => {
557
+ const base = relativePath.split('/').pop() ?? '';
558
+ return isDirectory && (exports.DEFAULT_IGNORED_DIRS.has(base) || base.startsWith('.git'));
559
+ },
560
+ readGitIndex: options.useGitIndex !== false,
561
+ });
562
+ }
563
+ catch {
564
+ project = (0, types_1.emptyProjectContext)(rootDir);
565
+ notices.push('Project detection failed; project-wide rules (RLS, framework flags) were skipped.');
566
+ }
567
+ options.onProject?.(project);
568
+ const discovered = await discoverFiles(rootDir, options);
569
+ if (!discovered.files.length) {
570
+ notices.push(options.paths?.length
571
+ ? `No scannable files matched: ${options.paths.join(', ')}`
572
+ : 'No scannable files were found. Check the path, or pass --include-docs for documentation.');
573
+ }
574
+ const cacheEnabled = options.cache === true;
575
+ const existing = cacheEnabled ? await readCache(rootDir) : null;
576
+ const cache = {
577
+ version: exports.CACHE_VERSION,
578
+ rules: rules_1.RULES_FINGERPRINT,
579
+ project: projectFingerprint(project),
580
+ files: {},
581
+ };
582
+ const concurrency = Math.max(1, Math.min(256, options.concurrency ?? exports.DEFAULT_CONCURRENCY));
583
+ const findings = [];
584
+ let suppressedCount = 0;
585
+ let cacheHits = 0;
586
+ let bytesScanned = 0;
587
+ let filesScanned = 0;
588
+ let ruleInvocations = 0;
589
+ let index = 0;
590
+ const handleFile = async (file) => {
591
+ let content;
592
+ try {
593
+ const buffer = await node_fs_1.promises.readFile(file.absolutePath);
594
+ // NUL sniff: a text file never contains one in the first 8 KiB.
595
+ if (buffer.subarray(0, 8192).includes(0))
596
+ return;
597
+ content = buffer.toString('utf8');
598
+ }
599
+ catch {
600
+ return;
601
+ }
602
+ const hash = (0, exports.contentHash)(content);
603
+ const cached = existing?.files[file.relativePath];
604
+ if (cached && cached.hash === hash && existing?.project === cache.project && existing?.rules === rules_1.RULES_FINGERPRINT) {
605
+ cacheHits += 1;
606
+ filesScanned += 1;
607
+ bytesScanned += file.bytes;
608
+ findings.push(...cached.findings);
609
+ cache.files[file.relativePath] = cached;
610
+ options.onFile?.(file.relativePath, (index += 1), discovered.files.length);
611
+ return;
612
+ }
613
+ const ctx = (0, context_1.createFileContext)(file.absolutePath, rootDir, content, project);
614
+ const applicable = rules.filter((rule) => (!rule.appliesTo || rule.appliesTo(ctx)) && !rule.skipFlags?.some((flag) => ctx.flags[flag] === true));
615
+ ruleInvocations += applicable.length;
616
+ const fileFindings = [];
617
+ for (const rule of applicable) {
618
+ // Cheap probe first: most rules never run on most files.
619
+ if (rule.contentHints?.length && !rule.contentHints.some((hint) => hint.test(content)))
620
+ continue;
621
+ let hits;
622
+ try {
623
+ hits = rule.detect(ctx);
624
+ }
625
+ catch {
626
+ // A throwing rule must never abort a scan over an unrelated file.
627
+ continue;
628
+ }
629
+ if (hits.length)
630
+ fileFindings.push(...toFindings(ctx, rule, hits));
631
+ }
632
+ const parsed = parseSuppressions(ctx.lines);
633
+ const { findings: kept, suppressed } = applySuppressions(fileFindings, parsed);
634
+ suppressedCount += suppressed;
635
+ const deduped = dedupe(kept);
636
+ findings.push(...deduped);
637
+ cache.files[file.relativePath] = { hash, findings: deduped, sizes: file.bytes };
638
+ filesScanned += 1;
639
+ bytesScanned += file.bytes;
640
+ options.onFile?.(file.relativePath, (index += 1), discovered.files.length);
641
+ };
642
+ // Bounded-concurrency pool: I/O heavy, so a simple work queue beats threads.
643
+ const queue = [...discovered.files];
644
+ const workers = Array.from({ length: Math.min(concurrency, Math.max(1, queue.length)) }, async () => {
645
+ for (;;) {
646
+ const file = queue.shift();
647
+ if (!file)
648
+ return;
649
+ await handleFile(file);
650
+ }
651
+ });
652
+ await Promise.all(workers);
653
+ const unique = dedupe(findings);
654
+ unique.sort((a, b) => types_1.SEVERITY_WEIGHT[b.severity] - types_1.SEVERITY_WEIGHT[a.severity] ||
655
+ a.filePath.localeCompare(b.filePath) ||
656
+ a.line - b.line ||
657
+ a.column - b.column ||
658
+ a.ruleId.localeCompare(b.ruleId));
659
+ if (cacheEnabled)
660
+ await writeCache(rootDir, cache);
661
+ const finished = Date.now();
662
+ const durationMs = Math.max(1, finished - started);
663
+ const score = scoreFindings(unique);
664
+ if (unique.length === 0 && discovered.files.length > 0) {
665
+ notices.push(`No vulnerabilities found across ${discovered.files.length} files. Nice.`);
666
+ }
667
+ if (suppressedCount > 0) {
668
+ notices.push(`${suppressedCount} finding${suppressedCount === 1 ? '' : 's'} suppressed by inline directives.`);
669
+ }
670
+ return {
671
+ rootDir,
672
+ command: 'scan',
673
+ findings: unique,
674
+ score,
675
+ stats: {
676
+ filesDiscovered: discovered.files.length + discovered.skipped,
677
+ filesScanned,
678
+ filesSkipped: discovered.skipped,
679
+ bytesScanned,
680
+ rulesExecuted: rules.length,
681
+ ruleInvocations,
682
+ cacheHits,
683
+ durationMs,
684
+ filesPerSecond: Math.round((filesScanned / durationMs) * 1000),
685
+ megabytesPerSecond: Math.round((bytesScanned / 1024 / 1024 / durationMs) * 1000 * 100) / 100,
686
+ },
687
+ scannedPaths: discovered.files.map((file) => file.relativePath),
688
+ startedAt: new Date(started).toISOString(),
689
+ finishedAt: new Date(finished).toISOString(),
690
+ version: await packageVersion(),
691
+ ruleCount: rules.length,
692
+ suppressedCount,
693
+ notices,
694
+ };
695
+ }
696
+ /** Remove duplicate findings (same rule, file, position and match). */
697
+ function dedupe(findings) {
698
+ const seen = new Set();
699
+ const out = [];
700
+ for (const finding of findings) {
701
+ const key = `${finding.ruleId}|${finding.filePath}|${finding.line}|${finding.column}|${finding.matched}`;
702
+ if (seen.has(key))
703
+ continue;
704
+ seen.add(key);
705
+ out.push(finding);
706
+ }
707
+ return out;
708
+ }
709
+ /** `--fail-on` helper shared by the CLI and the GitHub Action. */
710
+ const hasFindingsAtOrAbove = (result, threshold) => result.findings.some((finding) => (0, types_1.meetsSeverity)(finding.severity, threshold));
711
+ exports.hasFindingsAtOrAbove = hasFindingsAtOrAbove;
712
+ /** Paths tracked by git, used by the CLI's `--strict-gitignore` diagnostics. */
713
+ function gitTrackedPaths(rootDir) {
714
+ try {
715
+ const stdout = (0, node_child_process_1.execFileSync)('git', ['ls-files', '-z'], {
716
+ cwd: rootDir,
717
+ encoding: 'utf8',
718
+ maxBuffer: 64 * 1024 * 1024,
719
+ stdio: ['ignore', 'pipe', 'ignore'],
720
+ });
721
+ return stdout.split('\0').filter(Boolean).map(toPosix);
722
+ }
723
+ catch {
724
+ return [];
725
+ }
726
+ }