@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/deduplication.d.ts +16 -0
- package/dist/deduplication.d.ts.map +1 -0
- package/dist/deduplication.js +101 -0
- package/dist/deduplication.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/orchestrator.d.ts +13 -8
- package/dist/orchestrator.d.ts.map +1 -1
- package/dist/orchestrator.js +153 -38
- package/dist/orchestrator.js.map +1 -1
- package/dist/scanner.d.ts +9 -0
- package/dist/scanner.d.ts.map +1 -1
- package/dist/scoring.d.ts +19 -13
- package/dist/scoring.d.ts.map +1 -1
- package/dist/scoring.js +96 -26
- package/dist/scoring.js.map +1 -1
- package/package.json +5 -4
- package/src/deduplication.ts +118 -0
- package/src/index.ts +1 -0
- package/src/orchestrator.ts +183 -46
- package/src/scanner.ts +11 -0
- package/src/scoring.ts +107 -30
- package/tests/deduplication.test.ts +122 -0
- package/tests/orchestrator.test.ts +120 -96
- package/tests/scoring.test.ts +33 -25
- package/tsconfig.tsbuildinfo +0 -1
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
|
|
7
|
-
*
|
|
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
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
|
29
|
+
let info = 0;
|
|
22
30
|
for (const finding of findings) {
|
|
23
|
-
|
|
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
|
|
46
|
+
case 'LOW':
|
|
38
47
|
low++;
|
|
39
|
-
|
|
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
|
-
//
|
|
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
|
|
49
|
-
grade = '
|
|
86
|
+
else if (score >= 90) {
|
|
87
|
+
grade = 'A';
|
|
50
88
|
}
|
|
51
|
-
else if (score
|
|
52
|
-
grade = '
|
|
89
|
+
else if (score >= 80) {
|
|
90
|
+
grade = 'B';
|
|
53
91
|
}
|
|
54
|
-
else if (score
|
|
92
|
+
else if (score >= 70) {
|
|
55
93
|
grade = 'C';
|
|
56
94
|
}
|
|
57
|
-
else if (score
|
|
58
|
-
grade = '
|
|
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
|
-
|
|
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
|
package/dist/scoring.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"scoring.js","sourceRoot":"","sources":["../src/scoring.ts"],"names":[],"mappings":";;
|
|
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.
|
|
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
package/src/orchestrator.ts
CHANGED
|
@@ -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
|
|
76
|
+
* Executes registered scanners using a bounded concurrency pool.
|
|
16
77
|
*/
|
|
17
|
-
async runScan(input: ScanInput): Promise<
|
|
78
|
+
async runScan(input: ScanInput): Promise<OrchestratedScanResult> {
|
|
18
79
|
const startTime = new Date();
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const
|
|
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
|
-
|
|
32
|
-
|
|
170
|
+
for (const data of scannerResults) {
|
|
171
|
+
if (data.findings && data.findings.length > 0) {
|
|
33
172
|
allFindings = allFindings.concat(data.findings);
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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:
|
|
201
|
+
success: scannerResults.some(r => r.state === ScannerState.SUCCESS),
|
|
202
|
+
state: ScannerState.SUCCESS,
|
|
203
|
+
durationMs: endTime.getTime() - startTime.getTime(),
|
|
48
204
|
findings: deduplicatedFindings,
|
|
49
|
-
|
|
205
|
+
scannerResults,
|
|
206
|
+
coverage,
|
|
207
|
+
rawOutput: JSON.stringify(rawOutputs),
|
|
50
208
|
startTime,
|
|
51
|
-
endTime
|
|
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
|
*
|