/** Immutable scoring policy. Missing policies on historical assessments mean v1. */ export interface SecurityScoringPolicy { version: '1.0.0'; weights: Record; defaultWeight: number; } export function defaultSecurityScoringPolicy(): SecurityScoringPolicy { return { version: '1.0.0', weights: { 'purple-knight': 1, pingcastle: 1, 'cis-cat': 1, maester: 1, 'group-policy': 1 }, defaultWeight: 1 }; } export function validSecurityScoringPolicy(value: SecurityScoringPolicy): boolean { return Boolean(value && value.version === '1.0.0' && value.weights && typeof value.weights === 'object' && !Array.isArray(value.weights)) && Number.isFinite(value.defaultWeight) && value.defaultWeight > 0 && Object.values(value.weights).every(weight => Number.isFinite(weight) && weight > 0); } export interface SecurityScanScore { id: string; assessmentId: string; toolId: string; toolName: string; runIds: string[]; importFileIds: string[]; occurrenceIds: string[]; resultSignature?: string; evaluationScopeKey?: string; contentSha256?: string; /** Present only with an explicit assessed target and a known benchmark scope. */ comparisonKey?: string; observedAt?: string; processingKey: string; policy: SecurityScoringPolicy; score?: number; nativeValue?: number; nativeProperty?: string; method: 'native' | 'inverted-native' | 'pass-rate' | 'unavailable'; passed: number; deviations: number; assessed: number; eligible: number; openOccurrenceIds: string[]; issues: string[]; } export interface SecurityScoreContext { policy?: SecurityScoringPolicy; scans: SecurityScanScore[]; baseline?: SecurityScanScore[]; historyUnavailable?: boolean; } export interface SecuritySourceScore { toolId: string; scans: SecurityScanScore[]; score?: number; weight: number; methods: SecurityScanScore['method'][]; issues: string[]; } /** No intermediate rounding. Weight is per tool, never per imported file/check. */ export function weightedSecurityScore(values: Array<{ score?: number; weight?: number }>): number | undefined { const scored = values.filter(value => value.score !== undefined && Number.isFinite(value.score) && value.score >= 0 && value.score <= 100 && Number.isFinite(value.weight ?? 1) && (value.weight ?? 1) > 0); const denominator = scored.reduce((sum, value) => sum + (value.weight ?? 1), 0); return denominator ? scored.reduce((sum, value) => sum + value.score! * (value.weight ?? 1), 0) / denominator : undefined; } export function securitySourceScores(scans: SecurityScanScore[]): SecuritySourceScore[] { const groups = new Map(); for (const scan of scans) { const group = groups.get(scan.toolId); if (group) group.push(scan); else groups.set(scan.toolId, [scan]); } return [...groups].map(([toolId, items]) => { const policies = new Set(items.map(item => JSON.stringify([item.policy.version, item.policy.weights[toolId] ?? item.policy.defaultWeight]))); const issues = [...new Set(items.flatMap(item => item.issues))]; if (policies.size !== 1) issues.push('Scoring policies differ'); return { toolId, scans: items, score: policies.size === 1 ? weightedSecurityScore(items) : undefined, weight: items[0].policy.weights[toolId] ?? items[0].policy.defaultWeight, methods: [...new Set(items.map(item => item.method))], issues }; }); } /** Compare only the same target, benchmark, engine/processing and score method. */ export function compareSecurityScores(baseline: SecurityScanScore[], current: SecurityScanScore[]) { const matching = (left: SecurityScanScore, right: SecurityScanScore) => left.comparisonKey !== undefined && left.comparisonKey === right.comparisonKey && left.processingKey === right.processingKey && left.evaluationScopeKey !== undefined && left.evaluationScopeKey === right.evaluationScopeKey && left.observedAt !== undefined && right.observedAt !== undefined && Date.parse(right.observedAt) > Date.parse(left.observedAt) && left.issues.length === 0 && right.issues.length === 0 && left.method === right.method && left.policy.version === right.policy.version && (left.policy.weights[left.toolId] ?? left.policy.defaultWeight) === (right.policy.weights[right.toolId] ?? right.policy.defaultWeight); const pairs = baseline.flatMap(left => { const candidates = current.filter(right => matching(left, right)); return candidates.length === 1 && baseline.filter(other => matching(other, candidates[0])).length === 1 && left.score !== undefined && candidates[0].score !== undefined ? [{ left, right: candidates[0] }] : []; }); const baselineScore = weightedSecurityScore(securitySourceScores(pairs.map(pair => pair.left))); const currentScore = weightedSecurityScore(securitySourceScores(pairs.map(pair => pair.right))); const unmatched = baseline.filter(left => !current.some(right => left.comparisonKey !== undefined && left.comparisonKey === right.comparisonKey && right.observedAt !== undefined && left.observedAt !== undefined && Date.parse(right.observedAt) > Date.parse(left.observedAt) && right.assessed > 0 && !right.issues.includes('Source integrity diagnostic'))); return { baselineScore, currentScore, change: baselineScore === undefined || currentScore === undefined ? undefined : currentScore - baselineScore, comparable: pairs.length, scopeChanged: pairs.length !== baseline.length || pairs.length !== current.length, awaitingRevalidation: unmatched.map(scan => ({ assessmentId: scan.assessmentId, runIds: scan.runIds, observedAt: scan.observedAt, occurrenceIds: scan.openOccurrenceIds })) }; }