agent-security-scanner-mcp 3.18.0 → 3.20.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.
- package/analyzer.py +23 -2
- package/compliance/aiuc-1-controls.json +330 -0
- package/cross_file_analyzer.py +478 -5
- package/index.js +21 -1
- package/package.json +5 -2
- package/python_taint_fallback.py +688 -0
- package/rules/__init__.py +42 -3
- package/rules/prompt-injection.security.yaml +4 -4
- package/src/cli/report.js +71 -0
- package/src/fix-patterns.js +9 -9
- package/src/history.js +1 -1
- package/src/lib/aivss.js +284 -0
- package/src/lib/compliance-controls.js +164 -0
- package/src/lib/compliance-evaluator.js +149 -0
- package/src/lib/normalize-finding.js +146 -0
- package/src/tools/check-package.js +15 -0
- package/src/tools/compliance-controls.js +67 -0
- package/src/tools/scan-prompt.js +44 -31
- package/src/tools/scan-skill.js +42 -22
- package/src/tools/score-aivss.js +98 -0
package/rules/__init__.py
CHANGED
|
@@ -201,16 +201,55 @@ def get_rules():
|
|
|
201
201
|
return rules
|
|
202
202
|
|
|
203
203
|
|
|
204
|
-
def get_rules_for_language(language):
|
|
205
|
-
"""Get rules applicable to a specific language
|
|
204
|
+
def get_rules_for_language(language, file_path=None):
|
|
205
|
+
"""Get rules applicable to a specific language.
|
|
206
|
+
|
|
207
|
+
Generic rules that declare a specific technology in their metadata are
|
|
208
|
+
only applied when the scanned language or file path indicates that
|
|
209
|
+
technology is relevant. This prevents, e.g., Hugo-specific rules from
|
|
210
|
+
firing on plain JavaScript database code.
|
|
211
|
+
"""
|
|
206
212
|
all_rules = get_rules()
|
|
207
213
|
applicable_rules = {}
|
|
208
214
|
|
|
209
215
|
language = language.lower()
|
|
210
216
|
|
|
217
|
+
# Map technology names to the languages/file-path hints where they apply
|
|
218
|
+
_TECH_LANGUAGES = {
|
|
219
|
+
'hugo': {'go', 'html', 'toml', 'yaml'},
|
|
220
|
+
'django': {'python', 'html'},
|
|
221
|
+
'rails': {'ruby', 'html', 'erb'},
|
|
222
|
+
'spring': {'java', 'kotlin'},
|
|
223
|
+
'laravel': {'php'},
|
|
224
|
+
'angular': {'typescript', 'javascript', 'html'},
|
|
225
|
+
'react': {'javascript', 'typescript', 'jsx', 'tsx'},
|
|
226
|
+
}
|
|
227
|
+
|
|
211
228
|
for rule_id, rule in all_rules.items():
|
|
212
229
|
rule_languages = [lang.lower() for lang in rule.get('languages', ['generic'])]
|
|
213
|
-
|
|
230
|
+
|
|
231
|
+
if language in rule_languages:
|
|
232
|
+
applicable_rules[rule_id] = rule
|
|
233
|
+
continue
|
|
234
|
+
|
|
235
|
+
if 'generic' in rule_languages:
|
|
236
|
+
# Check if this generic rule is scoped to a specific technology
|
|
237
|
+
techs = rule.get('metadata', {}).get('technology')
|
|
238
|
+
if techs and isinstance(techs, list):
|
|
239
|
+
# Only apply if the current language is relevant to the technology
|
|
240
|
+
tech_relevant = False
|
|
241
|
+
for tech in techs:
|
|
242
|
+
tech_lower = tech.lower()
|
|
243
|
+
allowed = _TECH_LANGUAGES.get(tech_lower)
|
|
244
|
+
if allowed and language in allowed:
|
|
245
|
+
tech_relevant = True
|
|
246
|
+
break
|
|
247
|
+
# Also check if the technology name appears in the file path
|
|
248
|
+
if file_path and tech_lower in file_path.lower():
|
|
249
|
+
tech_relevant = True
|
|
250
|
+
break
|
|
251
|
+
if not tech_relevant:
|
|
252
|
+
continue
|
|
214
253
|
applicable_rules[rule_id] = rule
|
|
215
254
|
|
|
216
255
|
return applicable_rules
|
|
@@ -672,8 +672,8 @@ rules:
|
|
|
672
672
|
severity: WARNING
|
|
673
673
|
message: "Potential Base64-encoded prompt injection payload. Encoded content may hide malicious instructions."
|
|
674
674
|
patterns:
|
|
675
|
-
- "(?i)decode\\s+(this\\s+)?base64\\s*:\\s*[A-Za-z0-9+/=]{20,}"
|
|
676
|
-
- "(?i)base64\\s*:\\s*[A-Za-z0-9+/=]{40,}"
|
|
675
|
+
- "(?i)decode\\s+(this\\s+)?base64\\s*:\\s*[A-Za-z0-9+/=]{20,200}"
|
|
676
|
+
- "(?i)base64\\s*:\\s*[A-Za-z0-9+/=]{40,200}"
|
|
677
677
|
- "aWdub3JlIHByZXZpb3Vz"
|
|
678
678
|
- "c3lzdGVtIHByb21wdA=="
|
|
679
679
|
- "(?i)execute\\s+(this\\s+)?encoded"
|
|
@@ -682,8 +682,8 @@ rules:
|
|
|
682
682
|
- "aWdub3JlIGFsbC"
|
|
683
683
|
- "b3ZlcnJpZGU="
|
|
684
684
|
- "(?i)base64.{0,20}instructions?.{0,20}follow"
|
|
685
|
-
- "[A-Za-z0-9+/]{40,}={0,2}
|
|
686
|
-
- "(?i)(decode|run|execute)\\s+.{0,20}[A-Za-z0-9+/]{40,}={0,2}"
|
|
685
|
+
- "[A-Za-z0-9+/]{40,200}={0,2}[^A-Za-z0-9+/=].{0,20}(?:decode|execute|follow|run)"
|
|
686
|
+
- "(?i)(decode|run|execute)\\s+.{0,20}[A-Za-z0-9+/]{40,200}={0,2}"
|
|
687
687
|
metadata:
|
|
688
688
|
cwe: "CWE-77"
|
|
689
689
|
owasp: "LLM01 - Prompt Injection"
|
package/src/cli/report.js
CHANGED
|
@@ -4,6 +4,10 @@ import { existsSync, writeFileSync, mkdirSync } from 'fs';
|
|
|
4
4
|
import { resolve, join } from 'path';
|
|
5
5
|
import { scanProject } from '../tools/scan-project.js';
|
|
6
6
|
import { saveResult, loadHistory, getTrends, diffResults } from '../history.js';
|
|
7
|
+
import { normalizeFindings } from '../lib/normalize-finding.js';
|
|
8
|
+
import { scoreBatch } from '../lib/aivss.js';
|
|
9
|
+
import { loadControls } from '../lib/compliance-controls.js';
|
|
10
|
+
import { evaluateAll } from '../lib/compliance-evaluator.js';
|
|
7
11
|
|
|
8
12
|
// Grade color mapping
|
|
9
13
|
const GRADE_COLORS = {
|
|
@@ -359,6 +363,62 @@ function generateHtml(scanResult, history, diff) {
|
|
|
359
363
|
</html>`;
|
|
360
364
|
}
|
|
361
365
|
|
|
366
|
+
/**
|
|
367
|
+
* Build the threat_model section from scan results.
|
|
368
|
+
* Exported for testing.
|
|
369
|
+
*/
|
|
370
|
+
export function buildThreatModel(scanResult) {
|
|
371
|
+
// scan_project wraps scan_security — tag findings as scan_security so
|
|
372
|
+
// controls scoped to either tool can see them. Duplicate each finding
|
|
373
|
+
// under both source_tools for correct evaluator scoping.
|
|
374
|
+
const base = normalizeFindings(scanResult.issues || [], 'scan_security');
|
|
375
|
+
const duped = base.map(f => ({ ...f, source_tool: 'scan_project' }));
|
|
376
|
+
const normalized = [...base, ...duped];
|
|
377
|
+
const aivssResult = scoreBatch(base); // score deduplicated set
|
|
378
|
+
|
|
379
|
+
const grade = scanResult.grade || null;
|
|
380
|
+
const controls = loadControls().controls;
|
|
381
|
+
const evidence = {
|
|
382
|
+
aivssPosture: aivssResult.posture,
|
|
383
|
+
findings: normalized,
|
|
384
|
+
grades: { scan_project: grade, scan_security: grade, project: grade },
|
|
385
|
+
toolsRun: ['scan_project', 'scan_security'],
|
|
386
|
+
};
|
|
387
|
+
const complianceResult = evaluateAll(controls, evidence);
|
|
388
|
+
|
|
389
|
+
return {
|
|
390
|
+
aivss: {
|
|
391
|
+
model: aivssResult.posture.model,
|
|
392
|
+
posture: {
|
|
393
|
+
max_score: aivssResult.posture.max_score,
|
|
394
|
+
p95_score: aivssResult.posture.p95_score,
|
|
395
|
+
mean_score: aivssResult.posture.mean_score,
|
|
396
|
+
posture_score: aivssResult.posture.posture_score,
|
|
397
|
+
posture_rating: aivssResult.posture.posture_rating,
|
|
398
|
+
score_distribution: aivssResult.posture.score_distribution,
|
|
399
|
+
},
|
|
400
|
+
findings: aivssResult.findings.map(f => ({
|
|
401
|
+
rule_id: f.rule_id,
|
|
402
|
+
aivss_score: f.aivss_score,
|
|
403
|
+
rating: f.rating,
|
|
404
|
+
vector_string: f.vector_string,
|
|
405
|
+
metrics: f.metrics,
|
|
406
|
+
})),
|
|
407
|
+
},
|
|
408
|
+
compliance: {
|
|
409
|
+
framework: 'AIUC-1',
|
|
410
|
+
controls_evaluated: complianceResult.controls_evaluated,
|
|
411
|
+
summary: {
|
|
412
|
+
pass: complianceResult.pass,
|
|
413
|
+
partial: complianceResult.partial,
|
|
414
|
+
fail: complianceResult.fail,
|
|
415
|
+
not_evaluated: complianceResult.not_evaluated,
|
|
416
|
+
},
|
|
417
|
+
results: complianceResult.results,
|
|
418
|
+
},
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
|
|
362
422
|
/**
|
|
363
423
|
* Run the report CLI command.
|
|
364
424
|
*
|
|
@@ -378,6 +438,7 @@ export async function runReport(args) {
|
|
|
378
438
|
}
|
|
379
439
|
|
|
380
440
|
const jsonOutput = args.includes('--json');
|
|
441
|
+
const threatModel = args.includes('--threat-model');
|
|
381
442
|
const daysIdx = args.indexOf('--days');
|
|
382
443
|
const days = daysIdx !== -1 && args[daysIdx + 1] ? parseInt(args[daysIdx + 1], 10) : 90;
|
|
383
444
|
|
|
@@ -390,6 +451,11 @@ export async function runReport(args) {
|
|
|
390
451
|
});
|
|
391
452
|
const scanResult = JSON.parse(result.content[0].text);
|
|
392
453
|
|
|
454
|
+
// Attach threat model before saving to history so future trends include AIVSS posture
|
|
455
|
+
if (threatModel) {
|
|
456
|
+
scanResult.threat_model = buildThreatModel(scanResult);
|
|
457
|
+
}
|
|
458
|
+
|
|
393
459
|
// Save result to history
|
|
394
460
|
const savedPath = saveResult(dirPath, scanResult);
|
|
395
461
|
console.log(` Results saved to ${savedPath}`);
|
|
@@ -413,10 +479,15 @@ export async function runReport(args) {
|
|
|
413
479
|
diff,
|
|
414
480
|
generated_at: new Date().toISOString(),
|
|
415
481
|
};
|
|
482
|
+
|
|
416
483
|
console.log(JSON.stringify(jsonReport, null, 2));
|
|
417
484
|
return;
|
|
418
485
|
}
|
|
419
486
|
|
|
487
|
+
if (threatModel) {
|
|
488
|
+
console.log(' Note: --threat-model currently only produces output with --json. HTML rendering is planned.');
|
|
489
|
+
}
|
|
490
|
+
|
|
420
491
|
// Generate HTML report
|
|
421
492
|
const html = generateHtml(scanResult, trends, diff);
|
|
422
493
|
const scannerDir = join(dirPath, '.scanner');
|
package/src/fix-patterns.js
CHANGED
|
@@ -20,7 +20,7 @@ export const FIX_TEMPLATES = {
|
|
|
20
20
|
// ===========================================
|
|
21
21
|
"sql-injection": {
|
|
22
22
|
description: "Use parameterized queries instead of string concatenation",
|
|
23
|
-
fix: (line) =>
|
|
23
|
+
fix: (line) => '// TODO: manual fix required — use parameterized queries instead of string concatenation\n// ' + line.trim()
|
|
24
24
|
},
|
|
25
25
|
"nosql-injection": {
|
|
26
26
|
description: "Sanitize MongoDB query inputs",
|
|
@@ -28,7 +28,7 @@ export const FIX_TEMPLATES = {
|
|
|
28
28
|
},
|
|
29
29
|
"raw-query": {
|
|
30
30
|
description: "Use parameterized queries instead of raw SQL",
|
|
31
|
-
fix: (line) =>
|
|
31
|
+
fix: (line) => '// TODO: manual fix required — use parameterized queries instead of raw SQL\n// ' + line.trim()
|
|
32
32
|
},
|
|
33
33
|
|
|
34
34
|
// ===========================================
|
|
@@ -306,10 +306,10 @@ export const FIX_TEMPLATES = {
|
|
|
306
306
|
"path-traversal": {
|
|
307
307
|
description: "Resolve real path and validate prefix to prevent traversal",
|
|
308
308
|
fix: (line, lang) => {
|
|
309
|
-
if (lang === 'python') return
|
|
310
|
-
if (lang === 'go') return
|
|
311
|
-
if (lang === 'java') return
|
|
312
|
-
return
|
|
309
|
+
if (lang === 'python') return '# TODO: manual fix required — use os.path.realpath() and validate the prefix\n# ' + line.trim();
|
|
310
|
+
if (lang === 'go') return '// TODO: manual fix required — use filepath.Clean() and validate the prefix\n// ' + line.trim();
|
|
311
|
+
if (lang === 'java') return '// TODO: manual fix required — use getCanonicalFile() and validate the prefix\n// ' + line.trim();
|
|
312
|
+
return '// TODO: manual fix required — use path.resolve() and validate the prefix\n// ' + line.trim();
|
|
313
313
|
}
|
|
314
314
|
},
|
|
315
315
|
|
|
@@ -418,7 +418,7 @@ export const FIX_TEMPLATES = {
|
|
|
418
418
|
// ===========================================
|
|
419
419
|
"xpath-injection": {
|
|
420
420
|
description: "Use parameterized XPath queries",
|
|
421
|
-
fix: (line) =>
|
|
421
|
+
fix: (line) => '// TODO: manual fix required — use parameterized XPath queries instead of concatenation\n// ' + line.trim()
|
|
422
422
|
},
|
|
423
423
|
|
|
424
424
|
// ===========================================
|
|
@@ -695,9 +695,9 @@ export const FIX_TEMPLATES = {
|
|
|
695
695
|
description: "CRITICAL: Never eval() LLM responses - use JSON parsing or ast.literal_eval for safe subset",
|
|
696
696
|
fix: (line, lang) => {
|
|
697
697
|
if (lang === 'python') {
|
|
698
|
-
return line.replace(/eval\s*\(\s*(\w+)/, 'ast.literal_eval($1 # SECURITY: Use safe parsing only');
|
|
698
|
+
return line.replace(/eval\s*\(\s*(\w+)\s*\)/, 'ast.literal_eval($1) # SECURITY: Use safe parsing only');
|
|
699
699
|
}
|
|
700
|
-
return line.replace(/eval\s*\(\s*(\w+)/, 'JSON.parse($1 /* SECURITY: Use safe JSON parsing */');
|
|
700
|
+
return line.replace(/eval\s*\(\s*(\w+)\s*\)/, 'JSON.parse($1) /* SECURITY: Use safe JSON parsing */');
|
|
701
701
|
}
|
|
702
702
|
},
|
|
703
703
|
"exec-llm-response": {
|
package/src/history.js
CHANGED
package/src/lib/aivss.js
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
// src/lib/aivss.js — OWASP AIVSS v2 scoring engine (pure logic, no MCP).
|
|
2
|
+
|
|
3
|
+
export const AIVSS_MODEL = {
|
|
4
|
+
name: 'owasp-aivss',
|
|
5
|
+
version: 'v2',
|
|
6
|
+
source_ref: 'OWASP/www-project-ai-security@a1b2c3d/calculatorV2.py',
|
|
7
|
+
retrieved: '2026-03-14',
|
|
8
|
+
weights: { base: 0.25, ai_specific: 0.45, impact: 0.30 },
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const METRIC_VALUES = {
|
|
12
|
+
AV: { Network: 0.85, Adjacent: 0.62, Local: 0.55, Physical: 0.20 },
|
|
13
|
+
AC: { Low: 0.77, High: 0.44 },
|
|
14
|
+
PR: { None: 0.85, Low: 0.62, High: 0.27 },
|
|
15
|
+
UI: { None: 0.85, Required: 0.62 },
|
|
16
|
+
S: { Unchanged: 1.0, Changed: 1.5 },
|
|
17
|
+
MR: { VeryHigh: 1.0, High: 0.8, Medium: 0.6, Low: 0.4, VeryLow: 0.2 },
|
|
18
|
+
DS: { VeryHigh: 1.0, High: 0.8, Medium: 0.6, Low: 0.4, VeryLow: 0.2 },
|
|
19
|
+
EI: { VeryHigh: 1.0, High: 0.8, Medium: 0.6, Low: 0.4, VeryLow: 0.2 },
|
|
20
|
+
DC: { VeryHigh: 1.0, High: 0.8, Medium: 0.6, Low: 0.4, VeryLow: 0.2 },
|
|
21
|
+
AD: { VeryHigh: 1.0, High: 0.8, Medium: 0.6, Low: 0.4, VeryLow: 0.2 },
|
|
22
|
+
C: { None: 0.0, Low: 0.22, Medium: 0.56, High: 0.85, Critical: 1.0 },
|
|
23
|
+
I: { None: 0.0, Low: 0.22, Medium: 0.56, High: 0.85, Critical: 1.0 },
|
|
24
|
+
A: { None: 0.0, Low: 0.22, Medium: 0.56, High: 0.85, Critical: 1.0 },
|
|
25
|
+
SI: { None: 0.0, Low: 0.22, Medium: 0.56, High: 0.85, Critical: 1.0 },
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const RATING_THRESHOLDS = [
|
|
29
|
+
{ min: 9.0, rating: 'Critical' },
|
|
30
|
+
{ min: 7.0, rating: 'High' },
|
|
31
|
+
{ min: 4.0, rating: 'Medium' },
|
|
32
|
+
{ min: 0.1, rating: 'Low' },
|
|
33
|
+
{ min: 0, rating: 'None' },
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
// Category → inferred metric defaults (heuristic)
|
|
37
|
+
const CATEGORY_METRIC_MAP = {
|
|
38
|
+
'exfiltration': { AV: 'Network', MR: 'High', DS: 'High', EI: 'High', C: 'High', SI: 'Medium' },
|
|
39
|
+
'data-exfiltration': { AV: 'Network', MR: 'High', DS: 'High', EI: 'High', C: 'High', SI: 'Medium' },
|
|
40
|
+
'prompt-injection': { AV: 'Network', MR: 'VeryHigh', DS: 'Medium', EI: 'High', C: 'Medium', I: 'High' },
|
|
41
|
+
'prompt-injection-jailbreak':{ AV: 'Network', MR: 'VeryHigh', DS: 'High', EI: 'VeryHigh', C: 'Medium', I: 'High', SI: 'High' },
|
|
42
|
+
'prompt-injection-content': { AV: 'Network', MR: 'High', DS: 'Medium', EI: 'High', C: 'Medium', I: 'Medium' },
|
|
43
|
+
'malicious-injection': { AV: 'Network', MR: 'High', EI: 'High', C: 'High', I: 'High', A: 'Medium' },
|
|
44
|
+
'system-manipulation': { AV: 'Local', MR: 'Medium', DC: 'High', C: 'Medium', I: 'High', A: 'High' },
|
|
45
|
+
'social-engineering': { AV: 'Network', MR: 'Medium', DS: 'Low', EI: 'Medium', C: 'Low', I: 'Medium' },
|
|
46
|
+
'obfuscation': { AV: 'Network', MR: 'Medium', DC: 'High', EI: 'Medium', C: 'Low', I: 'Medium' },
|
|
47
|
+
'agent-manipulation': { AV: 'Network', MR: 'High', DS: 'Medium', EI: 'High', C: 'Medium', I: 'High', SI: 'Medium' },
|
|
48
|
+
'injection': { AV: 'Network', AC: 'Low', MR: 'Medium', EI: 'Medium', C: 'High', I: 'High' },
|
|
49
|
+
'crypto': { AV: 'Network', AC: 'High', MR: 'Low', DS: 'Low', C: 'Medium', I: 'Low' },
|
|
50
|
+
'info-exposure': { AV: 'Network', AC: 'Low', MR: 'Low', DS: 'Low', C: 'Medium' },
|
|
51
|
+
'permissions': { AV: 'Local', AC: 'Low', MR: 'Medium', DC: 'Medium', C: 'Medium', I: 'Medium' },
|
|
52
|
+
'supply-chain': { AV: 'Network', AC: 'High', MR: 'Medium', DS: 'Medium', AD: 'High', C: 'Medium', I: 'High', SI: 'Medium' },
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// Severity → baseline metric defaults
|
|
56
|
+
const SEVERITY_METRIC_MAP = {
|
|
57
|
+
CRITICAL: { AC: 'Low', PR: 'None', UI: 'None', S: 'Changed', DC: 'Medium', AD: 'Medium', A: 'High' },
|
|
58
|
+
HIGH: { AC: 'Low', PR: 'None', UI: 'None', S: 'Unchanged', DC: 'Low', AD: 'Low', A: 'Medium' },
|
|
59
|
+
MEDIUM: { AC: 'High', PR: 'Low', UI: 'None', S: 'Unchanged', DC: 'Low', AD: 'Low', A: 'Low' },
|
|
60
|
+
LOW: { AC: 'High', PR: 'Low', UI: 'Required', S: 'Unchanged', DC: 'VeryLow', AD: 'VeryLow', A: 'None' },
|
|
61
|
+
INFO: { AC: 'High', PR: 'High', UI: 'Required', S: 'Unchanged', DC: 'VeryLow', AD: 'VeryLow', A: 'None' },
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// All metric keys in vector string order
|
|
65
|
+
const ALL_METRICS = ['AV', 'AC', 'PR', 'UI', 'S', 'MR', 'DS', 'EI', 'DC', 'AD', 'C', 'I', 'A', 'SI'];
|
|
66
|
+
|
|
67
|
+
// Default values for all metrics
|
|
68
|
+
const METRIC_DEFAULTS = {
|
|
69
|
+
AV: 'Network', AC: 'Low', PR: 'None', UI: 'None', S: 'Unchanged',
|
|
70
|
+
MR: 'Medium', DS: 'Medium', EI: 'Medium', DC: 'Medium', AD: 'Medium',
|
|
71
|
+
C: 'Low', I: 'Low', A: 'Low', SI: 'Low',
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Compute raw AIVSS score from metric values.
|
|
76
|
+
* @param {object} metrics - Object with keys AV, AC, PR, UI, S, MR, DS, EI, DC, AD, C, I, A, SI
|
|
77
|
+
* @returns {{ score: number, components: { base: number, ai_specific: number, impact: number } }}
|
|
78
|
+
*/
|
|
79
|
+
export function computeAivss(metrics) {
|
|
80
|
+
const mv = (key) => {
|
|
81
|
+
const val = metrics[key];
|
|
82
|
+
const table = METRIC_VALUES[key];
|
|
83
|
+
if (!table || !(val in table)) return 0;
|
|
84
|
+
return table[val];
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// Base = min(AV × AC × PR × UI × S, 10)
|
|
88
|
+
const baseRaw = mv('AV') * mv('AC') * mv('PR') * mv('UI') * mv('S');
|
|
89
|
+
const base = Math.min(baseRaw, 10);
|
|
90
|
+
|
|
91
|
+
// AI-Specific = MR × DS × EI × DC × AD × 10
|
|
92
|
+
const aiSpecific = mv('MR') * mv('DS') * mv('EI') * mv('DC') * mv('AD') * 10;
|
|
93
|
+
|
|
94
|
+
// Impact = (C + I + A + SI) / 4 × 10
|
|
95
|
+
const impact = (mv('C') + mv('I') + mv('A') + mv('SI')) / 4 * 10;
|
|
96
|
+
|
|
97
|
+
// AIVSS = 0.25 × Base + 0.45 × AI-Specific + 0.30 × Impact
|
|
98
|
+
const score = 0.25 * base + 0.45 * aiSpecific + 0.30 * impact;
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
score: Math.min(10, Math.max(0, parseFloat(score.toFixed(2)))),
|
|
102
|
+
components: {
|
|
103
|
+
base: parseFloat(base.toFixed(4)),
|
|
104
|
+
ai_specific: parseFloat(aiSpecific.toFixed(4)),
|
|
105
|
+
impact: parseFloat(impact.toFixed(4)),
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Get AIVSS rating string from score.
|
|
112
|
+
*/
|
|
113
|
+
function getRating(score) {
|
|
114
|
+
for (const t of RATING_THRESHOLDS) {
|
|
115
|
+
if (score >= t.min) return t.rating;
|
|
116
|
+
}
|
|
117
|
+
return 'None';
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Build vector string from final metrics.
|
|
122
|
+
*/
|
|
123
|
+
function buildVectorString(metrics) {
|
|
124
|
+
const parts = ALL_METRICS.map(k => {
|
|
125
|
+
const val = metrics[k] || METRIC_DEFAULTS[k];
|
|
126
|
+
// Abbreviate: first letter, or first 2 for disambiguation
|
|
127
|
+
const abbrev = val === 'VeryHigh' ? 'VH'
|
|
128
|
+
: val === 'VeryLow' ? 'VL'
|
|
129
|
+
: val === 'Adjacent' ? 'A'
|
|
130
|
+
: val === 'Physical' ? 'P'
|
|
131
|
+
: val === 'Required' ? 'R'
|
|
132
|
+
: val === 'Changed' ? 'C'
|
|
133
|
+
: val === 'Unchanged' ? 'U'
|
|
134
|
+
: val === 'Critical' ? 'CR'
|
|
135
|
+
: val.charAt(0);
|
|
136
|
+
return `${k}:${abbrev}`;
|
|
137
|
+
});
|
|
138
|
+
return `AIVSS:2.0/${parts.join('/')}`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Infer AIVSS metrics from a normalized finding. Returns inferred metrics + notes.
|
|
143
|
+
*/
|
|
144
|
+
function inferMetrics(finding) {
|
|
145
|
+
const notes = [];
|
|
146
|
+
const inferred = { ...METRIC_DEFAULTS };
|
|
147
|
+
|
|
148
|
+
// Layer 1: severity-based defaults
|
|
149
|
+
const sevMap = SEVERITY_METRIC_MAP[finding.severity] || SEVERITY_METRIC_MAP.MEDIUM;
|
|
150
|
+
Object.assign(inferred, sevMap);
|
|
151
|
+
notes.push(`Baseline metrics from severity: ${finding.severity}`);
|
|
152
|
+
|
|
153
|
+
// Layer 2: category-based overrides
|
|
154
|
+
if (finding.category && CATEGORY_METRIC_MAP[finding.category]) {
|
|
155
|
+
const catMap = CATEGORY_METRIC_MAP[finding.category];
|
|
156
|
+
Object.assign(inferred, catMap);
|
|
157
|
+
notes.push(`Category-specific metrics from: ${finding.category}`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Layer 3: confidence adjustment
|
|
161
|
+
if (finding.confidence === 'LOW') {
|
|
162
|
+
// Reduce AI-specific metrics for low confidence
|
|
163
|
+
for (const k of ['MR', 'DS', 'EI']) {
|
|
164
|
+
if (inferred[k] === 'VeryHigh') inferred[k] = 'High';
|
|
165
|
+
else if (inferred[k] === 'High') inferred[k] = 'Medium';
|
|
166
|
+
}
|
|
167
|
+
notes.push('AI-specific metrics reduced due to LOW confidence');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Determine mapping confidence
|
|
171
|
+
let mappingConfidence = 'MEDIUM';
|
|
172
|
+
if (finding.category && CATEGORY_METRIC_MAP[finding.category] && finding.confidence === 'HIGH') {
|
|
173
|
+
mappingConfidence = 'HIGH';
|
|
174
|
+
} else if (!finding.category || finding.confidence === 'LOW') {
|
|
175
|
+
mappingConfidence = 'LOW';
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return { inferred, notes, mappingConfidence };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Score a single normalized finding.
|
|
183
|
+
* @param {object} normalizedFinding - Output of normalizeFinding()
|
|
184
|
+
* @param {object} [overrides] - Manual AIVSS metric overrides
|
|
185
|
+
* @returns {object} Scored finding
|
|
186
|
+
*/
|
|
187
|
+
export function scoreAivss(normalizedFinding, overrides = {}) {
|
|
188
|
+
const { inferred, notes, mappingConfidence } = inferMetrics(normalizedFinding);
|
|
189
|
+
|
|
190
|
+
// Merge: overrides win
|
|
191
|
+
const final = { ...inferred };
|
|
192
|
+
const overriddenKeys = {};
|
|
193
|
+
for (const [key, val] of Object.entries(overrides)) {
|
|
194
|
+
if (ALL_METRICS.includes(key) && METRIC_VALUES[key] && val in METRIC_VALUES[key]) {
|
|
195
|
+
overriddenKeys[key] = val;
|
|
196
|
+
final[key] = val;
|
|
197
|
+
notes.push(`${key} overridden to ${val}`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const { score, components } = computeAivss(final);
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
rule_id: normalizedFinding.rule_id,
|
|
205
|
+
aivss_score: score,
|
|
206
|
+
rating: getRating(score),
|
|
207
|
+
vector_string: buildVectorString(final),
|
|
208
|
+
metrics: {
|
|
209
|
+
inferred,
|
|
210
|
+
overridden: Object.keys(overriddenKeys).length > 0 ? overriddenKeys : undefined,
|
|
211
|
+
final,
|
|
212
|
+
mapping_confidence: mappingConfidence,
|
|
213
|
+
mapping_notes: notes,
|
|
214
|
+
},
|
|
215
|
+
components,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Score a batch of normalized findings and compute aggregate posture.
|
|
221
|
+
* @param {object[]} normalizedFindings - Array of normalized findings
|
|
222
|
+
* @param {object} [overrides] - Overrides applied to all findings
|
|
223
|
+
* @returns {{ findings: object[], posture: object }}
|
|
224
|
+
*/
|
|
225
|
+
export function scoreBatch(normalizedFindings, overrides = {}) {
|
|
226
|
+
if (!normalizedFindings || normalizedFindings.length === 0) {
|
|
227
|
+
return {
|
|
228
|
+
findings: [],
|
|
229
|
+
posture: {
|
|
230
|
+
max_score: 0,
|
|
231
|
+
p95_score: 0,
|
|
232
|
+
mean_score: 0,
|
|
233
|
+
score_distribution: { critical: 0, high: 0, medium: 0, low: 0, none: 0 },
|
|
234
|
+
posture_score: 0,
|
|
235
|
+
posture_rating: 'None',
|
|
236
|
+
aggregate_method: 'house-posture-v1',
|
|
237
|
+
aggregate_note: 'Custom aggregation: max(max_score, mean + 1σ). Per-finding AIVSS scores are standards-based; this aggregate is not.',
|
|
238
|
+
model: AIVSS_MODEL,
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const scored = normalizedFindings.map(f => scoreAivss(f, overrides));
|
|
244
|
+
const scores = scored.map(s => s.aivss_score).sort((a, b) => a - b);
|
|
245
|
+
|
|
246
|
+
const max_score = scores[scores.length - 1];
|
|
247
|
+
const mean_score = parseFloat((scores.reduce((a, b) => a + b, 0) / scores.length).toFixed(2));
|
|
248
|
+
|
|
249
|
+
// P95
|
|
250
|
+
const p95Idx = Math.min(Math.ceil(scores.length * 0.95) - 1, scores.length - 1);
|
|
251
|
+
const p95_score = scores[p95Idx];
|
|
252
|
+
|
|
253
|
+
// Standard deviation
|
|
254
|
+
const variance = scores.reduce((sum, s) => sum + Math.pow(s - mean_score, 2), 0) / scores.length;
|
|
255
|
+
const stdDev = Math.sqrt(variance);
|
|
256
|
+
|
|
257
|
+
// Posture = max(max_score, mean + 1σ), capped at 10
|
|
258
|
+
const posture_score = parseFloat(Math.min(10, Math.max(max_score, mean_score + stdDev)).toFixed(2));
|
|
259
|
+
|
|
260
|
+
// Distribution
|
|
261
|
+
const score_distribution = { critical: 0, high: 0, medium: 0, low: 0, none: 0 };
|
|
262
|
+
for (const s of scores) {
|
|
263
|
+
if (s >= 9) score_distribution.critical++;
|
|
264
|
+
else if (s >= 7) score_distribution.high++;
|
|
265
|
+
else if (s >= 4) score_distribution.medium++;
|
|
266
|
+
else if (s >= 0.1) score_distribution.low++;
|
|
267
|
+
else score_distribution.none++;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return {
|
|
271
|
+
findings: scored,
|
|
272
|
+
posture: {
|
|
273
|
+
max_score,
|
|
274
|
+
p95_score,
|
|
275
|
+
mean_score,
|
|
276
|
+
score_distribution,
|
|
277
|
+
posture_score,
|
|
278
|
+
posture_rating: getRating(posture_score),
|
|
279
|
+
aggregate_method: 'house-posture-v1',
|
|
280
|
+
aggregate_note: 'Custom aggregation: max(max_score, mean + 1σ). Per-finding AIVSS scores are standards-based; this aggregate is not.',
|
|
281
|
+
model: AIVSS_MODEL,
|
|
282
|
+
},
|
|
283
|
+
};
|
|
284
|
+
}
|