GitHub Commits Skipping Workflows


Description

Detects commits from cross-fork scenarios that contain workflow skip directives, which bypass GitHub Actions workflows. These skip patterns ([skip ci], [ci skip], [no ci], [skip actions], [actions skip], skip-checks:true) can be used to avoid security checks and CI/CD processes. This rule only alerts on commits to public forkable repositories.

Query · python

import re

from panther_github_helpers import github_reference_url, github_webhook_alert_context

SKIP_PATTERNS = [
    r"\[skip ci\]",
    r"\[ci skip\]",
    r"\[no ci\]",
    r"\[skip actions\]",
    r"\[actions skip\]",
    r"skip-checks:\s*true",
]

COMPILED_PATTERNS = [re.compile(pattern, re.IGNORECASE) for pattern in SKIP_PATTERNS]


def rule(event):
    if not event.get("pusher"):
        return False

    repo = event.get("repository", {})
    if repo.get("private") or not repo.get("allow_forking"):
        return False

    messages = event.deep_walk("commits", "message")
    if not isinstance(messages, list):
        messages = [messages]

    for message in messages:
        if _has_skip_pattern(message):
            return True

    return False


def _has_skip_pattern(message):
    if not message:
        return False

    return any(pattern.search(message) for pattern in COMPILED_PATTERNS)


def title(event):
    repo_name = event.deep_get("repository", "full_name", default="<UNKNOWN_REPO>")
    head_commit = event.deep_get("head_commit", default={})
    commit_sha = head_commit.get("id", "<NO_SHA>")[:8]

    return f"Cross-fork workflow skip commit detected in {repo_name} ({commit_sha})"


def alert_context(event):
    context = github_webhook_alert_context(event)

    skip_commits = []

    commits = event.get("commits", [{}])
    for commit in commits:
        commit_message = commit.get("message", "")
        if _has_skip_pattern(commit_message):
            matched_patterns = [
                SKIP_PATTERNS[i]
                for i, pattern in enumerate(COMPILED_PATTERNS)
                if pattern.search(commit_message)
            ]

            skip_commits.append(
                {
                    "id": commit.get("id"),
                    "message": commit_message,
                    "author": commit.get("author", {}).get("name"),
                    "matched_patterns": matched_patterns,
                }
            )

    context["skip_commits"] = skip_commits

    return context


def reference(event):
    if reference_url := github_reference_url(event):
        return reference_url

    return "DEFAULT"

Analyst notes

  1. Review the commit message and author to determine if the workflow skip was intentional and authorized 2. Verify that skipping workflows is appropriate for the type of changes made 3. Check if the repository has policies requiring workflow runs for certain changes 4. Consider if the skip bypasses important security or quality checks 5. Monitor for patterns of excessive workflow skipping that might indicate policy circumvention
Raw source GitHub Commits Skipping Workflows · Panther Python
Esc
Published by panther-labs/panther-analysis ↗, licensed under Apache 2.0 ↗. Reproduced here unmodified.
AnalysisType: rule
Filename: github_workflow_skip_commits.py
RuleID: "GitHub.Webhook.WorkflowSkipCommits"
DisplayName: "GitHub Commits Skipping Workflows"
Enabled: true
LogTypes:
  - GitHub.Webhook
Reports:
  MITRE ATT&CK:
    - TA0001:T1195.002  # Supply Chain Compromise: Compromise Software Supply Chain
    - TA0005:T1622  # Defense Evasion: Debugger Evasion
Tags:
  - CI/CD
  - Workflow
Severity: Medium
Description: >
  Detects commits from cross-fork scenarios that contain workflow skip directives, which bypass GitHub Actions workflows.
  These skip patterns ([skip ci], [ci skip], [no ci], [skip actions], [actions skip], skip-checks:true)
  can be used to avoid security checks and CI/CD processes. This rule only alerts on commits to public forkable repositories.
Runbook: >
  1. Review the commit message and author to determine if the workflow skip was intentional and authorized
  2. Verify that skipping workflows is appropriate for the type of changes made
  3. Check if the repository has policies requiring workflow runs for certain changes
  4. Consider if the skip bypasses important security or quality checks
  5. Monitor for patterns of excessive workflow skipping that might indicate policy circumvention
Reference: https://docs.github.com/en/actions/managing-workflow-runs/skipping-workflow-runs
Tests:
  - Name: Public Forkable Repo with Skip CI Pattern
    ExpectedResult: true
    Log:
      {
        "pusher": {
          "name": "Developer",
          "email": "dev@example.com"
        },
        "ref": "refs/heads/main",
        "repository": {
          "id": 123456789,
          "name": "test-repo",
          "full_name": "org/test-repo",
          "private": false,
          "allow_forking": true,
          "owner": {
            "login": "org"
          }
        },
        "commits": [
          {
            "id": "abc123",
            "message": "Fix documentation [skip ci]",
            "author": {
              "name": "Developer",
              "email": "dev@example.com"
            }
          }
        ],
        "p_log_type": "GitHub.Webhook"
      }
  - Name: Public Forkable Repo with Case Insensitive Skip Pattern
    ExpectedResult: true
    Log:
      {
        "pusher": {
          "name": "Developer",
          "email": "dev@example.com"
        },
        "ref": "refs/heads/feature",
        "repository": {
          "id": 123456789,
          "name": "test-repo",
          "full_name": "org/test-repo", 
          "private": false,
          "allow_forking": true
        },
        "commits": [
          {
            "id": "def456",
            "message": "Update config [SKIP CI]"
          }
        ],
        "p_log_type": "GitHub.Webhook"
      }
  - Name: Public Forkable Repo with Multiple Skip Patterns
    ExpectedResult: true
    Log:
      {
        "pusher": {
          "name": "Developer",
          "email": "dev@example.com"
        },
        "ref": "refs/heads/main",
        "repository": {
          "private": false,
          "allow_forking": true,
          "full_name": "org/test-repo"
        },
        "commits": [
          {
            "id": "ghi789",
            "message": "Regular commit"
          },
          {
            "id": "jkl012",
            "message": "Minor fix [ci skip]"
          }
        ],
        "p_log_type": "GitHub.Webhook"
      }
  - Name: Private Repo with Skip CI Pattern (Should Not Alert)
    ExpectedResult: false
    Log:
      {
        "pusher": {
          "name": "Developer",
          "email": "dev@example.com"
        },
        "ref": "refs/heads/main",
        "repository": {
          "private": true,
          "allow_forking": true,
          "full_name": "org/private-repo"
        },
        "commits": [
          {
            "id": "private123",
            "message": "Internal change [skip ci]"
          }
        ],
        "p_log_type": "GitHub.Webhook"
      }
  - Name: Public Repo with Forking Disabled and Skip CI (Should Not Alert)
    ExpectedResult: false
    Log:
      {
        "pusher": {
          "name": "Developer",
          "email": "dev@example.com"
        },
        "ref": "refs/heads/main",
        "repository": {
          "private": false,
          "allow_forking": false,
          "full_name": "org/no-fork-repo"
        },
        "commits": [
          {
            "id": "nofork123",
            "message": "Change [skip ci]"
          }
        ],
        "p_log_type": "GitHub.Webhook"
      }
  - Name: Public Forkable Repo without Skip Patterns (Should Not Alert)
    ExpectedResult: false
    Log:
      {
        "pusher": {
          "name": "Developer",
          "email": "dev@example.com"
        },
        "ref": "refs/heads/main",
        "repository": {
          "private": false,
          "allow_forking": true,
          "full_name": "org/test-repo"
        },
        "commits": [
          {
            "id": "clean123",
            "message": "Add new feature with tests"
          }
        ],
        "p_log_type": "GitHub.Webhook"
      }
  - Name: Non-Push Event with Skip Pattern (Should Not Alert)
    ExpectedResult: false
    Log:
      {
        "pusher": {
          "name": "Developer",
          "email": "dev@example.com"
        },
        "pull_request": {
          "id": 123,
          "title": "Fix bug [skip ci]"
        },
        "repository": {
          "private": false,
          "allow_forking": true,
          "full_name": "org/test-repo"
        },
        "p_log_type": "GitHub.Webhook"
      }
  - Name: Public Forkable Repo with Skip Actions Pattern
    ExpectedResult: true
    Log:
      {
        "pusher": {
          "name": "Developer",
          "email": "dev@example.com"
        },
        "ref": "refs/heads/main",
        "repository": {
          "private": false,
          "allow_forking": true,
          "full_name": "org/test-repo"
        },
        "commits": [
          {
            "id": "actions123",
            "message": "Update README [skip actions]"
          }
        ],
        "p_log_type": "GitHub.Webhook"
      }
  - Name: Public Forkable Repo with Skip Checks Trailer
    ExpectedResult: true
    Log:
      {
        "pusher": {
          "name": "Developer",
          "email": "dev@example.com"
        },
        "ref": "refs/heads/main",
        "repository": {
          "private": false,
          "allow_forking": true,
          "full_name": "org/test-repo"
        },
        "commits": [
          {
            "id": "trailer123",
            "message": "Minor typo fix\n\nskip-checks: true"
          }
        ],
        "p_log_type": "GitHub.Webhook"
      }
  - Name: Public Forkable Repo with No CI Pattern
    ExpectedResult: true
    Log:
      {
        "pusher": {
          "name": "Developer",
          "email": "dev@example.com"
        },
        "ref": "refs/heads/main",
        "repository": {
          "private": false,
          "allow_forking": true,
          "full_name": "org/test-repo"
        },
        "commits": [
          {
            "id": "noci123",
            "message": "Documentation update [no ci]"
          }
        ],
        "p_log_type": "GitHub.Webhook"
      }


# ------ paired body: github_workflow_skip_commits.py ------

import re

from panther_github_helpers import github_reference_url, github_webhook_alert_context

SKIP_PATTERNS = [
    r"\[skip ci\]",
    r"\[ci skip\]",
    r"\[no ci\]",
    r"\[skip actions\]",
    r"\[actions skip\]",
    r"skip-checks:\s*true",
]

COMPILED_PATTERNS = [re.compile(pattern, re.IGNORECASE) for pattern in SKIP_PATTERNS]


def rule(event):
    if not event.get("pusher"):
        return False

    repo = event.get("repository", {})
    if repo.get("private") or not repo.get("allow_forking"):
        return False

    messages = event.deep_walk("commits", "message")
    if not isinstance(messages, list):
        messages = [messages]

    for message in messages:
        if _has_skip_pattern(message):
            return True

    return False


def _has_skip_pattern(message):
    if not message:
        return False

    return any(pattern.search(message) for pattern in COMPILED_PATTERNS)


def title(event):
    repo_name = event.deep_get("repository", "full_name", default="<UNKNOWN_REPO>")
    head_commit = event.deep_get("head_commit", default={})
    commit_sha = head_commit.get("id", "<NO_SHA>")[:8]

    return f"Cross-fork workflow skip commit detected in {repo_name} ({commit_sha})"


def alert_context(event):
    context = github_webhook_alert_context(event)

    skip_commits = []

    commits = event.get("commits", [{}])
    for commit in commits:
        commit_message = commit.get("message", "")
        if _has_skip_pattern(commit_message):
            matched_patterns = [
                SKIP_PATTERNS[i]
                for i, pattern in enumerate(COMPILED_PATTERNS)
                if pattern.search(commit_message)
            ]

            skip_commits.append(
                {
                    "id": commit.get("id"),
                    "message": commit_message,
                    "author": commit.get("author", {}).get("name"),
                    "matched_patterns": matched_patterns,
                }
            )

    context["skip_commits"] = skip_commits

    return context


def reference(event):
    if reference_url := github_reference_url(event):
        return reference_url

    return "DEFAULT"

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.