From 2c582f74d8160523e7cbc2c361f23301d616f4a2 Mon Sep 17 00:00:00 2001 From: Tolga SEZER Date: Tue, 8 Sep 2026 17:31:46 +0000 Subject: [PATCH 1/2] Add Extuno expansion module Looks up the reputation of a browser extension, an IDE extension or a package from its store id or its listing URL, and reports whether it appears in a known-malicious catalog and whether Extuno's own static and sandbox analysis reached a verdict on it. Browser and IDE extensions are the reason the module exists. OSV and the other public advisory databases cover package registries, so an npm or PyPI name found in a build log can already be checked. An extension id found on a workstation cannot: no public advisory database covers the Chrome Web Store, addons.mozilla, the VS Code Marketplace, JetBrains or Eclipse. This closes that gap for the 140 existing expansion modules, none of which answer for an extension today. An analyst can paste the store listing URL, or use a text attribute holding store:id. Both resolve to the same lookup. A Chrome and an Edge extension id share one 32 character format, so a bare id that misses on Chrome is also tried against Edge rather than being reported as unknown. The lookup is read-only and never submits the artifact for analysis; only the identifier being looked up leaves the MISP instance. An API key is required and is checked before any request is made. --- documentation/logos/extuno.png | Bin 0 -> 16154 bytes misp_modules/modules/expansion/extuno.py | 283 +++++++++++++++++++++++ tests/test_extuno.py | 139 +++++++++++ 3 files changed, 422 insertions(+) create mode 100644 documentation/logos/extuno.png create mode 100644 misp_modules/modules/expansion/extuno.py create mode 100644 tests/test_extuno.py diff --git a/documentation/logos/extuno.png b/documentation/logos/extuno.png new file mode 100644 index 0000000000000000000000000000000000000000..54156f6bf3a29f702041bda9d2f22933d358f376 GIT binary patch literal 16154 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/, and older listings omit the slug. + detail = after("detail", 2) or after("detail", 1) + return ("chrome", detail) if detail else None + if host.endswith("microsoftedge.microsoft.com"): + detail = after("detail", 2) or after("detail", 1) + return ("edge", detail) if detail else None + if host.endswith("addons.mozilla.org"): + slug = after("addon") + return ("firefox", slug) if slug else None + if host.endswith("marketplace.visualstudio.com"): + # itemName=. is the only stable identifier here. + for pair in (parsed.query or "").split("&"): + if pair.startswith("itemName="): + return ("vscode", pair[len("itemName="):]) + return None + if host.endswith("open-vsx.org"): + publisher, name = after("extension", 1), after("extension", 2) + return ("openvsx", f"{publisher}.{name}") if publisher and name else None + if host.endswith("plugins.jetbrains.com"): + # /plugin/- + plugin = after("plugin") + return ("jetbrains", plugin.split("-", 1)[0]) if plugin else None + if host.endswith("marketplace.eclipse.org"): + slug = after("content") + return ("eclipse", slug) if slug else None + if host.endswith("npmjs.com"): + name = after("package") + if name and name.startswith("@") and len(parts) > parts.index("package") + 2: + name = f"{name}/{parts[parts.index('package') + 2]}" + return ("npm", name) if name else None + if host.endswith("pypi.org"): + name = after("project") + return ("pypi", name) if name else None + if host.endswith("packagist.org"): + vendor, package = after("packages", 1), after("packages", 2) + return ("composer", f"{vendor}/{package}") if vendor and package else None + if host.endswith("wordpress.org"): + slug = after("plugins") + return ("wordpress", slug) if slug else None + return None + + +def _resolve(attribute): + """Work out which store and identifier the attribute names.""" + value = str(attribute.get("value", "")).strip() + if not value: + raise UnknownArtifact("the attribute value is empty") + + if value.lower().startswith(("http://", "https://")): + resolved = _from_url(value) + if resolved: + return resolved + raise UnknownArtifact(f"{value} is not a store listing URL Extuno recognises") + + # An explicit store prefix is unambiguous, so it wins over any shape heuristic. + if ":" in value: + store, _, identifier = value.partition(":") + store = store.strip().lower() + if store in STORES and identifier.strip(): + return store, identifier.strip() + + bare = value.lower() + if len(bare) == _WEBSTORE_ID_LENGTH and set(bare) <= _WEBSTORE_ID_ALPHABET: + # Chrome and Edge share this id format, so both are asked and the first hit answers. + return "chrome", bare + + raise UnknownArtifact( + "prefix the value with its store (e.g. chrome:cjpalhdlnbpafiamejdnhcphjbkeiagm) " + "or use the store listing URL" + ) + + +def _lookup(api_url, api_key, store, identifier): + try: + response = requests.get( + f"{api_url}/v1/lookup", + params={"store": store, "id": identifier}, + headers={"X-Api-Key": api_key, "User-Agent": USER_AGENT, "Accept": "application/json"}, + timeout=TIMEOUT, + ) + if response.status_code in (401, 403): + return {"__error__": "Extuno rejected the API key."} + if response.status_code == 429: + return {"__error__": "Extuno rate limit reached; try again shortly."} + response.raise_for_status() + return response.json() + except (requests.exceptions.RequestException, ValueError): + return None + + +class ExtunoParser: + def __init__(self, api_url, api_key): + self.api_url = api_url + self.api_key = api_key + self.misp_event = MISPEvent() + self.found = False + + def _add(self, **kwargs): + self.misp_event.add_attribute(**kwargs) + self.found = True + + def parse(self, store, identifier): + result = _lookup(self.api_url, self.api_key, store, identifier) + if result and result.get("__error__"): + return result["__error__"] + # A Chrome id and an Edge id are the same 32 characters, so a miss on one is not an answer. + if store == "chrome" and result is not None and result.get("verdict") == "unknown": + edge = _lookup(self.api_url, self.api_key, "edge", identifier) + if edge and edge.get("verdict") != "unknown": + store, result = "edge", edge + if result is None: + return "Extuno could not be reached." + + verdict = result.get("verdict") or "unknown" + catalog = result.get("catalog") or {} + scan = result.get("scan") or {} + + if result.get("known_malicious"): + threat = catalog.get("threat_type") or "malicious" + summary = f"Extuno: {store}:{identifier} is listed as malicious ({threat})" + if catalog.get("still_active") is False: + summary += ", removed from the store" + self._add(type="text", value=summary, comment="Extuno: known-malicious catalog", + disable_correlation=True) + if catalog.get("reason"): + self._add(type="text", value=f"Extuno: {catalog['reason']}", + comment="Extuno: catalog detail", disable_correlation=True) + if catalog.get("source_url"): + self._add(type="link", value=catalog["source_url"], + comment="Extuno: advisory this listing came from", disable_correlation=True) + elif verdict != "unknown": + summary = f"Extuno analysed {store}:{identifier} and reached the verdict {verdict}" + if result.get("risk_score") is not None: + summary += f" (risk {result['risk_score']}/100)" + self._add(type="text", value=summary, comment="Extuno: analysis verdict", + disable_correlation=True) + else: + # Reported rather than returned as an error: "we looked and it is not on record" is a + # useful answer, and is not the same as the lookup having failed. + self._add( + type="text", + value=f"Extuno has no record of {store}:{identifier}: not in the malicious catalog " + "and not analysed.", + comment="Extuno: no record", disable_correlation=True) + return None + + for title in [f.get("title") for f in (scan.get("top_findings") or []) if f.get("title")][:5]: + self._add(type="text", value=f"Extuno finding: {title}", + comment="Extuno: evidence from analysis", disable_correlation=True) + return None + + def get_results(self): + if not self.found: + return {"error": "No Extuno results for this attribute."} + event = json.loads(self.misp_event.to_json()) + results = {key: event[key] for key in ("Attribute", "Object") if event.get(key)} + if not results: + return {"error": "No Extuno results for this attribute."} + return {"results": results} + + +def handler(q=False): + if q is False: + return False + request = json.loads(q) + + if not request.get("attribute") or not check_input_attribute(request["attribute"]): + return {"error": f"{standard_error_message}, which should contain at least a type, a value and an UUID."} + + attribute = request["attribute"] + if attribute.get("type") not in mispattributes["input"]: + return {"error": "Unsupported attribute type."} + + config = request.get("config") or {} + api_key = str(config.get("api_key") or "").strip() + if not api_key: + return {"error": "An Extuno API key is required; set api_key in the module configuration."} + api_url = str(config.get("api_url") or DEFAULT_API_URL).rstrip("/") + + try: + store, identifier = _resolve(attribute) + except UnknownArtifact as error: + return {"error": f"Extuno cannot look this up: {error}"} + + parser = ExtunoParser(api_url, api_key) + failure = parser.parse(store, quote(identifier, safe="@/.-_")) + if failure: + return {"error": failure} + return parser.get_results() + + +def introspection(): + return mispattributes + + +def version(): + moduleinfo["config"] = moduleconfig + return moduleinfo diff --git a/tests/test_extuno.py b/tests/test_extuno.py new file mode 100644 index 00000000..93f3e78c --- /dev/null +++ b/tests/test_extuno.py @@ -0,0 +1,139 @@ +import json +from unittest.mock import MagicMock, patch + +from misp_modules.modules.expansion import extuno + +UUID = "5b582d80-7a7e-4b6a-9f22-77656e72bb3b" + +MALICIOUS = { + "store": "chrome", + "ext_id": "bidgllfieacmghieipmhgabodmljimfh", + "verdict": "malicious", + "known_malicious": True, + "catalog": { + "threat_type": "Bundling Unwanted Software", + "reason": "Listed by a public advisory.", + "source_url": "https://example.com/report/", + "still_active": False, + }, + "scan": None, +} +UNKNOWN = {"store": "chrome", "verdict": "unknown", "known_malicious": False, "catalog": None, "scan": None} +ANALYSED = { + "store": "chrome", + "verdict": "review", + "known_malicious": False, + "risk_score": 72, + "catalog": None, + "scan": {"top_findings": [{"title": "Cookie access combined with broad host reach"}]}, +} + + +class MockResponse: + def __init__(self, payload, status_code=200): + self.payload = payload + self.status_code = status_code + + def json(self): + return self.payload + + def raise_for_status(self): + if self.status_code >= 400: + raise extuno.requests.exceptions.HTTPError(response=self) + + +def _query(value, type_="text", config=None): + attribute = {"type": type_, "value": value, "uuid": UUID} + config = {"api_key": "extk_test"} if config is None else config + return json.dumps({"module": "extuno", "attribute": attribute, "config": config}) + + +def _answer(*payloads): + """Return a requests.get replacement serving the given payloads in order.""" + queue = list(payloads) + + def _get(url, params=None, headers=None, timeout=None): + return MockResponse(queue.pop(0) if queue else UNKNOWN) + + return _get + + +def test_a_catalog_listing_reports_the_threat_and_the_advisory_it_came_from(): + with patch.object(extuno.requests, "get", _answer(MALICIOUS)): + results = extuno.handler(_query("chrome:bidgllfieacmghieipmhgabodmljimfh")) + + values = [a["value"] for a in results["results"]["Attribute"]] + assert any("listed as malicious" in v and "Bundling Unwanted Software" in v for v in values) + assert "https://example.com/report/" in values + # An extension pulled from the store is no longer installable from it, which changes what an + # analyst does next, so it is stated rather than left out. + assert any("removed from the store" in v for v in values) + + +def test_an_analysis_verdict_carries_its_evidence(): + with patch.object(extuno.requests, "get", _answer(ANALYSED)): + results = extuno.handler(_query("chrome:kbnfbcpkiaganjpcanopcgeoehkleeck")) + + values = [a["value"] for a in results["results"]["Attribute"]] + assert any("verdict review" in v and "72/100" in v for v in values) + assert any("Cookie access combined with broad host reach" in v for v in values) + + +def test_a_bare_webstore_id_is_also_looked_up_against_edge(): + """Chrome and Edge extension ids share one format, so a miss on Chrome is not an answer.""" + edge_hit = dict(MALICIOUS, store="edge") + with patch.object(extuno.requests, "get", _answer(UNKNOWN, edge_hit)): + results = extuno.handler(_query("bidgllfieacmghieipmhgabodmljimfh")) + + assert any("listed as malicious" in a["value"] for a in results["results"]["Attribute"]) + + +def test_no_record_is_reported_as_a_result_rather_than_an_error(): + """"We looked and it is not on record" is an answer; an error would read as "we did not look".""" + with patch.object(extuno.requests, "get", _answer(UNKNOWN)): + results = extuno.handler(_query("chrome:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")) + + assert "error" not in results + assert any("no record" in a["value"] for a in results["results"]["Attribute"]) + + +def test_a_value_that_cannot_be_resolved_is_never_reported_as_clean(): + """The dangerous failure for a reputation module: a parse failure read as a clean verdict.""" + results = extuno.handler(_query("some free text")) + assert "error" in results + assert "results" not in results + + +def test_a_missing_api_key_is_reported_before_any_request_is_made(): + """No key means no lookup: the module must not query on the caller's behalf without one.""" + request = MagicMock(return_value=MockResponse(MALICIOUS)) + with patch.object(extuno.requests, "get", request): + results = extuno.handler(_query("chrome:x", config={})) + assert results["error"].startswith("An Extuno API key is required") + request.assert_not_called() + + +def test_a_rejected_key_is_distinguished_from_an_absent_result(): + with patch.object(extuno.requests, "get", lambda *a, **k: MockResponse({}, 401)): + results = extuno.handler(_query("chrome:bidgllfieacmghieipmhgabodmljimfh")) + assert results["error"] == "Extuno rejected the API key." + + +def test_store_listing_urls_resolve_to_the_right_store_and_identifier(): + cases = { + "https://chromewebstore.google.com/detail/ublock-origin/cjpalhdlnbpafiamejdnhcphjbkeiagm": ( + "chrome", "cjpalhdlnbpafiamejdnhcphjbkeiagm"), + "https://addons.mozilla.org/en-US/firefox/addon/ublock-origin/": ("firefox", "ublock-origin"), + "https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode": ( + "vscode", "esbenp.prettier-vscode"), + "https://open-vsx.org/extension/vscodevim/vim": ("openvsx", "vscodevim.vim"), + "https://plugins.jetbrains.com/plugin/7495-intellijruby": ("jetbrains", "7495"), + "https://marketplace.eclipse.org/content/checkstyle-plug": ("eclipse", "checkstyle-plug"), + "https://www.npmjs.com/package/@types/node": ("npm", "@types/node"), + "https://pypi.org/project/requests/": ("pypi", "requests"), + "https://packagist.org/packages/monolog/monolog": ("composer", "monolog/monolog"), + "https://wordpress.org/plugins/classic-editor/": ("wordpress", "classic-editor"), + } + for url, expected in cases.items(): + assert extuno._from_url(url) == expected, url + assert extuno._from_url("https://example.com/whatever") is None From 4d764da3a38c5a5d87cc3d1eb579dae52167f50d Mon Sep 17 00:00:00 2001 From: Tolga SEZER Date: Tue, 8 Sep 2026 17:59:57 +0000 Subject: [PATCH 2/2] Fix six defects found auditing the module before merge Every one of these produced a positive-looking answer for a question the module had not actually answered, which for a reputation module is the failure that matters most. An auth failure or a rate limit on the Edge fallback was accepted as the answer. The first lookup checked for the error marker, the second did not, so a rejected key or a 429 became "not in the malicious catalog and not analysed". The fallback doubles the request rate for every bare id, so a rate limit there is the expected case rather than a rare one. Both lookups now go through one path that propagates the failure. Host matching used str.endswith(), so evil-pypi.org matched pypi.org and a lookalike delivery URL was enriched with the legitimate package's verdict. A typosquat URL is exactly the indicator a MISP event holds. The boundary is now a dot, and the genuine hosts and their subdomains still resolve. The identifier was percent-encoded here and again by requests, so a Maven coordinate reached the API as org.apache.commons%3Acommons-lang3, an artifact that cannot exist, and the guaranteed miss was reported as "no record". The same applied to Discord ids, which are URLs, and to the unicode slugs AMO publishes. A listing URL truncated at the slug fell back to the slug as the identifier, so a URL naming a real extension answered "no record". The extension id format was already defined in the module but never applied; it is now, along with the numeric id a JetBrains URL carries and the package an npm scope must be followed by. A 200 response of the wrong shape escaped as an AttributeError rather than the error dict the module contract expects. api_url is operator configurable and a proxy can rewrite a body, so the response and its nested fields are now checked before use. The Edge fallback tested the raw verdict field while the parser normalised it, so a response with no verdict, or a null one, skipped the fallback and reported a known-malicious Edge extension as unseen. Six regression tests, one per defect. Each fails against the previous revision. --- misp_modules/modules/expansion/extuno.py | 127 ++++++++++++++++------- tests/test_extuno.py | 98 +++++++++++++++++ 2 files changed, 188 insertions(+), 37 deletions(-) diff --git a/misp_modules/modules/expansion/extuno.py b/misp_modules/modules/expansion/extuno.py index fb09ff4e..8d7819dc 100644 --- a/misp_modules/modules/expansion/extuno.py +++ b/misp_modules/modules/expansion/extuno.py @@ -1,5 +1,5 @@ import json -from urllib.parse import quote, urlparse +from urllib.parse import urlparse import requests from pymisp import MISPEvent @@ -61,6 +61,24 @@ _WEBSTORE_ID_ALPHABET = set("abcdefghijklmnop") +def _host_is(host, domain): + """Whether the host is that domain or a subdomain of it. + + str.endswith() is the wrong test: "evil-pypi.org".endswith("pypi.org") is true, which would let a + lookalike delivery URL inherit the verdict of the legitimate package it is imitating. That URL is + exactly the kind of indicator a MISP event holds, so the boundary has to be a dot. + """ + return host == domain or host.endswith("." + domain) + + +def _webstore_id(value): + """A Chrome or Edge extension id, or None. The format is exactly 32 characters from a-p.""" + candidate = (value or "").strip().lower() + if len(candidate) == _WEBSTORE_ID_LENGTH and set(candidate) <= _WEBSTORE_ID_ALPHABET: + return candidate + return None + + class UnknownArtifact(Exception): """The attribute does not name an artifact this module can look up. @@ -82,44 +100,53 @@ def after(marker, offset=1): return parts[index] return None + def webstore(store): + # .../detail//, and older listings omit the slug. Taking whichever segment is + # present would turn a URL truncated at the slug into a lookup for the slug, so the id is + # validated rather than assumed. + return next(((store, found) for found in + (_webstore_id(after("detail", 2)), _webstore_id(after("detail", 1))) if found), + None) + if host in ("chromewebstore.google.com", "chrome.google.com"): - # .../detail//, and older listings omit the slug. - detail = after("detail", 2) or after("detail", 1) - return ("chrome", detail) if detail else None - if host.endswith("microsoftedge.microsoft.com"): - detail = after("detail", 2) or after("detail", 1) - return ("edge", detail) if detail else None - if host.endswith("addons.mozilla.org"): + return webstore("chrome") + if _host_is(host, "microsoftedge.microsoft.com"): + return webstore("edge") + if _host_is(host, "addons.mozilla.org"): slug = after("addon") return ("firefox", slug) if slug else None - if host.endswith("marketplace.visualstudio.com"): + if _host_is(host, "marketplace.visualstudio.com"): # itemName=. is the only stable identifier here. for pair in (parsed.query or "").split("&"): if pair.startswith("itemName="): - return ("vscode", pair[len("itemName="):]) + item = pair[len("itemName="):] + return ("vscode", item) if item else None return None - if host.endswith("open-vsx.org"): + if _host_is(host, "open-vsx.org"): publisher, name = after("extension", 1), after("extension", 2) return ("openvsx", f"{publisher}.{name}") if publisher and name else None - if host.endswith("plugins.jetbrains.com"): - # /plugin/- - plugin = after("plugin") - return ("jetbrains", plugin.split("-", 1)[0]) if plugin else None - if host.endswith("marketplace.eclipse.org"): + if _host_is(host, "plugins.jetbrains.com"): + # /plugin/-. A URL carrying only the slug names a plugin this module + # cannot address, so it is rejected rather than looked up under the slug. + plugin = (after("plugin") or "").split("-", 1)[0] + return ("jetbrains", plugin) if plugin.isdigit() else None + if _host_is(host, "marketplace.eclipse.org"): slug = after("content") return ("eclipse", slug) if slug else None - if host.endswith("npmjs.com"): + if _host_is(host, "npmjs.com"): name = after("package") - if name and name.startswith("@") and len(parts) > parts.index("package") + 2: - name = f"{name}/{parts[parts.index('package') + 2]}" + if name and name.startswith("@"): + # A scope on its own is not a package. + scoped = after("package", 2) + return ("npm", f"{name}/{scoped}") if scoped else None return ("npm", name) if name else None - if host.endswith("pypi.org"): + if _host_is(host, "pypi.org"): name = after("project") return ("pypi", name) if name else None - if host.endswith("packagist.org"): + if _host_is(host, "packagist.org"): vendor, package = after("packages", 1), after("packages", 2) return ("composer", f"{vendor}/{package}") if vendor and package else None - if host.endswith("wordpress.org"): + if _host_is(host, "wordpress.org"): slug = after("plugins") return ("wordpress", slug) if slug else None return None @@ -144,8 +171,8 @@ def _resolve(attribute): if store in STORES and identifier.strip(): return store, identifier.strip() - bare = value.lower() - if len(bare) == _WEBSTORE_ID_LENGTH and set(bare) <= _WEBSTORE_ID_ALPHABET: + bare = _webstore_id(value) + if bare: # Chrome and Edge share this id format, so both are asked and the first hit answers. return "chrome", bare @@ -168,7 +195,10 @@ def _lookup(api_url, api_key, store, identifier): if response.status_code == 429: return {"__error__": "Extuno rate limit reached; try again shortly."} response.raise_for_status() - return response.json() + body = response.json() + # A 200 carrying a JSON array or a bare string is not an answer from this API. Returning it + # would put a stack trace where the module contract expects an error dict. + return body if isinstance(body, dict) else None except (requests.exceptions.RequestException, ValueError): return None @@ -184,21 +214,38 @@ def _add(self, **kwargs): self.misp_event.add_attribute(**kwargs) self.found = True - def parse(self, store, identifier): + def _query(self, store, identifier): + """One lookup. Returns (payload, failure); exactly one of them is set.""" result = _lookup(self.api_url, self.api_key, store, identifier) - if result and result.get("__error__"): - return result["__error__"] + if result is None: + return None, "Extuno could not be reached." + if result.get("__error__"): + return None, result["__error__"] + return result, None + + def parse(self, store, identifier): + result, failure = self._query(store, identifier) + if failure: + return failure + # A Chrome id and an Edge id are the same 32 characters, so a miss on one is not an answer. - if store == "chrome" and result is not None and result.get("verdict") == "unknown": - edge = _lookup(self.api_url, self.api_key, "edge", identifier) - if edge and edge.get("verdict") != "unknown": + # The verdict is normalised the same way here as it is below: a response with no verdict, or + # a null one, is a miss and must reach the fallback like an explicit "unknown" does. + if store == "chrome" and (result.get("verdict") or "unknown") == "unknown": + edge, edge_failure = self._query("edge", identifier) + # A rejected key or a rate limit on the second call is a failed lookup, not a clean + # answer. Reporting "not in the malicious catalog" for a question that was never + # answered is the one outcome a reputation module must never produce. + if edge_failure: + return edge_failure + if (edge.get("verdict") or "unknown") != "unknown": store, result = "edge", edge - if result is None: - return "Extuno could not be reached." verdict = result.get("verdict") or "unknown" - catalog = result.get("catalog") or {} - scan = result.get("scan") or {} + # A field of the wrong type is treated as absent rather than trusted: an operator can point + # api_url at another deployment, and a proxy can rewrite a body. + catalog = result.get("catalog") if isinstance(result.get("catalog"), dict) else {} + scan = result.get("scan") if isinstance(result.get("scan"), dict) else {} if result.get("known_malicious"): threat = catalog.get("threat_type") or "malicious" @@ -229,7 +276,10 @@ def parse(self, store, identifier): comment="Extuno: no record", disable_correlation=True) return None - for title in [f.get("title") for f in (scan.get("top_findings") or []) if f.get("title")][:5]: + findings = scan.get("top_findings") + findings = findings if isinstance(findings, list) else [] + for title in [f["title"] for f in findings + if isinstance(f, dict) and f.get("title")][:5]: self._add(type="text", value=f"Extuno finding: {title}", comment="Extuno: evidence from analysis", disable_correlation=True) return None @@ -268,7 +318,10 @@ def handler(q=False): return {"error": f"Extuno cannot look this up: {error}"} parser = ExtunoParser(api_url, api_key) - failure = parser.parse(store, quote(identifier, safe="@/.-_")) + # The identifier is passed raw: requests encodes it once as a query parameter. Encoding it here + # as well would put a literal "%3A" on the wire for a Maven coordinate and look up an artifact + # that cannot exist. + failure = parser.parse(store, identifier) if failure: return {"error": failure} return parser.get_results() diff --git a/tests/test_extuno.py b/tests/test_extuno.py index 93f3e78c..c10a97e2 100644 --- a/tests/test_extuno.py +++ b/tests/test_extuno.py @@ -137,3 +137,101 @@ def test_store_listing_urls_resolve_to_the_right_store_and_identifier(): for url, expected in cases.items(): assert extuno._from_url(url) == expected, url assert extuno._from_url("https://example.com/whatever") is None + + +def test_a_failure_on_the_edge_fallback_is_not_reported_as_a_clean_answer(): + """The second lookup must propagate its failure like the first one does. + + A rejected key or a rate limit on the fallback used to be accepted as the answer, which turned + a question that was never answered into "not in the malicious catalog". The fallback doubles the + request rate for every bare id, so a rate limit there is the expected case, not the rare one. + """ + calls = {"n": 0} + + def _get(url, params=None, headers=None, timeout=None): + calls["n"] += 1 + return MockResponse(UNKNOWN) if calls["n"] == 1 else MockResponse({}, 401) + + with patch.object(extuno.requests, "get", _get): + results = extuno.handler(_query("bidgllfieacmghieipmhgabodmljimfh")) + + assert results["error"] == "Extuno rejected the API key." + assert "results" not in results + + +def test_a_lookalike_host_does_not_inherit_the_real_package_verdict(): + """A typosquat delivery URL is exactly the indicator a MISP event holds. + + Suffix matching accepted evil-pypi.org as pypi.org, so the lookalike was enriched with the + legitimate package's verdict. + """ + for url in ("https://evil-pypi.org/project/requests/", + "https://notnpmjs.com/package/left-pad", + "https://myaddons.mozilla.org/en-US/firefox/addon/ublock-origin/", + "https://fakepackagist.org/packages/monolog/monolog"): + assert extuno._from_url(url) is None, url + + # The genuine hosts, and subdomains of them, still resolve. + assert extuno._from_url("https://pypi.org/project/requests/") == ("pypi", "requests") + assert extuno._from_url("https://www.npmjs.com/package/left-pad") == ("npm", "left-pad") + + +def test_the_identifier_reaches_the_api_encoded_exactly_once(): + """requests encodes a query parameter; encoding it here as well queried a different artifact. + + A Maven coordinate is `group:artifact`, so the colon is intrinsic and the double encoding put a + literal %3A on the wire, guaranteeing a miss reported as "no record". + """ + seen = {} + + def _get(url, params=None, headers=None, timeout=None): + seen.update(params or {}) + return MockResponse(UNKNOWN) + + with patch.object(extuno.requests, "get", _get): + extuno.handler(_query("maven:org.apache.commons:commons-lang3")) + + assert seen["id"] == "org.apache.commons:commons-lang3" + + +def test_a_listing_url_without_an_identifier_is_rejected_rather_than_guessed(): + """Taking whichever path segment is present turned a truncated URL into a lookup for the slug, + which then answered "no record" for an extension that exists.""" + assert extuno._from_url("https://chromewebstore.google.com/detail/ublock-origin") is None + assert extuno._from_url("https://plugins.jetbrains.com/plugin/intellijruby") is None + assert extuno._from_url("https://www.npmjs.com/package/@types") is None + + assert extuno._from_url( + "https://chromewebstore.google.com/detail/ublock-origin/cjpalhdlnbpafiamejdnhcphjbkeiagm" + ) == ("chrome", "cjpalhdlnbpafiamejdnhcphjbkeiagm") + assert extuno._from_url("https://plugins.jetbrains.com/plugin/7495-intellijruby") == ( + "jetbrains", "7495") + + +def test_a_response_of_the_wrong_shape_returns_an_error_rather_than_raising(): + """api_url is operator configurable and a proxy can rewrite a body, so a 200 carrying something + other than the expected object must not escape as a stack trace.""" + for payload in ([], "text", 7, {"verdict": "review", "scan": "not a dict"}, + {"verdict": "review", "scan": {"top_findings": ["not a dict"]}}, + {"verdict": "malicious", "known_malicious": True, "catalog": []}): + with patch.object(extuno.requests, "get", _answer(payload)): + results = extuno.handler(_query("chrome:bidgllfieacmghieipmhgabodmljimfh")) + assert isinstance(results, dict) + assert "error" in results or "results" in results + + +def test_a_response_with_no_verdict_still_reaches_the_edge_fallback(): + """The fallback tested the raw field while the parser normalised it, so a missing or null + verdict skipped the fallback and a known-malicious Edge extension was reported as no record.""" + for first in ({"known_malicious": False}, {"verdict": None}): + stores = [] + + def _get(url, params=None, headers=None, timeout=None): + stores.append((params or {}).get("store")) + return MockResponse(first if len(stores) == 1 else dict(MALICIOUS, store="edge")) + + with patch.object(extuno.requests, "get", _get): + results = extuno.handler(_query("bidgllfieacmghieipmhgabodmljimfh")) + + assert stores == ["chrome", "edge"] + assert any("listed as malicious" in a["value"] for a in results["results"]["Attribute"])