diff --git a/secator/config.py b/secator/config.py index bea53e099..ab09f3b25 100644 --- a/secator/config.py +++ b/secator/config.py @@ -201,6 +201,7 @@ class MongodbAddon(StrictModel): 'is_false_positive', 'is_acknowledged', 'verified', + 'status', 'tags', ] @@ -216,6 +217,7 @@ class SqliteAddon(StrictModel): 'is_false_positive', 'is_acknowledged', 'verified', + 'status', 'tags', ] diff --git a/secator/definitions.py b/secator/definitions.py index fd58fab18..a23d3daf9 100644 --- a/secator/definitions.py +++ b/secator/definitions.py @@ -142,6 +142,7 @@ SOURCES = 'sources' STORED_RESPONSE_PATH = 'stored_response_path' STATE = 'state' +STATUS = 'status' STATUS_CODE = 'status_code' STRING = 'str' TAGS = 'tags' diff --git a/secator/output_types/vulnerability.py b/secator/output_types/vulnerability.py index 1a66c424f..b726958f4 100644 --- a/secator/output_types/vulnerability.py +++ b/secator/output_types/vulnerability.py @@ -2,7 +2,9 @@ from dataclasses import dataclass, field from typing import List -from secator.definitions import CONFIDENCE, CVSS_SCORE, EXTRA_DATA, ID, MATCHED_AT, NAME, REFERENCE, SEVERITY, TAGS +from secator.definitions import ( + CONFIDENCE, CVSS_SCORE, EXTRA_DATA, ID, MATCHED_AT, NAME, REFERENCE, SEVERITY, STATUS, TAGS +) from secator.output_types import OutputType from secator.utils import rich_to_ansi, rich_escape as _s, format_object, trim_string @@ -30,6 +32,7 @@ class Vulnerability(OutputType): verified: bool = field(default=False, compare=False) is_false_positive: bool = field(default=False, compare=False) is_acknowledged: bool = field(default=False, compare=False) + status: str = field(default='', compare=False) tags: list = field(default_factory=list, compare=False) _source: str = field(default='', repr=True, compare=False) _type: str = field(default='vulnerability', repr=True) @@ -40,7 +43,7 @@ class Vulnerability(OutputType): _duplicate: bool = field(default=False, repr=True, compare=False) _related: list = field(default_factory=list, compare=False) - _table_fields = [MATCHED_AT, SEVERITY, CONFIDENCE, NAME, ID, CVSS_SCORE, TAGS, EXTRA_DATA, REFERENCE] + _table_fields = [MATCHED_AT, SEVERITY, CONFIDENCE, NAME, ID, CVSS_SCORE, STATUS, TAGS, EXTRA_DATA, REFERENCE] _sort_by = ('confidence_nb', 'severity_nb', 'matched_at', 'cvss_score') @staticmethod diff --git a/tests/unit/test_dedup.py b/tests/unit/test_dedup.py new file mode 100644 index 000000000..d081dbb0d --- /dev/null +++ b/tests/unit/test_dedup.py @@ -0,0 +1,58 @@ +import unittest + +from secator.hooks._dedup import compute_duplicate_updates +from secator.output_types import Vulnerability + + +def _vuln(uuid, status='', verified=False, **kwargs): + return Vulnerability( + name='CVE-2025-53020', + id='CVE-2025-53020', + matched_at='host:80', + status=status, + verified=verified, + _uuid=uuid, + **kwargs, + ) + + +class TestComputeDuplicateUpdates(unittest.TestCase): + + def test_status_carried_forward_onto_new_main(self): + """A prior ACKNOWLEDGED main carries onto a re-found main with no status yet.""" + prev = _vuln('prev', status='ACKNOWLEDGED') + new = _vuln('new') # untouched -> status '' (empty, like any other field) + updates = compute_duplicate_updates([prev], [new], copy_fields=['status']) + assert updates['new']['status'] == 'ACKNOWLEDGED' + + def test_status_fixed_not_overwritten(self): + """A new main that already has a status keeps its value (not empty).""" + prev = _vuln('prev', status='ACKNOWLEDGED') + new = _vuln('new', status='FIXED') + updates = compute_duplicate_updates([prev], [new], copy_fields=['status']) + assert 'status' not in updates['new'] + + def test_prior_empty_status_not_carried(self): + """A prior empty status has nothing to carry forward.""" + prev = _vuln('prev') # status '' (empty) + new = _vuln('new') + updates = compute_duplicate_updates([prev], [new], copy_fields=['status']) + assert 'status' not in updates['new'] + + def test_non_status_field_keeps_not_value_semantics(self): + """Generic fields still use the `not value` emptiness check.""" + # Prior verified=True copies onto new verified=False (falsy -> empty). + prev = _vuln('prev', verified=True) + new = _vuln('new', verified=False) + updates = compute_duplicate_updates([prev], [new], copy_fields=['verified']) + assert updates['new']['verified'] is True + + # Prior verified=False is empty -> nothing to copy. + prev2 = _vuln('prev2', verified=False) + new2 = _vuln('new2', verified=True) + updates2 = compute_duplicate_updates([prev2], [new2], copy_fields=['verified']) + assert 'verified' not in updates2['new2'] + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/unit/test_output_types.py b/tests/unit/test_output_types.py index 614f77b42..0596d3452 100644 --- a/tests/unit/test_output_types.py +++ b/tests/unit/test_output_types.py @@ -42,6 +42,27 @@ def test_merge_with_exclude_fields(self): assert vuln1.name == 'CVE-2025-53020' +class TestVulnerabilityStatus(unittest.TestCase): + + def test_status_defaults_to_empty(self): + # status is a plain field: untouched vulns default to '' (rendered/treated + # as NEW downstream), which lets dedup carry a prior status forward generically. + vuln = Vulnerability(name='CVE-2025-53020') + assert vuln.status == '' + + def test_status_value_preserved_as_is(self): + # No coercion / uppercasing — treated like any other field. + assert Vulnerability(name='CVE-2025-53020', status='ACKNOWLEDGED').status == 'ACKNOWLEDGED' + assert Vulnerability(name='CVE-2025-53020', status='FIXED').status == 'FIXED' + + def test_status_does_not_affect_equality(self): + # Same identity (name/id/matched_at) but different status must still be equal (dedup-safe). + vuln1 = Vulnerability(name='CVE-2025-53020', id='CVE-2025-53020', matched_at='host:80', status='NEW') + vuln2 = Vulnerability(name='CVE-2025-53020', id='CVE-2025-53020', matched_at='host:80', status='FIXED') + assert vuln1 == vuln2 + assert vuln1._compare_key() == vuln2._compare_key() + + class TestErrorRich(unittest.TestCase): def test_error_rich_with_node_id(self):