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
11 changes: 11 additions & 0 deletions documentation/expansion/triage_query.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Triage Report Enrichment

Retrieve Triage overview reports by trusted report link or search existing analyses by file hash. api_url defaults to https://tria.ge/api; configure https://private.tria.ge/api for private accounts. Overview reports preserve verdicts, signatures, extracted configuration and available analysis metadata. This module performs lookups only. It does not submit samples or poll unfinished tasks.

Input: link, md5, sha1, sha256.

Configuration: api_key, api_url, max_results. Required: api_key.

max_results defaults to 25 and accepts 1–100. New attributes use `to_ids=false`; the source UUID, detection flag and metadata are preserved. Objects reference the source attribute. Requests verify TLS, reject redirects and use connection/read timeouts of 5/30 seconds. API errors do not echo credentials. No automatic submission retries or background polling occur.

Reference: https://tria.ge/docs/
228 changes: 228 additions & 0 deletions misp_modules/modules/expansion/triage_query.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
import ipaddress
import json
import re
import zipfile
from urllib.parse import urlsplit
from uuid import UUID
import requests
from pymisp import MISPEvent, MISPObject
from pymisp.exceptions import PyMISPError


class ModuleError(ValueError):
"""A diagnostic safe to show to the analyst."""


def _integer(config, key, default, maximum=100):
value = config.get(key)
if value is None or value == "":
return default
if isinstance(value, bool) or not str(value).isdigit() or (not 1 <= int(value) <= maximum):
raise ModuleError(f"{key} must be an integer between 1 and {maximum}.")
return int(value)


def _url(value):
if not isinstance(value, str) or any(ord(c) <= 32 or ord(c) == 127 for c in value):
raise ModuleError("Invalid HTTPS URL.")
parsed = urlsplit(value)
if parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password or parsed.fragment:
raise ModuleError("An HTTPS URL without credentials or fragment is required.")
parsed.port
return value.rstrip("/")


def _origin(config):
value = _url(config.get("api_url", ""))
if urlsplit(value).query:
raise ModuleError("api_url cannot contain a query string.")
return value


def _normalise(kind, value):
if not isinstance(value, str) or not value.strip() or any(ord(c) < 32 for c in value):
raise ModuleError("A nonempty string value is required.")
value = value.strip()
if kind in ("md5", "sha1", "sha256", "x509-fingerprint-sha256"):
length = {"md5": 32, "sha1": 40}.get(kind, 64)
if not re.fullmatch("[a-fA-F0-9]{%d}" % length, value):
raise ModuleError("Invalid hash.")
return value.lower()
if kind in ("ip-src", "ip-dst"):
return str(ipaddress.ip_address(value))
if kind in ("domain", "hostname"):
value = value.rstrip(".").encode("idna").decode("ascii").lower()
if len(value) > 253 or not all(
re.fullmatch("[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?", p) for p in value.split(".")
):
raise ModuleError("Invalid DNS name.")
if kind == "vulnerability":
value = value.upper()
if not re.fullmatch("CVE-[0-9]{4}-[0-9]{4,}", value):
raise ModuleError("A CVE identifier is required.")
return value


def _call(method, url, **kwargs):
try:
response = requests.request(method, url, timeout=(5, 30), verify=True, allow_redirects=False, **kwargs)
except requests.RequestException:
raise ModuleError(
"The service request failed or timed out; check the service before repeating a submission."
) from None
if response.status_code in (204, 404) and method == "GET":
return None
if response.status_code not in (200, 201, 202):
raise ModuleError(f"The service returned HTTP {response.status_code}.")
try:
return response.json()
except ValueError:
raise ModuleError("The service returned invalid JSON.") from None


def _object(event, attribute, name):
obj = MISPObject(name)
obj.add_reference(attribute["uuid"], "describes")
event.add_object(obj)
return obj


def _annotation(event, attribute, title, data):
obj = _object(event, attribute, "annotation")
obj.comment = title
obj.add_attribute("text", value=json.dumps(data, sort_keys=True), to_ids=False, disable_correlation=True)
return obj


def _pivot(event, attribute, kind, value):
value = _normalise(kind, value)
if kind == attribute["type"] and value == _normalise(attribute["type"], attribute["value"]):
return
obj = _object(
event, attribute, "file" if kind in ("md5", "sha1", "sha256") else ("url" if kind == "url" else "domain-ip")
)
relation = "ip" if kind in ("ip-src", "ip-dst") else kind
obj.add_attribute(relation, value=value, to_ids=False)


def _sandbox(event, attribute, payload, link=None):
obj = _object(event, attribute, "sandbox-report")
obj.add_attribute("sandbox-type", value="saas", to_ids=False)
obj.add_attribute("saas-sandbox", value="hatching-triage", to_ids=False)
obj.add_attribute("results", value=json.dumps(payload, sort_keys=True), to_ids=False, disable_correlation=True)
if link:
obj.add_attribute("permalink", value=link, to_ids=False)
return obj


def handler(q=False):
if q is False:
return False
try:
request = json.loads(q)
if not isinstance(request, dict) or not isinstance(request.get("attribute"), dict):
raise ModuleError("An attribute containing type, value and uuid is required.")
attribute = request["attribute"]
UUID(attribute["uuid"])
if attribute["type"] not in mispattributes["input"]:
raise ModuleError("Unsupported attribute type.")
value = _normalise(attribute["type"], attribute["value"])
config = request.get("config") or {}
if not isinstance(config, dict):
raise ModuleError("The config must be an object.")
for key in REQUIRED:
if not isinstance(config.get(key), str) or not config[key].strip():
raise ModuleError(f"Missing {key} configuration.")
limit = _integer(config, "max_results", 25)
event = MISPEvent()
event.add_attribute(**attribute)
_expand(event, attribute, value, config, limit)
result = json.loads(event.to_json())
return {"results": {key: result.get(key, []) for key in ("Attribute", "Object")}}
except ModuleError as exc:
return {"error": str(exc)}
except (
ValueError,
TypeError,
KeyError,
AttributeError,
IndexError,
OSError,
RuntimeError,
zipfile.BadZipFile,
PyMISPError,
):
return {"error": "Invalid request or service response."}


def introspection():
return mispattributes


def version():
return dict(moduleinfo, config=moduleconfig)


moduleinfo = {
"version": "1.0",
"author": "MISP Project",
"name": "Triage Report Enrichment",
"description": (
"Retrieve Triage overview reports by trusted report link or search existing analyses by file hash. api_url"
" defaults to https://tria.ge/api; configure https://private.tria.ge/api for private accounts. Overview reports"
" preserve verdicts, signatures, extracted configuration and available analysis metadata. This module performs"
" lookups only. It does not submit samples or poll unfinished tasks."
),
"module-type": ["expansion"],
"data-source": "external",
"logo": "",
"requirements": ["requests", "pymisp"],
"features": (
"Retrieve Triage overview reports by trusted report link or search existing analyses by file hash. api_url"
" defaults to https://tria.ge/api; configure https://private.tria.ge/api for private accounts. Overview reports"
" preserve verdicts, signatures, extracted configuration and available analysis metadata. This module performs"
" lookups only. It does not submit samples or poll unfinished tasks."
),
"references": ["https://tria.ge/docs/"],
"input": "link, md5, sha1, sha256",
"output": "MISP objects linked to the original attribute.",
}
mispattributes = {"input": ["link", "md5", "sha1", "sha256"], "format": "misp_standard"}
moduleconfig = ["api_key", "api_url", "max_results"]
REQUIRED = ["api_key"]


def _expand(event, attribute, value, config, limit):
base = _origin(config) if config.get("api_url") else "https://tria.ge/api"
headers = {"Authorization": "Bearer " + config["api_key"]}
web = base.removesuffix("/api")
if attribute["type"] == "link":
parsed = urlsplit(_url(value))
if parsed.netloc != urlsplit(web).netloc or not re.fullmatch("/[a-zA-Z0-9-]+/?", parsed.path) or parsed.query:
raise ModuleError("A report link on the configured Triage server is required.")
ids = [parsed.path.strip("/")]
else:
data = _call(
"GET",
base + "/v0/search",
headers=headers,
params={"query": attribute["type"] + ":" + value, "limit": limit},
)
if data is None:
return
ids = [row["id"] for row in data.get("data", [])[:limit]]
if data.get("next"):
_annotation(event, attribute, "Search limit", {"more_results_available": True, "max_results": limit})
for sample_id in ids:
if not re.fullmatch("[a-zA-Z0-9-]+", sample_id):
raise ModuleError("Triage returned an invalid sample ID.")
data = _call("GET", base + "/v1/samples/" + sample_id + "/overview.json", headers=headers)
if data is None:
continue
_sandbox(event, attribute, data, web + "/" + sample_id)
for target in data.get("targets", [])[:limit]:
for kind in ("md5", "sha1", "sha256"):
if target.get(kind):
_pivot(event, attribute, kind, target[kind])
for row in data.get("extracted", [])[:limit]:
_annotation(event, attribute, "Triage extracted configuration", row)
130 changes: 130 additions & 0 deletions tests/test_triage_query.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import json
from unittest.mock import Mock, patch

import pytest
import requests

from misp_modules.modules.expansion import triage_query as module

UUID = "32876e4a-916d-4935-9e51-7b69a76718f8"
SECRET = "test-service-secret"


def query(kind=None, value="example.com", config=None, **attribute_fields):
attribute = {
"type": kind or module.mispattributes["input"][0],
"value": value,
"uuid": UUID,
"to_ids": True,
"comment": "source context",
**attribute_fields,
}
return json.dumps(
{"attribute": attribute, "config": config if config is not None else {k: SECRET for k in module.REQUIRED}}
)


def reply(payload=None, status=200):
return Mock(status_code=status, headers={}, json=Mock(return_value=payload))


def assert_source(result):
assert "results" in result, result
source = next(a for a in result["results"]["Attribute"] if a["uuid"] == UUID)
assert source["to_ids"] is True
assert source["comment"] == "source context"
for obj in result["results"]["Object"]:
assert any(r["referenced_uuid"] == UUID for r in obj["ObjectReference"])
assert all(a["to_ids"] is False for a in obj["Attribute"])


def objects(result, name):
return [obj for obj in result["results"]["Object"] if obj["name"] == name]


def values(obj, relation):
return [a["value"] for a in obj["Attribute"] if a["object_relation"] == relation]


def test_discovery():
assert module.handler(False) is False
assert module.introspection()["format"] == "misp_standard"
assert module.version()["config"] == module.moduleconfig


@pytest.mark.parametrize("raw", ["{", "[]", "null", "{}", '{"attribute": []}'])
def test_invalid_envelope(raw):
with patch.object(module.requests, "request") as request:
assert "error" in module.handler(raw)
request.assert_not_called()


@pytest.mark.parametrize("status", [301, 401, 403, 429, 503])
def test_http_errors_are_sanitized(status):
with patch.object(module.requests, "request", return_value=reply({"error": SECRET}, status)):
with pytest.raises(module.ModuleError) as failure:
module._call("GET", "https://service.example/report")
assert str(status) in str(failure.value)
assert SECRET not in str(failure.value)


def test_timeout_is_bounded_and_sanitized():
with patch.object(module.requests, "request", side_effect=requests.Timeout(SECRET)) as request:
with pytest.raises(module.ModuleError) as failure:
module._call("POST", "https://service.example/submit")
assert SECRET not in str(failure.value)
assert request.call_count == 1
assert request.call_args.kwargs["verify"] is True
assert request.call_args.kwargs["allow_redirects"] is False
assert request.call_args.kwargs["timeout"] == (5, 30)


@pytest.mark.parametrize("raw", [0, -1, True, "bad", 101])
def test_invalid_result_limit(raw):
with pytest.raises(module.ModuleError):
module._integer({"max_results": raw}, "max_results", 25)


def test_hash_search_retrieves_overview():
responses = [
reply({"data": [{"id": "240101-abcd12"}]}),
reply({"analysis": {"score": 0}, "targets": [{"sha256": "b" * 64}], "extracted": []}),
]
with patch.object(module.requests, "request", side_effect=responses) as request:
result = module.handler(query(kind="sha256", value="a" * 64))
assert_source(result)
assert request.call_count == 2
assert request.call_args.args[1] == "https://tria.ge/api/v1/samples/240101-abcd12/overview.json"
assert objects(result, "sandbox-report")
assert objects(result, "file")


def test_foreign_link_never_receives_credentials():
with patch.object(module.requests, "request") as request:
assert "error" in module.handler(query(kind="link", value="https://other.example/240101-abcd12"))
request.assert_not_called()


def test_pivots_normalize_source_and_use_the_url_template():
from pymisp import MISPEvent

event = MISPEvent()
attribute = {"type": "sha256", "value": "A" * 64, "uuid": UUID}
module._pivot(event, attribute, "sha256", "a" * 64)
assert not event.objects
module._pivot(event, attribute, "url", "https://example.com/path")
assert event.objects[0].name == "url"
assert event.objects[0].attributes[0].object_relation == "url"


def test_sandbox_classification_matches_template_contract():
from pymisp import MISPEvent

event = MISPEvent()
attribute = {"uuid": UUID}
obj = module._sandbox(event, attribute, {"score": 0})
fields = {a.object_relation: a.value for a in obj.attributes}
assert fields["sandbox-type"] == "saas"
assert fields["saas-sandbox"] == "hatching-triage"
assert set(obj._definition.get("required", [])) <= set(fields)
assert set(obj._definition["requiredOneOf"]) & set(fields)