Repository: DataDog/guarddog
Pull request #532 (2025-02-26 21:14:41)

[SINT-3008] feat(github-action): add support for 'verify' command 
## Changes

- Add GitHub action support for `verify` command
  - Accepts a workflow YAML file
  - Parses all 3rd party (non-relative path) actions and their versions
  - Performs a scan for each action + version combination

## Testing

<details>
<summary>Verify action

[`.github/workflow/test.yaml`](https://github.com/DataDog/guarddog/blob/b025d86018a0498d1e888cc372695cf91be9c1da/.github/workflows/test.yml)

</summary>

```
> poetry run guarddog github_action verify .github/workflows/test.yml
INFO: Scanning using at most 16 parallel worker threads

Found 0 potentially malicious indicators scanning actions/setup-python version v5

Found 0 potentially malicious indicators scanning actions/checkout version v4

Found 0 potentially malicious indicators scanning docker/setup-qemu-action version v3

Found 0 potentially malicious indicators scanning docker/setup-buildx-action version v3

Found 0 potentially malicious indicators scanning docker/build-push-action version v5
```



Patch:
diff --git a/README.md b/README.md
index d1cfb25..0b7ac2f 100644
--- a/README.md
+++ b/README.md
@@ -10,6 +10,13 @@ GuardDog is a CLI tool that allows to identify malicious PyPI and npm packages o
 
 GuardDog can be used to scan local or remote PyPI and npm packages or Go modules using any of the available [heuristics](#heuristics).
 
+It downloads and scans code from:
+
+* NPM: Packages hosted in [npmjs.org](https://www.npmjs.com/) 
+* PyPI: Source files (tar.gz) packages hosted in [PyPI.org](https://pypi.org/)
+* Go: GoLang source files of repositories hosted in [GitHub.com](https://github.com)
+* GitHub Actions: Javascript source files of repositories hosted in [GitHub.com](https://github.com)
+
 ![GuardDog demo usage](docs/images/demo.png)
 
 ## Getting started
@@ -59,9 +66,18 @@ guarddog pypi verify --output-format=sarif workspace/guarddog/requirements.txt
 # Output JSON to standard output - works for every command
 guarddog pypi scan requests --output-format=json
 
-# All the commands also work on npm or go
+# All the commands also work on npm, go
 guarddog npm scan express
 
+guarddog go scan github.com/DataDog/dd-trace-go
+
+guarddog go verify /tmp/repo/go.mod
+
+# Additionally can support scanning GitHub actions that are implemented in JavaScript 
+guarddog github_action scan DataDog/synthetics-ci-github-action
+
+guarddog github_action verify /tmp/repo/.github/workflows/main.yml
+
 # Run in debug mode
 guarddog --log-level debug npm scan express
 ```
@@ -156,6 +172,9 @@ Metadata heuristics:
 
 
 ### GitHub Action
+ 
+For GitHub Actions that are implemented in JavaScript,
+GuardDog will run the same source code heuristics as for npm packages.
 
 Source code heuristics:
 
diff --git a/guarddog/scanners/__init__.py b/guarddog/scanners/__init__.py
index 515c087..55627bc 100644
--- a/guarddog/scanners/__init__.py
+++ b/guarddog/scanners/__init__.py
@@ -1,5 +1,6 @@
 from typing import Optional
 
+from .github_action_project_scanner import GitHubActionDependencyScanner
 from .npm_package_scanner import NPMPackageScanner
 from .npm_project_scanner import NPMRequirementsScanner
 from .pypi_package_scanner import PypiPackageScanner
@@ -54,4 +55,6 @@ def get_project_scanner(ecosystem: ECOSYSTEM) -> Optional[ProjectScanner]:
             return NPMRequirementsScanner()
         case ECOSYSTEM.GO:
             return GoDependenciesScanner()
+        case ECOSYSTEM.GITHUB_ACTION:
+            return GitHubActionDependencyScanner()
     return None
diff --git a/guarddog/scanners/github_action_project_scanner.py b/guarddog/scanners/github_action_project_scanner.py
new file mode 100644
index 0000000..cb61b2c
--- /dev/null
+++ b/guarddog/scanners/github_action_project_scanner.py
@@ -0,0 +1,101 @@
+import logging
+from typing import List, Dict, TypedDict
+from typing_extensions import NotRequired
+
+import yaml
+import re
+
+from guarddog.scanners.github_action_scanner import GithubActionScanner
+from guarddog.scanners.scanner import ProjectScanner
+
+log = logging.getLogger("guarddog")
+
+
+class GitHubWorkflowStep(TypedDict):
+    name: NotRequired[str]
+    uses: NotRequired[str]
+
+
+class GitHubWorkflowJob(TypedDict):
+    name: str
+    uses: str
+    runs_on: str
+    steps: List[GitHubWorkflowStep]
+
+
+class GitHubWorkflowFile(TypedDict):
+    name: str
+    jobs: Dict[str, GitHubWorkflowJob]
+
+
+class GitHubAction(TypedDict):
+    name: str
+    ref: str
+
+
+def parse_action_from_step(step: GitHubWorkflowStep) -> GitHubAction | None:
+    """
+    Parses a step in a GitHub workflow file and returns a GitHub action reference if it exists.
+
+    Args:
+        step (GitHubWorkflowStep): Step in a GitHub workflow file
+
+    Returns:
+        GitHubAction | None: GitHub action reference if it exists, None otherwise
+    """
+    if "uses" not in step:
+        return None
+
+    if step["uses"].startswith("/") or step["uses"].startswith("./"):
+        return None
+    parts = step["uses"].split("@", 1)
+    if len(parts) != 2:
+        log.debug(f"Invalid action reference: {step['uses']}")
+        return None
+
+    if re.search(r"^([\w-])+/([\w./-])+$", parts[0]):
+        return GitHubAction(name=parts[0], ref=parts[1])
+    return None
+
+
+class GitHubActionDependencyScanner(ProjectScanner):
+    """
+    Scans all 3rd party actions in a GitHub workflow file.
+    """
+
+    def __init__(self) -> None:
+        super().__init__(GithubActionScanner())
+
+    def parse_requirements(self, raw_requirements: str) -> dict[str, set[str]]:
+        actions = self.parse_workflow_3rd_party_actions(raw_requirements)
+
+        requirements: dict[str, set[str]] = {}
+        for action in actions:
+            repo, version = action["name"], action["ref"]
+            if repo in requirements:
+                requirements[repo].add(version)
+            else:
+                requirements[repo] = {version}
+        return requirements
+
+    def parse_workflow_3rd_party_actions(
+        self, workflow_file: str
+    ) -> List[GitHubAction]:
+        """
+        Parses a GitHub workflow file and returns a list of 3rd party actions
+        used in the workflow.
+
+        Args:
+            workflow_file (str): Contents of the GitHub workflow file
+
+        Returns:
+            List[GitHubAction]: List of 3rd party actions used in the workflow
+        """
+        f: GitHubWorkflowFile = yaml.safe_load(workflow_file)
+        actions = []
+        for job in f.get("jobs", {}).values():
+            for step in job.get("steps", []):
+                action = parse_action_from_step(step)
+                if action:
+                    actions.append(action)
+        return actions


Test patch:
diff --git a/tests/core/resources/workflow.yaml b/tests/core/resources/workflow.yaml
new file mode 100644
index 0000000..fe257d1
--- /dev/null
+++ b/tests/core/resources/workflow.yaml
@@ -0,0 +1,57 @@
+name: CI
+
+on:
+  push:
+    branches:
+      - main
+  pull_request:
+
+env:
+
+permissions:
+    contents: read
+    pull-requests: write
+
+jobs:
+  ci:
+    name: lint
+    runs-on: ubuntu-latest
+
+    steps:
+      # Pin by hash
+      - name: Checkout repository
+        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+        with:
+          persist-credentials: false
+
+      # Pin by ref name
+      - name: Setup python
+        uses: actions/setup-python@v5.3.0
+        with:
+          python-version-file: .python-version
+          cache: 'pip'
+          cache-dependency-path: '**/requirements*.txt'
+
+      # Non-uses step
+      - name: Install dependencies
+        run: pip install -r requirements.txt -r tasks/requirements.txt
+
+      # Same action, different version
+      - name: Checkout repository
+        uses: actions/checkout@v4.2.2
+        with:
+          persist-credentials: false
+
+      # Another pin by hash
+      - uses: actions/create-github-app-token@0d564482f06ca65fa9e77e2510873638c82206f2 # v1.11.5
+        id: app-token
+        with:
+          app-id: ${{ vars.DD_GITHUB_APP_ID }}
+          private-key: ${{ secrets.DD_GITHUB_TOKEN }}
+
+      # Local action step
+      - uses: ./.github/actions/lint
+
+      # 3rd party action pinned by tag
+      - name: "Create Pull Request"
+        uses: peter-evans/create-pull-request@v7
\ No newline at end of file
diff --git a/tests/core/test_github_action_project_scanner.py b/tests/core/test_github_action_project_scanner.py
new file mode 100644
index 0000000..8339ee8
--- /dev/null
+++ b/tests/core/test_github_action_project_scanner.py
@@ -0,0 +1,56 @@
+import os
+import pathlib
+
+import pytest
+
+from guarddog.scanners import GitHubActionDependencyScanner
+from guarddog.scanners.github_action_project_scanner import parse_action_from_step, GitHubWorkflowStep, GitHubAction
+
+
+def test_go_parse_requirements():
+    scanner = GitHubActionDependencyScanner()
+
+    with open(
+        os.path.join(pathlib.Path(__file__).parent.resolve(), "resources", "workflow.yaml"),
+        "r",
+    ) as f:
+        requirements = scanner.parse_requirements(f.read())
+        assert requirements == {
+            "actions/checkout": {"v4.2.2", "11bd71901bbe5b1630ceea73d27597364c9af683"},
+            "actions/setup-python": {"v5.3.0"},
+            "actions/create-github-app-token": {"0d564482f06ca65fa9e77e2510873638c82206f2"},
+            "peter-evans/create-pull-request": {"v7"},
+        }
+
+
+@pytest.mark.parametrize(
+    "step,expected_action",
+    [
+        (GitHubWorkflowStep(
+            name="Checkout code",
+            uses="actions/checkout@v4.2.2",
+        ), GitHubAction(name="actions/checkout", ref="v4.2.2")),
+        (GitHubWorkflowStep(
+            name="Setup Python",
+            uses="actions/setup-python@v5.3.0",
+        ), GitHubAction(name="actions/setup-python", ref="v5.3.0")),
+        (GitHubWorkflowStep(
+            name="Create GitHub App Token",
+            uses="actions/create-github-app-token@0d564482f06ca65fa9e77e2510873638c82206f2",
+        ), GitHubAction(name="actions/create-github-app-token", ref="0d564482f06ca65fa9e77e2510873638c82206f2")),
+        (GitHubWorkflowStep(
+            name="Create Pull Request",
+            uses="peter-evans/create-pull-request@v7",
+        ), GitHubAction(name="peter-evans/create-pull-request", ref="v7")),
+        (GitHubWorkflowStep(
+            name="non-uses step",
+            uses="",
+        ), None),
+        (GitHubWorkflowStep(
+            name="Relative path",
+            uses="./relative-path",
+        ), None)
+    ],
+)
+def test_parse_action_from_step(step, expected_action):
+    assert parse_action_from_step(step) == expected_action