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
2 changes: 2 additions & 0 deletions secator/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ class MongodbAddon(StrictModel):
'is_false_positive',
'is_acknowledged',
'verified',
'status',
'tags',
]

Expand All @@ -216,6 +217,7 @@ class SqliteAddon(StrictModel):
'is_false_positive',
'is_acknowledged',
'verified',
'status',
'tags',
]

Expand Down
1 change: 1 addition & 0 deletions secator/definitions.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@
SOURCES = 'sources'
STORED_RESPONSE_PATH = 'stored_response_path'
STATE = 'state'
STATUS = 'status'
STATUS_CODE = 'status_code'
STRING = 'str'
TAGS = 'tags'
Expand Down
7 changes: 5 additions & 2 deletions secator/output_types/vulnerability.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down
58 changes: 58 additions & 0 deletions tests/unit/test_dedup.py
Original file line number Diff line number Diff line change
@@ -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()
21 changes: 21 additions & 0 deletions tests/unit/test_output_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading