diff --git a/component_catalog/migrations/0016_alter_component_vcs_url_alter_package_download_url_and_more.py b/component_catalog/migrations/0016_alter_component_vcs_url_alter_package_download_url_and_more.py new file mode 100644 index 00000000..2492bfa6 --- /dev/null +++ b/component_catalog/migrations/0016_alter_component_vcs_url_alter_package_download_url_and_more.py @@ -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'), + ), + ] diff --git a/component_catalog/templates/component_catalog/includes/vulnerability_info_menu.html b/component_catalog/templates/component_catalog/includes/vulnerability_info_menu.html index e6e4e0d6..37c17a50 100644 --- a/component_catalog/templates/component_catalog/includes/vulnerability_info_menu.html +++ b/component_catalog/templates/component_catalog/includes/vulnerability_info_menu.html @@ -16,10 +16,15 @@ {% endfor %} {% endif %} - {% if vulnerability.highest_ssvc_decision %} + {% if vulnerability.highest_ssvc_decision or vulnerability.datasource_id %} {% if vulnerability.aliases %}
  • {% endif %} -
  • SSVC: {{ vulnerability.highest_ssvc_decision }}
  • + {% if vulnerability.highest_ssvc_decision %} +
  • SSVC: {{ vulnerability.highest_ssvc_decision }}
  • + {% endif %} + {% if vulnerability.datasource_id %} +
  • {% trans "Source" %}: {{ vulnerability.datasource_id }}
  • + {% endif %} {% endif %} diff --git a/docs/howto-7-vulnerability-triage-configuration.rst b/docs/howto-7-vulnerability-triage-configuration.rst index 3e2ed513..fd62cd45 100644 --- a/docs/howto-7-vulnerability-triage-configuration.rst +++ b/docs/howto-7-vulnerability-triage-configuration.rst @@ -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. diff --git a/docs/reference-vulnerability-management.rst b/docs/reference-vulnerability-management.rst index c67f0e93..8cedef84 100644 --- a/docs/reference-vulnerability-management.rst +++ b/docs/reference-vulnerability-management.rst @@ -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"**. diff --git a/docs/reference-vulnerability-triage.rst b/docs/reference-vulnerability-triage.rst index 225b3a8b..bfee7af6 100644 --- a/docs/reference-vulnerability-triage.rst +++ b/docs/reference-vulnerability-triage.rst @@ -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:: @@ -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 @@ -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 -------------- diff --git a/product_portfolio/migrations/0020_alter_product_vcs_url_and_more.py b/product_portfolio/migrations/0020_alter_product_vcs_url_and_more.py new file mode 100644 index 00000000..8d4fc304 --- /dev/null +++ b/product_portfolio/migrations/0020_alter_product_vcs_url_and_more.py @@ -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'), + ), + ] diff --git a/product_portfolio/templates/product_portfolio/tabs/tab_packages_vulnerabilities.html b/product_portfolio/templates/product_portfolio/tabs/tab_packages_vulnerabilities.html index a6c67d15..bab8be54 100644 --- a/product_portfolio/templates/product_portfolio/tabs/tab_packages_vulnerabilities.html +++ b/product_portfolio/templates/product_portfolio/tabs/tab_packages_vulnerabilities.html @@ -84,9 +84,9 @@ {% else %} {{ vulnerability.advisory_id }} {% endif %} - {% if vulnerability.aliases or vulnerability.highest_ssvc_decision %} - - {% include 'component_catalog/includes/vulnerability_info_menu.html' with vulnerability=vulnerability only %} diff --git a/product_portfolio/tests/test_views.py b/product_portfolio/tests/test_views.py index 33302f9e..54371451 100644 --- a/product_portfolio/tests/test_views.py +++ b/product_portfolio/tests/test_views.py @@ -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 = 'Source: ghsa' + 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( @@ -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 diff --git a/product_portfolio/views.py b/product_portfolio/views.py index e8a6aec5..a7dcecde 100644 --- a/product_portfolio/views.py +++ b/product_portfolio/views.py @@ -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 = { @@ -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 diff --git a/vulnerabilities/api.py b/vulnerabilities/api.py index 466d4eac..819abe8c 100644 --- a/vulnerabilities/api.py +++ b/vulnerabilities/api.py @@ -38,6 +38,7 @@ class Meta: "api_url", "uuid", "advisory_uid", + "datasource_id", "advisory_id", "resource_url", "summary", diff --git a/vulnerabilities/migrations/0014_vulnerability_datasource_id_and_more.py b/vulnerabilities/migrations/0014_vulnerability_datasource_id_and_more.py new file mode 100644 index 00000000..aa5b279c --- /dev/null +++ b/vulnerabilities/migrations/0014_vulnerability_datasource_id_and_more.py @@ -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'), + ), + ] diff --git a/vulnerabilities/models.py b/vulnerabilities/models.py index 547e04d3..b12f14bf 100644 --- a/vulnerabilities/models.py +++ b/vulnerabilities/models.py @@ -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=_( @@ -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"]), diff --git a/vulnerabilities/tests/data/vulnerabilities/idna_3.6_response.json b/vulnerabilities/tests/data/vulnerabilities/idna_3.6_response.json index f41832bf..753a1f21 100644 --- a/vulnerabilities/tests/data/vulnerabilities/idna_3.6_response.json +++ b/vulnerabilities/tests/data/vulnerabilities/idna_3.6_response.json @@ -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" @@ -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" ], @@ -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" diff --git a/vulnerabilities/tests/test_api.py b/vulnerabilities/tests/test_api.py index 0bfda9ca..c30018b0 100644 --- a/vulnerabilities/tests/test_api.py +++ b/vulnerabilities/tests/test_api.py @@ -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") @@ -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"]) diff --git a/vulnerabilities/tests/test_fetch.py b/vulnerabilities/tests/test_fetch.py index 5becb966..a67acec3 100644 --- a/vulnerabilities/tests/test_fetch.py +++ b/vulnerabilities/tests/test_fetch.py @@ -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) diff --git a/vulnerabilities/triage/admin.py b/vulnerabilities/triage/admin.py index 16ab2f75..d48934ac 100644 --- a/vulnerabilities/triage/admin.py +++ b/vulnerabilities/triage/admin.py @@ -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 @@ -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 diff --git a/vulnerabilities/triage/forms.py b/vulnerabilities/triage/forms.py index ba3a084a..8e723454 100644 --- a/vulnerabilities/triage/forms.py +++ b/vulnerabilities/triage/forms.py @@ -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 @@ -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, @@ -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: diff --git a/vulnerabilities/triage/rules.py b/vulnerabilities/triage/rules.py index 05cf17a4..109a4f28 100644 --- a/vulnerabilities/triage/rules.py +++ b/vulnerabilities/triage/rules.py @@ -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(), @@ -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) diff --git a/vulnerabilities/triage/tests/test_admin.py b/vulnerabilities/triage/tests/test_admin.py index 4f6a0901..acd98fd4 100644 --- a/vulnerabilities/triage/tests/test_admin.py +++ b/vulnerabilities/triage/tests/test_admin.py @@ -11,6 +11,7 @@ from django.urls import reverse from component_catalog.tests import make_package +from dje.admin import dejacode_site from dje.models import Dataspace from dje.tests import create_superuser from dje.tests import create_user @@ -18,9 +19,11 @@ from product_portfolio.tests import make_product from product_portfolio.tests import make_product_package from vulnerabilities.tests import make_vulnerability +from vulnerabilities.triage.admin import TriageRulesetAdmin from vulnerabilities.triage.engine import evaluate_ruleset from vulnerabilities.triage.models import TriageAction from vulnerabilities.triage.models import TriageRecord +from vulnerabilities.triage.models import TriageRuleset from vulnerabilities.triage.tests import make_product_triage_ruleset from vulnerabilities.triage.tests import make_triage_ruleset from workflow.models import RequestTemplate @@ -179,3 +182,20 @@ def test_disabling_then_reenabling_a_ruleset_reuses_the_existing_request(self): self.assertEqual(1, ruleset.triage_records.count()) self.assertEqual(original_request, TriageRecord.objects.get().request) + + +class TriageRulesetAdminEnabledRulesTestCase(TestCase): + def setUp(self): + self.dataspace = Dataspace.objects.create(name="nexB") + self.ruleset_admin = TriageRulesetAdmin(TriageRuleset, dejacode_site) + + def test_get_enabled_rules_joins_a_list_parameter_with_commas(self): + ruleset = make_triage_ruleset( + self.dataspace, + rules_config={ + "risk_score": {"is_active": True, "min_risk_score": 7.5}, + "advisory_source": {"is_active": True, "datasource_ids": ["ghsa", "nvd"]}, + }, + ) + expected = "Risk Score (min_risk_score: 7.5)
    Advisory Source (datasource_ids: ghsa, nvd)" + self.assertEqual(expected, self.ruleset_admin.get_enabled_rules(ruleset)) diff --git a/vulnerabilities/triage/tests/test_forms.py b/vulnerabilities/triage/tests/test_forms.py index 8ec9c700..ad082a52 100644 --- a/vulnerabilities/triage/tests/test_forms.py +++ b/vulnerabilities/triage/tests/test_forms.py @@ -50,6 +50,7 @@ def test_adds_a_dynamic_field_pair_for_every_registered_rule(self): self.assertIn("rule_risk_score_min_risk_score", form.fields) self.assertIn("rule_exploited_vulnerability_enabled", form.fields) self.assertNotIn("rule_exploited_vulnerability_min_risk_score", form.fields) + self.assertIn("rule_advisory_source_datasource_ids", form.fields) def test_save_builds_rules_config_from_the_submitted_rule_fields(self): data = { @@ -88,6 +89,33 @@ def test_save_keeps_an_inactive_rule_that_has_parameters_with_their_defaults(sel self.assertEqual( {"is_active": False, "min_risk_score": 8.0}, ruleset.rules_config["risk_score"] ) + self.assertEqual( + {"is_active": False, "datasource_ids": []}, ruleset.rules_config["advisory_source"] + ) + + def test_save_builds_a_list_parameter_from_comma_separated_values(self): + data = { + "name": "My Ruleset", + "precedence": 100, + "rule_advisory_source_enabled": "on", + "rule_advisory_source_datasource_ids": "ghsa, , nvd ", + } + form = TriageRulesetForm(data=data, instance=TriageRuleset(dataspace=self.dataspace)) + self.assertTrue(form.is_valid(), form.errors) + + ruleset = form.save() + + self.assertEqual( + {"is_active": True, "datasource_ids": ["ghsa", "nvd"]}, + ruleset.rules_config["advisory_source"], + ) + + def test_displays_a_list_parameter_as_comma_separated_values(self): + rules_config = {"advisory_source": {"is_active": True, "datasource_ids": ["ghsa", "nvd"]}} + ruleset = TriageRuleset(dataspace=self.dataspace, rules_config=rules_config) + form = TriageRulesetForm(instance=ruleset) + + self.assertIn('value="ghsa, nvd"', str(form["rule_advisory_source_datasource_ids"])) def test_rejects_a_request_template_with_no_creator(self): # A RequestTemplate normally always has a creator (the admin form sets it on diff --git a/vulnerabilities/triage/tests/test_rules.py b/vulnerabilities/triage/tests/test_rules.py index 3ce98f65..d4aa13a1 100644 --- a/vulnerabilities/triage/tests/test_rules.py +++ b/vulnerabilities/triage/tests/test_rules.py @@ -18,6 +18,7 @@ from product_portfolio.tests import make_product_package from vulnerabilities.tests import make_vulnerability from vulnerabilities.tests import make_vulnerability_analysis +from vulnerabilities.triage.rules import AdvisorySourceTriageRule from vulnerabilities.triage.rules import DevOnlyPackageTriageRule from vulnerabilities.triage.rules import ExploitedVulnerabilityTriageRule from vulnerabilities.triage.rules import ReachableVulnerabilityTriageRule @@ -26,6 +27,7 @@ from vulnerabilities.triage.rules import StaleVulnerabilityTriageRule from vulnerabilities.triage.rules import UnresolvedVulnerabilityTriageRule from vulnerabilities.triage.rules import WeightedRiskTriageRule +from vulnerabilities.triage.rules import format_rule_parameter_value from vulnerabilities.triage.rules import rule_parameters_from_config @@ -350,6 +352,45 @@ def test_excludes_vulnerability_when_also_carried_by_a_deployed_package(self): self.assertEqual([], list(matches)) +class AdvisorySourceTriageRuleTestCase(TestCase): + def setUp(self): + self.dataspace = Dataspace.objects.create(name="nexB") + self.product = make_product(self.dataspace) + self.package = make_package(self.dataspace) + + def test_matches_vulnerability_from_a_configured_datasource(self): + vulnerability = make_vulnerability( + self.dataspace, affecting=self.package, datasource_id="ghsa" + ) + make_product_package(self.product, package=self.package) + matches = AdvisorySourceTriageRule().get_matching_vulnerabilities( + self.product, parameters={"datasource_ids": ["ghsa", "nvd"]} + ) + self.assertEqual([vulnerability], list(matches)) + + def test_excludes_vulnerability_from_another_or_no_datasource(self): + make_vulnerability(self.dataspace, affecting=self.package, datasource_id="pypa") + make_vulnerability(self.dataspace, affecting=self.package) + make_product_package(self.product, package=self.package) + matches = AdvisorySourceTriageRule().get_matching_vulnerabilities( + self.product, parameters={"datasource_ids": ["ghsa"]} + ) + self.assertEqual([], list(matches)) + + def test_matches_nothing_with_the_default_empty_datasource_ids(self): + make_vulnerability(self.dataspace, affecting=self.package, datasource_id="ghsa") + make_product_package(self.product, package=self.package) + matches = AdvisorySourceTriageRule().get_matching_vulnerabilities(self.product) + self.assertEqual([], list(matches)) + + def test_ignores_vulnerabilities_affecting_packages_outside_the_product(self): + make_vulnerability(self.dataspace, affecting=self.package, datasource_id="ghsa") + matches = AdvisorySourceTriageRule().get_matching_vulnerabilities( + self.product, parameters={"datasource_ids": ["ghsa"]} + ) + self.assertEqual([], list(matches)) + + class RuleParametersFromConfigTestCase(TestCase): def test_excludes_is_active_key(self): config = {"is_active": True, "min_risk_score": 7.0} @@ -357,3 +398,11 @@ def test_excludes_is_active_key(self): def test_returns_empty_dict_when_only_is_active_present(self): self.assertEqual({}, rule_parameters_from_config({"is_active": True})) + + +class FormatRuleParameterValueTestCase(TestCase): + def test_joins_a_list_value_with_commas(self): + self.assertEqual("ghsa, nvd", format_rule_parameter_value(["ghsa", "nvd"])) + + def test_returns_a_scalar_value_as_a_string(self): + self.assertEqual("8.0", format_rule_parameter_value(8.0))