Assessor_2.0/src/domain/security-scoring.ts

121 lines
5.6 KiB
TypeScript

/** Immutable scoring policy. Missing policies on historical assessments mean v1. */
export interface SecurityScoringPolicy {
version: '1.0.0';
weights: Record<string, number>;
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<string, SecurityScanScore[]>();
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
}))
};
}