"""Reads a CVSS vector of any generation and says what it carries. Three generations are in circulation at once and a single advisory regularly states two of them, so nothing here assumes a version -- the vector names its own, and everything after that follows from the version it named. What changed between them, and why one parser cannot pretend they are the same: * **v2 (2007)** scores the whole system. It asks how often an attacker has to authenticate (``Au``) and grades impact as none, partial or complete. * **v3 (2015, 3.1 in 2019)** scores the vulnerable component instead. ``Au`` becomes ``PR``, which asks what privileges are needed rather than how many logins; ``UI`` and ``S`` are new; impact becomes none, low or high; ``AC`` loses its middle value. * **v4 (2023)** splits impact in two -- the vulnerable system (``VC``/``VI``/``VA``) and whatever sits behind it (``SC``/``SI``/``SA``) -- which makes ``S`` unnecessary. ``AT`` is new, ``UI`` gains a passive value, and the temporal group becomes the threat group. This module holds no database and no framework, so that the same reading serves persistent_storage today and archon when the tables move there. Everything that touches a session lives next to the model instead. >>> reading = parse_vector("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H") >>> reading.version, reading.reachability, reading.remotely_reachable ('3.1', 4, True) """ from __future__ import annotations from typing import Any, NamedTuple # A vector that names no version is a v2 one: v2 is the only generation that did not prefix itself. DEFAULT_VERSION = "2.0" # Which generation a version string belongs to, which is what decides how its metrics are read. GENERATIONS = {"2.0": 2, "3.0": 3, "3.1": 3, "4.0": 4} # The metric that says where an attacker has to be, and the value that puts them anywhere. FROM_ANYWHERE = "N" # What each generation calls "no obstacle at all" for the four metrics an attacker has to get past. # Answering all four with these is an attack that needs nothing: no position, no effort, no account # and nobody to fool. v2 has no user interaction metric, so it is scored over three. # TODO das sollte ich noch refaktoren das ist unübersichtlich # AV: Access Vector, AC: Access Complexity, Au: Authentication EASIEST: dict[int, dict[str, str]] = { 2: {"AV": "N", "AC": "L", "Au": "N"}, 3: {"AV": "N", "AC": "L", "PR": "N", "UI": "N"}, 4: {"AV": "N", "AC": "L", "PR": "N", "UI": "N"}, } # What every abbreviation means, per generation, for anything that puts a reading before a person. # Taken from the specifications; the weights behind them are the scoring formula's business and not # this module's, which reads a vector rather than recomputing the score from it. VOCABULARY: dict[int, dict[str, dict[str, str]]] = { 2: { "AV": {"L": "Local", "A": "Adjacent network", "N": "Network"}, "AC": {"H": "High", "M": "Medium", "L": "Low"}, "Au": {"M": "Multiple", "S": "Single", "N": "None"}, "C": {"N": "None", "P": "Partial", "C": "Complete"}, "I": {"N": "None", "P": "Partial", "C": "Complete"}, "A": {"N": "None", "P": "Partial", "C": "Complete"}, "E": {"U": "Unproven", "POC": "Proof of concept", "F": "Functional", "H": "High", "ND": "Not defined"}, "RL": {"OF": "Official fix", "TF": "Temporary fix", "W": "Workaround", "U": "Unavailable", "ND": "Not defined"}, "RC": {"UC": "Unconfirmed", "UR": "Uncorroborated", "C": "Confirmed", "ND": "Not defined"}, }, 3: { "AV": {"N": "Network", "A": "Adjacent network", "L": "Local", "P": "Physical"}, "AC": {"L": "Low", "H": "High"}, "PR": {"N": "None", "L": "Low", "H": "High"}, "UI": {"N": "None", "R": "Required"}, "S": {"U": "Unchanged", "C": "Changed"}, "C": {"N": "None", "L": "Low", "H": "High"}, "I": {"N": "None", "L": "Low", "H": "High"}, "A": {"N": "None", "L": "Low", "H": "High"}, "E": {"X": "Not defined", "U": "Unproven", "P": "Proof of concept", "F": "Functional", "H": "High"}, "RL": {"X": "Not defined", "O": "Official fix", "T": "Temporary fix", "W": "Workaround", "U": "Unavailable"}, "RC": {"X": "Not defined", "U": "Unknown", "R": "Reasonable", "C": "Confirmed"}, }, 4: { "AV": {"N": "Network", "A": "Adjacent network", "L": "Local", "P": "Physical"}, "AC": {"L": "Low", "H": "High"}, "AT": {"N": "None", "P": "Present"}, "PR": {"N": "None", "L": "Low", "H": "High"}, "UI": {"N": "None", "P": "Passive", "A": "Active"}, "VC": {"H": "High", "L": "Low", "N": "None"}, "VI": {"H": "High", "L": "Low", "N": "None"}, "VA": {"H": "High", "L": "Low", "N": "None"}, "SC": {"H": "High", "L": "Low", "N": "None"}, "SI": {"H": "High", "L": "Low", "N": "None"}, "SA": {"H": "High", "L": "Low", "N": "None"}, "E": {"X": "Not defined", "A": "Attacked", "P": "Proof of concept", "U": "Unreported"}, }, } # Which key carries the impact on the vulnerable system, per generation. v4 moved it aside to make # room for a second set covering whatever sits behind that system. IMPACT_KEYS = {2: ("C", "I", "A"), 3: ("C", "I", "A"), 4: ("VC", "VI", "VA")} # The second set, which only v4 has. SUBSEQUENT_KEYS = ("SC", "SI", "SA") # What the base score is called once it is a word. v2 stops at high; v3 added critical, and pushed # the same vulnerability further up the scale than v2 would have put it. SEVERITY_BANDS = { 2: ((0.0, "None"), (4.0, "Low"), (7.0, "Medium"), (10.0, "High")), 3: ((0.0, "None"), (4.0, "Low"), (7.0, "Medium"), (9.0, "High"), (10.0, "Critical")), 4: ((0.0, "None"), (4.0, "Low"), (7.0, "Medium"), (9.0, "High"), (10.0, "Critical")), } # What each value is worth when a set of findings has to be averaged into one number per axis. # # The base metrics carry the specification's own constants. They are not comparable between # generations as written -- v2 runs its access vector to 1.0 where v3 stops at 0.85 -- so # :func:`weight` divides by the largest value of that metric in that generation. What comes back is # "how far towards the worst case this value sits", 0 to 1, which does compare. # # The threat metrics do not carry them. ``E``, ``RL`` and ``RC`` are multipliers in the # specification, deliberately clustered between 0.91 and 1.0 so that they can only nudge a base # score. Averaging those would produce a number that is always 0.95 and says nothing, so they are # ranked by what they actually mean instead: an unproven exploit at 0, one under active attack at 1; # an official fix at 0, nothing available at 1. Both directions agree with the base metrics -- # larger is always worse. WEIGHTS: dict[int, dict[str, dict[str, float]]] = { 2: { "AV": {"L": 0.395, "A": 0.646, "N": 1.0}, "AC": {"H": 0.35, "M": 0.61, "L": 0.71}, "Au": {"M": 0.45, "S": 0.56, "N": 0.704}, "C": {"N": 0.0, "P": 0.275, "C": 0.660}, "I": {"N": 0.0, "P": 0.275, "C": 0.660}, "A": {"N": 0.0, "P": 0.275, "C": 0.660}, "E": {"U": 0.0, "POC": 0.5, "F": 0.85, "H": 1.0}, "RL": {"OF": 0.0, "TF": 0.33, "W": 0.67, "U": 1.0}, "RC": {"UC": 1.0, "UR": 0.5, "C": 0.0}, }, 3: { "AV": {"P": 0.2, "L": 0.55, "A": 0.62, "N": 0.85}, "AC": {"H": 0.44, "L": 0.77}, "PR": {"H": 0.27, "L": 0.62, "N": 0.85}, "UI": {"R": 0.62, "N": 0.85}, "C": {"N": 0.0, "L": 0.22, "H": 0.56}, "I": {"N": 0.0, "L": 0.22, "H": 0.56}, "A": {"N": 0.0, "L": 0.22, "H": 0.56}, "E": {"U": 0.0, "P": 0.5, "F": 0.85, "H": 1.0}, "RL": {"O": 0.0, "T": 0.33, "W": 0.67, "U": 1.0}, "RC": {"U": 1.0, "R": 0.5, "C": 0.0}, }, # v4 scores by lookup table rather than per-metric constants, so there are none to quote. The # metrics it shares with v3 keep v3's, which preserves their ordering and their spacing; the two # values v4 added are placed by where the specification ranks them -- ``AT:P`` is an extra # condition to meet, like a high complexity, and a passive interaction sits between none and an # active one. 4: { "AV": {"P": 0.2, "L": 0.55, "A": 0.62, "N": 0.85}, "AC": {"H": 0.44, "L": 0.77}, "AT": {"P": 0.44, "N": 0.85}, "PR": {"H": 0.27, "L": 0.62, "N": 0.85}, "UI": {"A": 0.62, "P": 0.73, "N": 0.85}, "VC": {"N": 0.0, "L": 0.22, "H": 0.56}, "VI": {"N": 0.0, "L": 0.22, "H": 0.56}, "VA": {"N": 0.0, "L": 0.22, "H": 0.56}, "SC": {"N": 0.0, "L": 0.22, "H": 0.56}, "SI": {"N": 0.0, "L": 0.22, "H": 0.56}, "SA": {"N": 0.0, "L": 0.22, "H": 0.56}, "E": {"U": 0.0, "P": 0.5, "A": 1.0}, }, } # The value every generation uses for "not stated", which is not a value and must not be averaged. NOT_DEFINED = ("X", "ND") # Widths a reading is stored in, so a malformed vector cannot fail an insert. MAX_VECTOR_LENGTH = 512 MAX_METRIC_LENGTH = 16 MAX_VERSION_LENGTH = 8 class CvssReading(NamedTuple): """What one CVSS vector states, and the two things that follow from it. Only the metrics a generation actually has are filled; the rest stay ``None``, because a metric a version does not define is not the same as one it defines as absent. Attributes: version: The version the vector named, e.g. ``"3.1"``. generation: ``2``, ``3`` or ``4`` -- which set of metrics applies. vector: The vector as the report wrote it. metrics: Every metric of the vector by its abbreviation, including the environmental and supplemental ones this class has no field for. attack_vector: ``AV``, in every generation. attack_complexity: ``AC``, in every generation. attack_requirements: ``AT``, v4 only. authentication: ``Au``, v2 only -- how often an attacker has to log in. privileges_required: ``PR``, v3 and v4 -- what account they need instead. user_interaction: ``UI``, v3 and v4. scope: ``S``, v3 only; v4 replaced it with a second impact set. confidentiality_impact: ``C`` in v2 and v3, ``VC`` in v4. integrity_impact: ``I`` in v2 and v3, ``VI`` in v4. availability_impact: ``A`` in v2 and v3, ``VA`` in v4. subsequent_confidentiality_impact: ``SC``, v4 only. subsequent_integrity_impact: ``SI``, v4 only. subsequent_availability_impact: ``SA``, v4 only. exploit_maturity: ``E`` -- temporal in v2 and v3, threat in v4. remediation_level: ``RL``, v2 and v3. report_confidence: ``RC``, v2 and v3. reachability: How many of the metrics an attacker has to get past sit at the value that costs them nothing, out of :func:`max_reachability`. remotely_reachable: Over the network and without an account, which is the pair that decides whether the internet is in a position to try. """ version: str generation: int vector: str metrics: dict[str, str] attack_vector: str | None attack_complexity: str | None attack_requirements: str | None authentication: str | None privileges_required: str | None user_interaction: str | None scope: str | None confidentiality_impact: str | None integrity_impact: str | None availability_impact: str | None subsequent_confidentiality_impact: str | None subsequent_integrity_impact: str | None subsequent_availability_impact: str | None exploit_maturity: str | None remediation_level: str | None report_confidence: str | None reachability: int remotely_reachable: bool def parse_vector(vector: str | None) -> CvssReading | None: """Read a CVSS vector of any generation. Args: vector: A vector string, e.g. ``CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H`` or a v2 one, which carries no prefix: ``AV:N/AC:L/Au:N/C:P/I:P/A:P``. Returns: What the vector states, or ``None`` when there is no vector or it names none of the metrics its generation defines. ``None`` means "not stated", which is not the same as "poses no risk", so the two are kept apart. """ if not vector: return None version = _version(vector) generation = GENERATIONS.get(version, 3) metrics = _metrics(vector, generation) if not metrics: return None confidentiality, integrity, availability = IMPACT_KEYS[generation] return CvssReading( version=version, generation=generation, vector=vector[:MAX_VECTOR_LENGTH], metrics=metrics, attack_vector=metrics.get("AV"), attack_complexity=metrics.get("AC"), attack_requirements=metrics.get("AT"), authentication=metrics.get("Au"), privileges_required=metrics.get("PR"), user_interaction=metrics.get("UI"), scope=metrics.get("S") if generation == 3 else None, confidentiality_impact=metrics.get(confidentiality), integrity_impact=metrics.get(integrity), availability_impact=metrics.get(availability), subsequent_confidentiality_impact=metrics.get("SC"), subsequent_integrity_impact=metrics.get("SI"), subsequent_availability_impact=metrics.get("SA"), exploit_maturity=metrics.get("E"), remediation_level=metrics.get("RL"), report_confidence=metrics.get("RC"), reachability=sum(metrics.get(key) == value for key, value in EASIEST[generation].items()), # A metric the vector does not state is not counted as an obstacle either, so an absent one # leaves the claim unmade rather than making it falsely. remotely_reachable=_remotely_reachable(metrics, generation), ) def max_reachability(generation: int) -> int: """How many obstacles a generation knows about. Args: generation: ``2``, ``3`` or ``4``. Returns: Three for v2, which has no user interaction metric, four otherwise. A reading is only comparable with another of the same generation, which is why this is asked for rather than assumed. """ return len(EASIEST.get(generation, EASIEST[3])) def severity_band(score: float | None, generation: int) -> str | None: """Return the word a base score falls under. Args: score: The base score, if the report stated one. generation: Which scale to read it against -- v2 stops at high, v3 and v4 added critical, so the same number is not the same word. Returns: The band, or ``None`` when there is no score. """ if score is None: return None for limit, name in SEVERITY_BANDS.get(generation, SEVERITY_BANDS[3]): if score <= limit: return name return SEVERITY_BANDS[generation][-1][1] def weight(metric: str, value: str | None, generation: int = 3) -> float | None: """Return how far towards the worst case one metric value sits, 0 to 1. # TODO hier nochmal das Gewichten explizit prüfen. This is what makes a set of findings averageable along one axis, and what makes a v2 reading averageable together with a v4 one: each value is placed between the best and the worst its own generation allows for that metric, so the result says "how far towards the worst case" rather than quoting a constant that only means something inside its own formula. Stretched across the axis rather than divided by its top, because the specification's constants are multipliers and do not start at zero: an attack that needs physical access is worth 0.2 of the 0.85 a network one is worth, and reported as 0.24 it would say a quarter of the worst case for what is in fact the best case the metric has. Zero here means "as hard as this metric can make it", one means "as easy". Args: metric: An abbreviation such as ``AV``, ``PR`` or ``VC``. value: The value the vector carried, if any. generation: Which generation's scale to read it against. Returns: The position between best and worst case, or ``None`` when the vector stated nothing, stated "not defined", or stated something the specification does not list. All three mean the same thing here -- there is nothing to average -- and are kept apart from a stated zero, which is a measurement. """ if value is None or value in NOT_DEFINED: return None scale = WEIGHTS.get(generation, {}).get(metric, {}) stated = scale.get(value) if stated is None or not scale: return None best, worst = min(scale.values()), max(scale.values()) # A metric with a single value says nothing about degree, so it reads as the worst case it has. return (stated - best) / (worst - best) if worst > best else 1.0 def describe(metric: str, value: str | None, generation: int = 3) -> str: """Return the word behind one abbreviation. Args: metric: An abbreviation such as ``AV``, ``PR`` or ``VC``. value: The value the vector carried, if any. generation: Which vocabulary to read it in. The same letter means different things across generations -- ``C`` under v2 is "complete", under v3 it is a metric name -- so this is not optional in practice. Returns: The name the specification gives it, the raw value when the vocabulary has no entry, or ``"Not stated"`` when the vector did not carry it. """ if value is None: return "Not stated" return VOCABULARY.get(generation, {}).get(metric, {}).get(value, value) def _version(vector: str) -> str: """Return the version a vector names. Args: vector: The vector string. v3 and v4 begin with ``CVSS:``; v2 begins with its first metric and is recognised by that absence. Returns: The version, defaulting to :data:`DEFAULT_VERSION` for an unprefixed vector and to the string as written for one this module does not know -- a version nobody has implemented yet is still worth recording. """ head = vector.split("/", 1)[0] if not head.upper().startswith("CVSS:"): return DEFAULT_VERSION return head.split(":", 1)[1].strip()[:MAX_VERSION_LENGTH] or DEFAULT_VERSION def _metrics(vector: str, generation: int) -> dict[str, str]: """Split a vector into its metrics. Args: vector: The vector string. generation: Which generation it belongs to, which decides whether the first segment is a version prefix to skip. Returns: The metrics by their abbreviation, spelled as the specification spells them -- ``Au`` keeps its lower-case second letter, which is the one abbreviation that is not upper case. A segment that is not a ``key:value`` pair is ignored rather than raising: a report this store cannot fully read is still a report worth keeping. """ segments = vector.split("/") metrics: dict[str, str] = {} for segment in segments if generation == 2 else segments[1:]: key, separator, value = segment.partition(":") if not separator or not key or not value: continue name = key.strip()[:MAX_METRIC_LENGTH] metrics["Au" if name.upper() == "AU" and generation == 2 else name.upper()] = value.strip().upper()[ :MAX_METRIC_LENGTH ] return metrics def _remotely_reachable(metrics: dict[str, str], generation: int) -> bool: """Whether the vector says an attacker needs neither a position nor an account. Args: metrics: The metrics of the vector. generation: Which generation states the account requirement, since v2 asks how often an attacker logs in and v3 onwards what they log in as. Returns: ``True`` only when the vector states both and both are open. """ account = "Au" if generation == 2 else "PR" return metrics.get("AV") == FROM_ANYWHERE and metrics.get(account) == "N" def as_row(reading: CvssReading, base_score: float | None = None) -> dict[str, Any]: """Return a reading shaped like the columns it is stored in. Kept here rather than next to the model so that the move to archon is a change of table and not a change of mapping: whatever holds these columns can be filled from this. Args: reading: What a vector stated. base_score: The score the report stated alongside the vector, if any. Not recomputed from the vector -- the report's own number is the one that has to come back out of the store. Returns: The reading by column name, ready to be handed to a model. """ return { "version": reading.version, "vector": reading.vector, "base_score": base_score, "base_severity": severity_band(base_score, reading.generation), "attack_vector": reading.attack_vector, "attack_complexity": reading.attack_complexity, "attack_requirements": reading.attack_requirements, "authentication": reading.authentication, "privileges_required": reading.privileges_required, "user_interaction": reading.user_interaction, "scope": reading.scope, "confidentiality_impact": reading.confidentiality_impact, "integrity_impact": reading.integrity_impact, "availability_impact": reading.availability_impact, "subsequent_confidentiality_impact": reading.subsequent_confidentiality_impact, "subsequent_integrity_impact": reading.subsequent_integrity_impact, "subsequent_availability_impact": reading.subsequent_availability_impact, "exploit_maturity": reading.exploit_maturity, "remediation_level": reading.remediation_level, "report_confidence": reading.report_confidence, "metrics": reading.metrics, "reachability": reading.reachability, "remotely_reachable": reading.remotely_reachable, }