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
18 changes: 18 additions & 0 deletions conf/default/processing.conf.default
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,24 @@ definitions = data/trid/triddefs.trd
enabled = no
binary = /usr/bin/diec

[magika]
# Google Magika - deep-learning content type identification.
# https://github.com/google/magika
# Install: poetry run pip install -U magika
# Main benfit is classification of text file types i.e. PowerShell, ini files etc.
enabled = no
# Optional path to a custom/pinned model directory. Empty = use the model
# shipped with the installed magika package.
model_dir =
# high_confidence (default, most conservative) | medium_confidence | best_guess
prediction_mode = high_confidence
# Display-only: results scoring below this are still recorded and shown, just
# flagged low_confidence so a weak prediction isn't read as a confident one.
min_score = 0.5
# Skip files larger than this (MB). 0 = no limit. Magika only reads the head,
# middle and tail of a file, so this is cheap to raise.
max_file_size = 100

[virustotal]
enabled = yes
on_demand = no
Expand Down
10 changes: 10 additions & 0 deletions lib/cuckoo/common/integrations/file_extra_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@
if integration_conf.floss.enabled and not integration_conf.floss.on_demand:
from lib.cuckoo.common.integrations.floss import HAVE_FLOSS, Floss

from lib.cuckoo.common.integrations.magika import magika_info

log = logging.getLogger(__name__)

logging.getLogger("Kixtart-Detokenizer").setLevel(logging.CRITICAL)
Expand Down Expand Up @@ -248,6 +250,14 @@ def static_file_info(
if processing_conf.die.enabled and "die" not in data_dictionary:
data_dictionary["die"] = detect_it_easy_info(file_path)

# Below the libmagic "type" already present in data_dictionary. Cached
# in the magika integration, so this is a no-op lookup for anything
# that already went through File.get_all().
if processing_conf.magika.enabled and "magika" not in data_dictionary:
magika_result = magika_info(file_path)
if magika_result:
data_dictionary["magika"] = magika_result

if HAVE_FLOSS and processing_conf.floss.enabled and "Mono" not in data_dictionary.get("type", "") and "floss" not in data_dictionary:
floss_strings = Floss(file_path, package).run()
if floss_strings:
Expand Down
250 changes: 250 additions & 0 deletions lib/cuckoo/common/integrations/magika.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
# Copyright (C) 2010-2015 Cuckoo Foundation.
# This file is part of Cuckoo Sandbox - http://www.cuckoosandbox.org
# See the file 'docs/LICENSE' for copying permission.
"""Google Magika deep-learning content type identification.

https://github.com/google/magika

Magika is a content type detector built on a small ONNX model rather than
on byte signatures. It complements -- it does not replace -- libmagic:

* libmagic is authoritative when a real magic signature is present
(`MZ`, `\\x7fELF`, `%PDF`, OLE CFB, ...). It is deterministic and it is
what the rest of CAPE substring-matches against ("PE32", "MS Windows
shortcut", "Java Jar", ...).
* Magika is useful exactly where libmagic returns `data` /
`application/octet-stream`: script fragments, decoded/deobfuscated
buffers, config blobs, shellcode-adjacent text, dropped files with no
header, and the endless supply of headerless CAPE payloads.

So this integration runs *below* the existing magic determination and is
purely additive: it stores a `magika` block next to `type` and never
modifies `type` itself. That is deliberate --

* the raw libmagic verdict stays visible and unaltered, which is what
every existing substring match in CAPE (and every analyst) relies on;
* keeping the two verdicts separate is itself a detection surface.
libmagic saying "ASCII text" while magika says `pebin`, or a `.jpg`
whose magika label is `powershell`, is a signal you can only see if
nothing has collapsed the two into one string.

Enable in: processing.conf -> [magika] -> enabled = yes
Requires: poetry run pip install -U magika
"""

import contextlib
import logging
import os
import threading
from importlib import import_module
from pathlib import Path

from cachetools import TTLCache

from lib.cuckoo.common.config import Config

log = logging.getLogger(__name__)

processing_conf = Config("processing")

# A stale conf tree (no [magika] section anywhere, e.g. a partially updated
# deployment) must degrade to "disabled", not to an AttributeError at import
# time in a module that objects.py imports unconditionally.
_magika_conf = getattr(processing_conf, "magika", None)


def _conf(key, default):
if _magika_conf is None:
return default
value = getattr(_magika_conf, key, default)
return default if value is None else value


# ConfigParser-backed booleans/ints are already coerced by CAPE's Config wrapper.
MAGIKA_ENABLED = bool(_conf("enabled", False))
MAGIKA_MODEL_DIR = _conf("model_dir", "") or ""
MAGIKA_PREDICTION_MODE = _conf("prediction_mode", "high_confidence") or "high_confidence"

# Display-only threshold: results below it are still stored and shown, just
# flagged so a weak prediction is not mistaken for a confident one.
try:
MAGIKA_MIN_SCORE = float(_conf("min_score", 0.5))
except (TypeError, ValueError):
MAGIKA_MIN_SCORE = 0.5

try:
# MB. 0 disables the guard.
MAGIKA_MAX_FILE_SIZE = int(_conf("max_file_size", 100))
except (TypeError, ValueError):
MAGIKA_MAX_FILE_SIZE = 100

HAVE_MAGIKA = False
_magika_module = None

if MAGIKA_ENABLED:
try:
# Absolute import: this file is `lib.cuckoo.common.integrations.magika`,
# the dependency is top-level `magika`. Python 3 has no implicit
# relative imports so these do not collide -- but we assert on the
# public symbol anyway so a shadowed import degrades to "disabled"
# instead of blowing up mid-analysis.
_magika_module = import_module("magika")
if not hasattr(_magika_module, "Magika"):
raise ImportError("imported 'magika' does not expose Magika (module shadowing?)")
HAVE_MAGIKA = True
except ImportError as e:
log.warning("Magika is enabled in processing.conf but unavailable: %s. Install with: poetry run pip install -U magika", e)

# The ONNX session is expensive to build (~0.5-1s) and cheap to reuse
# (~1-5ms/file), so it is a lazily-built per-process singleton. Workers are
# long-lived, hence the lock rather than a module-level constructor.
_MODEL_LOCK = threading.Lock()
_MAGIKA_INSTANCE = None

# Per-task result cache, same lifecycle contract as the clamav one: keyed by
# absolute path, TTL-bounded so a long-lived worker cannot grow without limit,
# explicitly cleared at task boundaries via `clear_magika_cache`.
_CACHE_LOCK = threading.Lock()
_MAGIKA_CACHE = TTLCache(maxsize=4096, ttl=3600)


def _get_magika():
"""Build (once) and return the process-wide Magika instance, or None."""
global _MAGIKA_INSTANCE
if not HAVE_MAGIKA:
return None
if _MAGIKA_INSTANCE is not None:
return _MAGIKA_INSTANCE
with _MODEL_LOCK:
if _MAGIKA_INSTANCE is not None:
return _MAGIKA_INSTANCE
kwargs = {}
if MAGIKA_MODEL_DIR:
model_dir = Path(MAGIKA_MODEL_DIR)
if model_dir.is_dir():
kwargs["model_dir"] = model_dir
else:
log.warning("magika model_dir '%s' does not exist, falling back to the bundled model", MAGIKA_MODEL_DIR)
prediction_mode = getattr(_magika_module, "PredictionMode", None)
if prediction_mode is not None:
try:
kwargs["prediction_mode"] = prediction_mode(MAGIKA_PREDICTION_MODE)
except ValueError:
log.warning(
"invalid magika prediction_mode '%s', using the library default (high_confidence)", MAGIKA_PREDICTION_MODE
)
try:
_MAGIKA_INSTANCE = _magika_module.Magika(**kwargs)
log.debug("magika initialised: module %s, model %s", _module_version(), _model_name())
except Exception as e:
log.error("failed to initialise magika: %s", e)
_MAGIKA_INSTANCE = None
return _MAGIKA_INSTANCE


def _module_version() -> str:
with contextlib.suppress(Exception):
return _MAGIKA_INSTANCE.get_module_version()
return ""


def _model_name() -> str:
with contextlib.suppress(Exception):
return _MAGIKA_INSTANCE.get_model_name()
return ""


def _result_to_dict(result) -> dict:
"""Normalise a MagikaResult across the 0.5.x / 0.6.x / 1.x APIs."""
# 0.6+ exposes .ok/.status; 0.5.x exposes .output directly.
if hasattr(result, "ok") and not result.ok:
log.debug("magika returned a non-ok status: %s", getattr(result, "status", "unknown"))
return {}

output = getattr(result, "output", None)
if output is None:
return {}

# 0.6+: output.label (ContentTypeLabel str-enum). 0.5.x: output.ct_label.
label = getattr(output, "label", None) or getattr(output, "ct_label", None)
if label is None:
return {}

score = getattr(result, "score", None)
if score is None:
# 0.5.x kept the score on the dl/output sub-object.
score = getattr(output, "score", None) or getattr(getattr(result, "dl", None), "score", None)

info = {
"label": str(label),
"description": getattr(output, "description", "") or "",
"mime_type": getattr(output, "mime_type", "") or "",
"group": getattr(output, "group", "") or "",
"is_text": bool(getattr(output, "is_text", False)),
"extensions": list(getattr(output, "extensions", []) or []),
"score": round(float(score), 4) if score is not None else None,
"model": _model_name(),
"version": _module_version(),
}

# Why the model's raw guess was overridden (low confidence, extension
# override, ...). Useful when triaging a surprising label.
overwrite_reason = getattr(getattr(result, "prediction", None), "overwrite_reason", None)
if overwrite_reason is not None and str(overwrite_reason) != "none":
info["overwrite_reason"] = str(overwrite_reason)
dl_label = getattr(getattr(getattr(result, "prediction", None), "dl", None), "label", None)
if dl_label is not None and str(dl_label) != info["label"]:
info["dl_label"] = str(dl_label)

info["low_confidence"] = info["score"] is not None and info["score"] < MAGIKA_MIN_SCORE
return info


def magika_info(file_path: str) -> dict:
"""Identify `file_path` with Magika.

Returns {} when magika is disabled, unavailable, or the file could not
be identified -- callers key off the empty dict to omit the field
entirely rather than storing a blank one. Results below `min_score` are
still returned (they are evidence) but carry `low_confidence: True` so
the UI can render them as weak.
"""
if not MAGIKA_ENABLED or not HAVE_MAGIKA or not file_path:
return {}

with _CACHE_LOCK:
cached = _MAGIKA_CACHE.get(file_path)
if cached is not None:
return dict(cached)

info = {}
try:
if not os.path.isfile(file_path):
return {}
size = os.path.getsize(file_path)
if size <= 0:
return {}
if MAGIKA_MAX_FILE_SIZE and size > MAGIKA_MAX_FILE_SIZE * 1024 * 1024:
log.debug("magika: skipping %s, %d MB exceeds max_file_size", file_path, size // (1024 * 1024))
return {}
magika = _get_magika()
if magika is None:
return {}
info = _result_to_dict(magika.identify_path(Path(file_path)))
except OSError as e:
log.debug("magika: unable to read %s: %s", file_path, e)
return {}
except Exception as e:
# Never let content identification take down processing.
log.warning("magika failed on %s: %s", file_path, e)
return {}

with _CACHE_LOCK:
_MAGIKA_CACHE[file_path] = info
return dict(info)


def clear_magika_cache():
"""Drop the per-task result cache. Call at task boundaries."""
with _CACHE_LOCK:
_MAGIKA_CACHE.clear()
25 changes: 25 additions & 0 deletions lib/cuckoo/common/objects.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
PAGE_WRITECOPY,
)
from lib.cuckoo.common.integrations.clamav import get_clamav
from lib.cuckoo.common.integrations.magika import MAGIKA_ENABLED, magika_info
from lib.cuckoo.common.integrations.parse_pe import IMAGE_FILE_MACHINE_AMD64, IMAGE_FILE_MACHINE_I386, IsPEImage
from lib.cuckoo.common.path_utils import path_exists

Expand Down Expand Up @@ -206,6 +207,7 @@ def __init__(self, file_path, guest_paths=None, file_name=None):
self._sha512 = None
self._pefile = False
self.file_type = None
self._magika = None
self.pe = None

def get_name(self):
Expand Down Expand Up @@ -428,6 +430,19 @@ def get_type(self):

return self.file_type

def get_magika(self):
"""Get the Google Magika content type prediction.
Enable in: processing.conf -> [magika] -> enabled

Reported alongside, never instead of, get_type(): the libmagic
verdict is left untouched so both are visible and comparable.

@return: dict with label/description/mime_type/group/score, or {}.
"""
if self._magika is None:
self._magika = magika_info(self.file_path_ansii)
return self._magika

def _yara_encode_string(self, yara_string):
# Beware, spaghetti code ahead.
if not isinstance(yara_string, bytes):
Expand Down Expand Up @@ -844,6 +859,16 @@ def get_all(self):
"sha3_384": self.get_sha3_384(),
}

# Sits alongside (below) "type" for every category that goes through
# File.get_all(): target, dropped, procdumps, CAPE payloads, extracted
# files, suricata files and process memory dumps. Absent -- not empty
# -- when magika is disabled or returns nothing, so the UI row simply
# does not render.
if MAGIKA_ENABLED:
magika_result = self.get_magika()
if magika_result:
infos["magika"] = magika_result

return infos, self.pe

def get_platform(self):
Expand Down
1 change: 1 addition & 0 deletions lib/cuckoo/common/web_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -1344,6 +1344,7 @@ def validate_task_by_path(tid):
"crc32": "crc32",
"die": "die",
"trid": "trid",
"magika": "magika.label",
"imphash": "imphash",
}

Expand Down
17 changes: 17 additions & 0 deletions modules/processing/CAPE.py
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,12 @@ def process_file(self, file_path, append_file, metadata: dict, *, category: str,

if "type" not in file_info:
file_info["type"] = f.get_type()
# `file_info` can come straight from the mongo file cache, which may
# predate magika being enabled (or a model change). Backfill it.
if processing_conf.magika.enabled and "magika" not in file_info:
magika_result = f.get_magika()
if magika_result:
file_info["magika"] = magika_result
if "name" not in file_info:
file_info["name"] = f.get_name()
if "guest_paths" not in file_info:
Expand Down Expand Up @@ -461,6 +467,17 @@ def run(self):
# legacy serial fallback inside get_clamav() still works.
log.debug("clamav prefetch failed", exc_info=True)

# Same lifecycle contract as the clamav cache: drop per-path magika
# results at the task boundary so a long-lived worker can't serve a
# stale prediction for a path that has been reused by another task.
if processing_conf.magika.enabled:
try:
from lib.cuckoo.common.integrations.magika import clear_magika_cache

clear_magika_cache()
except Exception:
log.debug("magika cache clear failed", exc_info=True)

# Static processing of submitted file
if self.task["category"] in ("file", "static"):
self.process_file(
Expand Down
Loading
Loading