121 lines
5.6 KiB
TypeScript
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
|
|
}))
|
|
};
|
|
}
|