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/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
- if language in rule_languages or 'generic' in rule_languages:
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}\\s*.{0,20}(?i)(decode|execute|follow|run)"
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');
@@ -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) => line.replace(/["']([^"']*)\s*["']\s*\+\s*(\w+)/, '"$1?", [$2]')
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) => line.replace(/\.query\s*\(\s*["'`]/, '.query("SELECT * FROM table WHERE id = ?", [')
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 line.replace(/open\s*\(\s*(\w+)/, 'open(os.path.realpath($1) # TODO: validate path prefix');
310
- if (lang === 'go') return line.replace(/os\.Open\s*\(\s*(\w+)/, 'os.Open(filepath.Clean($1) // TODO: validate path prefix');
311
- if (lang === 'java') return line.replace(/new File\s*\(\s*(\w+)/, 'new File($1).getCanonicalFile( // TODO: validate path prefix');
312
- return line.replace(/readFileSync\s*\(\s*(\w+)/, 'readFileSync(path.resolve($1) // TODO: validate path prefix');
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) => line.replace(/xpath\s*\(\s*["']([^"']*)\s*["']\s*\+\s*(\w+)/, 'xpath("$1?", [$2]')
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
@@ -49,7 +49,7 @@ export function saveResult(dirPath, scanResult) {
49
49
  };
50
50
 
51
51
  writeFileSync(filePath, JSON.stringify(historyEntry, null, 2) + '\n');
52
- return filePath;
52
+ return filePath.replace(/\\/g, '/');
53
53
  }
54
54
 
55
55
  /**
@@ -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
+ }