Files
pdf-reporting/report_generator/cvss_utils.py
T

493 lines
22 KiB
Python

"""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:<version>``; 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,
}