Repository: DataDog/guarddog
Pull request #355 (2024-05-07 18:10:53)

Add detection of compiled binaries in package code 
Adds new rule that detects packages containing EXE or ELF files.
The goal is to detect if executables are smuggled into the system.
This detection makes use of file magic bytes to detect file types regardless of their extension

An false positive analysis scan was performed with the following results: 
- against the top 1k npm produced 4 false positives 
- against the top 1k pypi packages produced 0 false positives
- against the last week pypi ~50 packages with 0 false positives
- against the last week npm ~196 packages with 0 false positives


Patch:
diff --git a/README.md b/README.md
index 02d421a..eee596c 100644
--- a/README.md
+++ b/README.md
@@ -108,6 +108,7 @@ Metadata heuristics:
 | unclaimed_maintainer_email_domain | Identify when a package maintainer e-mail domain (and therefore npm account) is unclaimed and can be registered by an attacker |
 | repository_integrity_mismatch | Identify packages with a linked GitHub repository where the package has extra unexpected files |
 | single_python_file | Identify packages that have only a single Python file |
+| bundled_binary | Identify packages bundling binaries |
 
 
 ### npm
@@ -135,6 +136,7 @@ Metadata heuristics:
 | typosquatting | Identify packages that are named closely to an highly popular package |
 | direct_url_dependency | Identify packages with direct URL dependencies. Dependencies fetched this way are not immutable and can be used to inject untrusted code or reduce the likelihood of a reproducible install. |
 | npm_metadata_mismatch | Identify packages which have mismatches between the npm package manifest and the package info for some critical fields |
+| bundled_binary | Identify packages bundling binaries |
 
 
 <!-- END_RULE_LIST -->
diff --git a/guarddog/analyzer/metadata/bundled_binary.py b/guarddog/analyzer/metadata/bundled_binary.py
new file mode 100644
index 0000000..310abed
--- /dev/null
+++ b/guarddog/analyzer/metadata/bundled_binary.py
@@ -0,0 +1,56 @@
+from guarddog.analyzer.metadata.detector import Detector
+from abc import abstractmethod
+from typing import Optional
+import os
+from functools import reduce
+import logging
+
+log = logging.getLogger("guarddog")
+
+
+class BundledBinary(Detector):
+    """This heuristic detects if the package includes bundles binary file."""
+
+    # magic bytes are the first few bytes of a file that can be used to identify the file type
+    # regardless of their extension
+    magic_bytes = {"exe": b"\x4D\x5A", "elf": b"\x7F\x45\x4C\x46"}
+
+    def __init__(self):
+        super().__init__(
+            name="bundled_binary", description="Identify packages bundling binaries"
+        )
+
+    @abstractmethod
+    def detect(
+        self,
+        package_info,
+        path: Optional[str] = None,
+        name: Optional[str] = None,
+        version: Optional[str] = None,
+    ) -> tuple[bool, str]:
+
+        log.debug(
+            f"Running bundled binary heuristic on package {name} version {version}"
+        )
+        if not path:
+            raise ValueError("path is needed to run heuristic " + self.get_name())
+
+        for root, _, files in os.walk(path):
+            bin_files = []
+
+            for f in files:
+                kind = self.is_binary(os.path.join(root, f))
+                if kind:
+                    bin_files.append(f"{f} type {kind}")
+        if bin_files:
+            return True, "Binary file/s detected in package: " + reduce(lambda x, y: f"{x}, {y}", bin_files)
+        return False, ""
+
+    def is_binary(self, path: str) -> Optional[str]:
+        max_head = len(max(self.magic_bytes.values()))
+        with open(os.path.join(path), "rb") as fd:
+            header: bytes = fd.read(max_head)
+            for k, v in self.magic_bytes.items():
+                if header[: len(v)] == v:
+                    return k
+        return None
diff --git a/guarddog/analyzer/metadata/npm/__init__.py b/guarddog/analyzer/metadata/npm/__init__.py
index b01db9d..b255cf0 100644
--- a/guarddog/analyzer/metadata/npm/__init__.py
+++ b/guarddog/analyzer/metadata/npm/__init__.py
@@ -11,6 +11,7 @@ from guarddog.analyzer.metadata.npm.direct_url_dependency import (
     NPMDirectURLDependencyDetector,
 )
 from guarddog.analyzer.metadata.npm.npm_metadata_mismatch import NPMMetadataMismatch
+from guarddog.analyzer.metadata.npm.bundled_binary import NPMBundledBinary
 
 NPM_METADATA_RULES = {}
 
@@ -22,6 +23,7 @@ classes = [
     NPMTyposquatDetector,
     NPMDirectURLDependencyDetector,
     NPMMetadataMismatch,
+    NPMBundledBinary,
 ]
 
 for detectorClass in classes:
diff --git a/guarddog/analyzer/metadata/npm/bundled_binary.py b/guarddog/analyzer/metadata/npm/bundled_binary.py
new file mode 100644
index 0000000..4045bfe
--- /dev/null
+++ b/guarddog/analyzer/metadata/npm/bundled_binary.py
@@ -0,0 +1,8 @@
+from guarddog.analyzer.metadata.bundled_binary import BundledBinary
+from typing import Optional
+
+
+class NPMBundledBinary(BundledBinary):
+    def detect(self, package_info, path: Optional[str] = None, name: Optional[str] = None,
+               version: Optional[str] = None) -> tuple[bool, str]:
+        return super().detect(package_info, path, name, version)
diff --git a/guarddog/analyzer/metadata/pypi/__init__.py b/guarddog/analyzer/metadata/pypi/__init__.py
index 23535b6..6663d77 100644
--- a/guarddog/analyzer/metadata/pypi/__init__.py
+++ b/guarddog/analyzer/metadata/pypi/__init__.py
@@ -7,6 +7,7 @@ from guarddog.analyzer.metadata.pypi.release_zero import PypiReleaseZeroDetector
 from guarddog.analyzer.metadata.pypi.repository_integrity_mismatch import PypiIntegrityMismatchDetector
 from guarddog.analyzer.metadata.pypi.single_python_file import PypiSinglePythonFileDetector
 from guarddog.analyzer.metadata.pypi.typosquatting import PypiTyposquatDetector
+from guarddog.analyzer.metadata.pypi.bundled_binary import PypiBundledBinary
 
 PYPI_METADATA_RULES = {}
 
@@ -17,8 +18,8 @@ classes = [
     PypiPotentiallyCompromisedEmailDomainDetector,
     PypiUnclaimedMaintainerEmailDomainDetector,
     PypiIntegrityMismatchDetector,
-    PypiSinglePythonFileDetector
-
+    PypiSinglePythonFileDetector,
+    PypiBundledBinary,
 ]
 
 for detectorClass in classes:
diff --git a/guarddog/analyzer/metadata/pypi/bundled_binary.py b/guarddog/analyzer/metadata/pypi/bundled_binary.py
new file mode 100644
index 0000000..2aaeec1
--- /dev/null
+++ b/guarddog/analyzer/metadata/pypi/bundled_binary.py
@@ -0,0 +1,8 @@
+from guarddog.analyzer.metadata.bundled_binary import BundledBinary
+from typing import Optional
+
+
+class PypiBundledBinary(BundledBinary):
+    def detect(self, package_info, path: Optional[str] = None, name: Optional[str] = None,
+               version: Optional[str] = None) -> tuple[bool, str]:
+        return super().detect(package_info, path, name, version)


Test patch:
diff --git a/tests/analyzer/metadata/test_bundled_binary.py b/tests/analyzer/metadata/test_bundled_binary.py
new file mode 100644
index 0000000..40eb9e5
--- /dev/null
+++ b/tests/analyzer/metadata/test_bundled_binary.py
@@ -0,0 +1,54 @@
+import os
+import tempfile
+
+import pytest
+
+from guarddog.analyzer.metadata.npm import NPMBundledBinary
+from guarddog.analyzer.metadata.pypi import PypiBundledBinary
+from tests.analyzer.metadata.resources.sample_project_info import (
+    PYPI_PACKAGE_INFO,
+    generate_pypi_project_info,
+)
+
+
+class TestBundleBinary:
+    pypi_detector = PypiBundledBinary()
+    npm_detector = NPMBundledBinary()
+    nonempty_information = PYPI_PACKAGE_INFO
+    
+
+    def test_exe_npm(self):
+        with tempfile.TemporaryDirectory() as dir:
+            full_path = os.path.join(dir, "package")
+            os.mkdir(full_path)
+            with open(os.path.join(full_path, "windows.txt"), "wb") as f:
+                f.write(b"\x4D\x5A" + b"0x90"*10) # exe plus nop sled
+            matches, _ = self.npm_detector.detect({}, dir)
+            assert matches
+
+    def test_elf_pypi(self):
+        with tempfile.TemporaryDirectory() as dir:
+            full_path = os.path.join(dir, "package")
+            os.mkdir(full_path)
+            with open(os.path.join(full_path, "linux.txt"), "wb") as f:
+                f.write(b"\x7F\x45\x4C\x46" + b"0x90"*10)
+            matches, _ = self.pypi_detector.detect({}, dir)
+            assert matches
+
+    def test_plain(self):
+        with tempfile.TemporaryDirectory() as dir:
+            full_path = os.path.join(dir, "package")
+            os.mkdir(full_path)
+            with open(os.path.join(full_path, "file.exe"), "w") as f:
+                f.write("Hello world")
+            matches, _ = self.npm_detector.detect({}, dir)
+            assert not matches
+
+    def test_empty(self):
+        with tempfile.TemporaryDirectory() as dir:
+            full_path = os.path.join(dir, "package")
+            os.mkdir(full_path)
+            with open(os.path.join(full_path, "some_file"), "w") as f:
+                pass
+            matches, _ = self.pypi_detector.detect({}, dir)
+            assert not matches
\ No newline at end of file