Learn how MCP is helping security teams move faster. Learn More

close

Learn how MCP is helping security teams move faster. Learn More

close

Learn how MCP is helping security teams move faster. Learn More

close

BLOG

BLOG

Detecting and Hunting for GitHub Actions Compromise

Alessandra

Rizzo

Nov 12, 2025

We had the pleasure of hosting a workshop at DEATHCON 2025 this November to showcase what defenders can, and should, do to detect and hunt for GitHub Actions abuse. Attacks targeting public repositories through GitHub's CI/CD suite have surged over the past few years, growing in both frequency and sophistication. Just this year, two high-profile attacks against S1ngularity and changed-files resulted in the compromise of over 2,000 GitHub accounts.

You can read more about these attacks here: 

https://panther.com/blog/nx-threat-analysis 

https://blog.gitguardian.com/compromised-tj-actions/

Attackers increasingly target public repositories as entry points because they accept contributions from external users. When these repositories serve as dependencies for thousands of downstream projects, their compromise provides immediate access to hundreds of potential targets. This attack vector requires significantly less effort than developing custom malware and establishing distribution channels. Setting up a throwaway GitHub profile and submitting malicious pull requests in hopes that maintainers won't notice is relatively trivial. Both attacks mentioned above demonstrate careful tailoring to their specific targets, with the tjactions/changed-files compromise allegedly aimed at breaching a specific organization through a complex dependency chain.

These incidents reveal that attackers are investing time in analyzing dependency chains to identify entry points that organizations fail to recognize as vulnerable, especially since GitHub Actions workflows frequently execute prepackaged code from third-party actions. While reviewing every line of code in every dependency used by Enterprise GitHub Actions would theoretically prevent such compromises, it's simply not a scalable or practical defense strategy.

Given this escalating threat landscape, defenders need practical detection strategies that work at scale. In our workshop, we've assembled a series of detection and hunting techniques that any organization with access to GitHub webhooks and audit logs can implement. We've leveraged Panther's Python detection engine to alert on suspicious activity in real-time and Panther's data lake to conduct retroactive threat hunts when needed.

GitHub Webhooks

GitHub Webhooks forward repository events to a configured endpoint in real-time, providing immediate visibility into repository activity. You can refer to the documentation here: https://docs.github.com/en/webhooks/webhook-events-and-payloads

Pull_request_target

The pull_request_target event runs a workflow when activity on a pull request occurs. Unlike standard pull request triggers, pull_request_target runs with the permissions of the target branch even when a PR is opened from a fork, granting access to repository secrets. This makes it particularly dangerous when handling untrusted contributions.

To detect workflows using the pull_request_target trigger in your organization:

def rule(event):
    return (
        event.deep_get("workflow_run", "event") == "pull_request_target"
        and event.get("action") == "completed"
    )

However, simply alerting on this event doesn't help assess severity as not all workflows are created equal. The risk level depends on additional context.

Cross-fork Pull Requests

In the NX/S1ngularity attack, the pull_request_target workflow was triggered by a cross-fork repository, significantly heightening the risk. Cross-fork PRs can result in highly privileged workflows running untrusted code from external contributors.

To identify cross-fork workflows, compare the head repository (where the PR originates) with the base repository (the target).  For workflow_run events, these fields vary depending on how GitHub populates the webhook payload:

def is_cross_fork_pr(event):
    """
    Check if this is a cross-fork pull request.

    Args:
        event: GitHub webhook event

    Returns:
        bool: True if this is a cross-fork PR (head repo != base repo), False otherwise
    """
    # Check direct pull_request event
    head_repo = event.deep_get("pull_request", "head", "repo", "full_name")
    base_repo = event.deep_get("pull_request", "base", "repo", "full_name")

    if head_repo and base_repo:
        return head_repo != base_repo

    # Check workflow_run event with pull_requests array
    pull_requests = event.deep_get("workflow_run", "pull_requests", default=[])
    for pull_request in pull_requests:
        pr_head_repo = deep_get(pull_request, "head", "repo", "id")
        pr_base_repo = deep_get(pull_request, "base", "repo", "id")
        if pr_head_repo and pr_base_repo and pr_head_repo != pr_base_repo:
            return True

    # Check workflow_run event with head_repository (for workflows from forks)
    # GitHub doesn't always populate pull_requests array, but head_repository is reliable
    head_repository = event.deep_get("workflow_run", "head_repository")
    base_repository = event.deep_get("workflow_run", "repository")

    if head_repository and base_repository:
        head_repo_id = head_repository.get("id")
        base_repo_id = base_repository.get("id")
        if head_repo_id and base_repo_id and head_repo_id != base_repo_id:
            return True

    return False

@actions/checkout with pull_request_target

The @actions/checkout action pulls repository code into the workflow runner. When combined with pull_request_target, this creates a critical vulnerability: if the workflow explicitly checks out the PR head using ref: ${{ github.event.pull_request.head.sha }} it executes untrusted code with access to repository secrets.

You can detect checkout actions in workflows by monitoring workflow_job events:

def rule(event):
    """Alert when a GitHub workflow job contains a checkout action step."""
    # Only check completed workflow jobs
    if event.get("action") != "completed":
        return False

    # Get the steps array from workflow_job
    steps = event.deep_get("workflow_job", "steps", default=[])

    # Iterate through each step and check if the name contains "checkout" (case-insensitive)
    for step in steps:
        step_name = step.get("name", "").lower()
        if "checkout" in step_name:
            return True

    return False

Using Panther's correlation rules, you can combine the pull_request_target detection with the @actions/checkout detection to create a high-fidelity alert. For example, in this workflow step:

  - name: Checkout code
        uses: actions/checkout@v4
 with: 
   ref: ${{ github.event.pull_request.head.sha }}

This configuration checks out the untrusted PR head, allowing malicious code to execute with the workflow's secrets. This pattern should trigger immediate investigation.

The correlation rule to detect this pattern:

Detection:
  - Group:
      - ID: PullRequestTarget
        RuleID: GitHub.Webhook.PullRequestTargetUsage
      - ID: WorkflowCheckout
        RuleID: GitHub.Webhook.WorkflowContainsCheckout
    MatchCriteria:
      field_name:
        - GroupID: PullRequestTarget
          Match: workflow_run.id
        - GroupID: WorkflowCheckout
          Match: workflow_job.run_id
    EventEvaluationOrder: Chronological
    LookbackWindowMinutes: 90
    Schedule:
      RateMinutes: 60
      TimeoutMinutes: 10

Self-hosted runners

When pull_request_target workflows run on self-hosted runners, the risk significantly escalates. Attackers can gain direct code execution on your infrastructure with potential access to internal networks, databases, and systems. Unlike GitHub-hosted runners that are destroyed after each job, self-hosted runners persist and can be permanently compromised. This pattern represents critical risk regardless of whether the PR is cross-fork or from the same repository.

Detects self-hosted runner usage by checking the runner_name field. GitHub-hosted runners use the reserved name "GitHub Actions":

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")

Combining this with pull_request_target detection creates a higher severity alert:

Detection:
  - Group:
      - ID: PullRequestTarget
        RuleID: GitHub.Webhook.PullRequestTargetUsage
      - ID: SelfHostedRunner
        RuleID: GitHub.Webhook.SelfHostedRunnerUsed
    MatchCriteria:
      field_name:
        - GroupID: PullRequestTarget
          Match: workflow_run.id
        - GroupID: SelfHostedRunner
          Match: workflow_job.run_id
    EventEvaluationOrder: Chronological
    LookbackWindowMinutes: 90
    Schedule:
      RateMinutes: 60
      TimeoutMinutes: 10

GitHub Audit Logs

While webhooks provide real-time visibility into repository activity, audit logs offer broader organizational context. Available at the Enterprise level, these logs capture administrative actions, permission changes, and system-level events, making them invaluable for both real-time detection and retroactive threat hunting.

Documentation: https://docs.github.com/en/organizations/keeping-your-organization-secure/managing-security-settings-for-your-organization/reviewing-the-audit-log-for-your-organization

GITHUB_TOKEN

Permission changes

GITHUB_TOKEN is a temporary token automatically generated for each workflow run. Before 2023, this token had default read-write permissions. Repositories created before this change retained write permissions unless manually updated, a configuration that directly enabled the NX/S1ngularity compromise. With read-only permissions, the attacker would have been unable to inject malicious code directly into the repository.

With the rise of targeted attacks against developers and administrators, a compromised admin account could silently escalate GITHUB_TOKEN permissions from read-only to read-write. While this may not seem like the most dangerous capability an attacker could abuse with admin access, consider the targeted nature of recent attacks: an attacker compromising a smaller organization maintaining a widely-trusted open-source project could inject malicious code that propagates to enterprise users downstream.

Detect GITHUB_TOKEN permission changes in real-time:

def rule(event):

    return (
        event.get("action") == "org.set_default_workflow_permissions"
        and event.get("operation_type") == "modify"
    )

Hunting for GITHUB_TOKEN activity

If an attacker exfiltrates a read-write GITHUB_TOKEN, they can manually trigger workflows to execute malicious code. In the NX/S1ngularity attack, the final payload required the attacker to:

  1. Inject malicious code into a script with access to the NPM token

  2. Manually trigger that specific workflow to run on the branch containing the malicious code

To hunt retroactively for GITHUB_TOKEN activity, query your audit logs for:

"programmatic_access_type" == "GitHub App server-to-server token"

This field reveals all actions performed by GITHUB_TOKEN. To specifically identify manual workflow dispatches, a strong indicator of the attack pattern above, combine multiple conditions:

def rule(event):

    return all(
        [
            event.get("programmatic_access_type") == "GitHub App server-to-server token",
            event.get("event") == "workflow_dispatch",
            event.get("actor") == "github-actions[bot]",
            event.get("action") == "workflows.created_workflow_run",
        ]
    )

Injection Attacks in User Input

Command injection remains a common attack vector in CI/CD pipelines. Attackers embed malicious commands in user-controllable fields like PR titles, commit messages, or issue bodies, which workflows then execute unsafely.

Panther's GitHub detection library includes regex patterns to identify common bash injection techniques:

BASH_INJECTION_PATTERNS = [
    # Command substitution
    r"\$\([^)]+\)",  # $(command) - requires non-empty command
    # Variable expansion with command substitution
    r"\$\{[^}]*\$\([^)]+\)[^}]*\}",  # ${var$(cmd)var}
    r"\$\{[^}]*`[^`]+`[^}]*\}",  # ${var`cmd`var}
    # Process substitution
    r"<\([^)]+\)",  # <(command)
    r">\([^)]+\)",  # >(command)
    # Direct shell invocation
    r"/bin/(?:sh|bash|dash|zsh)\s+-c\s+",  # /bin/bash -c "command"
    r"(?:bash|sh)\s+-c\s+['\"]",  # bash -c "command"
    # Encoding/obfuscation attempts
    r"\\x[0-9a-fA-F]{4,}",  # Multiple hex bytes (longer sequences)
    r"eval\s*\(\s*\$",  # eval($(...)) patterns
    r"exec\s*\(\s*\$",  # exec($(...)) patterns
    # Network exfiltration patterns
    r"(?:curl|wget)\s+[^|>]+\|\s*(?:sh|bash)",  # curl url | bash
    r"nc\s+[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\s+[0-9]+",  # nc IP PORT
]

COMPILED_BASH_PATTERNS = [
    re.compile(pattern, re.IGNORECASE | re.MULTILINE) for pattern in BASH_INJECTION_PATTERNS
]

Apply these patterns to any user-controllable fields in webhook events:

  • Commits: Author names, email addresses, commit messages

  • Pull Requests: Title, body, head ref, labels, default branch

  • Issues: Title and body

  • GitHub Pages: Site content

  • Comments and Reviews: User-submitted text

Example detection for pull request events:

def rule(event):
    if not is_pull_request_event(event) or event.deep_get("action") != "opened":
        return False

    # Check all untrusted PR-related inputs
    fields_to_check = [
        event.deep_get("pull_request", "title"),
        event.deep_get("pull_request", "body"),
        event.deep_get("pull_request", "head", "ref"),
        event.deep_get("pull_request", "head", "label"),
        event.deep_get("pull_request", "head", "repo", "default_branch"),
    ]

    for field in fields_to_check:
        if contains_bash_injection_pattern(field):
            return True

    return False

Conclusion

As GitHub Actions continue to be an attractive target for supply chain attacks, organizations must move beyond reactive security measures. The detection and hunting strategies outlined provide defenders with actionable strategies to identify compromise attempts before they snowball. By combining GitHub webhook monitoring with audit log analysis, security teams can establish comprehensive visibility into their CI/CD pipeline risks.

The key takeaway is that effective defense requires strategic monitoring of the chokepoints where attackers must operate. As we've seen with the S1ngularity and changed-files compromises, attackers are investing time to understand your supply chain. It's time defenders invest equally in understanding and monitoring their attack surface.

All detection rules and helpers discussed in this workshop are available in Panther's detection library on GitHub.

Ready to dive into more threat research? Read our NX Threat Analysis next.

Share:

Share:

Share:

Share:

Ready for less noise
and more control?

See Panther in action. Book a demo today.

Get product updates, webinars, and news

By submitting this form, you acknowledge and agree that Panther will process your personal information in accordance with the Privacy Policy.

Product
Resources
Support
Company