Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Generated by Django 6.1.1 on 2026-10-09 07:47

import django.core.validators
import re
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('component_catalog', '0015_component_latest_non_vulnerable_version_and_more'),
]

operations = [
migrations.AlterField(
model_name='component',
name='vcs_url',
field=models.CharField(blank=True, help_text='URL to the Version Control System (VCS).', max_length=1024, validators=[django.core.validators.RegexValidator(re.compile('^[\\w+.-]+://[\\S]+$'), code='invalid', message='Enter a valid URI.')], verbose_name='VCS URL'),
),
migrations.AlterField(
model_name='package',
name='download_url',
field=models.CharField(blank=True, help_text='The download URL for obtaining the package.', max_length=1024, validators=[django.core.validators.RegexValidator(re.compile('^[\\w+.-]+://[\\S]+$'), code='invalid', message='Enter a valid URI.')], verbose_name='Download URL'),
),
migrations.AlterField(
model_name='package',
name='vcs_url',
field=models.CharField(blank=True, help_text='URL to the Version Control System (VCS).', max_length=1024, validators=[django.core.validators.RegexValidator(re.compile('^[\\w+.-]+://[\\S]+$'), code='invalid', message='Enter a valid URI.')], verbose_name='VCS URL'),
),
]
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,15 @@
</li>
{% endfor %}
{% endif %}
{% if vulnerability.highest_ssvc_decision %}
{% if vulnerability.highest_ssvc_decision or vulnerability.datasource_id %}
{% if vulnerability.aliases %}
<li><hr class="dropdown-divider"></li>
{% endif %}
<li><span class="dropdown-item-text small"><strong>SSVC:</strong> {{ vulnerability.highest_ssvc_decision }}</span></li>
{% if vulnerability.highest_ssvc_decision %}
<li><span class="dropdown-item-text small"><strong>SSVC:</strong> {{ vulnerability.highest_ssvc_decision }}</span></li>
{% endif %}
{% if vulnerability.datasource_id %}
<li><span class="dropdown-item-text small"><strong>{% trans "Source" %}:</strong> {{ vulnerability.datasource_id }}</span></li>
{% endif %}
{% endif %}
</ul>
3 changes: 3 additions & 0 deletions docs/howto-7-vulnerability-triage-configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ Below the base fields, each built-in rule appears as a collapsible section.
restrict detection by weighted package exposure.
- **Stale Vulnerability**: set :guilabel:`Max days` (default 30) and
:guilabel:`Min risk score` (default 8.0).
- **Advisory Source**: set :guilabel:`Datasource ids` to a comma-separated list of
VulnerableCode datasource IDs (e.g., ``ghsa, nvd``). The rule matches nothing until
at least one ID is set.

A ruleset must have at least one rule enabled to produce any triage records.

Expand Down
3 changes: 3 additions & 0 deletions docs/reference-vulnerability-management.rst
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,9 @@ The **Vulnerability** model in DejaCode provides detailed information about soft
vulnerabilities. Each field captures specific aspects of a vulnerability, enabling
accurate assessment and management. Below is a description of the key fields:

- **datasource_id**:
The VulnerableCode datasource that provided the advisory. For example, **"ghsa"**.

- **advisory_id**:
A unique identifier for the vulnerability. For example, **"PYSEC-2020-2233"**.

Expand Down
14 changes: 13 additions & 1 deletion docs/reference-vulnerability-triage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ that are relevant to its security program via the **Admin** interface.
Built-in Rules
--------------

Eight rules are available out of the box. Each rule implements a specific detection
Nine rules are available out of the box. Each rule implements a specific detection
condition evaluated against the vulnerabilities known to affect the product's packages.

.. list-table::
Expand Down Expand Up @@ -64,6 +64,12 @@ condition evaluated against the vulnerabilities known to affect the product's pa
| ``dev_only_vulnerable_package``
- Detects vulnerabilities that exclusively affect packages marked as non-deployed
in the product, indicating a lower exposure risk.
* - | **Advisory Source**
| ``advisory_source``
- Detects vulnerabilities reported by one of the configured VulnerableCode
datasources. Use it to prioritize trusted sources, or, combined with the highest
precedence and an analysis preset (e.g., ``false_positive``), to dismiss the
advisories of a source.

.. seealso::
Refer to :ref:`reference_vulnerability_management` for background on vulnerability
Expand Down Expand Up @@ -92,6 +98,12 @@ The following parameters are supported by rules that accept them:
- ``min_risk_score`` (float, 0.0-10.0): only consider vulnerabilities whose risk score
is greater than or equal to this value. Defaults to ``8.0``.

**Advisory Source** (``advisory_source``)

- ``datasource_ids`` (list of strings): VulnerableCode datasource IDs to match, such as
``ghsa`` or ``nvd``. Entered as a comma-separated list. Defaults to an empty list,
which matches nothing.

Triage Actions
--------------

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Generated by Django 6.1.1 on 2026-10-09 07:47

import django.core.validators
import re
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('product_portfolio', '0019_productaffectedbyvulnerability_detected_date'),
]

operations = [
migrations.AlterField(
model_name='product',
name='vcs_url',
field=models.CharField(blank=True, help_text='URL to the Version Control System (VCS).', max_length=1024, validators=[django.core.validators.RegexValidator(re.compile('^[\\w+.-]+://[\\S]+$'), code='invalid', message='Enter a valid URI.')], verbose_name='VCS URL'),
),
migrations.AlterField(
model_name='productcomponent',
name='download_url',
field=models.CharField(blank=True, help_text='URL to the source of the Component Package. Once validated this should point to a Package. If you identified a DejaCode Component in the "component" field, and if it already has a Package defined with the download_url, then this is not necessary.', max_length=1024, validators=[django.core.validators.RegexValidator(re.compile('^[\\w+.-]+://[\\S]+$'), code='invalid', message='Enter a valid URI.')], verbose_name='Download URL'),
),
]
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,9 @@
{% else %}
{{ vulnerability.advisory_id }}
{% endif %}
{% if vulnerability.aliases or vulnerability.highest_ssvc_decision %}
<span class="dropdown d-inline" data-bs-toggle="tooltip" title="{% trans 'Aliases and SSVC decision' %}">
<button type="button" class="btn btn-link p-0 align-baseline" data-bs-toggle="dropdown" aria-expanded="false" aria-label="{% trans 'Aliases and SSVC decision' %}">
{% if vulnerability.aliases or vulnerability.highest_ssvc_decision or vulnerability.datasource_id %}
<span class="dropdown d-inline" data-bs-toggle="tooltip" title="{% trans 'Aliases, SSVC decision and source' %}">
<button type="button" class="btn btn-link p-0 align-baseline" data-bs-toggle="dropdown" aria-expanded="false" aria-label="{% trans 'Aliases, SSVC decision and source' %}">
<i class="fa-solid fa-circle-info fa-xs text-muted ms-1"></i>
</button>
{% include 'component_catalog/includes/vulnerability_info_menu.html' with vulnerability=vulnerability only %}
Expand Down
20 changes: 20 additions & 0 deletions product_portfolio/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -571,6 +571,16 @@ def test_product_portfolio_tab_vulnerability_view_aliases_menu(self):
)
self.assertContains(response, expected)

def test_product_portfolio_tab_vulnerability_view_datasource_in_menu(self):
self.client.login(username="nexb_user", password="secret")
package1 = make_package(self.dataspace)
make_vulnerability(self.dataspace, affecting=[package1], datasource_id="ghsa")
product1 = make_product(self.dataspace, inventory=[package1])

response = self.client.get(product1.get_url("tab_vulnerabilities"))
expected = '<span class="dropdown-item-text small"><strong>Source:</strong> ghsa</span>'
self.assertContains(response, expected)

def test_product_portfolio_tab_vulnerability_view_curation_badges(self):
self.client.login(username="nexb_user", password="secret")
DataspaceConfiguration.objects.update_or_create(
Expand Down Expand Up @@ -4742,6 +4752,16 @@ def test_get_displays_the_ruleset_analysis_preset(self):
response = self.client.get(url)
self.assertContains(response, preset.name)

def test_get_displays_a_list_rule_parameter_as_comma_separated_values(self):
self.ruleset.rules_config = {
"advisory_source": {"is_active": True, "datasource_ids": ["ghsa", "nvd"]},
}
self.ruleset.save()
self.client.login(username="nexb_user", password="secret")
url = self.product1.get_manage_triage_rulesets_url()
response = self.client.get(url)
self.assertContains(response, "Advisory Source (datasource ids: ghsa, nvd)")

def test_get_marks_the_assigned_rulesets_as_checked(self):
ProductTriageRuleset.objects.create(
product=self.product1, ruleset=self.ruleset, dataspace=self.dataspace
Expand Down
4 changes: 3 additions & 1 deletion product_portfolio/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@
from vulnerabilities.triage.models import TriageRecord
from vulnerabilities.triage.models import TriageRuleset
from vulnerabilities.triage.rules import RULE_REGISTRY as TRIAGE_RULE_REGISTRY
from vulnerabilities.triage.rules import format_rule_parameter_value
from vulnerabilities.triage.rules import rule_parameters_from_config

TRIAGE_ACTION_STYLES = {
Expand Down Expand Up @@ -2299,7 +2300,8 @@ def manage_triage_rulesets_view(request, dataspace, name, version=""):
handler = TRIAGE_RULE_REGISTRY[rule_type]
params = rule_parameters_from_config(config)
params_str = ", ".join(
f"{key.replace('_', ' ')}: {value}" for key, value in params.items()
f"{key.replace('_', ' ')}: {format_rule_parameter_value(value)}"
for key, value in params.items()
)
active_rules.append({"label": handler.label, "params_str": params_str})
ruleset.active_rules = active_rules
Expand Down
1 change: 1 addition & 0 deletions vulnerabilities/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ class Meta:
"api_url",
"uuid",
"advisory_uid",
"datasource_id",
"advisory_id",
"resource_url",
"summary",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Generated by Django 6.1.1 on 2026-10-09 07:47

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('dje', '0016_dataspaceconfiguration_policy_rules_config'),
('vulnerabilities', '0013_vulnerability_curating_advisories_and_more'),
]

operations = [
migrations.AddField(
model_name='vulnerability',
name='datasource_id',
field=models.CharField(blank=True, help_text='ID of the VulnerableCode datasource of this advisory, such as ghsa or nginx.', max_length=50),
),
migrations.AddIndex(
model_name='vulnerability',
index=models.Index(fields=['datasource_id'], name='vulnerabili_datasou_26c9e1_idx'),
),
]
6 changes: 6 additions & 0 deletions vulnerabilities/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,11 @@ class Vulnerability(HistoryDateFieldsMixin, DataspacedModel):
"e.g.: pysec_importer_v2/PYSEC-2020-2233"
),
)
datasource_id = models.CharField(
max_length=50,
blank=True,
help_text=_("ID of the VulnerableCode datasource of this advisory, such as ghsa or nginx."),
)
advisory_id = models.CharField(
max_length=200,
help_text=_(
Expand Down Expand Up @@ -209,6 +214,7 @@ class Meta:
unique_together = (("dataspace", "advisory_uid"), ("dataspace", "uuid"))
indexes = [
models.Index(fields=["advisory_uid"]),
models.Index(fields=["datasource_id"]),
models.Index(fields=["advisory_id"]),
models.Index(fields=["exploitability"]),
models.Index(fields=["weighted_severity"]),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
{
"advisory_id": "PYSEC-2024-60",
"advisory_uid": "pypa/idna/PYSEC-2024-60",
"datasource_id": "pypa",
"aliases": [
"CVE-2024-3651",
"GHSA-jjg7-2v4v-x38h"
Expand Down Expand Up @@ -57,6 +58,7 @@
{
"advisory_id": "CVE-2026-45409",
"advisory_uid": "gitlab/pypi/idna/CVE-2026-45409",
"datasource_id": "gitlab",
"aliases": [
"GHSA-65pc-fj4g-8rjx"
],
Expand Down Expand Up @@ -102,6 +104,7 @@
{
"advisory_id": "PYSEC-2026-215",
"advisory_uid": "pypa/idna/PYSEC-2026-215",
"datasource_id": "pypa",
"aliases": [
"CVE-2026-45409",
"GHSA-65pc-fj4g-8rjx"
Expand Down
2 changes: 2 additions & 0 deletions vulnerabilities/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ def test_api_vulnerabilities_list_endpoint_filters(self):

def test_api_vulnerabilities_detail_endpoint(self):
self.vulnerability1.fixed_by_packages = ["pkg:pypi/idna@3.7"]
self.vulnerability1.datasource_id = "ghsa"
self.vulnerability1.save()
detail_url = reverse("api_v2:vulnerability-detail", args=[self.vulnerability1.uuid])
self.client.login(username="super_user", password="secret")
Expand All @@ -107,6 +108,7 @@ def test_api_vulnerabilities_detail_endpoint(self):
self.assertContains(response, detail_url)
self.assertIn(detail_url, response.data["api_url"])
self.assertEqual(self.vulnerability1.advisory_uid, response.data["advisory_uid"])
self.assertEqual("ghsa", response.data["datasource_id"])
self.assertEqual(str(self.vulnerability1.uuid), response.data["uuid"])
self.assertEqual("0.0", response.data["risk_score"])
self.assertEqual(["pkg:pypi/idna@3.7"], response.data["fixed_by_packages"])
Expand Down
1 change: 1 addition & 0 deletions vulnerabilities/tests/test_fetch.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ def test_vulnerabilities_fetch_for_packages(self, mock_bulk_search_by_purl):
advisory_uid="pypa/idna/PYSEC-2024-60"
).get()
self.assertEqual("PYSEC-2024-60", vulnerability.advisory_id)
self.assertEqual("pypa", vulnerability.datasource_id)
self.assertEqual(Decimal("0.5"), vulnerability.exploitability)
self.assertEqual(Decimal("6.8"), vulnerability.weighted_severity)
self.assertEqual(Decimal("3.4"), vulnerability.risk_score)
Expand Down
5 changes: 4 additions & 1 deletion vulnerabilities/triage/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from vulnerabilities.triage.models import TriageAction
from vulnerabilities.triage.models import TriageRuleset
from vulnerabilities.triage.rules import RULE_REGISTRY
from vulnerabilities.triage.rules import format_rule_parameter_value
from vulnerabilities.triage.rules import rule_parameters_from_config


Expand Down Expand Up @@ -113,7 +114,9 @@ def get_enabled_rules(self, obj):
handler = RULE_REGISTRY[rule_type]
params = rule_parameters_from_config(config)
if params:
param_str = ", ".join(f"{key}: {value}" for key, value in params.items())
param_str = ", ".join(
f"{key}: {format_rule_parameter_value(value)}" for key, value in params.items()
)
label = f"{handler.label} ({param_str})"
else:
label = handler.label
Expand Down
12 changes: 11 additions & 1 deletion vulnerabilities/triage/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from django.core.exceptions import ValidationError

from dje.forms import DataspacedAdminForm
from dje.forms import JSONListField
from vulnerabilities.models import VulnerabilityAnalysisContentMixin
from vulnerabilities.triage.models import AnalysisPreset
from vulnerabilities.triage.models import TriageAction
Expand Down Expand Up @@ -78,6 +79,13 @@ def build_parameter_field(self, param_name, param_spec, initial_value):
label = param_name.replace("_", " ").capitalize()
help_text = param_spec["help_text"]
default = param_spec["default"]
if isinstance(default, list):
return JSONListField(
label=label,
help_text=help_text,
required=False,
initial=initial_value,
)
if isinstance(default, int):
return forms.IntegerField(
label=label,
Expand Down Expand Up @@ -126,7 +134,9 @@ def build_rules_config(self):
for param_name, param_spec in handler.parameters_schema.items():
default = param_spec["default"]
value = self.cleaned_data.get(f"rule_{rule_type}_{param_name}")
if value is not None:
if isinstance(default, list):
rule_config[param_name] = value
elif value is not None:
coerced = int(value) if isinstance(default, int) else float(value)
rule_config[param_name] = coerced
else:
Expand Down
30 changes: 30 additions & 0 deletions vulnerabilities/triage/rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,28 @@ def get_matching_vulnerabilities(self, product, parameters=None):
)


class AdvisorySourceTriageRule(BaseTriageRule):
rule_type = "advisory_source"
label = "Advisory Source"
description = "Vulnerabilities reported by one of the configured VulnerableCode datasources."
parameters_schema = {
"datasource_ids": {
"default": [],
"help_text": "Comma-separated VulnerableCode datasource IDs, such as: ghsa, nvd.",
},
}

def get_matching_vulnerabilities(self, product, parameters=None):
Vulnerability = apps.get_model("vulnerabilities", "Vulnerability")
datasource_ids = (parameters or {}).get(
"datasource_ids", self.parameters_schema["datasource_ids"]["default"]
)
return Vulnerability.objects.filter(
affected_packages__productpackages__product=product,
datasource_id__in=datasource_ids,
).distinct()


RULE_REGISTRY = {
RiskScoreTriageRule.rule_type: RiskScoreTriageRule(),
WeightedRiskTriageRule.rule_type: WeightedRiskTriageRule(),
Expand All @@ -255,9 +277,17 @@ def get_matching_vulnerabilities(self, product, parameters=None):
UnresolvedVulnerabilityTriageRule.rule_type: UnresolvedVulnerabilityTriageRule(),
StaleVulnerabilityTriageRule.rule_type: StaleVulnerabilityTriageRule(),
DevOnlyPackageTriageRule.rule_type: DevOnlyPackageTriageRule(),
AdvisorySourceTriageRule.rule_type: AdvisorySourceTriageRule(),
}


def rule_parameters_from_config(config):
"""Extract rule-specific parameters from a rule config dict, excluding is_active."""
return {key: value for key, value in config.items() if key != "is_active"}


def format_rule_parameter_value(value):
"""Return the rule parameter ``value`` as a string, a list being joined with commas."""
if isinstance(value, list):
return ", ".join(value)
return str(value)
Loading
Loading