Detection rules › Elastic

Several Failed Protected Branch Force Pushes by User

Status
production
Severity
medium
Time window
9m
Group by
user.name
Author
Elastic
Source
github.com/elastic/detection-rules

Detects a high number of failed force push attempts to protected branches by a single user within a short time frame. Adversaries may attempt multiple force pushes to overwrite commit history on protected branches, potentially leading to data loss or disruption of development workflows.

MITRE ATT&CK coverage

Event coverage

Rules detecting the same action

Other rules on this platform that filter on the same API call or operation.

Rule body elastic

[metadata]
creation_date = "2025/12/16"
integration = ["github"]
maturity = "production"
updated_date = "2026/04/10"

[rule]
author = ["Elastic"]
description = """
Detects a high number of failed force push attempts to protected branches by a single user within a short
time frame. Adversaries may attempt multiple force pushes to overwrite commit history on protected branches,
potentially leading to data loss or disruption of development workflows.
"""
from = "now-9m"
interval = "8m"
language = "esql"
license = "Elastic License v2"
name = "Several Failed Protected Branch Force Pushes by User"
note = """ ## Triage and analysis

> **Disclaimer**:
> This investigation guide was created using generative AI technology and has been reviewed to improve its accuracy and relevance. While every effort has been made to ensure its quality, we recommend validating the content and adapting it to suit your specific environment and operational needs.

### Investigating Several Failed Protected Branch Force Pushes by User

This rule flags a single user generating multiple failed force push attempts to protected branches within a short span, indicating attempts to rewrite commit history and bypass branch protections. An attacker with a compromised maintainer role repeatedly tries to roll back a security fix, delete prior commits, and erase history entries before pushing a malicious revision. This activity threatens code integrity, disrupts pipelines, and can propagate harmful changes across repositories.

### Possible investigation steps

- Pull audit entries for the rejected updates to confirm the rejection reasons and the exact org/repo/branch targets, then reconstruct the timeline and sequence of attempts.
- Verify the user's current and recent permissions, team membership, and role changes, and confirm whether any admin or ownership transfers occurred before the attempts.
- Correlate the attempts with authentication and token activity (SSO logins, PAT/SSH key usage, IP/device fingerprints, geo), flagging any new or unusual sources.
- Review branch protection settings and recent edits (require status checks, linear history, admin enforcement, force push exemptions) to detect policy tampering or misconfiguration.
- Identify the specific commits the force pushes sought to overwrite by diffing the attempted ref against the protected branch head, prioritizing impacts to security fixes, release branches, or signed commits.

### False positive analysis

- During a repository migration or history cleanup, a maintainer runs a local script that loops through branches and tries to push rewritten commits with --force, but newly tightened branch protection rejects each attempt, resulting in multiple failures.
- A developer who previously had a force-push exemption on a protected release branch loses that permission during a role or team change and continues their usual rebase-and-force-push workflow, causing several rapid rejected ref updates.

### Response and remediation

- Immediately block the user in the GitHub organization, revoke all active personal access tokens and SSH keys from their account, and force sign-out to stop further push attempts.
- On each affected repository and branch (e.g., main, release/*), remove any force-push exemptions, enable “Include administrators,” require signed commits and status checks, and restrict push access to specific teams.
- Purge staging artifacts by deleting any branches or tags the user created around the attempts, rotate the user’s password and regenerate PATs/SSH keys, and remove newly registered keys or OAuth apps added during the window.
- Validate recovery by confirming the protected branch HEAD matches the last known good signed commit SHA, re-running CI for impacted repos, and creating a restore point tag for rapid rollback.
- Escalate to incident response if any attempts targeted main or release branches, originated from a newly created PAT/SSH key or an unrecognized IP/device, or the user holds repo admin/organization owner rights.
- Harden long term by enforcing org-wide 2FA/SSO, removing all standing force-push exemptions, requiring CODEOWNERS approvals on protected branches, and enabling audit alerts for branch protection edits and new credential creation.
"""
references = [
    "https://www.wiz.io/blog/shai-hulud-2-0-ongoing-supply-chain-attack",
    "https://trigger.dev/blog/shai-hulud-postmortem",
    "https://posthog.com/blog/nov-24-shai-hulud-attack-post-mortem",
]
risk_score = 47
rule_id = "8bd1c36a-2c4f-4801-a43d-ba696c13ffc2"
severity = "medium"
tags = [
    "Domain: Cloud",
    "Use Case: Threat Detection",
    "Tactic: Impact",
    "Tactic: Exfiltration",
    "Data Source: Github",
    "Resources: Investigation Guide",
]
timestamp_override = "event.ingested"
type = "esql"
query = '''
from logs-github.audit-* metadata _id, _index, _version
| where
  data_stream.dataset == "github.audit" and
  github.category == "protected_branch" and
  event.action == "protected_branch.rejected_ref_update"
| stats
  Esql.document_count = COUNT(*),
  Esql.github_org_values = values(github.org),
  Esql.github_repo_values = values(github.repo),
  Esql.github_branch_values = values(github.branch),
  Esql.github_reasons_code_values = values(github.reasons.code),
  Esql.github_reasons_message_value = values(github.reasons.message),
  Esql.user_name_values = values(user.name),
  Esql.agent_id_values = values(agent.id),
  Esql.data_stream_dataset_values = values(data_stream.dataset),
  Esql.data_stream_namespace_values = values(data_stream.namespace)

  by user.name

| keep Esql.*

| where
  Esql.document_count >= 5
'''

[[rule.threat]]
framework = "MITRE ATT&CK"

[[rule.threat.technique]]
id = "T1485"
name = "Data Destruction"
reference = "https://attack.mitre.org/techniques/T1485/"

[[rule.threat.technique]]
id = "T1565"
name = "Data Manipulation"
reference = "https://attack.mitre.org/techniques/T1565/"

[[rule.threat.technique.subtechnique]]
id = "T1565.001"
name = "Stored Data Manipulation"
reference = "https://attack.mitre.org/techniques/T1565/001/"

[rule.threat.tactic]
id = "TA0040"
name = "Impact"
reference = "https://attack.mitre.org/tactics/TA0040/"

[[rule.threat]]
framework = "MITRE ATT&CK"

[[rule.threat.technique]]
id = "T1020"
name = "Automated Exfiltration"
reference = "https://attack.mitre.org/techniques/T1020/"

[[rule.threat.technique]]
id = "T1567"
name = "Exfiltration Over Web Service"
reference = "https://attack.mitre.org/techniques/T1567/"

[[rule.threat.technique.subtechnique]]
id = "T1567.001"
name = "Exfiltration to Code Repository"
reference = "https://attack.mitre.org/techniques/T1567/001/"

[rule.threat.tactic]
id = "TA0010"
name = "Exfiltration"
reference = "https://attack.mitre.org/tactics/TA0010/"

Stages and Predicates

Stage 1: from

from logs-github.audit-* metadata _id, _index, _version

Stage 2: where

| where
  data_stream.dataset == "github.audit" and
  github.category == "protected_branch" and
  event.action == "protected_branch.rejected_ref_update"

Stage 3: stats

| stats
  Esql.document_count = COUNT(*),
  Esql.github_org_values = values(github.org),
  Esql.github_repo_values = values(github.repo),
  Esql.github_branch_values = values(github.branch),
  Esql.github_reasons_code_values = values(github.reasons.code),
  Esql.github_reasons_message_value = values(github.reasons.message),
  Esql.user_name_values = values(user.name),
  Esql.agent_id_values = values(agent.id),
  Esql.data_stream_dataset_values = values(data_stream.dataset),
  Esql.data_stream_namespace_values = values(data_stream.namespace)

  by user.name

Stage 4: keep

| keep Esql.*

Stage 5: where

| where
  Esql.document_count >= 5

Indicators

Each row is a field, operator, and value that the rule matches. The corpus column counts how many other rules in the catalog look for the same combination: high numbers point to widely-used, community-vetted indicators. Blank or 1 shows that the indicator is specific to this rule.

FieldKindValues
Esql.document_countge
  • 5
data_stream.dataseteq
  • github.audit
event.actioneq
  • protected_branch.rejected_ref_update
github.categoryeq
  • protected_branch

Output fields

Fields the rule emits when it matches. Chronicle authors list these in the outcome block; they appear on the detection and $risk_score drives alerting. Sentinel / Defender XDR rules build them up through project / summarize / extend stages. Sentinel maps these into alert fields via entityMappings and customDetails; Defender XDR custom detections surface them as alert fields directly.

FieldSource
Esql.*KEEP Esql.*