From afd3cabca63f393889b9d6ab7e9137a39b53c5d6 Mon Sep 17 00:00:00 2001 From: Matthew Rafuse Date: Mon, 13 Jul 2026 14:56:10 -0400 Subject: [PATCH 1/4] fix(api): ensure hit statuses can't be mismatched on ingest --- api/howler/odm/models/howler_data.py | 32 ++++++++++++++-------------- api/howler/services/hit_service.py | 13 +++++++++++ 2 files changed, 29 insertions(+), 16 deletions(-) diff --git a/api/howler/odm/models/howler_data.py b/api/howler/odm/models/howler_data.py index 781998602..8d7e653a5 100644 --- a/api/howler/odm/models/howler_data.py +++ b/api/howler/odm/models/howler_data.py @@ -160,17 +160,17 @@ def __init__(self, data: dict = None, *args, **kwargs): @odm.model(index=True, store=True, description="Hit outline header.") class Header(odm.Model): - threat: Optional[str] = odm.Optional(odm.Keyword(description="The IP of the threat.")) - target: Optional[str] = odm.Optional(odm.Keyword(description="The target of the hit.")) + threat: str | None = odm.Optional(odm.Keyword(description="The IP of the threat.")) + target: str | None = odm.Optional(odm.Keyword(description="The target of the hit.")) indicators: list[str] = odm.List(odm.Keyword(description="Indicators of the hit."), default=[]) - summary: Optional[str] = odm.Optional(odm.Keyword(description="Summary of the hit.")) + summary: str | None = odm.Optional(odm.Keyword(description="Summary of the hit.")) @odm.model(index=True, store=True, description="Fields describing the location where this alert has been retained.") class Incident(odm.Model): platform: str = odm.Keyword(description="The name of the platform for this incident.") - incident_id: Optional[str] = odm.Keyword(description="The ID of the incident.", optional=True) - url: Optional[str] = odm.Keyword(description="The url where the incident can be found.", optional=True) + incident_id: str | None = odm.Keyword(description="The ID of the incident.", optional=True) + url: str | None = odm.Keyword(description="The url where the incident can be found.", optional=True) @odm.model(index=True, store=True, description="Labels for the hit") @@ -206,11 +206,11 @@ class Votes(odm.Model): class HowlerData(odm.Model): id: str = odm.UUID(description="A UUID for this hit.") analytic: str = odm.CaseInsensitiveKeyword(description="Title of the analytic.") - assignment: Optional[str] = odm.Keyword( + assignment: str | None = odm.Keyword( description="Unique identifier of the assigned user.", default=DEFAULT_ASSIGNMENT, ) - assessor: Optional[str] = odm.Optional( + assessor: str | None = odm.Optional( odm.Keyword( description="The most recent person to assess a hit", default=None, @@ -232,7 +232,7 @@ class HowlerData(odm.Model): default=[], description="A list of links associated with this hit.", ) - detection: Optional[str] = odm.Optional( + detection: str | None = odm.Optional( odm.CaseInsensitiveKeyword(description="The detection that produced this hit.") ) hash: str = odm.HowlerHash( @@ -273,13 +273,13 @@ class HowlerData(odm.Model): score: Optional[float] = odm.Optional( odm.Float(description="A score assigned by an enrichment to help prioritize triage.", default=0) ) - status = odm.Enum(values=HitStatus, default=HitStatus.OPEN, description="Status of the hit.") - scrutiny = odm.Enum( + status: str = odm.Enum(values=HitStatus, default=HitStatus.OPEN, description="Status of the hit.") + scrutiny: str = odm.Enum( values=Scrutiny, default=Scrutiny.UNSEEN, description="Level of scrutiny done to this hit.", ) - escalation = odm.Enum( + escalation: str = odm.Enum( values=Escalation, default=Escalation.HIT, description="Level of escalation of this hit.", @@ -289,8 +289,8 @@ class HowlerData(odm.Model): description="User selected time for hit expiry", ) ) - assessment: Optional[str] = odm.Optional(odm.Enum(values=Assessment, description="Assessment of the hit.")) - rationale: Optional[str] = odm.Optional( + assessment: str | None = odm.Optional(odm.Enum(values=Assessment, description="Assessment of the hit.")) + rationale: str | None = odm.Optional( odm.Keyword( description=( "The rationale behind the hit assessment. Allows it to be understood and verified by other analysts." @@ -307,9 +307,9 @@ class HowlerData(odm.Model): default=[], description="A list of changes to the hit with timestamps and attribution.", ) - monitored: Optional[str] = odm.Optional(odm.Keyword(description="Link to the incident monitoring dashboard.")) - reported: Optional[str] = odm.Optional(odm.Keyword(description="Link to the incident report.")) - mitigated: Optional[str] = odm.Optional(odm.Keyword(description="Link to the mitigation record (tool dependent).")) + monitored: str | None = odm.Optional(odm.Keyword(description="Link to the incident monitoring dashboard.")) + reported: str | None = odm.Optional(odm.Keyword(description="Link to the incident report.")) + mitigated: str | None = odm.Optional(odm.Keyword(description="Link to the mitigation record (tool dependent).")) outline: Optional[Header] = odm.Optional(odm.Compound(Header), description="The user specified header of the hit") incidents: list[Incident] = odm.List( odm.Compound(Incident), default=[], description="Fields describing an incident associated with this alert." diff --git a/api/howler/services/hit_service.py b/api/howler/services/hit_service.py index 59e4a9fc5..c3ffb4d5b 100644 --- a/api/howler/services/hit_service.py +++ b/api/howler/services/hit_service.py @@ -348,6 +348,19 @@ def convert_hit(data: dict[str, Any], unique: bool, ignore_extra_values: bool = "See howler's documentation for more information." ) + if odm.howler.assessment and odm.howler.escalation != AssessmentEscalationMap[odm.howler.assessment]: + target_escalation = cast(Escalation, AssessmentEscalationMap[odm.howler.assessment]) + + warnings.append( + f"Hits with assessment {odm.howler.assessment} must also have escalation set to {target_escalation.value}." + ) + + odm.howler.escalation = target_escalation.value + + if odm.howler.escalation in [Escalation.MISS, Escalation.EVIDENCE] and odm.howler.status != HitStatus.RESOLVED: + warnings.append("Hits with escalation miss or evidence must also have their status set to resolved.") + odm.howler.status = HitStatus.RESOLVED.value + if odm.event: odm.event.id = odm.howler.id if not odm.event.created: From 12f9caffabaf8440cf81187e1593af705b4a92e7 Mon Sep 17 00:00:00 2001 From: Matthew Rafuse Date: Mon, 13 Jul 2026 14:59:41 -0400 Subject: [PATCH 2/4] unit tests --- api/test/unit/odm/test_hit_fields.py | 48 ++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/api/test/unit/odm/test_hit_fields.py b/api/test/unit/odm/test_hit_fields.py index 63d69684a..a946bf3be 100644 --- a/api/test/unit/odm/test_hit_fields.py +++ b/api/test/unit/odm/test_hit_fields.py @@ -111,3 +111,51 @@ def test_howler_hashing(): for _hash in EXAMPLE_HASHES.split("\n"): Hit({"howler.analytic": "Test Hash Analytic", "howler.hash": _hash}) + + +def test_convert_hit_assessment_normalizes_escalation_with_warning(): + hit, warnings = hit_service.convert_hit( + { + "howler.analytic": "Assessment Test", + "howler.assessment": "security", + "howler.escalation": "alert", + }, + unique=False, + ) + + assert hit.howler.assessment == "security" + assert hit.howler.escalation == "miss" + assert "Hits with assessment security must also have escalation set to miss." in warnings + + +def test_convert_hit_escalation_normalizes_status_with_warning(): + hit, warnings = hit_service.convert_hit( + { + "howler.analytic": "Escalation Test", + "howler.escalation": "evidence", + "howler.status": "open", + }, + unique=False, + ) + + assert hit.howler.escalation == "evidence" + assert hit.howler.status == "resolved" + assert "Hits with escalation miss or evidence must also have their status set to resolved." in warnings + + +def test_convert_hit_assessment_and_escalation_normalize_together(): + hit, warnings = hit_service.convert_hit( + { + "howler.analytic": "Combined Assessment Test", + "howler.assessment": "security", + "howler.escalation": "evidence", + "howler.status": "open", + }, + unique=False, + ) + + assert hit.howler.assessment == "security" + assert hit.howler.escalation == "miss" + assert hit.howler.status == "resolved" + assert "Hits with assessment security must also have escalation set to miss." in warnings + assert "Hits with escalation miss or evidence must also have their status set to resolved." in warnings From 93282134b30d49f7431e882d09e10187310110f3 Mon Sep 17 00:00:00 2001 From: Matthew Rafuse <84039190+cccs-mdr@users.noreply.github.com> Date: Tue, 14 Jul 2026 11:52:54 -0400 Subject: [PATCH 3/4] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- api/howler/odm/models/howler_data.py | 2 +- api/howler/services/hit_service.py | 15 +++++++-------- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/api/howler/odm/models/howler_data.py b/api/howler/odm/models/howler_data.py index 8d7e653a5..318eeb120 100644 --- a/api/howler/odm/models/howler_data.py +++ b/api/howler/odm/models/howler_data.py @@ -206,7 +206,7 @@ class Votes(odm.Model): class HowlerData(odm.Model): id: str = odm.UUID(description="A UUID for this hit.") analytic: str = odm.CaseInsensitiveKeyword(description="Title of the analytic.") - assignment: str | None = odm.Keyword( + assignment: str = odm.Keyword( description="Unique identifier of the assigned user.", default=DEFAULT_ASSIGNMENT, ) diff --git a/api/howler/services/hit_service.py b/api/howler/services/hit_service.py index c3ffb4d5b..95f39fe9c 100644 --- a/api/howler/services/hit_service.py +++ b/api/howler/services/hit_service.py @@ -348,14 +348,13 @@ def convert_hit(data: dict[str, Any], unique: bool, ignore_extra_values: bool = "See howler's documentation for more information." ) - if odm.howler.assessment and odm.howler.escalation != AssessmentEscalationMap[odm.howler.assessment]: - target_escalation = cast(Escalation, AssessmentEscalationMap[odm.howler.assessment]) - - warnings.append( - f"Hits with assessment {odm.howler.assessment} must also have escalation set to {target_escalation.value}." - ) - - odm.howler.escalation = target_escalation.value + if odm.howler.assessment: + target_escalation = AssessmentEscalationMap[odm.howler.assessment].value + if odm.howler.escalation != target_escalation: + warnings.append( + f"Hits with assessment {odm.howler.assessment} must also have escalation set to {target_escalation}." + ) + odm.howler.escalation = target_escalation if odm.howler.escalation in [Escalation.MISS, Escalation.EVIDENCE] and odm.howler.status != HitStatus.RESOLVED: warnings.append("Hits with escalation miss or evidence must also have their status set to resolved.") From 9a9c2d83ebb9b21b903b19623e46a636fb5e1ed6 Mon Sep 17 00:00:00 2001 From: Matthew Rafuse Date: Tue, 21 Jul 2026 09:23:32 -0400 Subject: [PATCH 4/4] fix type error --- api/howler/services/hit_service.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/howler/services/hit_service.py b/api/howler/services/hit_service.py index 95f39fe9c..ec3a91bf0 100644 --- a/api/howler/services/hit_service.py +++ b/api/howler/services/hit_service.py @@ -349,12 +349,12 @@ def convert_hit(data: dict[str, Any], unique: bool, ignore_extra_values: bool = ) if odm.howler.assessment: - target_escalation = AssessmentEscalationMap[odm.howler.assessment].value + target_escalation = AssessmentEscalationMap[odm.howler.assessment] if odm.howler.escalation != target_escalation: warnings.append( f"Hits with assessment {odm.howler.assessment} must also have escalation set to {target_escalation}." ) - odm.howler.escalation = target_escalation + odm.howler.escalation = str(target_escalation) if odm.howler.escalation in [Escalation.MISS, Escalation.EVIDENCE] and odm.howler.status != HitStatus.RESOLVED: warnings.append("Hits with escalation miss or evidence must also have their status set to resolved.")