@vibefoundry/vibe-shield 1.1.1 → 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 (35) hide show
  1. package/README.md +31 -11
  2. package/SKILL.md +26 -93
  3. package/dist/bin/vibe-shield.d.ts +1 -1
  4. package/dist/bin/vibe-shield.js +38 -11
  5. package/dist/src/init/scaffold.d.ts +1 -1
  6. package/dist/src/init/scaffold.js +26 -93
  7. package/dist/src/mcp/server.js +5 -1
  8. package/dist/src/mcp-guard/skill-auditor.js +11 -3
  9. package/dist/src/reporter/console-reporter.js +14 -0
  10. package/dist/src/reporter/executive.d.ts +4 -0
  11. package/dist/src/reporter/executive.js +58 -0
  12. package/dist/src/reporter/formatters.d.ts +1 -1
  13. package/dist/src/reporter/formatters.js +10 -3
  14. package/dist/src/reporter/patch-prompt-writer.js +3 -3
  15. package/dist/src/scanner/attack-surface.d.ts +4 -0
  16. package/dist/src/scanner/attack-surface.js +50 -0
  17. package/dist/src/scanner/engine.d.ts +2 -3
  18. package/dist/src/scanner/engine.js +21 -39
  19. package/dist/src/scanner/mappings.d.ts +17 -0
  20. package/dist/src/scanner/mappings.js +39 -0
  21. package/dist/src/scanner/redact.d.ts +1 -0
  22. package/dist/src/scanner/redact.js +12 -0
  23. package/dist/src/scanner/rules/hardening.d.ts +5 -0
  24. package/dist/src/scanner/rules/hardening.js +116 -0
  25. package/dist/src/scanner/rules/helpers.js +2 -0
  26. package/dist/src/scanner/rules/index.js +6 -0
  27. package/dist/src/scanner/rules/response-hardening.d.ts +2 -0
  28. package/dist/src/scanner/rules/response-hardening.js +120 -0
  29. package/dist/src/scanner/rules/secret-entropy.js +0 -2
  30. package/dist/src/scanner/types.d.ts +33 -1
  31. package/dist/src/scanner/types.js +6 -2
  32. package/dist/src/scanner/version.d.ts +2 -0
  33. package/dist/src/scanner/version.js +69 -0
  34. package/dist/src/secrets/patterns.js +40 -0
  35. package/package.json +99 -99
@@ -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,37 +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
- // 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
- }
517
+ var version_2 = require("./version");
518
+ Object.defineProperty(exports, "packageVersion", { enumerable: true, get: function () { return version_2.packageVersion; } });
544
519
  /**
545
520
  * Scan a project. This is the one function the CLI, the tests and any embedding
546
521
  * application call; everything else in this module is an implementation detail.
@@ -581,6 +556,7 @@ async function scanProject(options) {
581
556
  };
582
557
  const concurrency = Math.max(1, Math.min(256, options.concurrency ?? exports.DEFAULT_CONCURRENCY));
583
558
  const findings = [];
559
+ const attackSurface = (0, attack_surface_1.emptyAttackSurface)();
584
560
  let suppressedCount = 0;
585
561
  let cacheHits = 0;
586
562
  let bytesScanned = 0;
@@ -599,9 +575,11 @@ async function scanProject(options) {
599
575
  catch {
600
576
  return;
601
577
  }
578
+ const ctx = (0, context_1.createFileContext)(file.absolutePath, rootDir, content, project);
579
+ (0, attack_surface_1.inventoryFile)(ctx, attackSurface);
602
580
  const hash = (0, exports.contentHash)(content);
603
581
  const cached = existing?.files[file.relativePath];
604
- 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) {
605
583
  cacheHits += 1;
606
584
  filesScanned += 1;
607
585
  bytesScanned += file.bytes;
@@ -610,7 +588,6 @@ async function scanProject(options) {
610
588
  options.onFile?.(file.relativePath, (index += 1), discovered.files.length);
611
589
  return;
612
590
  }
613
- const ctx = (0, context_1.createFileContext)(file.absolutePath, rootDir, content, project);
614
591
  const applicable = rules.filter((rule) => (!rule.appliesTo || rule.appliesTo(ctx)) && !rule.skipFlags?.some((flag) => ctx.flags[flag] === true));
615
592
  ruleInvocations += applicable.length;
616
593
  const fileFindings = [];
@@ -650,6 +627,10 @@ async function scanProject(options) {
650
627
  }
651
628
  });
652
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));
653
634
  const unique = dedupe(findings);
654
635
  unique.sort((a, b) => types_1.SEVERITY_WEIGHT[b.severity] - types_1.SEVERITY_WEIGHT[a.severity] ||
655
636
  a.filePath.localeCompare(b.filePath) ||
@@ -670,6 +651,7 @@ async function scanProject(options) {
670
651
  return {
671
652
  rootDir,
672
653
  command: 'scan',
654
+ attackSurface,
673
655
  findings: unique,
674
656
  score,
675
657
  stats: {
@@ -687,7 +669,7 @@ async function scanProject(options) {
687
669
  scannedPaths: discovered.files.map((file) => file.relativePath),
688
670
  startedAt: new Date(started).toISOString(),
689
671
  finishedAt: new Date(finished).toISOString(),
690
- version: await packageVersion(),
672
+ version: await (0, version_1.packageVersion)(),
691
673
  ruleCount: rules.length,
692
674
  suppressedCount,
693
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
+ }
@@ -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;
@@ -0,0 +1,116 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.DOCKER_DEFAULT_ROOT_RULE = exports.MISSING_LOCKFILE_RULE = exports.HARDENING_RULES = void 0;
37
+ /** Focused, local syntax checks. These are indicators, not data-flow proofs. */
38
+ const node_fs_1 = require("node:fs");
39
+ const path = __importStar(require("node:path"));
40
+ const helpers_1 = require("./helpers");
41
+ const shared_1 = require("./shared");
42
+ const code = (c) => /\.(?:[cm]?[jt]sx?|vue|svelte)$/i.test(c.fileName) && !c.flags.isDocumentation && !c.flags.isAgentInstructionFile && !c.flags.isTestFile;
43
+ const cookieCode = (c) => code(c) && /\bcookie/i.test(c.code);
44
+ const manifest = (c) => c.fileName === 'package.json' && !c.flags.isTestFile;
45
+ const docker = (c) => c.language === 'dockerfile' && !c.flags.isTestFile;
46
+ const config = (c) => /^(?:next|vite)\.config\.[cm]?[jt]s$/i.test(c.fileName);
47
+ const specs = [
48
+ { id: 'VSB-CLIENT-008', title: 'Production source maps enabled', category: 'client-exposure', pattern: /\b(?:productionBrowserSourceMaps|sourcemap)\s*:\s*true\b/g, example: 'productionBrowserSourceMaps: true', fix: 'Disable production source maps or upload them privately to a restricted error-monitoring service.', cwe: shared_1.CWES.exposureOfSecrets, scope: config },
49
+ { id: 'VSB-AUTH-010', title: 'JWT accepts the none algorithm', category: 'unguarded-routes', pattern: /\balgorithms\s*:\s*\[[^\]]*['"]none['"]/gi, example: "algorithms: ['none']", fix: 'Require a fixed allowlist of strong signed algorithms and verify issuer, audience and expiry.', cwe: shared_1.CWES.improperCertValidation, scope: code },
50
+ { id: 'VSB-AUTH-011', title: 'JWT decoded without a verification call', category: 'unguarded-routes', pattern: /\bjwt\.decode\s*\(/g, example: 'jwt.decode(token)', fix: 'Verify the signature, allowed algorithm, issuer, audience and expiry before trusting claims.', cwe: shared_1.CWES.improperCertValidation, scope: c => code(c) && !/\bjwt\.verify\s*\(/.test(c.code) },
51
+ { id: 'VSB-INFRA-001', title: 'Wildcard cross-origin response', category: 'infrastructure', pattern: /['"]Access-Control-Allow-Origin['"]\s*[:,]\s*['"]\*['"]/gi, example: "'Access-Control-Allow-Origin': '*'", fix: 'Restrict origins to a reviewed allowlist for sensitive responses.', cwe: shared_1.CWES.browserSecurity, scope: code },
52
+ { id: 'VSB-INFRA-002', title: 'Container explicitly runs as root', category: 'infrastructure', pattern: /^\s*USER\s+(?:root|0)(?:\s|$)/gim, example: 'USER root', fix: 'Use a dedicated unprivileged runtime user and grant only required filesystem access.', cwe: shared_1.CWES.privilegeEscalation, scope: docker },
53
+ { id: 'VSB-INFRA-003', title: 'Secret named in container environment', category: 'infrastructure', pattern: /^\s*(?:ENV|ARG)\s+\w*(?:PASSWORD|SECRET|TOKEN|PRIVATE_KEY)\w*\s*[= ]\s*[^\s$#]+/gim, example: 'ENV API_SECRET=example', fix: 'Use runtime secret injection or build secret mounts; rotate any baked credentials.', cwe: shared_1.CWES.hardcodedCredentials, scope: docker },
54
+ { id: 'VSB-INFRA-004', title: 'Redirect directly uses next parameter', category: 'infrastructure', pattern: /\bredirect\s*\(\s*(?:\w+\.)?searchParams\.get\s*\(\s*['"](?:next|redirect|url)['"]\s*\)/gi, example: "redirect(searchParams.get('next'))", fix: 'Allow only validated relative paths or a strict same-origin allowlist before redirecting.', cwe: shared_1.CWES.openRedirect, scope: code },
55
+ { id: 'VSB-DEPS-001', title: 'Wildcard package dependency', category: 'dependencies', pattern: /"[^"\n]+"\s*:\s*"\*"/g, example: '"library": "*"', fix: 'Pin an appropriate dependency version and commit the lockfile.', cwe: shared_1.CWES.untrustedSearchPath, scope: manifest },
56
+ { id: 'VSB-DEPS-002', title: 'Latest tag used for package dependency', category: 'dependencies', pattern: /"[^"\n]+"\s*:\s*"latest"/gi, example: '"library": "latest"', fix: 'Use a reviewed version and commit a lockfile.', cwe: shared_1.CWES.untrustedSearchPath, scope: manifest },
57
+ { id: 'VSB-DEPS-003', title: 'Remote package dependency', category: 'dependencies', pattern: /"[^"\n]+"\s*:\s*"(?:git\+|git:|https?:\/\/)[^"\n]+"/gi, example: '"library": "https://example.invalid/library.tgz"', fix: 'Review the origin and integrity, prefer a trusted registry and lock immutable versions.', cwe: shared_1.CWES.dependencyConfusion, scope: manifest },
58
+ { id: 'VSB-DEPS-004', title: 'Remote shell execution in install script', category: 'dependencies', pattern: /"(?:preinstall|install|postinstall)"\s*:\s*"[^"\n]*(?:curl|wget)[^"\n]*\|\s*(?:ba)?sh\b/gi, example: '"postinstall": "curl example.invalid | sh"', fix: 'Remove remote shell execution; review and pin any required build tooling.', cwe: shared_1.CWES.commandInjection, scope: manifest },
59
+ { id: 'VSB-DEPS-005', title: 'Encoded PowerShell in install script', category: 'dependencies', pattern: /"(?:preinstall|install|postinstall)"\s*:\s*"[^"\n]*powershell(?:\.exe)?\s+-(?:enc|encodedcommand)\b/gi, example: '"postinstall": "powershell -enc ..."', fix: 'Remove encoded lifecycle scripts and inspect their decoded content before trusting the package.', cwe: shared_1.CWES.commandInjection, scope: manifest },
60
+ { id: 'VSB-CRYPTO-001', title: 'Weak password digest', category: 'secret-entropy', pattern: /\b(?:createHash|hashlib\.(?:new))\s*\(\s*['"](?:md5|sha1)['"]\s*\)[^\n]*(?:password|passwd|pwd)/gi, example: "createHash('sha1').update(password)", fix: 'Use a modern password hashing algorithm such as Argon2id with a unique salt.', cwe: shared_1.CWES.weakCrypto, scope: code },
61
+ { id: 'VSB-CRYPTO-002', title: 'Predictable security token generated with Math.random', category: 'secret-entropy', pattern: /(?:token|otp|csrf|reset|nonce)\w*\s*=\s*[^\n;]*\bMath\.random\s*\(/gi, example: 'const token = Math.random()', fix: 'Use a cryptographically secure random source such as crypto.randomBytes or crypto.randomUUID.', cwe: shared_1.CWES.weakCrypto, scope: code },
62
+ { id: 'VSB-CLIENT-009', title: 'Inline production source maps enabled', category: 'client-exposure', pattern: /\bsourcemap\s*:\s*['"]inline['"]/gi, example: "sourcemap: 'inline'", fix: 'Disable public source maps for production builds or store them privately.', cwe: shared_1.CWES.exposureOfSecrets, scope: config },
63
+ { id: 'VSB-AUTH-012', title: 'JWT signed with a literal secret', category: 'unguarded-routes', pattern: /\bjwt\.sign\s*\([^,\n]+,\s*['"][^'"]{4,}['"]/g, example: "jwt.sign(payload, 'literal-secret')", fix: 'Use a high-entropy secret from a secret manager, rotate exposed keys and specify the expected signing algorithm.', cwe: shared_1.CWES.hardcodedCredentials, scope: code },
64
+ { id: 'VSB-INFRA-005', title: 'Cookie explicitly disables HttpOnly', category: 'infrastructure', pattern: /\bhttpOnly\s*:\s*false\b/gi, example: 'httpOnly: false', fix: 'Set httpOnly on session cookies so browser scripts cannot read them.', cwe: shared_1.CWES.exposureOfSecrets, scope: cookieCode },
65
+ { id: 'VSB-INFRA-006', title: 'Cookie explicitly disables Secure', category: 'infrastructure', pattern: /\bsecure\s*:\s*false\b/gi, example: 'secure: false', fix: 'Set secure on sensitive cookies in production and serve exclusively over HTTPS.', cwe: shared_1.CWES.exposureOfSecrets, scope: cookieCode },
66
+ { id: 'VSB-INFRA-007', title: 'Cookie explicitly disables SameSite', category: 'infrastructure', pattern: /\bsameSite\s*:\s*false\b/gi, example: 'sameSite: false', fix: 'Use SameSite Lax or Strict where compatible, and use CSRF protection for state-changing requests.', cwe: shared_1.CWES.csrf, scope: cookieCode },
67
+ { id: 'VSB-INFRA-008', title: 'CORS reflects origins while allowing credentials', category: 'infrastructure', pattern: /\borigin\s*:\s*true\b/g, example: 'cors({ origin: true, credentials: true })', fix: 'Use an explicit trusted origin allowlist and verify preflight and credential handling.', cwe: shared_1.CWES.browserSecurity, scope: c => code(c) && /\bcredentials\s*:\s*true\b/.test(c.code) },
68
+ { id: 'VSB-CRYPTO-003', title: 'ECB mode encryption', category: 'secret-entropy', pattern: /\b(?:createCipheriv|createCipher)\s*\(\s*['"][^'"]*ecb['"]/gi, example: "createCipheriv('aes-256-ecb', key, null)", fix: 'Use an authenticated encryption mode such as AES-GCM with a unique nonce.', cwe: shared_1.CWES.weakCrypto, scope: code },
69
+ { id: 'VSB-CRYPTO-004', title: 'TLS certificate validation disabled', category: 'secret-entropy', pattern: /\brejectUnauthorized\s*:\s*false\b/g, example: 'rejectUnauthorized: false', fix: 'Enable certificate validation and configure the correct trusted CA for the target.', cwe: shared_1.CWES.improperCertValidation, scope: code },
70
+ { id: 'VSB-CRYPTO-005', title: 'TLS verification disabled by environment', category: 'secret-entropy', pattern: /\bNODE_TLS_REJECT_UNAUTHORIZED\s*=\s*['"]0['"]/g, example: "process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'", fix: 'Remove the override and configure a trusted CA instead of disabling certificate verification.', cwe: shared_1.CWES.improperCertValidation, scope: code },
71
+ ];
72
+ exports.HARDENING_RULES = specs.map(s => (0, helpers_1.defineRule)({
73
+ id: s.id, slug: s.title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/-$/, ''), title: s.title,
74
+ category: s.category, severity: s.id === 'VSB-AUTH-010' ? 'high' : 'medium', confidence: 'medium', cwe: s.cwe,
75
+ description: `${s.title}. Review the surrounding configuration and actual exposure.`,
76
+ insecureExample: s.example, secureExample: s.fix, remediation: [s.fix, 'Review the finding in context and add a regression test for the intended security boundary.'], references: [s.cwe.url],
77
+ appliesTo: s.scope, detect: ctx => {
78
+ if (!['VSB-DEPS-001', 'VSB-DEPS-002', 'VSB-DEPS-003'].includes(s.id))
79
+ return (0, helpers_1.regexHits)(ctx.code, s.pattern);
80
+ try {
81
+ const pkg = JSON.parse(ctx.content);
82
+ const values = ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'].flatMap(key => Object.entries((pkg[key] ?? {})));
83
+ return (0, helpers_1.regexHits)(ctx.content, s.pattern, m => {
84
+ const pair = /^"([^"]+)"\s*:\s*"([^"]+)"/.exec(m[0]);
85
+ return pair && values.some(([key, value]) => key === pair[1] && value === pair[2]) ? {} : null;
86
+ });
87
+ }
88
+ catch {
89
+ return [];
90
+ }
91
+ },
92
+ }));
93
+ exports.MISSING_LOCKFILE_RULE = (0, helpers_1.defineRule)({
94
+ id: 'VSB-DEPS-006', slug: 'missing-node-lockfile', title: 'Node project has no lockfile', category: 'dependencies',
95
+ severity: 'medium', confidence: 'medium', cwe: shared_1.CWES.untrustedSearchPath,
96
+ description: 'A package manifest without a lockfile can resolve different dependency versions across installs.',
97
+ insecureExample: 'package.json without a lockfile', secureExample: 'Commit a lockfile generated by the package manager.',
98
+ remediation: ['Commit a reviewed lockfile for the package manager used by this project.', 'Use the package manager’s frozen-lockfile mode in CI.'], references: ['https://docs.npmjs.com/cli/v10/configuring-npm/package-lock-json'],
99
+ appliesTo: ctx => manifest(ctx) && ctx.absolutePath === path.join(ctx.project.rootDir, 'package.json'),
100
+ detect: ctx => ['package-lock.json', 'npm-shrinkwrap.json', 'yarn.lock', 'pnpm-lock.yaml', 'bun.lock', 'bun.lockb'].some(n => (0, node_fs_1.existsSync)(path.join(ctx.project.rootDir, n))) ? [] : [{ line: 1, column: 1, matched: 'package.json' }],
101
+ });
102
+ /** Docker defaults to root when the final stage never selects a runtime user. */
103
+ exports.DOCKER_DEFAULT_ROOT_RULE = (0, helpers_1.defineRule)({
104
+ id: 'VSB-INFRA-009', slug: 'container-default-root', title: 'Container has no final-stage USER', category: 'infrastructure',
105
+ severity: 'medium', confidence: 'medium', cwe: shared_1.CWES.privilegeEscalation,
106
+ description: 'Without a USER directive in the final build stage, a container may run as root unless its base image specifies a user.',
107
+ insecureExample: 'FROM node:22-alpine', secureExample: 'FROM node:22-alpine\nUSER node',
108
+ remediation: ['Select a non-root runtime user in the final stage.', 'Test file permissions and runtime access with that user.'], references: [shared_1.CWES.privilegeEscalation.url],
109
+ appliesTo: docker, detect: ctx => {
110
+ const stages = [...ctx.code.matchAll(/^\s*FROM\s+[^\n]+/gim)];
111
+ const last = stages[stages.length - 1];
112
+ if (!last || /^\s*USER\s+\S+/im.test(ctx.code.slice(last.index)))
113
+ return [];
114
+ return [{ offset: last.index, matched: last[0].trim() }];
115
+ },
116
+ });
@@ -13,6 +13,7 @@ exports.defineRule = defineRule;
13
13
  exports.regexHits = regexHits;
14
14
  exports.bodyFrom = bodyFrom;
15
15
  exports.fileHit = fileHit;
16
+ const mappings_1 = require("../mappings");
16
17
  const text_1 = require("../text");
17
18
  /** Does this file match the declarative scope of a rule? */
18
19
  function inScope(def, ctx) {
@@ -36,6 +37,7 @@ function defineRule(def) {
36
37
  const custom = def.appliesTo;
37
38
  const rule = {
38
39
  ...def,
40
+ owasp: def.owasp ?? (0, mappings_1.owaspMappings)(def.id, def.cwe.id),
39
41
  appliesTo: (ctx) => (custom ? custom(ctx) && inScope(def, ctx) : inScope(def, ctx)),
40
42
  };
41
43
  return rule;
@@ -12,6 +12,8 @@ exports.RULES_FINGERPRINT = exports.ALL_RULES = exports.SCAN_RULES = exports.fil
12
12
  exports.getRule = getRule;
13
13
  exports.rulesByCategory = rulesByCategory;
14
14
  exports.selectRules = selectRules;
15
+ const response_hardening_1 = require("./response-hardening");
16
+ const hardening_1 = require("./hardening");
15
17
  const ai_vulnerabilities_1 = require("./ai-vulnerabilities");
16
18
  const client_exposure_1 = require("./client-exposure");
17
19
  const database_exposure_1 = require("./database-exposure");
@@ -44,6 +46,10 @@ exports.SCAN_RULES = [
44
46
  ...database_exposure_1.DATABASE_EXPOSURE_RULES,
45
47
  ...ai_vulnerabilities_1.AI_VULNERABILITY_RULES,
46
48
  ...secret_entropy_1.SECRET_ENTROPY_RULES,
49
+ ...hardening_1.HARDENING_RULES,
50
+ ...response_hardening_1.RESPONSE_HARDENING_RULES,
51
+ hardening_1.MISSING_LOCKFILE_RULE,
52
+ hardening_1.DOCKER_DEFAULT_ROOT_RULE,
47
53
  ];
48
54
  /** Every rule the tool knows about, including the agent-integrity family. */
49
55
  exports.ALL_RULES = [...exports.SCAN_RULES, ...skill_auditor_1.AGENT_RULES];
@@ -0,0 +1,2 @@
1
+ import type { Rule } from '../types';
2
+ export declare const RESPONSE_HARDENING_RULES: readonly Rule[];
@@ -0,0 +1,120 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.RESPONSE_HARDENING_RULES = void 0;
4
+ /** Local configuration signals, not proof of effective response headers or cookies. */
5
+ const helpers_1 = require("./helpers");
6
+ const shared_1 = require("./shared");
7
+ const sourceFile = (ctx) => /\.(?:[cm]?[jt]sx?)$/i.test(ctx.fileName) &&
8
+ !ctx.flags.isTestFile && !ctx.flags.isDocumentation && !ctx.flags.isGenerated && !ctx.flags.isAgentInstructionFile;
9
+ const nextConfig = (ctx) => /^next\.config\.[cm]?[jt]s$/i.test(ctx.fileName);
10
+ const serverConfig = (ctx) => /\b(?:app|server|fastify)\.listen\s*\(/.test(ctx.code) && /\b(?:express|fastify|hono)\b/i.test(ctx.code);
11
+ const headers = [
12
+ { id: 'VSB-INFRA-010', slug: 'missing-content-security-policy', name: 'Content-Security-Policy', probe: /\b(?:Content-Security-Policy|contentSecurityPolicy)\b/i, hint: 'Define a restrictive Content-Security-Policy appropriate to the app, including a safe script-src.', cwe: (0, shared_1.cwe)('CWE-693', 'Protection Mechanism Failure') },
13
+ { id: 'VSB-INFRA-011', slug: 'missing-frame-protection', name: 'X-Frame-Options', probe: /\b(?:X-Frame-Options|frameguard|frame-ancestors)\b/i, hint: 'Set X-Frame-Options or a CSP frame-ancestors directive appropriate to the application.', cwe: (0, shared_1.cwe)('CWE-693', 'Protection Mechanism Failure') },
14
+ { id: 'VSB-INFRA-012', slug: 'missing-hsts', name: 'Strict-Transport-Security', probe: /\b(?:Strict-Transport-Security|hsts)\b/i, hint: 'Enable HSTS at the HTTPS edge after verifying HTTPS coverage across the relevant hosts.', cwe: (0, shared_1.cwe)('CWE-693', 'Protection Mechanism Failure') },
15
+ ];
16
+ const HEADER_RULES = headers.map(header => (0, helpers_1.defineRule)({
17
+ id: header.id, slug: header.slug, title: `${header.name} not observed in server configuration`,
18
+ category: 'infrastructure', severity: 'low', confidence: 'low', cwe: header.cwe,
19
+ description: `No ${header.name} configuration was observed in this server configuration file. It may be supplied by middleware, the hosting platform, or a reverse proxy; verify the deployed response.`,
20
+ insecureExample: '// No security header configured in this file', secureExample: header.hint,
21
+ remediation: [header.hint, 'Verify actual response headers at the deployed edge, including error responses and any proxy configuration.'],
22
+ references: ['https://cheatsheetseries.owasp.org/cheatsheets/HTTP_Headers_Cheat_Sheet.html', header.cwe.url],
23
+ appliesTo: ctx => sourceFile(ctx) && (nextConfig(ctx) || serverConfig(ctx)),
24
+ detect: ctx => {
25
+ // Helmet with no options enables these default protections. A configured or
26
+ // indirect middleware is not assumed to do so, and the result is advisory.
27
+ if (header.probe.test(ctx.code) || /\bhelmet\s*\(\s*\)/.test(ctx.code))
28
+ return [];
29
+ return [{ line: 1, column: 1, matched: ctx.lineText(1).trim(), message: `${header.name} was not observed in this file. Check middleware and deployment headers before treating this as missing.` }];
30
+ },
31
+ }));
32
+ /** Extract top-level call arguments, respecting nested delimiters and quotes. */
33
+ function callArguments(code, open) {
34
+ const stack = [')'];
35
+ const args = [];
36
+ let start = open + 1;
37
+ let quote = '';
38
+ for (let i = start; i < code.length && i < open + 12_000; i += 1) {
39
+ const ch = code[i];
40
+ if (quote) {
41
+ if (ch === '\\') {
42
+ i += 1;
43
+ continue;
44
+ }
45
+ if (ch === quote)
46
+ quote = '';
47
+ continue;
48
+ }
49
+ if (ch === '"' || ch === "'" || ch === '`') {
50
+ quote = ch;
51
+ continue;
52
+ }
53
+ if (ch === '(' || ch === '[' || ch === '{') {
54
+ stack.push(ch === '(' ? ')' : ch === '[' ? ']' : '}');
55
+ continue;
56
+ }
57
+ if (ch === stack[stack.length - 1]) {
58
+ stack.pop();
59
+ if (!stack.length) {
60
+ args.push(code.slice(start, i).trim());
61
+ return args;
62
+ }
63
+ continue;
64
+ }
65
+ if (ch === ',' && stack.length === 1) {
66
+ args.push(code.slice(start, i).trim());
67
+ start = i + 1;
68
+ }
69
+ }
70
+ return null;
71
+ }
72
+ function cookieCalls(ctx) {
73
+ const calls = [];
74
+ const pattern = /\b(?:[\w$]+\.cookie|[\w$]+\.cookies\.set|cookies\s*\(\s*\)\s*\.set|setCookie|cookie\.serialize)\s*\(/g;
75
+ for (const match of ctx.code.matchAll(pattern)) {
76
+ const args = callArguments(ctx.code, match.index + match[0].lastIndexOf('('));
77
+ if (!args || !args[0])
78
+ continue;
79
+ const singleObject = args.length === 1 && args[0].startsWith('{');
80
+ const required = /^setCookie\s*\(/.test(match[0]) ? 3 : 2;
81
+ const last = args[args.length - 1];
82
+ // Dynamic option expressions and object spreads may provide flags; skip
83
+ // rather than claiming they are absent. Different setters remain separate.
84
+ if (singleObject) {
85
+ if (/\.\.\./.test(last))
86
+ continue;
87
+ calls.push({ offset: match.index, options: last });
88
+ }
89
+ else if (args.length === required) {
90
+ calls.push({ offset: match.index, options: null });
91
+ }
92
+ else if (args.length === required + 1 && /^\{[\s\S]*\}$/.test(last) && !/\.\.\./.test(last)) {
93
+ calls.push({ offset: match.index, options: last });
94
+ }
95
+ }
96
+ return calls;
97
+ }
98
+ const flags = [
99
+ { id: 'VSB-INFRA-013', slug: 'cookie-missing-httponly', name: 'HttpOnly', key: 'httpOnly', cwe: (0, shared_1.cwe)('CWE-1004', 'Sensitive Cookie Without HttpOnly Flag'), fix: 'Set httpOnly: true on sensitive cookies to limit access from browser scripts.' },
100
+ { id: 'VSB-INFRA-014', slug: 'cookie-missing-secure', name: 'Secure', key: 'secure', cwe: (0, shared_1.cwe)('CWE-614', 'Sensitive Cookie in HTTPS Session Without Secure Attribute'), fix: 'Set secure: true on sensitive cookies served over HTTPS.' },
101
+ { id: 'VSB-INFRA-015', slug: 'cookie-missing-samesite', name: 'SameSite', key: 'sameSite', cwe: (0, shared_1.cwe)('CWE-1275', 'Sensitive Cookie with Improper SameSite Attribute'), fix: 'Set sameSite to lax or strict where appropriate, and use CSRF protections for state-changing requests.' },
102
+ ];
103
+ /** Only top-level keys belong to the cookie options object. */
104
+ function hasOption(options, key) {
105
+ const properties = callArguments(`(${options.slice(1, -1)})`, 0) ?? [];
106
+ const name = new RegExp(`^\\s*["']?${key}["']?(?:\\s*:|\\s*$)`, 'i');
107
+ return properties.some(property => name.test(property));
108
+ }
109
+ const COOKIE_RULES = flags.map(flag => (0, helpers_1.defineRule)({
110
+ id: flag.id, slug: flag.slug, title: `Cookie setter does not specify ${flag.name}`, category: 'infrastructure',
111
+ severity: 'medium', confidence: 'medium', cwe: flag.cwe,
112
+ description: `The observed cookie setter omits ${flag.name} in its literal options. Framework defaults and shared configuration may supply it; confirm the deployed Set-Cookie header and whether the cookie is sensitive.`,
113
+ insecureExample: "res.cookie('session', token, {})", secureExample: `res.cookie('session', token, { httpOnly: true, secure: true, sameSite: 'lax' })`,
114
+ remediation: [flag.fix, 'Check framework defaults, shared middleware and the actual Set-Cookie response; add regression tests for the chosen attributes.'],
115
+ references: ['https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html', flag.cwe.url],
116
+ appliesTo: sourceFile,
117
+ detect: ctx => cookieCalls(ctx).filter(call => !call.options || !hasOption(call.options, flag.key))
118
+ .map((call) => ({ offset: call.offset, matched: ctx.lineText(ctx.locate(call.offset).line).trim().slice(0, 120), message: `${flag.name} was not specified on this cookie setter; check defaults and the actual response.` })),
119
+ }));
120
+ exports.RESPONSE_HARDENING_RULES = [...HEADER_RULES, ...COOKIE_RULES];
@@ -68,7 +68,6 @@ const vendorSecret = (0, helpers_1.defineRule)({
68
68
  extensions: [...CLIENT_EXTENSIONS, 'py', 'rb', 'php', 'go', 'sh', 'yml', 'yaml', 'json', 'toml', 'tf', 'env'],
69
69
  skipFlags: ['isLockfile', 'isGenerated'],
70
70
  appliesTo: (ctx) => !isNonShipping(ctx) && !isDetectorMaterial(ctx),
71
- contentHints: [/sk_|pk_live|AKIA|ghp_|xox[baprs]-|AIza|sk-ant-|eyJhbGciOi|whsec_|SG\./],
72
71
  detect(ctx) {
73
72
  return secretHits(ctx, { skipGeneric: true })
74
73
  .filter((match) => match.family !== 'private-key' && match.family !== 'jwt')
@@ -216,7 +215,6 @@ const clientVendorSecret = (0, helpers_1.defineRule)({
216
215
  extensions: CLIENT_EXTENSIONS,
217
216
  skipFlags: ['isLockfile', 'isGenerated'],
218
217
  appliesTo: (ctx) => (0, shared_1.isBrowserFile)(ctx) && !isNonShipping(ctx) && !isDetectorMaterial(ctx),
219
- contentHints: [/sk_|pk_live|AKIA|ghp_|xox[baprs]-|AIza|sk-ant-|eyJhbGciOi|whsec_/],
220
218
  detect(ctx) {
221
219
  return secretHits(ctx, { skipGeneric: true })
222
220
  .filter((match) => match.family !== 'private-key')
@@ -1,3 +1,4 @@
1
+ import type { OwaspMappings } from './mappings';
1
2
  /**
2
3
  * vibe-shield :: scanner type system
3
4
  * ----------------------------------
@@ -16,7 +17,7 @@ export type Confidence = (typeof CONFIDENCES)[number];
16
17
  * Rule families. Each family maps 1:1 to a module in `src/scanner/rules`
17
18
  * (plus `agent-integrity`, which is produced by the MCP / skill auditor).
18
19
  */
19
- export declare const RULE_CATEGORIES: readonly ["client-exposure", "unguarded-routes", "database-exposure", "ai-vulnerabilities", "secret-entropy", "agent-integrity"];
20
+ export declare const RULE_CATEGORIES: readonly ["client-exposure", "unguarded-routes", "database-exposure", "ai-vulnerabilities", "secret-entropy", "infrastructure", "dependencies", "agent-integrity"];
20
21
  export type RuleCategory = (typeof RULE_CATEGORIES)[number];
21
22
  export declare const CATEGORY_TITLES: Record<RuleCategory, string>;
22
23
  /** Relative severity weight used by the scoring model. */
@@ -218,6 +219,8 @@ export interface Rule {
218
219
  severity: Severity;
219
220
  confidence: Confidence;
220
221
  cwe: Cwe;
222
+ /** OWASP associations; LLM mappings apply only where context warrants. */
223
+ owasp?: OwaspMappings;
221
224
  /** Why this matters — one or two sentences, written for the coding agent. */
222
225
  description: string;
223
226
  /** Code shape that triggers the rule. */
@@ -264,8 +267,11 @@ export interface Finding {
264
267
  severity: Severity;
265
268
  confidence: Confidence;
266
269
  cwe: Cwe;
270
+ owasp?: OwaspMappings;
267
271
  /** POSIX path relative to the scan root. */
268
272
  filePath: string;
273
+ /** Alias for filePath, relative to the scan root. */
274
+ file: string;
269
275
  absolutePath: string;
270
276
  /** 1-based. */
271
277
  line: number;
@@ -297,6 +303,31 @@ export interface ScanStats {
297
303
  }
298
304
  /** Severity histogram. */
299
305
  export type SeverityCounts = Record<Severity, number>;
306
+ /** Static inventory; guard and RLS signals are not proof of effective protection. */
307
+ export interface AttackSurface {
308
+ routes: Array<{
309
+ file: string;
310
+ line: number;
311
+ method: string;
312
+ path: string;
313
+ auth: 'guard-signal' | 'unknown';
314
+ }>;
315
+ tables: Array<{
316
+ file: string;
317
+ line: number;
318
+ table: string;
319
+ rls: 'enabled-signal' | 'not-observed';
320
+ }>;
321
+ aiEntrypoints: Array<{
322
+ file: string;
323
+ line: number;
324
+ }>;
325
+ agentFiles: Array<{
326
+ file: string;
327
+ kind: 'mcp' | 'skill';
328
+ name?: string;
329
+ }>;
330
+ }
300
331
  /** Final scorecard. */
301
332
  export interface SecurityScore {
302
333
  /** 0-100, rounded to one decimal. */
@@ -313,6 +344,7 @@ export interface SecurityScore {
313
344
  export interface ScanResult {
314
345
  rootDir: string;
315
346
  command: 'scan' | 'audit-skills';
347
+ attackSurface?: AttackSurface;
316
348
  findings: Finding[];
317
349
  score: SecurityScore;
318
350
  stats: ScanStats;