Repository: DataDog/guarddog
Pull request #144 (2023-02-09 11:18:12)

Local target
The current implementation considers that the target is local if it exists in the current dir which is incorrect. For instance, `guarddog pypi scan guarddog` would try to scan the **local folder `guarddog`** instead of the remote package, if the folder exists


Patch:
diff --git a/README.md b/README.md
index 29f59d1..2d1f44d 100644
--- a/README.md
+++ b/README.md
@@ -6,9 +6,9 @@
   <img src="./docs/images/logo.png" alt="GuardDog" width="300" />
 </p>
 
-GuardDog is a CLI tool that allows to identify malicious PyPI and npm packages. It runs a set of heuristics on the package source code (through Semgrep rules) and on the package metadata.
+GuardDog is a CLI tool that allows to identify malicious PyPI packages. It runs a set of heuristics on the package source code (through Semgrep rules) and on the package metadata.
 
-GuardDog can be used to scan local or remote PyPI and npm packages using any of the available [heuristics](#heuristics).
+GuardDog can be used to scan local or remote PyPI packages using any of the available [heuristics](#heuristics).
 
 ![GuardDog demo usage](docs/images/demo.png)
 
@@ -68,7 +68,7 @@ GuardDog comes with 2 types of heuristics:
 
 * [**Source code heuristics**](https://github.com/DataDog/guarddog/tree/main/guarddog/analyzer/sourcecode): Semgrep rules running against the package source code.
 
-* [**Package metadata heuristics**](https://github.com/DataDog/guarddog/tree/main/guarddog/analyzer/metadata): Python or Javascript heuristics running against the package metadata on PyPI or npm.
+* [**Package metadata heuristics**](https://github.com/DataDog/guarddog/tree/main/guarddog/analyzer/metadata): Python heuristics running against the package metadata on PyPI.
 
 <!-- BEGIN_RULE_LIST -->
 ### PyPI
@@ -214,6 +214,14 @@ flake8 guarddog --count --select=E9,F63,F7,F82 --show-source --statistics --excl
 flake8 guarddog --count --max-line-length=120 --statistics --exclude tests/analyzer/sourcecode,tests/analyzer/metadata/resources,evaluator/data --ignore=E203,W503
 ```
 
+### Adding new source code heuristics
+
+TBD
+
+### Adding new package metadata heuristics
+
+TBD
+
 ## Acknowledgments
 
 Authors: 
diff --git a/guarddog/analyzer/metadata/pypi/empty_information.py b/guarddog/analyzer/metadata/pypi/empty_information.py
index ddaff2f..96c6690 100644
--- a/guarddog/analyzer/metadata/pypi/empty_information.py
+++ b/guarddog/analyzer/metadata/pypi/empty_information.py
@@ -2,14 +2,18 @@
 
 Detects if a package contains an empty description
 """
+import logging
 from typing import Optional
 
 from guarddog.analyzer.metadata.empty_information import EmptyInfoDetector
 
 MESSAGE = "This package has an empty description on PyPi"
 
+log = logging.getLogger("guarddog")
+
 
 class PypiEmptyInfoDetector(EmptyInfoDetector):
     def detect(self, package_info, path: Optional[str] = None, name: Optional[str] = None,
                version: Optional[str] = None) -> tuple[bool, str]:
+        log.debug(f"Running PyPI empty description heuristic on package {name} version {version}")
         return len(package_info["info"]["description"].strip()) == 0, EmptyInfoDetector.MESSAGE_TEMPLATE % "PyPI"
diff --git a/guarddog/analyzer/metadata/pypi/potentially_compromised_email_domain.py b/guarddog/analyzer/metadata/pypi/potentially_compromised_email_domain.py
index 66c6bf0..9e8d630 100644
--- a/guarddog/analyzer/metadata/pypi/potentially_compromised_email_domain.py
+++ b/guarddog/analyzer/metadata/pypi/potentially_compromised_email_domain.py
@@ -2,7 +2,6 @@
 
 Detects if a maintainer's email domain might have been compromised.
 """
-
 from datetime import datetime
 from typing import Optional
 
diff --git a/guarddog/analyzer/metadata/pypi/release_zero.py b/guarddog/analyzer/metadata/pypi/release_zero.py
index 1bc120a..f4d7244 100644
--- a/guarddog/analyzer/metadata/pypi/release_zero.py
+++ b/guarddog/analyzer/metadata/pypi/release_zero.py
@@ -2,14 +2,18 @@
 
 Detects when a package has its latest release version to 0.0.0
 """
+import logging
 from typing import Optional
 
 from guarddog.analyzer.metadata.release_zero import ReleaseZeroDetector
 
+log = logging.getLogger("guarddog")
+
 
 class PypiReleaseZeroDetector(ReleaseZeroDetector):
 
     def detect(self, package_info, path: Optional[str] = None, name: Optional[str] = None,
                version: Optional[str] = None) -> tuple[bool, str]:
+        log.debug(f"Running zero version heuristic on PyPI package {name} version {version}")
         return (package_info["info"]["version"] in ["0.0.0", "0.0"],
                 ReleaseZeroDetector.MESSAGE_TEMPLATE % package_info["info"]["version"])
diff --git a/guarddog/analyzer/metadata/pypi/repository_integrity_mismatch.py b/guarddog/analyzer/metadata/pypi/repository_integrity_mismatch.py
index b19efef..b5163c2 100644
--- a/guarddog/analyzer/metadata/pypi/repository_integrity_mismatch.py
+++ b/guarddog/analyzer/metadata/pypi/repository_integrity_mismatch.py
@@ -4,6 +4,7 @@ Detects if a package contains an empty description
 """
 import configparser
 import hashlib
+import logging
 import os
 import re
 from typing import Optional, Tuple
@@ -16,6 +17,8 @@ from guarddog.analyzer.metadata.repository_integrity_mismatch import IntegrityMi
 GH_REPO_REGEX = r'(?:https?://)?(?:www\.)?github\.com/(?:[\w-]+/)(?:[\w-]+)'
 GH_REPO_OWNER_REGEX = r'(?:https?://)?(?:www\.)?github\.com/([\w-]+)/([\w-]+)'
 
+log = logging.getLogger("guarddog")
+
 
 def extract_owner_and_repo(url) -> Tuple[Optional[str], Optional[str]]:
     match = re.search(GH_REPO_OWNER_REGEX, url)
@@ -213,6 +216,8 @@ class PypiIntegrityMismatchDetector(IntegrityMismatch):
             raise Exception("Detector needs the name of the package")
         if path is None:
             raise Exception("Detector needs the path of the package")
+
+        log.debug(f"Running repository integrity mismatch heuristic on PyPI package {name} version {version}")
         # let's extract a source repository (GitHub only for now) if we can
         github_urls, best_github_candidate = find_github_candidates(package_info)
         if len(github_urls) == 0:
@@ -224,6 +229,7 @@ class PypiIntegrityMismatchDetector(IntegrityMismatch):
         if github_url is None:
             return False, "Could not find a good GitHub url in the project's description"
 
+        log.debug(f"Using GitHub URL {github_url}")
         # ok, now let's try to find the version! (I need to know which version we are scanning)
         if version is None:
             version = package_info["info"]["version"]
diff --git a/guarddog/analyzer/metadata/pypi/typosquatting.py b/guarddog/analyzer/metadata/pypi/typosquatting.py
index a30852f..7b095d0 100644
--- a/guarddog/analyzer/metadata/pypi/typosquatting.py
+++ b/guarddog/analyzer/metadata/pypi/typosquatting.py
@@ -1,4 +1,5 @@
 import json
+import logging
 import os
 from datetime import datetime, timedelta
 from typing import Optional
@@ -9,6 +10,9 @@ from packaging.utils import canonicalize_name
 from guarddog.analyzer.metadata.typosquatting import TyposquatDetector
 
 
+log = logging.getLogger("guarddog")
+
+
 class PypiTyposquatDetector(TyposquatDetector):
     """
     Detector for typosquatting attacks. Detects if a package name is a typosquat of one of the top 1000 packages.
@@ -77,7 +81,7 @@ class PypiTyposquatDetector(TyposquatDetector):
             typosquatting from
             @param **kwargs:
         """
-
+        log.debug(f"Running typosquatting heuristic on PyPI package {name}")
         similar_package_names = self.get_typosquatted_package(package_info["info"]["name"])
         if len(similar_package_names) > 0:
             return True, TyposquatDetector.MESSAGE_TEMPLATE % ", ".join(similar_package_names)
diff --git a/guarddog/cli.py b/guarddog/cli.py
index 3977574..73defdd 100644
--- a/guarddog/cli.py
+++ b/guarddog/cli.py
@@ -131,6 +131,21 @@ def _verify(path, rules, exclude_rules, output_format, exit_non_zero_on_finding,
     return return_value  # this is mostly for testing
 
 
+def is_local_target(identifier: str) -> bool:
+    """
+    @param identifier:  The name/path of the package as passed to "guarddog ecosystem scan"
+    @return:            Whether the identifier should be consider a local path
+    """
+    if identifier.startswith("/") or identifier.startswith("./"):
+        return True
+
+    # If this looks like an archive, consider it as a local target if the target exists on the local filesystem
+    if identifier.endswith(".tar.gz") or identifier.endswith(".zip") or identifier.endswith(".whl"):
+        return os.path.exists(identifier)
+
+    return False
+
+
 def _scan(identifier, version, rules, exclude_rules, output_format, exit_non_zero_on_finding, ecosystem: ECOSYSTEM):
     """Scan a package
 
@@ -146,7 +161,7 @@ def _scan(identifier, version, rules, exclude_rules, output_format, exit_non_zer
         sys.stderr.write(f"Command scan is not supported for ecosystem {ecosystem}")
         exit(1)
     results = {}
-    if os.path.exists(identifier):
+    if is_local_target(identifier):
         results = scanner.scan_local(identifier, rule_param)
     else:
         try:


Test patch:
diff --git a/tests/core/test_cli.py b/tests/core/test_cli.py
new file mode 100644
index 0000000..4b87062
--- /dev/null
+++ b/tests/core/test_cli.py
@@ -0,0 +1,19 @@
+import os
+import unittest.mock
+
+import guarddog.cli
+
+
+def test_is_local_target():
+    assert guarddog.cli.is_local_target("/tmp/foo")
+    assert guarddog.cli.is_local_target("./foo")
+    assert not guarddog.cli.is_local_target("foo")
+
+    with unittest.mock.patch('os.path.exists') as mock:
+        mock.return_value = True
+        assert guarddog.cli.is_local_target("foo.tar.gz")
+
+    with unittest.mock.patch('os.path.exists') as mock:
+        mock.return_value = False
+        assert not guarddog.cli.is_local_target("foo.tar.gz")
+