GitHub Workflow Using Self-Hosted Runner


Description

Detects when a GitHub Actions workflow runs on a self-hosted runner.

Query · python

def rule(event):
    # Only check completed workflow jobs
    if event.get("action") != "completed":
        return False

    # GitHub-hosted runners always have "GitHub Actions" in the runner_name
    # Self-hosted runners cannot use this reserved name
    runner_name = event.deep_get("workflow_job", "runner_name", default="")

    # Must have a runner name and it must not be GitHub-hosted
    if not runner_name:
        return False

    return not runner_name.startswith("GitHub Actions")


def title(event):
    workflow_name = event.deep_get("workflow_job", "name", default="Unknown Workflow")
    repo_name = event.deep_get("repository", "full_name", default="Unknown Repository")
    runner_name = event.deep_get("workflow_job", "runner_name", default="Unknown Runner")

    return f"Self-hosted runner '{runner_name}' used in workflow '{workflow_name}' for {repo_name}"


def alert_context(event):
    workflow_job = event.get("workflow_job", {})
    repository = event.get("repository", {})

    return {
        "workflow_name": workflow_job.get("name"),
        "workflow_job_id": workflow_job.get("id"),
        "workflow_run_id": workflow_job.get("run_id"),
        "workflow_url": workflow_job.get("html_url"),
        "repository": repository.get("full_name"),
        "repository_private": repository.get("private"),
        "repository_visibility": repository.get("visibility"),
        "head_branch": workflow_job.get("head_branch"),
        "head_sha": workflow_job.get("head_sha"),
        "conclusion": workflow_job.get("conclusion"),
        "runner_name": workflow_job.get("runner_name"),
        "runner_group_name": workflow_job.get("runner_group_name"),
        "runner_id": workflow_job.get("runner_id"),
        "runner_group_id": workflow_job.get("runner_group_id"),
        "actor": event.deep_get("sender", "login"),
    }


def severity(event):
    # Public or forkable repos with self-hosted runners have a medium risk
    repo_visibility = event.deep_get("repository", "visibility")
    allow_forking = event.deep_get("repository", "allow_forking", default=False)
    is_private = event.deep_get("repository", "private", default=True)

    if repo_visibility == "public" or (not is_private) or allow_forking:
        return "MEDIUM"

    # Private, non-forkable repos are low risk
    return "INFO"
Raw source GitHub Workflow Using Self-Hosted Runner · Panther Python
Esc
Published by panther-labs/panther-analysis ↗, licensed under Apache 2.0 ↗. Reproduced here unmodified.
AnalysisType: rule
Filename: github_self_hosted_runner_used.py
RuleID: "GitHub.Webhook.SelfHostedRunnerUsed"
DisplayName: "GitHub Workflow Using Self-Hosted Runner"
Enabled: true
LogTypes:
  - GitHub.Webhook
Reports:
  MITRE ATT&CK:
    - TA0001:T1195.002  # Supply Chain Compromise: Compromise Software Supply Chain
    - TA0002:T1072  # Execution: Software Deployment Tools
    - TA0008:T1021  # Lateral Movement: Remote Services
Tags:
  - CI/CD
  - Workflow
  - Self-Hosted
  - Infrastructure
CreateAlert: false
Severity: Info
DedupPeriodMinutes: 60
Description: Detects when a GitHub Actions workflow runs on a self-hosted runner.
Reference: https://docs.github.com/en/actions/hosting-your-own-runners/managing-self-hosted-runners/about-self-hosted-runners#self-hosted-runner-security
Tests:
  - Name: "Self-hosted runner on public repository"
    ExpectedResult: true
    Log:
      action: "completed"
      workflow_job:
        id: 52841003143
        name: "Build"
        status: "completed"
        conclusion: "success"
        run_url: "https://github.com/example-org/example-repo/actions/runs/12345678/job/52841003143"
        head_branch: "main"
        head_sha: "abc123"
        run_id: 12345678
        runner_group_id: 1
        runner_group_name: "Default"
        runner_id: 42
        runner_name: "my-runner-01"
        started_at: "2025-10-15T18:31:06Z"
        completed_at: "2025-10-15T18:41:54Z"
        steps:
          - name: "Setup"
            status: "completed"
            conclusion: "success"
      repository:
        id: 123456789
        full_name: "example-org/example-repo"
        private: false
        visibility: "public"
        allow_forking: true
      sender:
        login: "developer"

  - Name: "Self-hosted runner on private forkable repository"
    ExpectedResult: true
    Log:
      action: "completed"
      workflow_job:
        id: 52841003144
        name: "Deploy"
        status: "completed"
        conclusion: "success"
        run_id: 12345679
        runner_group_id: 2
        runner_group_name: "Production"
        runner_id: 43
        runner_name: "prod-runner-vm-02"
      repository:
        id: 123456790
        full_name: "example-org/private-repo"
        private: true
        visibility: "private"
        allow_forking: true
      sender:
        login: "developer"

  - Name: "Self-hosted runner on private non-forkable repository"
    ExpectedResult: true
    Log:
      action: "completed"
      workflow_job:
        id: 52841003145
        name: "Test"
        status: "completed"
        conclusion: "success"
        run_id: 12345680
        runner_group_id: 1
        runner_group_name: "Default"
        runner_id: 44
        runner_name: "internal-runner"
      repository:
        id: 123456791
        full_name: "example-org/internal-repo"
        private: true
        visibility: "private"
        allow_forking: false
      sender:
        login: "developer"

  - Name: "GitHub-hosted runner (not self-hosted)"
    ExpectedResult: false
    Log:
      action: "completed"
      workflow_job:
        id: 52841003146
        name: "Lint"
        status: "completed"
        conclusion: "success"
        run_id: 12345681
        runner_group_id: 0
        runner_group_name: "GitHub Actions"
        runner_id: 1000000001
        runner_name: "GitHub Actions 1000000001"
      repository:
        id: 123456792
        full_name: "example-org/example-repo"
        private: true

  - Name: "Workflow job in progress (not completed)"
    ExpectedResult: false
    Log:
      action: "in_progress"
      workflow_job:
        id: 52841003147
        name: "Build"
        status: "in_progress"
        run_id: 12345682
        runner_name: "my-runner-01"
      repository:
        id: 123456793
        full_name: "example-org/example-repo"

  - Name: "Self-hosted runner with empty name"
    ExpectedResult: false
    Log:
      action: "completed"
      workflow_job:
        id: 52841003148
        status: "completed"
        run_id: 12345683
        runner_name: ""
      repository:
        id: 123456794
        full_name: "example-org/example-repo"

# ------ paired body: github_self_hosted_runner_used.py ------

def rule(event):
    # Only check completed workflow jobs
    if event.get("action") != "completed":
        return False

    # GitHub-hosted runners always have "GitHub Actions" in the runner_name
    # Self-hosted runners cannot use this reserved name
    runner_name = event.deep_get("workflow_job", "runner_name", default="")

    # Must have a runner name and it must not be GitHub-hosted
    if not runner_name:
        return False

    return not runner_name.startswith("GitHub Actions")


def title(event):
    workflow_name = event.deep_get("workflow_job", "name", default="Unknown Workflow")
    repo_name = event.deep_get("repository", "full_name", default="Unknown Repository")
    runner_name = event.deep_get("workflow_job", "runner_name", default="Unknown Runner")

    return f"Self-hosted runner '{runner_name}' used in workflow '{workflow_name}' for {repo_name}"


def alert_context(event):
    workflow_job = event.get("workflow_job", {})
    repository = event.get("repository", {})

    return {
        "workflow_name": workflow_job.get("name"),
        "workflow_job_id": workflow_job.get("id"),
        "workflow_run_id": workflow_job.get("run_id"),
        "workflow_url": workflow_job.get("html_url"),
        "repository": repository.get("full_name"),
        "repository_private": repository.get("private"),
        "repository_visibility": repository.get("visibility"),
        "head_branch": workflow_job.get("head_branch"),
        "head_sha": workflow_job.get("head_sha"),
        "conclusion": workflow_job.get("conclusion"),
        "runner_name": workflow_job.get("runner_name"),
        "runner_group_name": workflow_job.get("runner_group_name"),
        "runner_id": workflow_job.get("runner_id"),
        "runner_group_id": workflow_job.get("runner_group_id"),
        "actor": event.deep_get("sender", "login"),
    }


def severity(event):
    # Public or forkable repos with self-hosted runners have a medium risk
    repo_visibility = event.deep_get("repository", "visibility")
    allow_forking = event.deep_get("repository", "allow_forking", default=False)
    is_private = event.deep_get("repository", "private", default=True)

    if repo_visibility == "public" or (not is_private) or allow_forking:
        return "MEDIUM"

    # Private, non-forkable repos are low risk
    return "INFO"

Detection rules belong to the projects that publish them and remain under their own licenses. This site indexes and links to them; it claims no rights in them.