Skip to content

Commit 0c4ebfe

Browse files
committed
Add pipeline to collect SSVC Trees
Signed-off-by: Tushar Goel <[email protected]>
1 parent 62b9a8e commit 0c4ebfe

File tree

5 files changed

+139
-101
lines changed

5 files changed

+139
-101
lines changed

vulnerabilities/api_v2.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,18 @@ class AdvisoryV2Serializer(serializers.ModelSerializer):
146146
references = AdvisoryReferenceSerializer(many=True)
147147
severities = AdvisorySeveritySerializer(many=True)
148148
advisory_id = serializers.CharField(source="avid", read_only=True)
149+
ssvc_trees = serializers.SerializerMethodField()
150+
151+
def get_ssvc_trees(self, obj):
152+
ssvc_trees = obj.ssvc_entries.all()
153+
return [
154+
{
155+
"vector": ssvc.vector,
156+
"decision": ssvc.decision,
157+
"options": ssvc.options,
158+
}
159+
for ssvc in ssvc_trees
160+
]
149161

150162
class Meta:
151163
model = AdvisoryV2
@@ -160,6 +172,7 @@ class Meta:
160172
"exploitability",
161173
"weighted_severity",
162174
"risk_score",
175+
"ssvc_trees",
163176
]
164177

165178
def get_aliases(self, obj):
@@ -1033,13 +1046,13 @@ def list(self, request, *args, **kwargs):
10331046
return self.get_paginated_response({"advisories": advisory_data, "packages": data})
10341047

10351048
# If pagination is not applied, collect vulnerabilities for all packages
1036-
for package in queryset:
1049+
for package in filtered_queryset:
10371050
advisories.update({impact.advisory for impact in package.affected_in_impacts.all()})
10381051
advisories.update({impact.advisory for impact in package.fixed_in_impacts.all()})
10391052

10401053
advisory_data = {f"{adv.avid}": AdvisoryV2Serializer(adv).data for adv in advisories}
10411054

1042-
serializer = self.get_serializer(queryset, many=True)
1055+
serializer = self.get_serializer(filtered_queryset, many=True)
10431056
data = serializer.data
10441057
return Response({"advisories": advisory_data, "packages": data})
10451058

vulnerabilities/improvers/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from vulnerabilities.pipelines import flag_ghost_packages
2020
from vulnerabilities.pipelines import populate_vulnerability_summary_pipeline
2121
from vulnerabilities.pipelines import remove_duplicate_advisories
22+
from vulnerabilities.pipelines.v2_improvers import collect_ssvc_trees
2223
from vulnerabilities.pipelines.v2_improvers import compute_advisory_todo as compute_advisory_todo_v2
2324
from vulnerabilities.pipelines.v2_improvers import compute_package_risk as compute_package_risk_v2
2425
from vulnerabilities.pipelines.v2_improvers import (
@@ -70,5 +71,6 @@
7071
compute_advisory_todo_v2.ComputeToDo,
7172
unfurl_version_range_v2.UnfurlVersionRangePipeline,
7273
compute_advisory_todo.ComputeToDo,
74+
collect_ssvc_trees.CollectSSVCPipeline,
7375
]
7476
)

vulnerabilities/migrations/0104_ssvc.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Generated by Django 4.2.25 on 2025-11-26 13:31
1+
# Generated by Django 4.2.25 on 2025-12-15 15:15
22

33
from django.db import migrations, models
44
import django.db.models.deletion
@@ -35,17 +35,25 @@ class Migration(migrations.Migration):
3535
models.CharField(help_text="The decision string for the SSVC.", max_length=255),
3636
),
3737
(
38-
"advisory",
38+
"related_advisories",
39+
models.ManyToManyField(
40+
help_text="Advisories associated with this SSVC.",
41+
related_name="related_ssvcs",
42+
to="vulnerabilities.advisoryv2",
43+
),
44+
),
45+
(
46+
"source_advisory",
3947
models.ForeignKey(
40-
help_text="The advisory associated with this SSVC.",
48+
help_text="The advisory that was used to generate this SSVC decision.",
4149
on_delete=django.db.models.deletion.CASCADE,
42-
related_name="ssvc_entries",
50+
related_name="source_ssvcs",
4351
to="vulnerabilities.advisoryv2",
4452
),
4553
),
4654
],
4755
options={
48-
"unique_together": {("vector", "advisory", "decision")},
56+
"unique_together": {("vector", "source_advisory")},
4957
},
5058
),
5159
]

vulnerabilities/models.py

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3419,16 +3419,21 @@ class Meta:
34193419
class SSVC(models.Model):
34203420
vector = models.CharField(max_length=255, help_text="The vector string representing the SSVC.")
34213421
options = models.JSONField(help_text="A JSON object containing the SSVC options.")
3422-
advisory = models.ForeignKey(
3422+
decision = models.CharField(max_length=255, help_text="The decision string for the SSVC.")
3423+
related_advisories = models.ManyToManyField(
3424+
AdvisoryV2,
3425+
related_name="related_ssvcs",
3426+
help_text="Advisories associated with this SSVC.",
3427+
)
3428+
source_advisory = models.ForeignKey(
34233429
AdvisoryV2,
34243430
on_delete=models.CASCADE,
3425-
related_name="ssvc_entries",
3426-
help_text="The advisory associated with this SSVC.",
3431+
related_name="source_ssvcs",
3432+
help_text="The advisory that was used to generate this SSVC decision.",
34273433
)
3428-
decision = models.CharField(max_length=255, help_text="The decision string for the SSVC.")
34293434

34303435
def __str__(self):
3431-
return f"SSVC for Advisory {self.advisory.advisory_id}: {self.decision}"
3436+
return f"SSVC Decision: {self.vector} -> {self.decision}"
34323437

34333438
class Meta:
3434-
unique_together = ("vector", "advisory", "decision")
3439+
unique_together = ("vector", "source_advisory")
Lines changed: 98 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,20 @@
1-
import json
2-
import logging
3-
from pathlib import Path
4-
from typing import Iterable, List
1+
#
2+
# Copyright (c) nexB Inc. and others. All rights reserved.
3+
# VulnerableCode is a trademark of nexB Inc.
4+
# SPDX-License-Identifier: Apache-2.0
5+
# See http://www.apache.org/licenses/LICENSE-2.0 for the license text.
6+
# See https://github.com/aboutcode-org/vulnerablecode for support or download.
7+
# See https://aboutcode.org for more information about nexB OSS projects.
8+
#
59

6-
from fetchcode.vcs import fetch_via_vcs
10+
import logging
711

8-
from vulnerabilities.importer import AdvisoryData
9-
from vulnerabilities.models import SSVC, AdvisoryV2
12+
from django.db.models import Q
13+
from vulnerabilities.models import SSVC
14+
from vulnerabilities.models import AdvisoryV2
1015
from vulnerabilities.pipelines import VulnerableCodePipeline
16+
from vulnerabilities.pipelines.v2_importers.vulnrichment_importer import VulnrichImporterPipeline
1117
from vulnerabilities.severity_systems import SCORING_SYSTEMS
12-
from vulnerabilities.utils import ssvc_calculator
1318

1419
logger = logging.getLogger(__name__)
1520

@@ -21,92 +26,97 @@ class CollectSSVCPipeline(VulnerableCodePipeline):
2126
This pipeline collects SSVC from Vulnrichment project and associates them with existing advisories.
2227
"""
2328

24-
pipeline_id = "collect_ssvc"
29+
pipeline_id = "collect_ssvc_tree_v2"
2530
spdx_license_expression = "CC0-1.0"
26-
license_url = "https://github.com/cisagov/vulnrichment/blob/develop/LICENSE"
27-
repo_url = "git+https://github.com/cisagov/vulnrichment.git"
2831

2932
@classmethod
3033
def steps(cls):
3134
return (
32-
cls.clone,
3335
cls.collect_ssvc_data,
34-
cls.clean_downloads,
3536
)
3637

37-
def clone(self):
38-
self.log(f"Cloning `{self.repo_url}`")
39-
self.vcs_response = fetch_via_vcs(self.repo_url)
40-
4138
def collect_ssvc_data(self):
42-
self.log(self.vcs_response.dest_dir)
43-
base_path = Path(self.vcs_response.dest_dir)
44-
for file_path in base_path.glob("**/**/*.json"):
45-
self.log(f"Processing file: {file_path}")
46-
if not file_path.name.startswith("CVE-"):
47-
continue
48-
with open(file_path) as f:
49-
raw_data = json.load(f)
50-
file_name = file_path.name
51-
# strip .json from file name
52-
cve_id = file_name[:-5]
53-
advisories = list(AdvisoryV2.objects.filter(advisory_id=cve_id))
54-
if not advisories:
55-
self.log(f"No advisories found for CVE ID: {cve_id}")
56-
continue
57-
self.parse_cve_advisory(raw_data, advisories)
58-
59-
def parse_cve_advisory(self, raw_data, advisories: List[AdvisoryV2]):
60-
self.log(f"Processing CVE data")
61-
cve_metadata = raw_data.get("cveMetadata", {})
62-
cve_id = cve_metadata.get("cveId")
63-
64-
containers = raw_data.get("containers", {})
65-
adp_data = containers.get("adp", {})
66-
self.log(f"Processing ADP")
67-
68-
metrics = [
69-
adp_metrics for data in adp_data for adp_metrics in data.get("metrics", [])
70-
]
71-
72-
vulnrichment_scoring_system = {
73-
"other": {
74-
"ssvc": SCORING_SYSTEMS["ssvc"],
75-
}, # ignore kev
76-
}
77-
78-
for metric in metrics:
79-
self.log(metric)
80-
self.log(f"Processing metric")
81-
for metric_type, metric_value in metric.items():
82-
if metric_type not in vulnrichment_scoring_system:
83-
continue
84-
85-
if metric_type == "other":
86-
other_types = metric_value.get("type")
87-
self.log(f"Processing SSVC")
88-
if other_types == "ssvc":
89-
content = metric_value.get("content", {})
90-
options = content.get("options", {})
91-
vector_string, decision = ssvc_calculator(content)
92-
advisories = list(AdvisoryV2.objects.filter(advisory_id=cve_id))
93-
if not advisories:
94-
continue
95-
ssvc_trees = []
96-
for advisory in advisories:
97-
obj = SSVC(
98-
advisory=advisory,
99-
options=options,
100-
decision=decision,
101-
vector=vector_string,
102-
)
103-
ssvc_trees.append(obj)
104-
SSVC.objects.bulk_create(ssvc_trees, ignore_conflicts=True, batch_size=1000)
105-
106-
def clean_downloads(self):
107-
if self.vcs_response:
108-
self.log("Removing cloned repository")
109-
self.vcs_response.delete()
110-
111-
def on_failure(self):
112-
self.clean_downloads()
39+
vulnrichment_advisories = AdvisoryV2.objects.filter(
40+
datasource_id=VulnrichImporterPipeline.pipeline_id,
41+
)
42+
for advisory in vulnrichment_advisories:
43+
severities = advisory.severities.filter(scoring_system=SCORING_SYSTEMS["ssvc"])
44+
for severity in severities:
45+
ssvc_vector = severity.scoring_elements
46+
try:
47+
ssvc_tree, decision = convert_vector_to_tree_and_decision(ssvc_vector)
48+
self.log(f"Advisory: {advisory.advisory_id}, SSVC Tree: {ssvc_tree}, Decision: {decision}, vector: {ssvc_vector}")
49+
ssvc_obj, _ = SSVC.objects.get_or_create(
50+
source_advisory=advisory,
51+
defaults={
52+
"options": ssvc_tree,
53+
"decision": decision,
54+
},
55+
)
56+
# All advisories that have advisory.advisory_id in their aliases or advisory_id same as advisory.advisory_id
57+
related_advisories = AdvisoryV2.objects.filter(
58+
Q(advisory_id=advisory.advisory_id) |
59+
Q(aliases__alias=advisory.advisory_id)
60+
).distinct()
61+
# remove the current advisory from related advisories
62+
related_advisories = related_advisories.exclude(id=advisory.id)
63+
ssvc_obj.related_advisories.set(related_advisories)
64+
except ValueError as e:
65+
logger.error(f"Failed to parse SSVC vector '{ssvc_vector}' for advisory '{advisory}': {e}")
66+
67+
REVERSE_POINTS = {
68+
"E": ("Exploitation", {"N": "none", "P": "poc", "A": "active"}),
69+
"A": ("Automatable", {"N": "no", "Y": "yes"}),
70+
"T": ("Technical Impact", {"P": "partial", "T": "total"}),
71+
"P": ("Mission Prevalence", {"M": "minimal", "S": "support", "E": "essential"}),
72+
"B": ("Public Well-being Impact", {"M": "minimal", "A": "material", "I": "irreversible"}),
73+
"M": ("Mission & Well-being", {"L": "low", "M": "medium", "H": "high"}),
74+
}
75+
76+
REVERSE_DECISION = {
77+
"T": "Track",
78+
"R": "Track*",
79+
"A": "Attend",
80+
"C": "Act",
81+
}
82+
83+
VECTOR_ORDER = ["E", "A", "T", "P", "B", "M"]
84+
85+
def convert_vector_to_tree_and_decision(vector: str):
86+
"""
87+
Convert a given SSVC vector string into a structured tree and decision.
88+
89+
Args:
90+
vector (str): The SSVC vector string.
91+
92+
Returns:
93+
tuple: A tuple containing the SSVC tree (dict) and decision (str).
94+
"""
95+
if not vector.startswith("SSVCv2/"):
96+
raise ValueError("Invalid SSVC vector")
97+
98+
parts = [p for p in vector.replace("SSVCv2/", "").split("/") if p]
99+
100+
options = []
101+
decision = None
102+
103+
for part in parts:
104+
if ":" not in part:
105+
continue
106+
107+
key, value = part.split(":", 1)
108+
109+
if key == "D":
110+
decision = REVERSE_DECISION.get(value)
111+
continue
112+
113+
if key in REVERSE_POINTS:
114+
name, mapping = REVERSE_POINTS[key]
115+
options.append({name: mapping[value]})
116+
117+
# Preserve canonical SSVC order
118+
options.sort(key=lambda o: VECTOR_ORDER.index(
119+
next(k for k, _ in REVERSE_POINTS.values() if k == next(iter(o)))
120+
) if False else 0)
121+
122+
return options, decision

0 commit comments

Comments
 (0)