diff --git a/documentation/expansion/fortisandbox.md b/documentation/expansion/fortisandbox.md new file mode 100644 index 00000000..2b862a5b --- /dev/null +++ b/documentation/expansion/fortisandbox.md @@ -0,0 +1,13 @@ +# FortiSandbox Enrichment + +Authenticate to a FortiSandbox appliance and retrieve an existing rating by SHA-1 or SHA-256. Set api_url to the appliance HTTPS origin, without /jsonrpc. For supplied files, calculate SHA-256 and look up the rating first. submit_samples defaults to false; true permits submission when the rating is unknown. max_size_mb defaults to 25 (1–100). A submission receipt is returned without polling. Provider ratings remain in their original vocabulary. + +Input: sha1, sha256, attachment, malware-sample. + +Configuration: api_url, username, password, submit_samples, max_size_mb, max_results. Required: api_url, username, password. + +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://www.fortinet.com/products/sandbox/fortisandbox + +MD5-only lookup is not supported by the appliance single-file rating endpoint. diff --git a/misp_modules/modules/expansion/fortisandbox.py b/misp_modules/modules/expansion/fortisandbox.py new file mode 100644 index 00000000..f025a9fb --- /dev/null +++ b/misp_modules/modules/expansion/fortisandbox.py @@ -0,0 +1,280 @@ +import base64 +import hashlib +import io +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 _enabled(config, key, default=False): + value = config.get(key) + if value is None or value == "": + return default + if isinstance(value, bool): + return value + if str(value).lower() not in ("true", "false", "1", "0"): + raise ModuleError(f"{key} must be true or false.") + return str(value).lower() in ("true", "1") + + +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 _sandbox(event, attribute, payload, link=None): + obj = _object(event, attribute, "sandbox-report") + obj.add_attribute("sandbox-type", value="on-premise", to_ids=False) + obj.add_attribute("on-premise-sandbox", value="fortisandbox", 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 _binary(attribute, config): + limit = _integer(config, "max_size_mb", 25, 100) * 1024 * 1024 + encoded = attribute.get("data") + if not isinstance(encoded, str) or len(encoded) > 2 * limit: + raise ModuleError("Missing or oversized base64 sample data.") + content = base64.b64decode(encoded, validate=True) + filename = attribute["value"] + if attribute["type"] == "malware-sample": + filename, separator, digest = filename.rpartition("|") + if not separator or not re.fullmatch(r"[a-fA-F0-9]{32}", digest): + raise ModuleError("A malware-sample value must contain filename|md5.") + with zipfile.ZipFile(io.BytesIO(content)) as archive: + entries = [ + e for e in archive.infolist() if not e.is_dir() and e.filename != digest.lower() + ".filename.txt" + ] + if len(entries) != 1 or entries[0].file_size > limit: + raise ModuleError("A malware-sample archive must contain one payload within max_size_mb.") + content = archive.read(entries[0], pwd=b"infected") + if hashlib.md5(content).hexdigest() != digest.lower(): + raise ModuleError("The unpacked sample does not match the declared MD5.") + filename = filename.replace("\\", "/").rsplit("/", 1)[-1] + if not filename or not content or len(content) > limit: + raise ModuleError("The sample is empty or exceeds max_size_mb.") + return filename, content, hashlib.sha256(content).hexdigest() + + +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": "FortiSandbox Enrichment", + "description": ( + "Authenticate to a FortiSandbox appliance and retrieve an existing rating by SHA-1 or SHA-256. Set api_url" + " to the appliance HTTPS origin, without /jsonrpc. For supplied files, calculate SHA-256 and look up the rating" + " first. submit_samples defaults to false; true permits submission when the rating is unknown. max_size_mb" + " defaults to 25 (1–100). A submission receipt is returned without polling. Provider ratings remain in their" + " original vocabulary." + ), + "module-type": ["expansion"], + "data-source": "external", + "logo": "", + "requirements": ["requests", "pymisp"], + "features": ( + "Authenticate to a FortiSandbox appliance and retrieve an existing rating by SHA-1 or SHA-256. Set api_url" + " to the appliance HTTPS origin, without /jsonrpc. For supplied files, calculate SHA-256 and look up the rating" + " first. submit_samples defaults to false; true permits submission when the rating is unknown. max_size_mb" + " defaults to 25 (1–100). A submission receipt is returned without polling. Provider ratings remain in their" + " original vocabulary." + ), + "references": ["https://www.fortinet.com/products/sandbox/fortisandbox"], + "input": "sha1, sha256, attachment, malware-sample", + "output": "MISP objects linked to the original attribute.", +} +mispattributes = {"input": ["sha1", "sha256", "attachment", "malware-sample"], "format": "misp_standard"} +moduleconfig = ["api_url", "username", "password", "submit_samples", "max_size_mb", "max_results"] +REQUIRED = ["api_url", "username", "password"] + + +def _rpc(base, method, path, session=None, **params): + body = {"method": method, "params": [{"url": path, **params}], "id": 1, "ver": "2.1"} + if session: + body["session"] = session + data = _call("POST", base + "/jsonrpc", json=body) + if not isinstance(data, dict): + raise ModuleError("FortiSandbox returned invalid JSON-RPC data.") + result = data.get("result", {}) + if isinstance(result, list): + result = result[0] if result else {} + if not isinstance(result, dict) or result.get("status", {}).get("code", 0) != 0: + raise ModuleError("FortiSandbox rejected the JSON-RPC operation.") + return (data, result) + + +def _expand(event, attribute, value, config, limit): + base = _origin(config) + if urlsplit(base).path not in ("", "/"): + raise ModuleError("api_url must be a FortiSandbox HTTPS origin without a path.") + login, _ = _rpc(base, "exec", "/sys/login/user", data=[{"user": config["username"], "passwd": config["password"]}]) + session = login.get("session") + if not isinstance(session, str) or not session: + raise ModuleError("FortiSandbox authentication failed.") + try: + kind = attribute["type"] + sample = None + if kind in ("attachment", "malware-sample"): + sample = _binary(attribute, config) + kind, value = ("sha256", sample[2]) + _, report = _rpc(base, "get", "/scan/result/filerating", session, checksum=value, ctype=kind) + payload = report.get("data", report) + if isinstance(payload, list): + payload = payload[0] if payload else {} + rating = payload.get("rating") if isinstance(payload, dict) else None + ratings = rating if isinstance(rating, list) else [rating] + if any(r is not None and str(r).lower() not in ("unknown", "") for r in ratings): + _sandbox(event, attribute, payload) + elif sample and _enabled(config, "submit_samples"): + _, receipt = _rpc( + base, + "set", + "/alert/ondemand/submit-file", + session, + type="file", + filename=base64.b64encode(sample[0].encode("utf-8")).decode(), + file=base64.b64encode(sample[1]).decode(), + ) + _annotation(event, attribute, "FortiSandbox submission receipt", receipt) + else: + _annotation(event, attribute, "FortiSandbox lookup", {"hash": value, "rating": "unknown"}) + finally: + try: + _rpc(base, "exec", "/sys/logout", session) + except ModuleError: + pass diff --git a/tests/test_fortisandbox.py b/tests/test_fortisandbox.py new file mode 100644 index 00000000..1b96ccfe --- /dev/null +++ b/tests/test_fortisandbox.py @@ -0,0 +1,168 @@ +import json +from unittest.mock import Mock, patch + +import pytest +import requests + +from misp_modules.modules.expansion import fortisandbox 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_rating_query_uses_correct_checksum_type_and_logs_out(): + responses = [ + reply({"session": "session-id", "result": {"status": {"code": 0}}}), + reply({"result": {"status": {"code": 0}, "data": {"rating": "Clean"}}}), + reply({"result": {"status": {"code": 0}}}), + ] + config = {"api_url": "https://sandbox.example", "username": "analyst", "password": SECRET} + with patch.object(module.requests, "request", side_effect=responses) as request: + result = module.handler(query(kind="sha256", value="a" * 64, config=config)) + assert_source(result) + assert request.call_args_list[1].kwargs["json"]["params"][0]["ctype"] == "sha256" + assert request.call_args_list[-1].kwargs["json"]["params"][0]["url"] == "/sys/logout" + assert objects(result, "sandbox-report") + + +def test_failed_rating_still_closes_session_and_hides_password(): + responses = [ + reply({"session": "session-id"}), + reply({"result": {"status": {"code": -1, "message": SECRET}}}), + reply({"result": {}}), + ] + config = {"api_url": "https://sandbox.example", "username": "analyst", "password": SECRET} + with patch.object(module.requests, "request", side_effect=responses) as request: + result = module.handler(query(kind="sha1", value="a" * 40, config=config)) + assert "error" in result and SECRET not in str(result) + assert request.call_count == 3 + + +def test_current_sample_zip_metadata_digest_and_pipe_filename(): + import base64, hashlib, io, zipfile + + payload = b"actual sample" + digest = hashlib.md5(payload).hexdigest() + output = io.BytesIO() + with zipfile.ZipFile(output, "w") as archive: + archive.writestr(digest, payload) + archive.writestr(digest + ".filename.txt", "sample|name.exe") + attribute = { + "type": "malware-sample", + "value": "sample|name.exe|" + digest, + "data": base64.b64encode(output.getvalue()).decode(), + } + assert module._binary(attribute, {})[:2] == ("sample|name.exe", payload) + attribute["value"] = "sample|name.exe|" + "0" * 32 + with pytest.raises(module.ModuleError): + module._binary(attribute, {}) + assert ( + module._binary({"type": "attachment", "value": "sample|name.exe", "data": "c2FtcGxl"}, {})[0] + == "sample|name.exe" + ) + + +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"] == "on-premise" + assert fields["on-premise-sandbox"] == "fortisandbox" + assert set(obj._definition.get("required", [])) <= set(fields) + assert set(obj._definition["requiredOneOf"]) & set(fields) + + +def test_unknown_rating_array_submits_encoded_filename(): + import base64 + + replies = [ + reply({"session": "session-id"}), + reply({"result": {"data": {"rating": ["Unknown"]}}}), + reply({"result": {"data": {"sid": "1"}}}), + reply({"result": {}}), + ] + config = {"api_url": "https://sandbox.example", "username": "analyst", "password": SECRET, "submit_samples": True} + with patch.object(module.requests, "request", side_effect=replies) as request: + result = module.handler(query(kind="attachment", value="sample.bin", data="c2FtcGxl", config=config)) + assert_source(result) + params = request.call_args_list[2].kwargs["json"]["params"][0] + assert params["filename"] == base64.b64encode(b"sample.bin").decode() + assert not objects(result, "sandbox-report")