Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[SINT-3008] feat(github-action): add support for 'verify' command #532

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
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
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,17 @@ 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 and github actions
guarddog npm scan express

guarddog go scan github.com/DataDog/dd-trace-go

guarddog go verify /tmp/repo/go.mod

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
```
Expand Down
3 changes: 3 additions & 0 deletions guarddog/scanners/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
101 changes: 101 additions & 0 deletions guarddog/scanners/github_action_project_scanner.py
Original file line number Diff line number Diff line change
@@ -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
57 changes: 57 additions & 0 deletions tests/core/resources/workflow.yaml
Original file line number Diff line number Diff line change
@@ -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/[email protected]
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/[email protected]
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
56 changes: 56 additions & 0 deletions tests/core/test_github_action_project_scanner.py
Original file line number Diff line number Diff line change
@@ -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/[email protected]",
), GitHubAction(name="actions/checkout", ref="v4.2.2")),
(GitHubWorkflowStep(
name="Setup Python",
uses="actions/[email protected]",
), 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