Skip to content

Commit 5d38aae

Browse files
committed
fix(scanmalware): map the malicious risk level, tag from threat_categories
Two mapping bugs found by running the analyzer against the live archive rather than against fixtures. RISK_LEVELS_MEANING_MALICIOUS was ("high", "critical"). Sampling the live API gives low 74, malicious 14, high 9, medium 2, and no critical at all, so a page the scanner calls outright Malicious at 95% confidence set no evaluation, while the value being checked for is one the API has not been observed to emit. The vocabulary had been taken from the SMQL `verdict` filter enum (LOW_RISK/MODERATE_RISK/HIGH_RISK), which is a different field from security_verdict.risk_level. Tags were derived from risk_factors alone. A live verdict of "High Risk (Credential Phishing on disposable hosting)" carrying threat_categories: ["Credential Phishing"] was tagged with nothing, because its only risk factor named the flagged IPs. threat_categories is the structured field and is now read first, with the verdict string and the risk factors kept as prose fallbacks. Three tests added, each failing against the previous code.
1 parent 491467d commit 5d38aae

2 files changed

Lines changed: 75 additions & 9 deletions

File tree

api_app/analyzers_manager/observable_analyzers/scanmalware.py

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,13 @@ def run(self):
6060
# model. The AI endpoint returns model prose ("The site shows a simple document
6161
# download page…"), which is useful to read and not something to convert into
6262
# an evaluation.
63-
RISK_LEVELS_MEANING_MALICIOUS = ("high", "critical")
63+
# `malicious` is the scanner's strongest level and is NOT a synonym for `high`:
64+
# sampling the live archive on 2026-09-01 found low 74, malicious 14, high 9,
65+
# medium 2, and no `critical` at all. Omitting it meant a scan the scanner calls
66+
# outright Malicious at 95% confidence produced no evaluation, while the value
67+
# actually being checked for was one the API never emits. `critical` is kept
68+
# because it costs nothing and the API does not publish this enum.
69+
RISK_LEVELS_MEANING_MALICIOUS = ("malicious", "critical", "high")
6470

6571
def _do_create_data_model(self) -> bool:
6672
return super()._do_create_data_model() and bool(self.__security_verdict())
@@ -75,8 +81,8 @@ def _update_data_model(self, data_model) -> None:
7581
factors = verdict.get("risk_factors") or []
7682

7783
# A low or medium risk level is not a statement that the site is safe, so
78-
# it sets no evaluation. Only an explicit high risk is asserted, and even
79-
# then the confidence the scanner reported travels with it.
84+
# it sets no evaluation. Only an explicit high-or-worse level is asserted,
85+
# and even then the confidence the scanner reported travels with it.
8086
if risk_level in self.RISK_LEVELS_MEANING_MALICIOUS:
8187
data_model.evaluation = self.EVALUATIONS.MALICIOUS.value
8288
confidence = verdict.get("confidence")
@@ -86,13 +92,24 @@ def _update_data_model(self, data_model) -> None:
8692
# be banker's rounding: 85 and 75 both land on 8 while 86 goes to 9.
8793
data_model.reliability = int(max(0, min(10, confidence // 10)))
8894

89-
# Keyword matching over the scanner's own risk factors. "credential" earns
90-
# its place: "Credential form posts to an unrelated domain" is among the
91-
# strongest phishing signals it emits. "download" deliberately does not,
92-
# because a document download page is not evidence of malware.
95+
# Three fields are read, because the risk factors alone are the weakest of
96+
# them. A live run on 2026-09-01 returned the verdict "High Risk (Credential
97+
# Phishing on disposable hosting)" and `threat_categories: ["Credential
98+
# Phishing"]`, while its single risk factor named only the flagged IPs, so
99+
# matching factors alone tagged an outright credential-phishing page with
100+
# nothing. `threat_categories` is the structured field and the one to trust;
101+
# the other two are prose and are matched as a fallback.
102+
#
103+
# "credential" earns its keyword place: "Credential form posts to an
104+
# unrelated domain" is among the strongest phishing signals emitted.
105+
# "download" deliberately does not, because a document download page is
106+
# not evidence of malware.
107+
haystack = [str(f).lower() for f in factors]
108+
haystack += [str(c).lower() for c in (verdict.get("threat_categories") or [])]
109+
haystack.append(str(verdict.get("verdict") or "").lower())
110+
93111
tags = set()
94-
for factor in factors:
95-
text = str(factor).lower()
112+
for text in haystack:
96113
if any(k in text for k in ("phish", "impersonation", "lure", "credential")):
97114
tags.add(DataModelTags.PHISHING.value)
98115
if any(k in text for k in ("malware", "trojan", "ransomware", "payload", "drive-by")):

tests/api_app/analyzers_manager/unit_tests/observable_analyzers/test_scanmalware.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,55 @@ def test_high_risk_is_reported_as_malicious(self):
245245
self.assertEqual(dm.tags, ["phishing"])
246246
self.assertEqual(dm.additional_info["risk_level"], "high")
247247

248+
def test_phishing_is_tagged_from_the_structured_category(self):
249+
# Shape taken from a live 2026-09-01 run: the verdict and threat_categories
250+
# both say credential phishing while the only risk factor names the flagged
251+
# IPs. Matching risk factors alone left this page untagged.
252+
dm = self._mapped(
253+
{
254+
"verdict": "High Risk (Credential Phishing on disposable hosting)",
255+
"risk_level": "high",
256+
"confidence": 75,
257+
"threat_categories": ["Credential Phishing"],
258+
"risk_factors": ["References flagged external domain(s): 43.174.246.29"],
259+
}
260+
)
261+
self.assertEqual(dm.tags, ["phishing"])
262+
263+
def test_a_clean_verdict_is_tagged_with_nothing(self):
264+
# The fallback reads free text, so it has to stay quiet on a benign report.
265+
dm = self._mapped(
266+
{
267+
"verdict": "Low Risk",
268+
"risk_level": "low",
269+
"confidence": 40,
270+
"threat_categories": [],
271+
"risk_factors": ["Document download page"],
272+
}
273+
)
274+
self.assertIsNone(dm.tags)
275+
276+
def test_every_risk_level_the_archive_emits_is_mapped(self):
277+
# Sampled from the live archive on 2026-09-01: low 74, malicious 14, high 9,
278+
# medium 2, critical 0. `malicious` is the scanner's strongest level, not a
279+
# synonym for `high`, and it was originally missed: a site called outright
280+
# Malicious at 95% confidence set no evaluation at all, while `critical`,
281+
# the value that WAS being checked, is one this API has never been seen to
282+
# emit. Enumerate the real vocabulary here so a future edit cannot quietly
283+
# drop one of them again.
284+
for level, expected in (
285+
("malicious", "malicious"),
286+
("critical", "malicious"),
287+
("high", "malicious"),
288+
("medium", None),
289+
("low", None),
290+
):
291+
with self.subTest(risk_level=level):
292+
dm = self._mapped(
293+
{"verdict": level.title(), "risk_level": level, "confidence": 95, "risk_factors": []}
294+
)
295+
self.assertEqual(dm.evaluation, expected)
296+
248297
@staticmethod
249298
def _high_risk(confidence):
250299
return {"verdict": "High Risk", "risk_level": "high", "confidence": confidence, "risk_factors": []}

0 commit comments

Comments
 (0)