@maverick006/security-engine 1.0.2 → 1.0.4

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/dist/scoring.js CHANGED
@@ -3,69 +3,139 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.calculateScore = calculateScore;
4
4
  const types_1 = require("@maverick006/types");
5
5
  /**
6
- * Calculates a deterministic security score and grade based on the volume and severity of findings.
7
- * Weights: CRITICAL=100, HIGH=20, MEDIUM=5, LOW=1
6
+ * Calculates a deterministic, reproducible security score (0 to 100) and grade
7
+ * based on the volume and severity of unique findings.
8
8
  *
9
- * Grade thresholds:
10
- * A: <= 10 (e.g., up to 2 mediums, or 10 lows)
11
- * B: <= 30 (e.g., 1 high, or several mediums)
12
- * C: <= 70 (e.g., 3 highs)
13
- * D: <= 150 (e.g., many highs, but no criticals. Or 1 critical and nothing else = 100, wait, 1 critical is auto F)
14
- * F: > 150 OR any CRITICAL finding.
9
+ * Rules:
10
+ * - Base score: 100
11
+ * - Critical: -30 points per finding (any critical forces Grade F and caps score at <= 49)
12
+ * - High: -10 points per finding
13
+ * - Medium: -3 points per finding
14
+ * - Low: -1 point per finding
15
+ * - Info: 0 points
16
+ *
17
+ * Grades:
18
+ * A: 90 - 100
19
+ * B: 80 - 89
20
+ * C: 70 - 79
21
+ * D: 50 - 69
22
+ * F: < 50 OR any CRITICAL finding
15
23
  */
16
- function calculateScore(findings) {
24
+ function calculateScore(findings, coverage) {
17
25
  let critical = 0;
18
26
  let high = 0;
19
27
  let medium = 0;
20
28
  let low = 0;
21
- let score = 0;
29
+ let info = 0;
22
30
  for (const finding of findings) {
23
- switch (finding.severity) {
31
+ const sev = (finding.severity || '').toUpperCase();
32
+ switch (sev) {
24
33
  case types_1.Severity.CRITICAL:
34
+ case 'CRITICAL':
25
35
  critical++;
26
- score += 100;
27
36
  break;
28
37
  case types_1.Severity.HIGH:
38
+ case 'HIGH':
29
39
  high++;
30
- score += 20;
31
40
  break;
32
41
  case types_1.Severity.MEDIUM:
42
+ case 'MEDIUM':
33
43
  medium++;
34
- score += 5;
35
44
  break;
36
45
  case types_1.Severity.LOW:
37
- case types_1.Severity.INFO:
46
+ case 'LOW':
38
47
  low++;
39
- score += 1;
48
+ break;
49
+ default:
50
+ info++;
40
51
  break;
41
52
  }
42
53
  }
54
+ const critDeduction = critical * 30;
55
+ const highDeduction = high * 10;
56
+ const medDeduction = medium * 3;
57
+ const lowDeduction = low * 1;
58
+ const totalDeductions = critDeduction + highDeduction + medDeduction + lowDeduction;
59
+ let rawScore = 100 - totalDeductions;
60
+ let score = Math.max(0, Math.min(100, rawScore));
61
+ const explanation = [
62
+ 'Baseline score: 100/100'
63
+ ];
64
+ if (critical > 0) {
65
+ explanation.push(`-${critDeduction} points: ${critical} Critical severity ${critical === 1 ? 'finding' : 'findings'} (-30 pts each)`);
66
+ }
67
+ if (high > 0) {
68
+ explanation.push(`-${highDeduction} points: ${high} High severity ${high === 1 ? 'finding' : 'findings'} (-10 pts each)`);
69
+ }
70
+ if (medium > 0) {
71
+ explanation.push(`-${medDeduction} points: ${medium} Medium severity ${medium === 1 ? 'finding' : 'findings'} (-3 pts each)`);
72
+ }
73
+ if (low > 0) {
74
+ explanation.push(`-${lowDeduction} points: ${low} Low severity ${low === 1 ? 'finding' : 'findings'} (-1 pt each)`);
75
+ }
76
+ if (findings.length === 0) {
77
+ explanation.push('No security findings identified across executed scanners (+0 deductions)');
78
+ }
43
79
  let grade = 'A';
44
80
  if (critical > 0) {
45
- // A single CRITICAL vulnerability drops the grade to F immediately.
81
+ // Critical vulnerability automatically caps grade to F and score to at most 49
46
82
  grade = 'F';
83
+ score = Math.min(score, 49);
84
+ explanation.push(`Grade Override: F (1 or more Critical severity findings detected)`);
47
85
  }
48
- else if (score > 150) {
49
- grade = 'F';
86
+ else if (score >= 90) {
87
+ grade = 'A';
50
88
  }
51
- else if (score > 70) {
52
- grade = 'D';
89
+ else if (score >= 80) {
90
+ grade = 'B';
53
91
  }
54
- else if (score > 30) {
92
+ else if (score >= 70) {
55
93
  grade = 'C';
56
94
  }
57
- else if (score > 10) {
58
- grade = 'B';
95
+ else if (score >= 50) {
96
+ grade = 'D';
97
+ }
98
+ else {
99
+ grade = 'F';
59
100
  }
101
+ explanation.push(`Final deterministic score: ${score}/100 (Grade ${grade})`);
102
+ const deductions = {
103
+ critical: critDeduction,
104
+ high: highDeduction,
105
+ medium: medDeduction,
106
+ low: lowDeduction,
107
+ info: 0,
108
+ totalDeductions
109
+ };
110
+ const defaultCoverage = {
111
+ code: false,
112
+ dependencies: false,
113
+ secrets: false,
114
+ containers: false,
115
+ iac: false,
116
+ web: false,
117
+ cloud: false,
118
+ ...coverage
119
+ };
60
120
  return {
61
121
  score,
62
122
  grade,
63
- metrics: {
123
+ deductions,
124
+ breakdown: {
64
125
  critical,
65
126
  high,
66
127
  medium,
67
128
  low,
68
- }
129
+ info
130
+ },
131
+ metrics: {
132
+ critical,
133
+ high,
134
+ medium,
135
+ low
136
+ },
137
+ coverage: defaultCoverage,
138
+ explanation
69
139
  };
70
140
  }
71
141
  //# sourceMappingURL=scoring.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"scoring.js","sourceRoot":"","sources":["../src/scoring.ts"],"names":[],"mappings":";;AAwBA,wCAsDC;AA9ED,8CAAiE;AAajE;;;;;;;;;;GAUG;AACH,SAAgB,cAAc,CAAC,QAA6B;IAC1D,IAAI,QAAQ,GAAG,CAAC,CAAC;IACjB,IAAI,IAAI,GAAG,CAAC,CAAC;IACb,IAAI,MAAM,GAAG,CAAC,CAAC;IACf,IAAI,GAAG,GAAG,CAAC,CAAC;IACZ,IAAI,KAAK,GAAG,CAAC,CAAC;IAEd,KAAK,MAAM,OAAO,IAAI,QAAQ,EAAE,CAAC;QAC/B,QAAQ,OAAO,CAAC,QAAQ,EAAE,CAAC;YACzB,KAAK,gBAAQ,CAAC,QAAQ;gBACpB,QAAQ,EAAE,CAAC;gBACX,KAAK,IAAI,GAAG,CAAC;gBACb,MAAM;YACR,KAAK,gBAAQ,CAAC,IAAI;gBAChB,IAAI,EAAE,CAAC;gBACP,KAAK,IAAI,EAAE,CAAC;gBACZ,MAAM;YACR,KAAK,gBAAQ,CAAC,MAAM;gBAClB,MAAM,EAAE,CAAC;gBACT,KAAK,IAAI,CAAC,CAAC;gBACX,MAAM;YACR,KAAK,gBAAQ,CAAC,GAAG,CAAC;YAClB,KAAK,gBAAQ,CAAC,IAAI;gBAChB,GAAG,EAAE,CAAC;gBACN,KAAK,IAAI,CAAC,CAAC;gBACX,MAAM;QACV,CAAC;IACH,CAAC;IAED,IAAI,KAAK,GAAyB,GAAG,CAAC;IAEtC,IAAI,QAAQ,GAAG,CAAC,EAAE,CAAC;QACjB,oEAAoE;QACpE,KAAK,GAAG,GAAG,CAAC;IACd,CAAC;SAAM,IAAI,KAAK,GAAG,GAAG,EAAE,CAAC;QACvB,KAAK,GAAG,GAAG,CAAC;IACd,CAAC;SAAM,IAAI,KAAK,GAAG,EAAE,EAAE,CAAC;QACtB,KAAK,GAAG,GAAG,CAAC;IACd,CAAC;SAAM,IAAI,KAAK,GAAG,EAAE,EAAE,CAAC;QACtB,KAAK,GAAG,GAAG,CAAC;IACd,CAAC;SAAM,IAAI,KAAK,GAAG,EAAE,EAAE,CAAC;QACtB,KAAK,GAAG,GAAG,CAAC;IACd,CAAC;IAED,OAAO;QACL,KAAK;QACL,KAAK;QACL,OAAO,EAAE;YACP,QAAQ;YACR,IAAI;YACJ,MAAM;YACN,GAAG;SACJ;KACF,CAAC;AACJ,CAAC"}
1
+ {"version":3,"file":"scoring.js","sourceRoot":"","sources":["../src/scoring.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@maverick006/security-engine",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
+ "description": "VibeGuard Security Engine",
4
5
  "main": "dist/index.js",
5
6
  "types": "dist/index.d.ts",
6
7
  "scripts": {
@@ -10,12 +11,12 @@
10
11
  "typecheck": "tsc --noEmit"
11
12
  },
12
13
  "dependencies": {
13
- "@maverick006/types": "*"
14
+ "@maverick006/types": "*",
15
+ "typescript": "^5.0.0"
14
16
  },
15
17
  "devDependencies": {
16
- "typescript": "^5.0.0",
17
18
  "jest": "^29.5.0",
18
19
  "@types/jest": "^29.5.0",
19
20
  "ts-jest": "^29.1.0"
20
21
  }
21
- }
22
+ }
@@ -0,0 +1,118 @@
1
+ import { NormalizedFinding, Severity } from '@maverick006/types';
2
+
3
+ /**
4
+ * Options for finding deduplication.
5
+ */
6
+ export interface DeduplicationOptions {
7
+ lineTolerance?: number; // default +/- 3 lines
8
+ }
9
+
10
+ /**
11
+ * Normalizes a rule or CVE identifier to detect cross-scanner duplicates.
12
+ */
13
+ function normalizeIdentifier(finding: NormalizedFinding): string {
14
+ // If CVE or CWE is present, prioritize that as universal identifier
15
+ const cveMatch = (finding.ruleId || finding.title || finding.description || '').match(/CVE-\d{4}-\d+/i);
16
+ if (cveMatch) {
17
+ return cveMatch[0].toUpperCase();
18
+ }
19
+
20
+ // If package-based vulnerability, match package + cwe/rule
21
+ if (finding.package) {
22
+ const pkg = finding.package.toLowerCase();
23
+ const cwe = finding.cwe ? finding.cwe.toLowerCase() : '';
24
+ return `pkg:${pkg}:${cwe || finding.ruleId || 'vuln'}`;
25
+ }
26
+
27
+ // Generic rule ID fallback
28
+ return (finding.ruleId || finding.title || 'unknown').toLowerCase().trim();
29
+ }
30
+
31
+ /**
32
+ * Normalizes file paths for reliable cross-platform comparison.
33
+ */
34
+ function normalizeFilePath(filePath?: string): string {
35
+ if (!filePath) return '';
36
+ return filePath.replace(/\\/g, '/').toLowerCase().trim();
37
+ }
38
+
39
+ /**
40
+ * Merges two findings for the same vulnerability, retaining the highest confidence and most complete metadata.
41
+ */
42
+ function mergeFindings(primary: NormalizedFinding, secondary: NormalizedFinding): NormalizedFinding {
43
+ return {
44
+ ...primary,
45
+ // Keep the more severe or primary severity
46
+ severity: primary.severity || secondary.severity,
47
+ confidence: primary.confidence || secondary.confidence,
48
+ cwe: primary.cwe || secondary.cwe,
49
+ owasp: primary.owasp || secondary.owasp,
50
+ remediation: primary.remediation || secondary.remediation,
51
+ codeSnippet: primary.codeSnippet || secondary.codeSnippet,
52
+ package: primary.package || secondary.package,
53
+ packageVersion: primary.packageVersion || secondary.packageVersion,
54
+ fixedVersion: primary.fixedVersion || secondary.fixedVersion,
55
+ references: Array.from(new Set([...(primary.references || []), ...(secondary.references || [])])),
56
+ // Note in scanner if multiple scanners detected it
57
+ scanner: primary.scanner.includes(secondary.scanner)
58
+ ? primary.scanner
59
+ : `${primary.scanner}, ${secondary.scanner}`
60
+ };
61
+ }
62
+
63
+ /**
64
+ * Deduplicates findings across multiple scanners:
65
+ * 1. Exact duplicates (same scanner, file, rule, line).
66
+ * 2. Cross-scanner duplicates (e.g. Trivy & npm-audit reporting the same CVE or package vuln).
67
+ * 3. Line shift tolerance (+/- 3 lines) to handle minor offset differences.
68
+ * 4. Preserves distinct vulnerabilities on the same file/line.
69
+ */
70
+ export function deduplicateFindings(
71
+ findings: NormalizedFinding[],
72
+ options: DeduplicationOptions = {}
73
+ ): NormalizedFinding[] {
74
+ const lineTolerance = options.lineTolerance ?? 3;
75
+ const deduplicated: NormalizedFinding[] = [];
76
+
77
+ for (const candidate of findings) {
78
+ const candidateFile = normalizeFilePath(candidate.file);
79
+ const candidateId = normalizeIdentifier(candidate);
80
+ const candidateLine = candidate.line || 0;
81
+
82
+ let matchedIndex = -1;
83
+
84
+ for (let i = 0; i < deduplicated.length; i++) {
85
+ const existing = deduplicated[i];
86
+ const existingFile = normalizeFilePath(existing.file);
87
+ const existingId = normalizeIdentifier(existing);
88
+ const existingLine = existing.line || 0;
89
+
90
+ // Check for same file & same vulnerability identity
91
+ const isSameFile = candidateFile && existingFile ? candidateFile === existingFile : true;
92
+ const isSameVuln = candidateId === existingId;
93
+
94
+ if (isSameFile && isSameVuln) {
95
+ // If line numbers exist, check line tolerance
96
+ if (candidateLine > 0 && existingLine > 0) {
97
+ if (Math.abs(candidateLine - existingLine) <= lineTolerance) {
98
+ matchedIndex = i;
99
+ break;
100
+ }
101
+ } else {
102
+ // If either lacks line numbers (e.g. package/repo level), treat as match
103
+ matchedIndex = i;
104
+ break;
105
+ }
106
+ }
107
+ }
108
+
109
+ if (matchedIndex >= 0) {
110
+ // Merge with existing finding
111
+ deduplicated[matchedIndex] = mergeFindings(deduplicated[matchedIndex], candidate);
112
+ } else {
113
+ deduplicated.push({ ...candidate });
114
+ }
115
+ }
116
+
117
+ return deduplicated;
118
+ }
package/src/index.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export * from './scanner';
2
2
  export * from './orchestrator';
3
3
  export * from './scoring';
4
+ export * from './deduplication';
@@ -1,75 +1,212 @@
1
- import { NormalizedFinding, ScanInput, ScannerResult } from '@maverick006/types';
1
+ import { NormalizedFinding, ScanInput, ScannerResult, ScannerState, ScannerCoverage } from '@maverick006/types';
2
2
  import { SecurityScanner } from './scanner';
3
+ import { deduplicateFindings } from './deduplication';
4
+
5
+ export interface OrchestratorOptions {
6
+ concurrencyLimit?: number; // default: 3
7
+ timeoutMs?: number; // default: 120,000ms (2 mins per scanner)
8
+ }
9
+
10
+ export interface OrchestratedScanResult extends ScannerResult {
11
+ scannerResults: ScannerResult[];
12
+ coverage: ScannerCoverage;
13
+ }
14
+
15
+ /**
16
+ * Runs tasks with a bounded concurrency pool (no unlimited Promise.all).
17
+ */
18
+ async function runWithConcurrencyLimit<T, R>(
19
+ items: T[],
20
+ limit: number,
21
+ fn: (item: T) => Promise<R>
22
+ ): Promise<R[]> {
23
+ const results: R[] = new Array(items.length);
24
+ let currentIndex = 0;
25
+
26
+ const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
27
+ while (currentIndex < items.length) {
28
+ const index = currentIndex++;
29
+ results[index] = await fn(items[index]);
30
+ }
31
+ });
32
+
33
+ await Promise.all(workers);
34
+ return results;
35
+ }
36
+
37
+ /**
38
+ * Wraps a promise with a timeout.
39
+ */
40
+ function withTimeout<T>(promise: Promise<T>, timeoutMs: number, scannerName: string): Promise<T> {
41
+ return new Promise<T>((resolve, reject) => {
42
+ const timer = setTimeout(() => {
43
+ const err = new Error(`Scanner ${scannerName} timed out after ${timeoutMs}ms`);
44
+ (err as any).code = 'ETIMEDOUT';
45
+ reject(err);
46
+ }, timeoutMs);
47
+
48
+ promise
49
+ .then(val => {
50
+ clearTimeout(timer);
51
+ resolve(val);
52
+ })
53
+ .catch(err => {
54
+ clearTimeout(timer);
55
+ reject(err);
56
+ });
57
+ });
58
+ }
3
59
 
4
60
  /**
5
61
  * Orchestrates the execution of multiple security scanners and aggregates/deduplicates their findings.
6
62
  */
7
63
  export class Orchestrator {
8
64
  private scanners: SecurityScanner[] = [];
65
+ private options: OrchestratorOptions;
9
66
 
10
- constructor(scanners: SecurityScanner[]) {
67
+ constructor(scanners: SecurityScanner[], options: OrchestratorOptions = {}) {
11
68
  this.scanners = scanners;
69
+ this.options = {
70
+ concurrencyLimit: options.concurrencyLimit ?? 3,
71
+ timeoutMs: options.timeoutMs ?? 120_000
72
+ };
12
73
  }
13
74
 
14
75
  /**
15
- * Executes all registered scanners against the given input.
76
+ * Executes registered scanners using a bounded concurrency pool.
16
77
  */
17
- async runScan(input: ScanInput): Promise<ScannerResult> {
78
+ async runScan(input: ScanInput): Promise<OrchestratedScanResult> {
18
79
  const startTime = new Date();
19
-
20
- // Execute all scanners concurrently
21
- const scanPromises = this.scanners.map(scanner => scanner.scan(input));
22
- const results = await Promise.allSettled(scanPromises);
23
-
80
+ const concurrency = this.options.concurrencyLimit ?? 3;
81
+ const timeout = this.options.timeoutMs ?? 120_000;
82
+
83
+ const executeScanner = async (scanner: SecurityScanner): Promise<ScannerResult> => {
84
+ const scanStart = new Date();
85
+
86
+ // Check applicability if scanner provides capability detection
87
+ if (scanner.capabilities?.detectApplicability) {
88
+ try {
89
+ const isApplicable = await scanner.capabilities.detectApplicability(input.repositoryPath, input);
90
+ if (!isApplicable) {
91
+ const scanEnd = new Date();
92
+ return {
93
+ scanner: scanner.name,
94
+ success: true,
95
+ state: ScannerState.SKIPPED,
96
+ reason: `Skipped: Not applicable to repository or target`,
97
+ findings: [],
98
+ startTime: scanStart,
99
+ endTime: scanEnd,
100
+ durationMs: scanEnd.getTime() - scanStart.getTime()
101
+ };
102
+ }
103
+ } catch (err: any) {
104
+ console.warn(`Applicability check error for ${scanner.name}:`, err.message);
105
+ }
106
+ }
107
+
108
+ try {
109
+ const scanPromise = scanner.scan(input);
110
+ const result = await withTimeout(scanPromise, timeout, scanner.name);
111
+ const scanEnd = new Date();
112
+
113
+ return {
114
+ ...result,
115
+ state: result.state || (result.success ? ScannerState.SUCCESS : ScannerState.FAILED),
116
+ durationMs: scanEnd.getTime() - scanStart.getTime(),
117
+ endTime: scanEnd
118
+ };
119
+ } catch (err: any) {
120
+ const scanEnd = new Date();
121
+ const durationMs = scanEnd.getTime() - scanStart.getTime();
122
+
123
+ if (err.code === 'ETIMEDOUT') {
124
+ return {
125
+ scanner: scanner.name,
126
+ success: false,
127
+ state: ScannerState.TIMEOUT,
128
+ error: err.message,
129
+ reason: `Execution exceeded timeout of ${timeout}ms`,
130
+ findings: [],
131
+ startTime: scanStart,
132
+ endTime: scanEnd,
133
+ durationMs
134
+ };
135
+ }
136
+
137
+ if (err.code === 'ENOENT') {
138
+ return {
139
+ scanner: scanner.name,
140
+ success: false,
141
+ state: ScannerState.NOT_INSTALLED,
142
+ error: err.message,
143
+ reason: `Scanner executable was not found on PATH`,
144
+ findings: [],
145
+ startTime: scanStart,
146
+ endTime: scanEnd,
147
+ durationMs
148
+ };
149
+ }
150
+
151
+ return {
152
+ scanner: scanner.name,
153
+ success: false,
154
+ state: ScannerState.FAILED,
155
+ error: err.message || String(err),
156
+ findings: [],
157
+ startTime: scanStart,
158
+ endTime: scanEnd,
159
+ durationMs
160
+ };
161
+ }
162
+ };
163
+
164
+ // Execute through bounded concurrency worker pool
165
+ const scannerResults = await runWithConcurrencyLimit(this.scanners, concurrency, executeScanner);
166
+
24
167
  let allFindings: NormalizedFinding[] = [];
25
168
  const rawOutputs: Record<string, string> = {};
26
-
27
- for (let i = 0; i < results.length; i++) {
28
- const result = results[i];
29
- const scannerName = this.scanners[i].name;
30
169
 
31
- if (result.status === 'fulfilled') {
32
- const data = result.value;
170
+ for (const data of scannerResults) {
171
+ if (data.findings && data.findings.length > 0) {
33
172
  allFindings = allFindings.concat(data.findings);
34
- if (data.rawOutput) {
35
- rawOutputs[scannerName] = data.rawOutput;
36
- }
37
- } else {
38
- // Log scanner failure safely (in production this would use a proper logger)
39
- console.error(`Scanner ${scannerName} failed:`, result.reason);
173
+ }
174
+ if (data.rawOutput) {
175
+ rawOutputs[data.scanner] = data.rawOutput;
40
176
  }
41
177
  }
42
178
 
43
- const deduplicatedFindings = this.deduplicateFindings(allFindings);
179
+ // High-precision deduplication across scanners with line tolerance
180
+ const deduplicatedFindings = deduplicateFindings(allFindings);
181
+
182
+ // Standardize finding IDs to VG-FIND-001, VG-FIND-002, etc.
183
+ deduplicatedFindings.forEach((finding, index) => {
184
+ finding.id = `VG-FIND-${String(index + 1).padStart(3, '0')}`;
185
+ });
186
+
187
+ // Compute coverage matrix based on successful scanner executions
188
+ const coverage: ScannerCoverage = {
189
+ code: scannerResults.some(r => r.scanner.toLowerCase().includes('semgrep') && r.state === ScannerState.SUCCESS),
190
+ dependencies: scannerResults.some(r => (r.scanner.toLowerCase().includes('npm') || r.scanner.toLowerCase().includes('audit') || r.scanner.toLowerCase().includes('trivy')) && r.state === ScannerState.SUCCESS),
191
+ secrets: scannerResults.some(r => r.scanner.toLowerCase().includes('gitleaks') && r.state === ScannerState.SUCCESS),
192
+ containers: scannerResults.some(r => r.scanner.toLowerCase().includes('trivy') && r.state === ScannerState.SUCCESS),
193
+ iac: scannerResults.some(r => r.scanner.toLowerCase().includes('checkov') && r.state === ScannerState.SUCCESS),
194
+ web: scannerResults.some(r => r.scanner.toLowerCase().includes('zap') && r.state === ScannerState.SUCCESS),
195
+ cloud: scannerResults.some(r => (r.scanner.toLowerCase().includes('prowler') || r.scanner.toLowerCase().includes('cspm')) && r.state === ScannerState.SUCCESS)
196
+ };
44
197
 
198
+ const endTime = new Date();
45
199
  return {
46
200
  scanner: 'VibeGuard_Orchestrator',
47
- success: true,
201
+ success: scannerResults.some(r => r.state === ScannerState.SUCCESS),
202
+ state: ScannerState.SUCCESS,
203
+ durationMs: endTime.getTime() - startTime.getTime(),
48
204
  findings: deduplicatedFindings,
49
- rawOutput: JSON.stringify(rawOutputs), // Aggregate raw outputs
205
+ scannerResults,
206
+ coverage,
207
+ rawOutput: JSON.stringify(rawOutputs),
50
208
  startTime,
51
- endTime: new Date()
209
+ endTime
52
210
  };
53
211
  }
54
-
55
- /**
56
- * Deduplicates findings based on a fingerprint generated from ruleId, file, line, and column.
57
- */
58
- private deduplicateFindings(findings: NormalizedFinding[]): NormalizedFinding[] {
59
- const unique = new Map<string, NormalizedFinding>();
60
-
61
- for (const finding of findings) {
62
- // Create a unique fingerprint for the finding
63
- const fingerprint = `${finding.ruleId}-${finding.file}-${finding.line}-${finding.column}`;
64
-
65
- if (!unique.has(fingerprint)) {
66
- unique.set(fingerprint, finding);
67
- } else {
68
- // If it already exists, we might want to merge information, but for now we keep the first one
69
- // Optionally, if the new one has higher confidence, we could replace it.
70
- }
71
- }
72
-
73
- return Array.from(unique.values());
74
- }
75
212
  }
package/src/scanner.ts CHANGED
@@ -1,5 +1,11 @@
1
1
  import { ScanInput, ScannerResult } from '@maverick006/types';
2
2
 
3
+ export interface ScannerCapability {
4
+ category: 'code' | 'dependencies' | 'secrets' | 'containers' | 'iac' | 'web' | 'cloud';
5
+ requiresCredentials?: boolean;
6
+ detectApplicability?: (repoPath: string, input?: ScanInput) => Promise<boolean> | boolean;
7
+ }
8
+
3
9
  /**
4
10
  * Base interface that all VibeGuard scanners must implement.
5
11
  * This provides the core abstraction allowing new scanners to be added
@@ -16,6 +22,11 @@ export interface SecurityScanner {
16
22
  */
17
23
  version?: string;
18
24
 
25
+ /**
26
+ * Optional capability metadata and applicability detection.
27
+ */
28
+ capabilities?: ScannerCapability;
29
+
19
30
  /**
20
31
  * Executes the scanner against the given input.
21
32
  *