@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/src/scoring.ts CHANGED
@@ -1,8 +1,6 @@
1
- import { NormalizedFinding, Severity } from '@maverick006/types';
1
+ import { NormalizedFinding, Severity, ScannerCoverage, DeterministicScore, ScoreDeductions } from '@maverick006/types';
2
2
 
3
- export interface ScoreResult {
4
- score: number;
5
- grade: 'A' | 'B' | 'C' | 'D' | 'F';
3
+ export interface ScoreResult extends DeterministicScore {
6
4
  metrics: {
7
5
  critical: number;
8
6
  high: number;
@@ -12,68 +10,147 @@ export interface ScoreResult {
12
10
  }
13
11
 
14
12
  /**
15
- * Calculates a deterministic security score and grade based on the volume and severity of findings.
16
- * Weights: CRITICAL=100, HIGH=20, MEDIUM=5, LOW=1
13
+ * Calculates a deterministic, reproducible security score (0 to 100) and grade
14
+ * based on the volume and severity of unique findings.
17
15
  *
18
- * Grade thresholds:
19
- * A: <= 10 (e.g., up to 2 mediums, or 10 lows)
20
- * B: <= 30 (e.g., 1 high, or several mediums)
21
- * C: <= 70 (e.g., 3 highs)
22
- * D: <= 150 (e.g., many highs, but no criticals. Or 1 critical and nothing else = 100, wait, 1 critical is auto F)
23
- * F: > 150 OR any CRITICAL finding.
16
+ * Rules:
17
+ * - Base score: 100
18
+ * - Critical: -30 points per finding (any critical forces Grade F and caps score at <= 49)
19
+ * - High: -10 points per finding
20
+ * - Medium: -3 points per finding
21
+ * - Low: -1 point per finding
22
+ * - Info: 0 points
23
+ *
24
+ * Grades:
25
+ * A: 90 - 100
26
+ * B: 80 - 89
27
+ * C: 70 - 79
28
+ * D: 50 - 69
29
+ * F: < 50 OR any CRITICAL finding
24
30
  */
25
- export function calculateScore(findings: NormalizedFinding[]): ScoreResult {
31
+ export function calculateScore(
32
+ findings: NormalizedFinding[],
33
+ coverage?: Partial<ScannerCoverage>
34
+ ): ScoreResult {
26
35
  let critical = 0;
27
36
  let high = 0;
28
37
  let medium = 0;
29
38
  let low = 0;
30
- let score = 0;
39
+ let info = 0;
31
40
 
32
41
  for (const finding of findings) {
33
- switch (finding.severity) {
42
+ const sev = (finding.severity || '').toUpperCase();
43
+ switch (sev) {
34
44
  case Severity.CRITICAL:
45
+ case 'CRITICAL':
35
46
  critical++;
36
- score += 100;
37
47
  break;
38
48
  case Severity.HIGH:
49
+ case 'HIGH':
39
50
  high++;
40
- score += 20;
41
51
  break;
42
52
  case Severity.MEDIUM:
53
+ case 'MEDIUM':
43
54
  medium++;
44
- score += 5;
45
55
  break;
46
56
  case Severity.LOW:
47
- case Severity.INFO:
57
+ case 'LOW':
48
58
  low++;
49
- score += 1;
59
+ break;
60
+ default:
61
+ info++;
50
62
  break;
51
63
  }
52
64
  }
53
65
 
66
+ const critDeduction = critical * 30;
67
+ const highDeduction = high * 10;
68
+ const medDeduction = medium * 3;
69
+ const lowDeduction = low * 1;
70
+ const totalDeductions = critDeduction + highDeduction + medDeduction + lowDeduction;
71
+
72
+ let rawScore = 100 - totalDeductions;
73
+ let score = Math.max(0, Math.min(100, rawScore));
74
+
75
+ const explanation: string[] = [
76
+ 'Baseline score: 100/100'
77
+ ];
78
+
79
+ if (critical > 0) {
80
+ explanation.push(`-${critDeduction} points: ${critical} Critical severity ${critical === 1 ? 'finding' : 'findings'} (-30 pts each)`);
81
+ }
82
+ if (high > 0) {
83
+ explanation.push(`-${highDeduction} points: ${high} High severity ${high === 1 ? 'finding' : 'findings'} (-10 pts each)`);
84
+ }
85
+ if (medium > 0) {
86
+ explanation.push(`-${medDeduction} points: ${medium} Medium severity ${medium === 1 ? 'finding' : 'findings'} (-3 pts each)`);
87
+ }
88
+ if (low > 0) {
89
+ explanation.push(`-${lowDeduction} points: ${low} Low severity ${low === 1 ? 'finding' : 'findings'} (-1 pt each)`);
90
+ }
91
+ if (findings.length === 0) {
92
+ explanation.push('No security findings identified across executed scanners (+0 deductions)');
93
+ }
94
+
54
95
  let grade: ScoreResult['grade'] = 'A';
55
96
 
56
97
  if (critical > 0) {
57
- // A single CRITICAL vulnerability drops the grade to F immediately.
98
+ // Critical vulnerability automatically caps grade to F and score to at most 49
58
99
  grade = 'F';
59
- } else if (score > 150) {
60
- grade = 'F';
61
- } else if (score > 70) {
62
- grade = 'D';
63
- } else if (score > 30) {
64
- grade = 'C';
65
- } else if (score > 10) {
100
+ score = Math.min(score, 49);
101
+ explanation.push(`Grade Override: F (1 or more Critical severity findings detected)`);
102
+ } else if (score >= 90) {
103
+ grade = 'A';
104
+ } else if (score >= 80) {
66
105
  grade = 'B';
106
+ } else if (score >= 70) {
107
+ grade = 'C';
108
+ } else if (score >= 50) {
109
+ grade = 'D';
110
+ } else {
111
+ grade = 'F';
67
112
  }
68
113
 
114
+ explanation.push(`Final deterministic score: ${score}/100 (Grade ${grade})`);
115
+
116
+ const deductions: ScoreDeductions = {
117
+ critical: critDeduction,
118
+ high: highDeduction,
119
+ medium: medDeduction,
120
+ low: lowDeduction,
121
+ info: 0,
122
+ totalDeductions
123
+ };
124
+
125
+ const defaultCoverage: ScannerCoverage = {
126
+ code: false,
127
+ dependencies: false,
128
+ secrets: false,
129
+ containers: false,
130
+ iac: false,
131
+ web: false,
132
+ cloud: false,
133
+ ...coverage
134
+ };
135
+
69
136
  return {
70
137
  score,
71
138
  grade,
72
- metrics: {
139
+ deductions,
140
+ breakdown: {
73
141
  critical,
74
142
  high,
75
143
  medium,
76
144
  low,
77
- }
145
+ info
146
+ },
147
+ metrics: {
148
+ critical,
149
+ high,
150
+ medium,
151
+ low
152
+ },
153
+ coverage: defaultCoverage,
154
+ explanation
78
155
  };
79
156
  }
@@ -0,0 +1,122 @@
1
+ import { deduplicateFindings } from '../src/deduplication';
2
+ import { NormalizedFinding, Severity } from '@maverick006/types';
3
+
4
+ describe('Cross-Scanner Deduplication Engine', () => {
5
+ it('should remove exact duplicate findings from the same scanner', () => {
6
+ const findings: NormalizedFinding[] = [
7
+ {
8
+ scanner: 'Semgrep',
9
+ ruleId: 'sql-injection',
10
+ title: 'SQL Injection in Query',
11
+ description: 'User input concatenation',
12
+ severity: Severity.CRITICAL,
13
+ file: 'src/db.ts',
14
+ line: 42,
15
+ column: 10
16
+ },
17
+ {
18
+ scanner: 'Semgrep',
19
+ ruleId: 'sql-injection',
20
+ title: 'SQL Injection in Query',
21
+ description: 'User input concatenation',
22
+ severity: Severity.CRITICAL,
23
+ file: 'src/db.ts',
24
+ line: 42,
25
+ column: 10
26
+ }
27
+ ];
28
+
29
+ const result = deduplicateFindings(findings);
30
+ expect(result).toHaveLength(1);
31
+ expect(result[0].scanner).toBe('Semgrep');
32
+ });
33
+
34
+ it('should deduplicate cross-scanner findings with same CVE on same file/package', () => {
35
+ const findings: NormalizedFinding[] = [
36
+ {
37
+ scanner: 'npm-audit',
38
+ ruleId: 'CVE-2023-45133',
39
+ title: 'CVE-2023-45133 in @babel/traverse',
40
+ description: 'Arbitrary code execution via traverse',
41
+ severity: Severity.HIGH,
42
+ package: '@babel/traverse',
43
+ packageVersion: '7.23.0',
44
+ fixedVersion: '7.23.2',
45
+ file: 'package-lock.json'
46
+ },
47
+ {
48
+ scanner: 'Trivy',
49
+ ruleId: 'CVE-2023-45133',
50
+ title: 'Babel traverse code execution (CVE-2023-45133)',
51
+ description: 'Arbitrary code execution in babel/traverse',
52
+ severity: Severity.HIGH,
53
+ package: '@babel/traverse',
54
+ packageVersion: '7.23.0',
55
+ fixedVersion: '7.23.2',
56
+ file: 'package-lock.json'
57
+ }
58
+ ];
59
+
60
+ const result = deduplicateFindings(findings);
61
+ expect(result).toHaveLength(1);
62
+ expect(result[0].scanner).toContain('npm-audit');
63
+ expect(result[0].scanner).toContain('Trivy');
64
+ expect(result[0].package).toBe('@babel/traverse');
65
+ });
66
+
67
+ it('should deduplicate findings with nearby line shifts (+/- 3 lines)', () => {
68
+ const findings: NormalizedFinding[] = [
69
+ {
70
+ scanner: 'Semgrep',
71
+ ruleId: 'hardcoded-secret',
72
+ title: 'Hardcoded API Token',
73
+ description: 'Detected high entropy string',
74
+ severity: Severity.HIGH,
75
+ file: 'src/config.ts',
76
+ line: 15
77
+ },
78
+ {
79
+ scanner: 'Gitleaks',
80
+ ruleId: 'hardcoded-secret',
81
+ title: 'Generic API Key',
82
+ description: 'Detected secret key',
83
+ severity: Severity.HIGH,
84
+ file: 'src/config.ts',
85
+ line: 17 // within 2 lines
86
+ }
87
+ ];
88
+
89
+ const result = deduplicateFindings(findings, { lineTolerance: 3 });
90
+ expect(result).toHaveLength(1);
91
+ expect(result[0].scanner).toContain('Semgrep');
92
+ expect(result[0].scanner).toContain('Gitleaks');
93
+ });
94
+
95
+ it('should preserve distinct vulnerabilities on the same file and line', () => {
96
+ const findings: NormalizedFinding[] = [
97
+ {
98
+ scanner: 'Semgrep',
99
+ ruleId: 'sqli-rule-1',
100
+ title: 'SQL Injection',
101
+ description: 'Unescaped SQL query',
102
+ severity: Severity.CRITICAL,
103
+ file: 'src/api/user.ts',
104
+ line: 25
105
+ },
106
+ {
107
+ scanner: 'Semgrep',
108
+ ruleId: 'xss-rule-2',
109
+ title: 'Cross-Site Scripting (XSS)',
110
+ description: 'Unsanitized HTML rendering',
111
+ severity: Severity.HIGH,
112
+ file: 'src/api/user.ts',
113
+ line: 25
114
+ }
115
+ ];
116
+
117
+ const result = deduplicateFindings(findings);
118
+ expect(result).toHaveLength(2);
119
+ expect(result.map(r => r.title)).toContain('SQL Injection');
120
+ expect(result.map(r => r.title)).toContain('Cross-Site Scripting (XSS)');
121
+ });
122
+ });
@@ -1,96 +1,120 @@
1
- import { Orchestrator } from '../src/orchestrator';
2
- import { SecurityScanner } from '../src/scanner';
3
- import { ScanInput, ScannerResult, Severity, NormalizedFinding } from '@maverick006/types';
4
-
5
- class MockScanner implements SecurityScanner {
6
- public name: string;
7
- public version = '1.0.0';
8
- private findings: NormalizedFinding[];
9
-
10
- constructor(name: string, findings: NormalizedFinding[]) {
11
- this.name = name;
12
- this.findings = findings;
13
- }
14
-
15
- async scan(input: ScanInput): Promise<ScannerResult> {
16
- return {
17
- scanner: this.name,
18
- success: true,
19
- findings: this.findings,
20
- startTime: new Date(),
21
- endTime: new Date(),
22
- };
23
- }
24
- }
25
-
26
- describe('Orchestrator', () => {
27
- it('should aggregate findings from multiple scanners', async () => {
28
- const finding1: NormalizedFinding = {
29
- scanner: 'Mock1',
30
- title: 'Finding 1',
31
- description: 'Desc 1',
32
- severity: Severity.HIGH,
33
- ruleId: 'rule-1',
34
- file: 'test.js',
35
- line: 10,
36
- };
37
-
38
- const finding2: NormalizedFinding = {
39
- scanner: 'Mock2',
40
- title: 'Finding 2',
41
- description: 'Desc 2',
42
- severity: Severity.MEDIUM,
43
- ruleId: 'rule-2',
44
- file: 'test2.js',
45
- line: 20,
46
- };
47
-
48
- const scanner1 = new MockScanner('Mock1', [finding1]);
49
- const scanner2 = new MockScanner('Mock2', [finding2]);
50
-
51
- const orchestrator = new Orchestrator([scanner1, scanner2]);
52
-
53
- const result = await orchestrator.runScan({
54
- scanId: 'scan-1',
55
- repositoryPath: '/fake/path'
56
- });
57
-
58
- expect(result.findings.length).toBe(2);
59
- expect(result.findings).toEqual(expect.arrayContaining([finding1, finding2]));
60
- });
61
-
62
- it('should deduplicate findings with the same fingerprint', async () => {
63
- const finding1: NormalizedFinding = {
64
- scanner: 'Mock1',
65
- title: 'Finding 1',
66
- description: 'Desc 1',
67
- severity: Severity.HIGH,
68
- ruleId: 'rule-1',
69
- file: 'test.js',
70
- line: 10,
71
- };
72
-
73
- const finding2: NormalizedFinding = {
74
- scanner: 'Mock2',
75
- title: 'Finding 1 duplicate',
76
- description: 'Desc 1 duplicate',
77
- severity: Severity.HIGH,
78
- ruleId: 'rule-1', // Same rule
79
- file: 'test.js', // Same file
80
- line: 10, // Same line
81
- };
82
-
83
- const scanner1 = new MockScanner('Mock1', [finding1]);
84
- const scanner2 = new MockScanner('Mock2', [finding2]);
85
-
86
- const orchestrator = new Orchestrator([scanner1, scanner2]);
87
-
88
- const result = await orchestrator.runScan({
89
- scanId: 'scan-2',
90
- repositoryPath: '/fake/path'
91
- });
92
-
93
- expect(result.findings.length).toBe(1);
94
- expect(result.findings[0]).toEqual(finding1); // Should keep the first one
95
- });
96
- });
1
+ import { Orchestrator } from '../src/orchestrator';
2
+ import { SecurityScanner } from '../src/scanner';
3
+ import { ScanInput, ScannerResult, ScannerState, Severity } from '@maverick006/types';
4
+
5
+ describe('Security Orchestrator with Bounded Concurrency', () => {
6
+ const mockInput: ScanInput = {
7
+ scanId: 'test-scan-123',
8
+ repositoryPath: '/mock/repo'
9
+ };
10
+
11
+ it('should execute scanners with bounded concurrency and aggregate findings', async () => {
12
+ let runningCount = 0;
13
+ let maxConcurrent = 0;
14
+
15
+ const createMockScanner = (name: string, delayMs: number): SecurityScanner => ({
16
+ name,
17
+ scan: async (input: ScanInput): Promise<ScannerResult> => {
18
+ runningCount++;
19
+ maxConcurrent = Math.max(maxConcurrent, runningCount);
20
+ await new Promise(resolve => setTimeout(resolve, delayMs));
21
+ runningCount--;
22
+
23
+ return {
24
+ scanner: name,
25
+ success: true,
26
+ state: ScannerState.SUCCESS,
27
+ findings: [
28
+ {
29
+ scanner: name,
30
+ ruleId: `${name}-rule`,
31
+ title: `${name} finding`,
32
+ description: 'sample',
33
+ severity: Severity.LOW,
34
+ file: `${name}.ts`,
35
+ line: 1
36
+ }
37
+ ],
38
+ startTime: new Date(),
39
+ endTime: new Date()
40
+ };
41
+ }
42
+ });
43
+
44
+ const scanners: SecurityScanner[] = [
45
+ createMockScanner('Semgrep', 30),
46
+ createMockScanner('Gitleaks', 30),
47
+ createMockScanner('Trivy', 30),
48
+ createMockScanner('npm-audit', 30),
49
+ createMockScanner('Checkov', 30)
50
+ ];
51
+
52
+ const orchestrator = new Orchestrator(scanners, { concurrencyLimit: 2 });
53
+ const result = await orchestrator.runScan(mockInput);
54
+
55
+ expect(maxConcurrent).toBeLessThanOrEqual(2);
56
+ expect(result.scannerResults).toHaveLength(5);
57
+ expect(result.findings).toHaveLength(5);
58
+ expect(result.coverage.code).toBe(true);
59
+ expect(result.coverage.secrets).toBe(true);
60
+ });
61
+
62
+ it('should handle timeout gracefully without crashing the whole scan', async () => {
63
+ const normalScanner: SecurityScanner = {
64
+ name: 'Semgrep',
65
+ scan: async () => ({
66
+ scanner: 'Semgrep',
67
+ success: true,
68
+ state: ScannerState.SUCCESS,
69
+ findings: [],
70
+ startTime: new Date(),
71
+ endTime: new Date()
72
+ })
73
+ };
74
+
75
+ const slowScanner: SecurityScanner = {
76
+ name: 'SlowScanner',
77
+ scan: async () => {
78
+ await new Promise(resolve => setTimeout(resolve, 200));
79
+ return {
80
+ scanner: 'SlowScanner',
81
+ success: true,
82
+ findings: [],
83
+ startTime: new Date(),
84
+ endTime: new Date()
85
+ };
86
+ }
87
+ };
88
+
89
+ const orchestrator = new Orchestrator([normalScanner, slowScanner], { timeoutMs: 50 });
90
+ const result = await orchestrator.runScan(mockInput);
91
+
92
+ expect(result.scannerResults).toHaveLength(2);
93
+ const slowResult = result.scannerResults.find(r => r.scanner === 'SlowScanner');
94
+ expect(slowResult?.state).toBe(ScannerState.TIMEOUT);
95
+ expect(slowResult?.success).toBe(false);
96
+
97
+ const normalResult = result.scannerResults.find(r => r.scanner === 'Semgrep');
98
+ expect(normalResult?.state).toBe(ScannerState.SUCCESS);
99
+ });
100
+
101
+ it('should detect when scanner capability detects repo is not applicable and return SKIPPED', async () => {
102
+ const iacScanner: SecurityScanner = {
103
+ name: 'Checkov',
104
+ capabilities: {
105
+ category: 'iac',
106
+ detectApplicability: async () => false // No IaC files
107
+ },
108
+ scan: async () => {
109
+ throw new Error('Should not be called when detectApplicability returns false');
110
+ }
111
+ };
112
+
113
+ const orchestrator = new Orchestrator([iacScanner]);
114
+ const result = await orchestrator.runScan(mockInput);
115
+
116
+ expect(result.scannerResults).toHaveLength(1);
117
+ expect(result.scannerResults[0].state).toBe(ScannerState.SKIPPED);
118
+ expect(result.scannerResults[0].findings).toHaveLength(0);
119
+ });
120
+ });
@@ -1,62 +1,70 @@
1
1
  import { calculateScore } from '../src/scoring';
2
2
  import { NormalizedFinding, Severity } from '@maverick006/types';
3
3
 
4
- describe('Deterministic Scoring Engine', () => {
5
-
6
- const createFinding = (severity: Severity): NormalizedFinding => ({
4
+ describe('Deterministic Scoring Engine (0-100)', () => {
5
+ const createFinding = (severity: Severity, title = 'test finding'): NormalizedFinding => ({
7
6
  scanner: 'test',
8
7
  ruleId: 'test-rule',
9
- title: 'test finding',
8
+ title,
10
9
  description: 'test desc',
11
10
  severity,
12
11
  file: 'test.ts',
13
12
  line: 1
14
13
  });
15
14
 
16
- it('should return grade A for perfect score (0 findings)', () => {
15
+ it('should return score 100 and grade A for perfect score (0 findings)', () => {
17
16
  const result = calculateScore([]);
18
- expect(result.score).toBe(0);
17
+ expect(result.score).toBe(100);
19
18
  expect(result.grade).toBe('A');
19
+ expect(result.deductions.totalDeductions).toBe(0);
20
+ expect(result.explanation).toContain('Baseline score: 100/100');
21
+ expect(result.explanation.some(e => e.includes('No security findings identified'))).toBe(true);
20
22
  });
21
23
 
22
- it('should return grade A for score <= 10 (e.g. 2 mediums)', () => {
24
+ it('should deduct 3 points per medium finding (e.g. 2 mediums = 94, Grade A)', () => {
23
25
  const findings = [
24
- createFinding(Severity.MEDIUM), // 5
25
- createFinding(Severity.MEDIUM), // 5
26
+ createFinding(Severity.MEDIUM), // -3
27
+ createFinding(Severity.MEDIUM), // -3
26
28
  ];
27
29
  const result = calculateScore(findings);
28
- expect(result.score).toBe(10);
30
+ expect(result.score).toBe(94);
29
31
  expect(result.grade).toBe('A');
32
+ expect(result.deductions.medium).toBe(6);
33
+ expect(result.breakdown.medium).toBe(2);
30
34
  });
31
35
 
32
- it('should return grade B for score <= 30 (e.g. 1 high, 2 lows)', () => {
36
+ it('should deduct 10 for high and 1 for low (e.g. 1 high, 2 lows = 88, Grade B)', () => {
33
37
  const findings = [
34
- createFinding(Severity.HIGH), // 20
35
- createFinding(Severity.LOW), // 1
36
- createFinding(Severity.LOW), // 1
38
+ createFinding(Severity.HIGH), // -10
39
+ createFinding(Severity.LOW), // -1
40
+ createFinding(Severity.LOW), // -1
37
41
  ];
38
42
  const result = calculateScore(findings);
39
- expect(result.score).toBe(22);
43
+ expect(result.score).toBe(88);
40
44
  expect(result.grade).toBe('B');
45
+ expect(result.deductions.high).toBe(10);
46
+ expect(result.deductions.low).toBe(2);
47
+ expect(result.deductions.totalDeductions).toBe(12);
41
48
  });
42
49
 
43
- it('should immediately return grade F if any CRITICAL finding exists', () => {
50
+ it('should immediately force grade F and cap score to <= 49 if any CRITICAL exists', () => {
44
51
  const findings = [
45
- createFinding(Severity.CRITICAL), // 100
46
- createFinding(Severity.LOW), // 1
52
+ createFinding(Severity.CRITICAL), // -30, and caps grade to F, score to <= 49
53
+ createFinding(Severity.LOW), // -1
47
54
  ];
48
55
  const result = calculateScore(findings);
49
- // Score is 101, which is normally D, but CRITICAL forces F
50
- expect(result.score).toBe(101);
56
+ expect(result.score).toBeLessThanOrEqual(49);
51
57
  expect(result.grade).toBe('F');
58
+ expect(result.deductions.critical).toBe(30);
59
+ expect(result.breakdown.critical).toBe(1);
60
+ expect(result.explanation.some(e => e.includes('Grade Override: F'))).toBe(true);
52
61
  });
53
62
 
54
- it('should return grade F if score > 150 without criticals', () => {
55
- // 8 HIGHs = 160 score
56
- const findings = Array(8).fill(createFinding(Severity.HIGH));
63
+ it('should clamp minimum score at 0 and return grade F for heavy deductions', () => {
64
+ const findings = Array(15).fill(createFinding(Severity.HIGH)); // 15 * 10 = -150
57
65
  const result = calculateScore(findings);
58
- expect(result.score).toBe(160);
66
+ expect(result.score).toBe(0);
59
67
  expect(result.grade).toBe('F');
68
+ expect(result.deductions.totalDeductions).toBe(150);
60
69
  });
61
-
62
70
  });