Skip to content
Open
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
22 changes: 22 additions & 0 deletions msticpy/context/tiproviders/scanmalware.py
Original file line number Diff line number Diff line change
Expand Up @@ -287,19 +287,41 @@ def _scan_of_the_observable(
query, so it is used rather than string-matching the URL. For a URL
observable the scan must additionally be of that exact URL, because a
verdict on `site.com/a` says nothing about `site.com/b`.

The list for a host also holds its subdomains, so the scan's own host
must be the observable's host too (see `_is_scan_of_host`).
"""
if not isinstance(rows, list):
return None
host: str = cls._host_of(observable)
for row in rows:
if not isinstance(row, dict) or row.get("status") != "completed":
continue
if _MATCH_SUBMITTED not in (row.get("matched_on") or []):
continue
if not cls._is_scan_of_host(row.get("url"), host):
continue
if ioc_type == "url" and not cls._same_url(row.get("url"), observable):
continue
return row
return None

@staticmethod
def _is_scan_of_host(scanned: Any, host: str) -> bool:
"""
Whether a scanned URL is on the host itself, or on its www name.

On a shared platform such as github.io, netlify.app or workers.dev a
subdomain is an unrelated tenant, so the newest scan listed under the
platform's own name is usually somebody's site, often a phishing page.
Its verdict says nothing about the platform.
"""
if not isinstance(scanned, str):
return False
wanted: str = host.lower().rstrip(".")
scanned_host: str = (urlparse(scanned).hostname or "").lower()
return bool(wanted) and scanned_host in (wanted, f"www.{wanted}")

@staticmethod
def _same_url(scanned: Any, observable: str) -> bool:
"""Compare two URLs, tolerating only a trailing slash."""
Expand Down
88 changes: 88 additions & 0 deletions tests/context/test_scanmalware.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,94 @@ def test_incomplete_scan_is_skipped():
check.equal([r for r in client.requests if r["url"].startswith(_RESULT_URL)], [])


def _scan_of(scan_id: str, url: str) -> dict[str, Any]:
"""Return a completed row whose scan was submitted for `url`."""
return {
"scan_id": scan_id,
"url": url,
"final_url": url,
"matched_on": ["url", "final_url"],
"status": "completed",
"title": "Some Site",
"completed_at": "2026-10-09T10:00:00",
}


@pytest.mark.parametrize("query_type", [None, "quick"])
def test_subdomain_tenant_does_not_lend_its_scan(query_type):
"""
On a shared platform a subdomain is somebody else's site.

The newest scan listed under netlify.app is a tenant's page, often a
phishing kit. Neither its verdict nor, on `-quick`, its scan details may be
reported for netlify.app.
"""
provider, client = _provider(
scans=[_scan_of("tenant-scan", "https://gilded-macaron-79e0e2.netlify.app/")],
result=_verdict("malicious"),
)
row = _lookup(provider, "netlify.app", "dns", query_type=query_type)
check.is_true(row["Result"])
check.equal(row["Severity"], ResultSeverity.information.name)
check.equal([r for r in client.requests if r["url"].startswith(_RESULT_URL)], [])
check.is_false("LastScanUrl" in row["Details"])


def test_platform_own_scan_behind_a_tenant_is_used():
"""The platform's own scan still counts when a tenant's is newer."""
provider, client = _provider(
scans=[
_scan_of("tenant-scan", "https://kit.netlify.app/"),
_scan_of("own-scan", "https://netlify.app/"),
],
result=_verdict("low"),
)
row = _lookup(provider, "netlify.app", "dns")
result_calls = [r for r in client.requests if r["url"].startswith(_RESULT_URL)]
check.equal(len(result_calls), 1)
check.is_true(result_calls[0]["url"].endswith("own-scan"))
check.equal(row["Details"]["LastScanUrl"], "https://netlify.app/")


def test_lookalike_host_does_not_lend_its_verdict():
"""A different name listed under the domain is not the domain."""
provider, client = _provider(
scans=[_scan_of("other-scan", "https://example.com.ru/")],
result=_verdict("malicious"),
)
row = _lookup(provider, "example.com", "dns")
check.equal(row["Severity"], ResultSeverity.information.name)
check.equal([r for r in client.requests if r["url"].startswith(_RESULT_URL)], [])


def test_deeper_subdomain_does_not_lend_its_verdict_to_a_hostname():
"""The rule holds for hostnames, not only for registrable domains."""
provider, client = _provider(
scans=[_scan_of("deeper-scan", "https://login.app.example.com/")],
result=_verdict("malicious"),
)
row = _lookup(provider, "app.example.com", "hostname")
check.equal(row["Severity"], ResultSeverity.information.name)
check.equal([r for r in client.requests if r["url"].startswith(_RESULT_URL)], [])


@pytest.mark.parametrize(
"scan_url",
["https://www.example.com/", "https://EXAMPLE.com/", "https://example.com:8443/"],
)
def test_www_name_case_and_port_still_count(scan_url):
"""Guard against over-tightening: these are all scans of example.com."""
provider, client = _provider(
scans=[_scan_of("own-scan", scan_url)],
result=_verdict("high"),
)
row = _lookup(provider, "example.com", "dns")
result_calls = [r for r in client.requests if r["url"].startswith(_RESULT_URL)]
check.equal(len(result_calls), 1)
check.equal(row["Details"]["LastScanUrl"], scan_url)
check.equal(row["Severity"], ResultSeverity.high.name)


# --- severity mapping -----------------------------------------------------


Expand Down
Loading