493 lines
22 KiB
Python
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,
|
|
}
|