diff --git a/src/humanizer.ts b/src/humanizer.ts index b36510c..f9e46ce 100644 --- a/src/humanizer.ts +++ b/src/humanizer.ts @@ -1,7 +1,7 @@ -import { BaseMetric, BaseMetricValue } from './models'; +import { BaseMetric, Metric, MetricValue } from './models'; -export const humanizeBaseMetric = (baseMetric: BaseMetric): string => { - switch (baseMetric) { +export const humanizeBaseMetric = (metric: Metric): string => { + switch (metric) { case BaseMetric.ATTACK_VECTOR: return 'Attack Vector'; case BaseMetric.ATTACK_COMPLEXITY: @@ -25,8 +25,8 @@ export const humanizeBaseMetric = (baseMetric: BaseMetric): string => { // eslint-disable-next-line complexity export const humanizeBaseMetricValue = ( - value: BaseMetricValue, - metric: BaseMetric + value: MetricValue, + metric: Metric ): string => { switch (value) { case 'A': diff --git a/src/models.ts b/src/models.ts index 47880b0..9c9ca6f 100644 --- a/src/models.ts +++ b/src/models.ts @@ -9,6 +9,26 @@ export enum BaseMetric { AVAILABILITY = 'A' } +export enum TemporalMetric { + EXPLOIT_CODE_MATURITY = 'E', + REMEDIATION_LEVEL = 'RL', + REPORT_CONFIDENCE = 'RC' +} + +export enum EnvironmentalMetric { + CONFIDENTIALITY_REQUIREMENT = 'CR', + INTEGRITY_REQUIREMENT = 'IR', + AVAILABILITY_REQUIREMENT = 'AR', + MODIFIED_ATTACK_VECTOR = 'MAV', + MODIFIED_ATTACK_COMPLEXITY = 'MAC', + MODIFIED_PRIVILEGES_REQUIRED = 'MPR', + MODIFIED_USER_INTERACTION = 'MUI', + MODIFIED_SCOPE = 'MS', + MODIFIED_CONFIDENTIALITY = 'MC', + MODIFIED_INTEGRITY = 'MI', + MODIFIED_AVAILABILITY = 'MA' +} + export const baseMetrics: ReadonlyArray = [ BaseMetric.ATTACK_VECTOR, BaseMetric.ATTACK_COMPLEXITY, @@ -20,9 +40,27 @@ export const baseMetrics: ReadonlyArray = [ BaseMetric.AVAILABILITY ]; -export type BaseMetricValue = 'A' | 'C' | 'H' | 'L' | 'N' | 'P' | 'R' | 'U'; +export const temporalMetrics: Metrics = [ + TemporalMetric.EXPLOIT_CODE_MATURITY, + TemporalMetric.REMEDIATION_LEVEL, + TemporalMetric.REPORT_CONFIDENCE +]; + +export const environmentalMetrics: Metrics = [ + EnvironmentalMetric.AVAILABILITY_REQUIREMENT, + EnvironmentalMetric.CONFIDENTIALITY_REQUIREMENT, + EnvironmentalMetric.INTEGRITY_REQUIREMENT, + EnvironmentalMetric.MODIFIED_ATTACK_VECTOR, + EnvironmentalMetric.MODIFIED_ATTACK_COMPLEXITY, + EnvironmentalMetric.MODIFIED_PRIVILEGES_REQUIRED, + EnvironmentalMetric.MODIFIED_USER_INTERACTION, + EnvironmentalMetric.MODIFIED_SCOPE, + EnvironmentalMetric.MODIFIED_CONFIDENTIALITY, + EnvironmentalMetric.MODIFIED_INTEGRITY, + EnvironmentalMetric.MODIFIED_AVAILABILITY +]; -export const baseMetricValues: Record = { +export const baseMetricValues: MetricValues = { [BaseMetric.ATTACK_VECTOR]: ['N', 'A', 'L', 'P'], [BaseMetric.ATTACK_COMPLEXITY]: ['L', 'H'], [BaseMetric.PRIVILEGES_REQUIRED]: ['N', 'L', 'H'], @@ -32,3 +70,57 @@ export const baseMetricValues: Record = { [BaseMetric.INTEGRITY]: ['N', 'L', 'H'], [BaseMetric.AVAILABILITY]: ['N', 'L', 'H'] }; + +export const temporalMetricValues: MetricValues< + TemporalMetric, + TemporalMetricValue +> = { + [TemporalMetric.EXPLOIT_CODE_MATURITY]: ['X', 'H', 'F', 'P', 'U'], + [TemporalMetric.REMEDIATION_LEVEL]: ['X', 'U', 'W', 'T', 'O'], + [TemporalMetric.REPORT_CONFIDENCE]: ['X', 'C', 'R', 'U'] +}; + +export const environmentalMetricValues: MetricValues< + EnvironmentalMetric, + EnvironmentalMetricValue +> = { + [EnvironmentalMetric.CONFIDENTIALITY_REQUIREMENT]: ['X', 'H', 'M', 'L'], + [EnvironmentalMetric.INTEGRITY_REQUIREMENT]: ['X', 'H', 'M', 'L'], + [EnvironmentalMetric.AVAILABILITY_REQUIREMENT]: ['X', 'H', 'M', 'L'], + [EnvironmentalMetric.MODIFIED_ATTACK_VECTOR]: ['X', 'N', 'A', 'L', 'P'], + [EnvironmentalMetric.MODIFIED_ATTACK_COMPLEXITY]: ['X', 'L', 'H'], + [EnvironmentalMetric.MODIFIED_PRIVILEGES_REQUIRED]: ['X', 'N', 'L', 'H'], + [EnvironmentalMetric.MODIFIED_USER_INTERACTION]: ['X', 'N', 'R'], + [EnvironmentalMetric.MODIFIED_SCOPE]: ['X', 'U', 'C'], + [EnvironmentalMetric.MODIFIED_CONFIDENTIALITY]: ['X', 'N', 'L', 'H'], + [EnvironmentalMetric.MODIFIED_INTEGRITY]: ['X', 'N', 'L', 'H'], + [EnvironmentalMetric.MODIFIED_AVAILABILITY]: ['X', 'N', 'L', 'H'] +}; + +export type Metric = BaseMetric | TemporalMetric | EnvironmentalMetric; +export type BaseMetricValue = 'A' | 'C' | 'H' | 'L' | 'N' | 'P' | 'R' | 'U'; +export type TemporalMetricValue = + | 'X' + | 'F' + | 'H' + | 'O' + | 'T' + | 'W' + | 'U' + | 'P' + | 'C' + | 'R'; +export type EnvironmentalMetricValue = BaseMetricValue | 'M' | 'X'; +export type MetricValue = + | BaseMetricValue + | TemporalMetricValue + | EnvironmentalMetricValue; +export type MetricValues< + M extends Metric = Metric, + V extends MetricValue = MetricValue +> = Record; +export type Metrics = ReadonlyArray; +export type AllMetricValues = + | typeof baseMetricValues + | typeof temporalMetricValues + | typeof environmentalMetricValues; diff --git a/src/parser.ts b/src/parser.ts index 4b558c9..1ce71ed 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -1,4 +1,4 @@ -import { BaseMetric, BaseMetricValue } from './models'; +import { BaseMetric, BaseMetricValue, Metric, MetricValue } from './models'; export interface KeyValue { key: K; @@ -30,9 +30,7 @@ export const parseMetrics = (vectorStr: string): KeyValue[] => return { key: parts[0], value: parts[1] }; }); -export const parseMetricsAsMap = ( - cvssStr: string -): Map => +export const parseMetricsAsMap = (cvssStr: string): Map => parseMetrics(parseVector(cvssStr) || '').reduce( ( res: Map, diff --git a/src/score-calculator.ts b/src/score-calculator.ts index a3dafb4..0b3ea33 100644 --- a/src/score-calculator.ts +++ b/src/score-calculator.ts @@ -1,5 +1,15 @@ -import { BaseMetric, BaseMetricValue } from './models'; -import { parseMetricsAsMap } from './parser'; +import { + BaseMetric, + BaseMetricValue, + EnvironmentalMetric, + environmentalMetrics, + EnvironmentalMetricValue, + Metric, + MetricValue, + TemporalMetric, + temporalMetrics, + TemporalMetricValue +} from './models'; import { validate } from './validator'; // https://www.first.org/cvss/v3.1/specification-document#7-4-Metric-Values @@ -17,9 +27,57 @@ const baseMetricValueScores: Record< [BaseMetric.AVAILABILITY]: { N: 0, L: 0.22, H: 0.56 } }; +const temporalMetricValueScores: Record< + TemporalMetric, + Partial> | null +> = { + [TemporalMetric.EXPLOIT_CODE_MATURITY]: { + X: 1, + U: 0.91, + F: 0.97, + P: 0.94, + H: 1 + }, + [TemporalMetric.REMEDIATION_LEVEL]: { X: 1, O: 0.95, T: 0.96, W: 0.97, U: 1 }, + [TemporalMetric.REPORT_CONFIDENCE]: { X: 1, U: 0.92, R: 0.96, C: 1 } +}; + +const environmentalMetricValueScores: Record< + EnvironmentalMetric, + Partial> | null +> = { + [EnvironmentalMetric.CONFIDENTIALITY_REQUIREMENT]: { + M: 1, + L: 0.5, + H: 1.5, + X: 1 + }, + [EnvironmentalMetric.INTEGRITY_REQUIREMENT]: { M: 1, L: 0.5, H: 1.5, X: 1 }, + [EnvironmentalMetric.AVAILABILITY_REQUIREMENT]: { + M: 1, + L: 0.5, + H: 1.5, + X: 1 + }, + [EnvironmentalMetric.MODIFIED_ATTACK_VECTOR]: + baseMetricValueScores[BaseMetric.ATTACK_VECTOR], + [EnvironmentalMetric.MODIFIED_ATTACK_COMPLEXITY]: + baseMetricValueScores[BaseMetric.ATTACK_COMPLEXITY], + [EnvironmentalMetric.MODIFIED_PRIVILEGES_REQUIRED]: null, // scope-dependent: see getPrivilegesRequiredNumericValue() + [EnvironmentalMetric.MODIFIED_USER_INTERACTION]: + baseMetricValueScores[BaseMetric.USER_INTERACTION], + [EnvironmentalMetric.MODIFIED_SCOPE]: baseMetricValueScores[BaseMetric.SCOPE], + [EnvironmentalMetric.MODIFIED_CONFIDENTIALITY]: + baseMetricValueScores[BaseMetric.CONFIDENTIALITY], + [EnvironmentalMetric.MODIFIED_INTEGRITY]: + baseMetricValueScores[BaseMetric.INTEGRITY], + [EnvironmentalMetric.MODIFIED_AVAILABILITY]: + baseMetricValueScores[BaseMetric.AVAILABILITY] +}; + const getPrivilegesRequiredNumericValue = ( - value: BaseMetricValue, - scopeValue: BaseMetricValue + value: MetricValue, + scopeValue: MetricValue ): number => { if (scopeValue !== 'U' && scopeValue !== 'C') { throw new Error(`Unknown Scope value: ${scopeValue}`); @@ -38,9 +96,9 @@ const getPrivilegesRequiredNumericValue = ( }; const getMetricValue = ( - metric: BaseMetric, - metricsMap: Map -): BaseMetricValue => { + metric: Metric, + metricsMap: Map +): MetricValue => { if (!metricsMap.has(metric)) { throw new Error(`Missing metric: ${metric}`); } @@ -49,20 +107,36 @@ const getMetricValue = ( }; const getMetricNumericValue = ( - metric: BaseMetric, - metricsMap: Map + metric: Metric, + metricsMap: Map ): number => { - const value = getMetricValue(metric, metricsMap); + const value = getMetricValue( + (metric as BaseMetric) || TemporalMetric || EnvironmentalMetric, + metricsMap + ); if (metric === BaseMetric.PRIVILEGES_REQUIRED) { return getPrivilegesRequiredNumericValue( value, - getMetricValue(BaseMetric.SCOPE, metricsMap) + getMetricValue(BaseMetric.SCOPE as BaseMetric, metricsMap) + ); + } + if (metric === EnvironmentalMetric.MODIFIED_PRIVILEGES_REQUIRED) { + return getPrivilegesRequiredNumericValue( + value, + getMetricValue( + EnvironmentalMetric.MODIFIED_SCOPE as EnvironmentalMetric, + metricsMap + ) ); } - const score: Partial> | null = - baseMetricValueScores[metric]; + const score: Partial> | null = { + ...baseMetricValueScores, + ...temporalMetricValueScores, + ...environmentalMetricValueScores + }[metric]; + if (!score) { throw new Error(`Internal error. Missing metric score: ${metric}`); } @@ -71,9 +145,7 @@ const getMetricNumericValue = ( }; // ISS = 1 - [ (1 - Confidentiality) × (1 - Integrity) × (1 - Availability) ] -export const calculateIss = ( - metricsMap: Map -): number => { +export const calculateIss = (metricsMap: Map): number => { const confidentiality = getMetricNumericValue( BaseMetric.CONFIDENTIALITY, metricsMap @@ -87,22 +159,77 @@ export const calculateIss = ( return 1 - (1 - confidentiality) * (1 - integrity) * (1 - availability); }; +// https://www.first.org/cvss/v3.1/specification-document#7-3-Environmental-Metrics-Equations +// MISS = Minimum ( 1 - [ (1 - ConfidentialityRequirement × ModifiedConfidentiality) × (1 - IntegrityRequirement × ModifiedIntegrity) × (1 - AvailabilityRequirement × ModifiedAvailability) ], 0.915) +export const calculateMiss = (metricsMap: Map): number => { + const rConfidentiality = getMetricNumericValue( + EnvironmentalMetric.CONFIDENTIALITY_REQUIREMENT, + metricsMap + ); + const mConfidentiality = getMetricNumericValue( + EnvironmentalMetric.MODIFIED_CONFIDENTIALITY, + metricsMap + ); + + const rIntegrity = getMetricNumericValue( + EnvironmentalMetric.INTEGRITY_REQUIREMENT, + metricsMap + ); + const mIntegrity = getMetricNumericValue( + EnvironmentalMetric.MODIFIED_INTEGRITY, + metricsMap + ); + + const rAvailability = getMetricNumericValue( + EnvironmentalMetric.AVAILABILITY_REQUIREMENT, + metricsMap + ); + const mAvailability = getMetricNumericValue( + EnvironmentalMetric.MODIFIED_AVAILABILITY, + metricsMap + ); + + return Math.min( + 1 - + (1 - rConfidentiality * mConfidentiality) * + (1 - rIntegrity * mIntegrity) * + (1 - rAvailability * mAvailability), + 0.915 + ); +}; + // https://www.first.org/cvss/v3.1/specification-document#7-1-Base-Metrics-Equations // Impact = // If Scope is Unchanged 6.42 × ISS -// If Scope is Changed 7.52 × (ISS - 0.029) - 3.25 × (ISS - 0.02) +// If Scope is Changed 7.52 × (ISS - 0.029) - 3.25 × (ISS - 0.02)^15 export const calculateImpact = ( - metricsMap: Map, + metricsMap: Map, iss: number ): number => metricsMap.get(BaseMetric.SCOPE) === 'U' ? 6.42 * iss : 7.52 * (iss - 0.029) - 3.25 * Math.pow(iss - 0.02, 15); +// https://www.first.org/cvss/v3.1/specification-document#7-3-Environmental-Metrics-Equations +// ModifiedImpact = +// If ModifiedScope is Unchanged 6.42 × MISS +// If ModifiedScope is Changed 7.52 × (MISS - 0.029) - 3.25 × (MISS × 0.9731 - 0.02)^13 +// ModifiedExploitability = 8.22 × ModifiedAttackVector × ModifiedAttackComplexity × ModifiedPrivilegesRequired × ModifiedUserInteraction +// Note : Math.pow is 15 in 3.0 but 13 in 3.1 +export const calculateMImpact = ( + metricsMap: Map, + miss: number, + versionStr: string | null +): number => + metricsMap.get(EnvironmentalMetric.MODIFIED_SCOPE) === 'U' + ? 6.42 * miss + : 7.52 * (miss - 0.029) - + 3.25 * Math.pow(miss * 0.9731 - 0.02, versionStr === '3.0' ? 15 : 13); + // https://www.first.org/cvss/v3.1/specification-document#7-1-Base-Metrics-Equations // Exploitability = 8.22 × AttackVector × AttackComplexity × PrivilegesRequired × UserInteraction export const calculateExploitability = ( - metricsMap: Map + metricsMap: Map ): number => 8.22 * getMetricNumericValue(BaseMetric.ATTACK_VECTOR, metricsMap) * @@ -110,6 +237,29 @@ export const calculateExploitability = ( getMetricNumericValue(BaseMetric.PRIVILEGES_REQUIRED, metricsMap) * getMetricNumericValue(BaseMetric.USER_INTERACTION, metricsMap); +// https://www.first.org/cvss/v3.1/specification-document#7-3-Environmental-Metrics-Equations +// Exploitability = 8.22 × ModifiedAttackVector × ModifiedAttackComplexity × ModifiedPrivilegesRequired × ModifiedUserInteraction +export const calculateMExploitability = ( + metricsMap: Map +): number => + 8.22 * + getMetricNumericValue( + EnvironmentalMetric.MODIFIED_ATTACK_VECTOR, + metricsMap + ) * + getMetricNumericValue( + EnvironmentalMetric.MODIFIED_ATTACK_COMPLEXITY, + metricsMap + ) * + getMetricNumericValue( + EnvironmentalMetric.MODIFIED_PRIVILEGES_REQUIRED, + metricsMap + ) * + getMetricNumericValue( + EnvironmentalMetric.MODIFIED_USER_INTERACTION, + metricsMap + ); + // https://www.first.org/cvss/v3.1/specification-document#Appendix-A---Floating-Point-Rounding const roundUp = (input: number): number => { const intInput = Math.round(input * 100000); @@ -119,24 +269,182 @@ const roundUp = (input: number): number => { : (Math.floor(intInput / 10000) + 1) / 10; }; +export const modifiedMetricsMap: { [key: string]: BaseMetric } = { + MAV: BaseMetric.ATTACK_VECTOR, + MAC: BaseMetric.ATTACK_COMPLEXITY, + MPR: BaseMetric.PRIVILEGES_REQUIRED, + MUI: BaseMetric.USER_INTERACTION, + MS: BaseMetric.SCOPE, + MC: BaseMetric.CONFIDENTIALITY, + MI: BaseMetric.INTEGRITY, + MA: BaseMetric.AVAILABILITY +}; + +// When Modified Temporal metric value is 'Not Defined' ('X'), which is the default value, +// then Base metric value should be used. +export const populateTemporalMetricDefaults = ( + metricsMap: Map +): Map => { + [...temporalMetrics].forEach((metric) => { + if (!metricsMap.has(metric)) { + metricsMap.set(metric, 'X'); + } + }); + + return metricsMap; +}; + +export const populateEnvironmentalMetricDefaults = ( + metricsMap: Map +): Map => { + [...environmentalMetrics].forEach((metric: EnvironmentalMetric) => { + if (!metricsMap.has(metric)) { + metricsMap.set(metric, 'X'); + } + + if (metricsMap.get(metric) === 'X') { + metricsMap.set( + metric, + metricsMap.has(modifiedMetricsMap[metric]) + ? (metricsMap.get(modifiedMetricsMap[metric]) as MetricValue) + : 'X' + ); + } + }); + + return metricsMap; +}; + +export type ScoreResult = { + score: number; + impact: number; + exploitability: number; + metricsMap: Map; +}; + // https://www.first.org/cvss/v3.1/specification-document#7-1-Base-Metrics-Equations // If Impact <= 0 => 0; else // If Scope is Unchanged => Roundup (Minimum [(Impact + Exploitability), 10]) // If Scope is Changed => Roundup (Minimum [1.08 × (Impact + Exploitability), 10]) -export const calculateBaseScore = (cvssString: string): number => { - validate(cvssString); +export const calculateBaseResult = (cvssString: string): ScoreResult => { + const { metricsMap } = validate(cvssString); - const metricsMap: Map = parseMetricsAsMap( - cvssString - ); const iss = calculateIss(metricsMap); const impact = calculateImpact(metricsMap, iss); const exploitability = calculateExploitability(metricsMap); const scopeUnchanged = metricsMap.get(BaseMetric.SCOPE) === 'U'; - return impact <= 0 - ? 0 - : scopeUnchanged - ? roundUp(Math.min(impact + exploitability, 10)) - : roundUp(Math.min(1.08 * (impact + exploitability), 10)); + const score = + impact <= 0 + ? 0 + : scopeUnchanged + ? roundUp(Math.min(impact + exploitability, 10)) + : roundUp(Math.min(1.08 * (impact + exploitability), 10)); + + return { + score, + metricsMap, + impact: impact <= 0 ? 0 : roundUp(impact), + exploitability: impact <= 0 ? 0 : roundUp(exploitability) + }; +}; + +export const calculateBaseScore = (cvssString: string): number => { + const { score } = calculateBaseResult(cvssString); + + return score; +}; + +// https://www.first.org/cvss/v3.1/specification-document#7-3-Environmental-Metrics-Equations +// If ModifiedImpact <= 0 => 0; else +// If ModifiedScope is Unchanged => Roundup (Roundup [Minimum ([ModifiedImpact + ModifiedExploitability], 10)] × ExploitCodeMaturity × RemediationLevel × ReportConfidence) +// If ModifiedScope is Changed => Roundup (Roundup [Minimum (1.08 × [ModifiedImpact + ModifiedExploitability], 10)] × ExploitCodeMaturity × RemediationLevel × ReportConfidence) +export const calculateEnvironmentalResult = ( + cvssString: string +): ScoreResult => { + const { versionStr } = validate(cvssString); + let { metricsMap } = validate(cvssString); + + metricsMap = populateTemporalMetricDefaults(metricsMap); + metricsMap = populateEnvironmentalMetricDefaults(metricsMap); + const miss = calculateMiss(metricsMap); + const impact = calculateMImpact(metricsMap, miss, versionStr); + const exploitability = calculateMExploitability(metricsMap); + const scopeUnchanged = + metricsMap.get(EnvironmentalMetric.MODIFIED_SCOPE) === 'U'; + + const score = + impact <= 0 + ? 0 + : scopeUnchanged + ? roundUp( + roundUp(Math.min(impact + exploitability, 10)) * + getMetricNumericValue( + TemporalMetric.EXPLOIT_CODE_MATURITY, + metricsMap + ) * + getMetricNumericValue( + TemporalMetric.REMEDIATION_LEVEL, + metricsMap + ) * + getMetricNumericValue(TemporalMetric.REPORT_CONFIDENCE, metricsMap) + ) + : roundUp( + roundUp(Math.min(1.08 * (impact + exploitability), 10)) * + getMetricNumericValue( + TemporalMetric.EXPLOIT_CODE_MATURITY, + metricsMap + ) * + getMetricNumericValue( + TemporalMetric.REMEDIATION_LEVEL, + metricsMap + ) * + getMetricNumericValue(TemporalMetric.REPORT_CONFIDENCE, metricsMap) + ); + + return { + score, + metricsMap, + impact: impact <= 0 ? 0 : roundUp(impact), + exploitability: impact <= 0 ? 0 : roundUp(exploitability) + }; +}; + +export const calculateEnvironmentalScore = (cvssString: string): number => { + const { score } = calculateEnvironmentalResult(cvssString); + + return score; +}; + +// https://www.first.org/cvss/v3.1/specification-document#7-2-Temporal-Metrics-Equations +// Roundup (BaseScore × ExploitCodeMaturity × RemediationLevel × ReportConfidence) +export const calculateTemporalResult = (cvssString: string): ScoreResult => { + const { metricsMap } = validate(cvssString); + // populate temp metrics if not provided + [...temporalMetrics].map((metric) => { + if (![...metricsMap.keys()].includes(metric)) { + metricsMap.set(metric, 'X'); + } + }); + const { score, impact, exploitability } = calculateBaseResult(cvssString); + + const tempScore = roundUp( + score * + getMetricNumericValue(TemporalMetric.REPORT_CONFIDENCE, metricsMap) * + getMetricNumericValue(TemporalMetric.EXPLOIT_CODE_MATURITY, metricsMap) * + getMetricNumericValue(TemporalMetric.REMEDIATION_LEVEL, metricsMap) + ); + + return { + score: tempScore, + metricsMap, + impact, + exploitability + }; +}; + +export const calculateTemporalScore = (cvssString: string): number => { + const { score } = calculateTemporalResult(cvssString); + + return score; }; diff --git a/src/validator.ts b/src/validator.ts index e6d5d72..9b369cd 100644 --- a/src/validator.ts +++ b/src/validator.ts @@ -1,8 +1,16 @@ import { - BaseMetric, + Metric, + MetricValue, + Metrics, baseMetrics, - BaseMetricValue, - baseMetricValues + temporalMetrics, + environmentalMetrics, + AllMetricValues, + baseMetricValues, + temporalMetricValues, + environmentalMetricValues, + TemporalMetric, + EnvironmentalMetric } from './models'; import { humanizeBaseMetric, humanizeBaseMetricValue } from './humanizer'; import { parseMetricsAsMap, parseVector, parseVersion } from './parser'; @@ -29,11 +37,20 @@ const validateVector = (vectorStr: string | null): void => { } }; -const checkUnknownBaseMetrics = (metricsMap: Map): void => { +const checkUnknownMetrics = ( + metricsMap: Map, + knownMetrics?: Metrics +): void => { + const allKnownMetrics = knownMetrics || [ + ...baseMetrics, + ...temporalMetrics, + ...environmentalMetrics + ]; + [...metricsMap.keys()].forEach((userMetric: string) => { - if (!baseMetrics.includes(userMetric as BaseMetric)) { + if (!allKnownMetrics.includes(userMetric as Metric)) { throw new Error( - `Unknown CVSS metric "${userMetric}". Allowed metrics: ${baseMetrics.join( + `Unknown CVSS metric "${userMetric}". Allowed metrics: ${allKnownMetrics.join( ', ' )}` ); @@ -41,33 +58,42 @@ const checkUnknownBaseMetrics = (metricsMap: Map): void => { }); }; -const checkMandatoryBaseMetrics = (metricsMap: Map): void => { - baseMetrics.forEach((baseMetric: BaseMetric) => { - if (!metricsMap.has(baseMetric)) { +const checkMandatoryMetrics = ( + metricsMap: Map, + metrics: Metrics = baseMetrics +): void => { + metrics.forEach((metric: Metric) => { + if (!metricsMap.has(metric)) { // eslint-disable-next-line max-len throw new Error( - `Missing mandatory CVSS base metric ${baseMetric} (${humanizeBaseMetric( - baseMetric + `Missing mandatory CVSS metric ${metrics} (${humanizeBaseMetric( + metric )})` ); } }); }; -const checkBaseMetricsValues = (metricsMap: Map): void => { - baseMetrics.forEach((baseMetric: BaseMetric) => { - const userValue = metricsMap.get(baseMetric); - if (!baseMetricValues[baseMetric].includes(userValue as BaseMetricValue)) { - const allowedValuesHumanized = baseMetricValues[baseMetric] +const checkMetricsValues = ( + metricsMap: Map, + metrics: Metrics, + metricsValues: AllMetricValues +): void => { + metrics.forEach((metric: Metric) => { + const userValue = metricsMap.get(metric); + if (!userValue) { + return; + } + if (!metricsValues[metric].includes(userValue as MetricValue)) { + const allowedValuesHumanized = metricsValues[metric] .map( - (value: BaseMetricValue) => - `${value} (${humanizeBaseMetricValue(value, baseMetric)})` + (value: MetricValue) => + `${value} (${humanizeBaseMetricValue(value, metric)})` ) .join(', '); - // eslint-disable-next-line max-len throw new Error( - `Invalid value for CVSS metric ${baseMetric} (${humanizeBaseMetric( - baseMetric + `Invalid value for CVSS metric ${metric} (${humanizeBaseMetric( + metric )})${ userValue ? `: ${userValue}` : '' }. Allowed values: ${allowedValuesHumanized}` @@ -76,10 +102,31 @@ const checkBaseMetricsValues = (metricsMap: Map): void => { }); }; -export const validate = (cvssStr: string): void => { +type ValidationResult = { + isTemporal: boolean; + isEnvironmental: boolean; + metricsMap: Map; + versionStr: string | null; +}; + +/** + * Validate that the given string is a valid cvss vector + * @param cvssStr + */ +export const validate = (cvssStr: string): ValidationResult => { if (!cvssStr || !cvssStr.startsWith('CVSS:')) { throw new Error('CVSS vector must start with "CVSS:"'); } + const allKnownMetrics = [ + ...baseMetrics, + ...temporalMetrics, + ...environmentalMetrics + ]; + const allKnownMetricsValues = { + ...baseMetricValues, + ...temporalMetricValues, + ...environmentalMetricValues + }; const versionStr = parseVersion(cvssStr); validateVersion(versionStr); @@ -87,8 +134,22 @@ export const validate = (cvssStr: string): void => { const vectorStr = parseVector(cvssStr); validateVector(vectorStr); - const metricsMap: Map = parseMetricsAsMap(cvssStr); - checkUnknownBaseMetrics(metricsMap); - checkMandatoryBaseMetrics(metricsMap); - checkBaseMetricsValues(metricsMap); + const metricsMap = parseMetricsAsMap(cvssStr); + checkMandatoryMetrics(metricsMap); + checkUnknownMetrics(metricsMap, allKnownMetrics); + checkMetricsValues(metricsMap, allKnownMetrics, allKnownMetricsValues); + + const isTemporal = [...metricsMap.keys()].some((metric) => + temporalMetrics.includes(metric as TemporalMetric) + ); + const isEnvironmental = [...metricsMap.keys()].some((metric) => + environmentalMetrics.includes(metric as EnvironmentalMetric) + ); + + return { + metricsMap, + isTemporal, + isEnvironmental, + versionStr + }; }; diff --git a/tests/score-calculator.spec.ts b/tests/score-calculator.spec.ts index 874821d..7f673dd 100644 --- a/tests/score-calculator.spec.ts +++ b/tests/score-calculator.spec.ts @@ -1,59 +1,95 @@ -import { calculateBaseScore } from '../src'; +import { + calculateBaseScore, + calculateEnvironmentalScore, + calculateTemporalScore +} from '../src'; import { expect } from 'chai'; -describe('calculator', () => { - it('should calculate "CVSS:3.0/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:N/A:N" score as 8.6', () => { - const result = calculateBaseScore( - 'CVSS:3.0/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:N/A:N' - ); - expect(result).to.equal(8.6); - }); - - it('should calculate "CVSS:3.0/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H" score as 10.0', () => { - const result = calculateBaseScore( - 'CVSS:3.0/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H' - ); - expect(result).to.equal(10); - }); - - it('should calculate "CVSS:3.0/AV:L/AC:L/PR:N/UI:N/S:C/C:N/I:N/A:N" score as 0.0', () => { - const result = calculateBaseScore( - 'CVSS:3.0/AV:L/AC:L/PR:N/UI:N/S:C/C:N/I:N/A:N' - ); - expect(result).to.equal(0); - }); - - // https://www.first.org/cvss/user-guide#3-1-CVSS-Scoring-in-the-Exploit-Life-Cycle - it('should calculate "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N" score as 7.5', () => { - const result = calculateBaseScore( - 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N' - ); - expect(result).to.equal(7.5); - }); - - // https://www.first.org/cvss/user-guide#3-1-CVSS-Scoring-in-the-Exploit-Life-Cycle - it('should calculate "CVSS:3.0/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H" score as 7.8', () => { - const result = calculateBaseScore( - 'CVSS:3.0/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H' - ); - expect(result).to.equal(7.8); - }); - - // https://www.first.org/cvss/user-guide#3-6-Vulnerable-Components-Protected-by-a-Firewall - it('should calculate "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:L/I:L/A:N" score as 6.4', () => { - const result = calculateBaseScore( - 'CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:L/I:L/A:N' - ); - expect(result).to.equal(6.4); - }); - - it('should calculate "CVSS:3.1/S:C/C:L/I:L/A:N/AV:N/AC:L/PR:L/UI:N" (non-normalized order) score as 6.4', () => { - const result = calculateBaseScore( - 'CVSS:3.1/S:C/C:L/I:L/A:N/AV:N/AC:L/PR:L/UI:N' - ); - expect(result).to.equal(6.4); - }); +// CVSS => base, temporal, environmental +const cvssTests = { + 'CVSS:3.0/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:N/A:N': [8.6, 8.6, 8.6], + 'CVSS:3.0/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H': [10.0, 10.0, 10.0], + 'CVSS:3.0/AV:L/AC:L/PR:N/UI:N/S:C/C:N/I:N/A:N': [0.0, 0.0, 0.0], + 'CVSS:3.0/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H': [7.8, 7.8, 7.8], // https://www.first.org/cvss/user-guide#3-1-CVSS-Scoring-in-the-Exploit-Life-Cycle + 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N': [7.5, 7.5, 7.5], // https://www.first.org/cvss/user-guide#3-1-CVSS-Scoring-in-the-Exploit-Life-Cycle + 'CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:L/I:L/A:N': [6.4, 6.4, 6.4], // https://www.first.org/cvss/user-guide#3-6-Vulnerable-Components-Protected-by-a-Firewall + 'CVSS:3.1/S:C/C:L/I:L/A:N/AV:N/AC:L/PR:L/UI:N': [6.4, 6.4, 6.4], // non-normalized order + 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:N/A:N': [8.6, 8.6, 8.6], + 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H': [10.0, 10.0, 10.0], + 'CVSS:3.1/AV:L/AC:L/PR:N/UI:N/S:C/C:N/I:N/A:N': [0.0, 0.0, 0.0], + 'CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H': [7.8, 7.8, 7.8], + 'CVSS:3.1/AV:A/AC:H/PR:L/UI:R/S:C/C:L/I:L/A:L/E:U/RL:O/RC:U/CR:M/IR:M/AR:M/MAV:A/MAC:H/MPR:L/MUI:N/MS:X/MC:N/MI:H/MA:X': [ + 5.1, + 4.1, + 5.2 + ], + 'CVSS:3.1/AV:A/AC:L/PR:L/UI:N/S:U/C:L/I:L/A:N/E:U/RL:T/RC:C/CR:X/IR:L/AR:L/MAV:N/MAC:H/MPR:L/MUI:N/MS:U/MC:L/MI:L/MA:L': [ + 4.6, + 4.1, + 3.6 + ], + 'CVSS:3.1/AV:A/AC:H/PR:L/UI:N/S:C/C:L/I:N/A:N/E:P/RL:W/RC:C/IR:L/AR:L/MAV:A/MAC:H/MPR:L/MUI:N/MS:C/MI:L/MA:L': [ + 3.0, + 2.8, + 4.0 + ], + 'CVSS:3.1/AV:A/AC:H/PR:L/UI:R/S:C/C:L/I:L/A:L/E:U/RL:O/RC:U/CR:H/IR:H/AR:L/MAV:P/MAC:H/MPR:H/MUI:R/MS:C/MC:N/MI:N/MA:N': [ + 5.1, + 4.1, + 0.0 + ], + 'CVSS:3.1/AV:A/AC:H/PR:L/UI:R/S:C/C:L/I:L/A:L/E:H/RL:U/RC:C/CR:M/IR:M/AR:M/MAV:N/MAC:L/MPR:N/MUI:N/MS:C/MC:H/MI:H/MA:H': [ + 5.1, + 5.1, + 10.0 + ], + 'CVSS:3.1/AV:A/AC:H/PR:H/UI:R/S:U/C:L/I:L/A:L/CR:M/IR:M/AR:L/MAV:N/MAC:H/MPR:N/MUI:R/MS:U/MC:N/MI:N/MA:L': [ + 3.8, + 3.8, + 2.4 + ], + 'CVSS:3.1/AV:A/AC:H/PR:H/UI:R/S:U/C:L/I:L/A:L/CR:H/IR:M/AR:H/MAV:A/MAC:H/MPR:N/MUI:R/MC:N/MI:H/MA:N': [ + 3.8, + 3.8, + 4.8 + ], + 'CVSS:3.1/AV:A/AC:H/PR:H/UI:R/S:U/C:L/I:L/A:L/CR:H/IR:M/AR:H/MAV:A/MAC:H/MPR:N/MUI:R/MS:C/MC:N/MI:H/MA:N': [ + 3.8, + 3.8, + 5.6 + ], + 'CVSS:3.1/AV:P/AC:H/PR:N/UI:R/S:C/C:L/I:H/A:L/E:H/RL:U/RC:C/MAV:P/MAC:H/MPR:N/MUI:R/MS:C/MC:L': [ + 6.2, + 6.2, + 6.1 + ], + 'CVSS:3.0/AV:P/AC:H/PR:N/UI:R/S:C/C:L/I:H/A:L/E:H/RL:U/RC:C/MAV:P/MAC:H/MPR:N/MUI:R/MS:C/MC:L': [ + 6.2, + 6.2, + 6.2 + ], + 'CVSS:3.0/AV:P/AC:H/PR:N/UI:R/S:C/C:L/I:H/A:L': [6.2, 6.2, 6.2], + 'CVSS:3.0/AV:P/AC:H/PR:N/UI:R/S:C/C:L/I:H/A:L/CR:H': [6.2, 6.2, 6.4], + 'CVSS:3.0/AV:P/AC:H/PR:N/UI:R/S:C/C:L/I:H/A:L/CR:H/IR:H/MAV:L/MUI:R/MS:U/MC:L': [ + 6.2, + 6.2, + 7.0 + ], + 'CVSS:3.1/AV:P/AC:H/PR:N/UI:R/S:C/C:L/I:H/A:L': [6.2, 6.2, 6.1], + 'CVSS:3.1/AV:P/AC:H/PR:N/UI:R/S:C/C:L/I:H/A:L/MUI:R/MS:U/MC:L': [ + 6.2, + 6.2, + 5.1 + ], + 'CVSS:3.1/AV:P/AC:H/PR:L/UI:R/S:C/C:L/I:L/A:L/E:U/RL:O/RC:U/CR:M/IR:M/AR:M/MAV:A/MAC:L/MPR:L/MUI:N/MC:N/MI:H': [ + 4.4, + 3.5, + 6.1 + ], + 'CVSS:3.0/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:N/A:N/RL:O/CR:L': [8.6, 8.2, 6.0] +}; +describe('Calculator', () => { // https://www.first.org/cvss/user-guide#3-1-CVSS-Scoring-in-the-Exploit-Life-Cycle it('should throw an exception on empty value', () => { expect(() => calculateBaseScore('')).to.throw(); @@ -61,7 +97,7 @@ describe('calculator', () => { // https://www.first.org/cvss/user-guide#3-1-CVSS-Scoring-in-the-Exploit-Life-Cycle it('should throw an exception on missing metric', () => { - expect(() => calculateBaseScore('CVSS:3.0/A:H')).to.throw(); + expect(() => calculateBaseScore('CVSS:3.1/A:H')).to.throw(); }); // https://www.first.org/cvss/user-guide#3-1-CVSS-Scoring-in-the-Exploit-Life-Cycle @@ -71,3 +107,36 @@ describe('calculator', () => { ).to.throw(); }); }); + +describe('Calculate correctly base scores', () => { + Object.entries(cvssTests).map((entry) => { + const cvss = entry[0]; + const baseScore = entry[1][0]; + it(`should calculate a score of ${baseScore} for ${cvss}`, () => { + const score = calculateBaseScore(cvss); + expect(score).to.equal(baseScore); + }); + }); +}); + +describe('Calculate correctly temporal scores', () => { + Object.entries(cvssTests).map((entry) => { + const cvss = entry[0]; + const temporalScore = entry[1][1]; + it(`should calculate a score of ${temporalScore} for ${cvss}`, () => { + const score = calculateTemporalScore(cvss); + expect(score).to.equal(temporalScore); + }); + }); +}); + +describe('Calculate correctly environmental scores', () => { + Object.entries(cvssTests).map((entry) => { + const cvss = entry[0]; + const environmentalScore = entry[1][2]; + it(`should calculate a score of ${environmentalScore} for ${cvss}`, () => { + const score = calculateEnvironmentalScore(cvss); + expect(score).to.equal(environmentalScore); + }); + }); +}); diff --git a/tests/validator.spec.ts b/tests/validator.spec.ts index c80fad9..2b6f54e 100644 --- a/tests/validator.spec.ts +++ b/tests/validator.spec.ts @@ -61,4 +61,18 @@ describe('parser', () => { validate('CVSS:3.1/AV:N/AC:L//PR:N/UI:N/S:C/C:H/I:H/A:H') ).to.throw('Invalid'); }); + + it('should produce exception on double separator', () => { + expect(() => + validate('CVSS:3.1/AV:N/AC:L//PR:N/UI:N/S:C/C:H/I:H/A:H') + ).to.throw('Invalid'); + }); + + it('should not throw when validating extra scopes', () => { + expect(() => + validate( + 'CVSS:3.0/AV:A/AC:H/PR:L/UI:R/S:C/C:L/I:L/A:L/E:H/RL:U/RC:C/CR:M/IR:M/AR:M/MAV:N/MAC:L/MPR:N/MUI:N/MS:C/MC:H/MI:H/MA:H' + ) + ).not.to.throw(); + }); });