Non-Repudiation in GitHub
A Comprehensive Guide to accurately determining root causes during security incidents
What Is Non-Repudiation?
It ensures that a person cannot later deny having performed an action.
Non-repudiation in the context of software development means having cryptographically verifiable evidence that links specific actions (commits, merges, releases) to specific individuals’ verified identity.
This property is crucial for:
Regulatory compliance: Many regulations like PCI DSS, GDPR, DORA, COPPA, and HIPAA require knowing who accessed or modified sensitive data
Legal protection: In case of intellectual property disputes or security breaches
Incident response: Accurately determining root causes during security incidents
Internal accountability: Ensuring team members are accountable for their code contributions
Git and GitHub Defaults: Privacy Over Accountability
Git Defaults
Git was designed with developer privacy and distributed workflows in mind, not enterprise views of accountability. i.e. It has these characteristics:
User identity: Git only requires a name and email, which can be arbitrarily set with
git config user.name
andgit config user.email
No verification: By default, Git doesn't verify that commits come from the claimed author
Local configuration: Identity settings are stored locally (each distribution point is a ‘local’ copy), not centrally enforced
Optional signing: While Git supports commit signing, it's not enabled by default - and not a simple ‘switch it on’ option
GitHub Defaults
GitHub builds on Git but inherits many of its trust assumptions:
Self-attestation: Users self-declare their identity when setting up accounts, there’s no identity verification unless you later decide to make payments
Multiple authentication methods: HTTPS with passwords/tokens or SSH keys - all with different security characteristics, all are available by default
Optional verification: "Verified" badges for signed commits are shown, but signing isn't mandatory or offered to be configured during onboarding
Flexible branching: No default protections on branches
Basic logging: Limited visibility into who performs specific actions, viewable in UI only with limited records and searching ability
Risks of Default Settings
Leaving these defaults in place creates several security risks:
Identity spoofing: Anyone can commit as anyone else by changing local Git settings, like the text name and text email address
Plausible deniability: Contributors can claim they didn't make specific changes if the change is not showing the “Verified” badge
Supply chain attacks: Malicious code can be inserted with false attribution to trusted parties
Credential sharing: Team members may share credentials, obscuring the individual it was intended to represent
Audit gaps: Inability to definitively prove who made what changes for compliance purposes
Legal vulnerability: Weakened position in intellectual property or security breach litigation
Implementing Non-Repudiation in GitHub Organizations
Implementing non-repudiation in GitHub requires a multi-layered approach that fundamentally shifts from Git's trust-based model to a verification-based model. By enforcing organisational email addresses, SSH authentication, commit signing, branch protections, and comprehensive logging, organisations can establish a robust chain of evidence linking actions to individuals.
The following 8 mandatory steps for gaining assurance of non-repudiation:
Enforce Organisation Domain Email Addresses
Why it matters: Ensures all contributors have verified organisational email addresses, establishing a baseline identity check.
source=github_audit_logs
| where _time > strptime("2025-03-01 00:00:00", "%Y-%m-%d %H:%M:%S")
| parse field=commit_author "* <*>" as author_name, author_email
| where NOT author_email MATCHES ".*@yourdomain\.com$"
| stats count by author_name, author_email, repo_name, actor
| sort count desc
| rename count as unauthorized_commits
| eval compliance_status="VIOLATION"
| fields _time, author_name, author_email, repo_name, actor, unauthorized_commits, compliance_status
You can even write a little verification metric too:
source=github_audit_logs
| where _time > strptime("2025-03-01 00:00:00", "%Y-%m-%d %H:%M:%S")
| parse field=commit_author "* <*>" as author_name, author_email
| stats count(eval(author_email MATCHES ".*@yourdomain\.com$")) as compliant_commits,
count(eval(NOT author_email MATCHES ".*@yourdomain\.com$")) as non_compliant_commits by repo_name
| eval compliance_percentage=round((compliant_commits/(compliant_commits+non_compliant_commits))*100, 2)
| sort compliance_percentage
| fields repo_name, compliant_commits, non_compliant_commits, compliance_percentage
Enforce SSH for Repository Access
Why it matters: Prevents password-based authentication, which is more susceptible to sharing and theft, and ensures stronger identity binding through cryptographic keys.
Navigate to your organisation's settings page
Select "Authentication security" from the left sidebar
Under "SSH certificate authorities", configure your organisation's CA
Go to "SSH key restrictions" and enable "Only allow SSH key pairs published by SSH Certificate Authorities"
Save changes
Verify in the logs that this policy is working as intended:
source=github_audit_logs
| where _time > strptime("2025-03-01 00:00:00", "%Y-%m-%d %H:%M:%S")
| where action="git.clone" OR action="git.fetch" OR action="git.pull"
| parse clone_url "^(https?|ssh|git)://*" as protocol
| where protocol != "ssh"
| stats count by actor, protocol, repo_name, _time
| sort _time desc
| rename count as non_compliant_operations
| eval compliance_status="VIOLATION"
Implement SSH Key-Based Commit Signing
Why it matters: Links commits cryptographically to the developer's SSH key, providing strong evidence of authorship.
Create documentation for developers explaining SSH commit signing
For MDM-managed devices, create deployment scripts that:
Generate and install SSH keys
Configure Git settings for SSH signing
Register SSH keys with GitHub
Key Registration:
curl -X POST \
-H "Accept: application/vnd.github.v3+json" \
-H "Authorization: Bearer YOUR_TOKEN" \
-d '{
"title": "Work Machine Key",
"key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJmr9zJbwkjZ...",
"type": "signing_key"
}' \
https://api.github.com/user/ssh_signing_keys
And use this query to find any repositories not following this policy:
source=github_audit_logs
| where _time > strptime("2025-03-05 00:00:00", "%Y-%m-%d %H:%M:%S")
| where action="git.push"
| join type=left commit_id [
source=github_commit_logs
| fields commit_id, verification_status
]
| where verification_status != "verified"
| stats count by actor, repo_name, verification_status, branch_name
| rename count as unsigned_commits
| eval compliance_status="VIOLATION"
Enforce Commit Signing
Why it matters: Ensures all code contributions have verifiable author signatures, preventing identity spoofing.
UI Implementation:
Navigate to your organisation's settings
Select "Repository defaults" from the left sidebar
Check "Require signed commits"
Save changes
For individual repositories:
Go to the repository settings
Select "Branches" from the left sidebar
Click "Add branch protection rule"
Enter the branch pattern (e.g.,
main
)Check "Require signed commits"
Save changes
Find any events where this control is being bypassed:
source=github_audit_logs
| where _time > strptime("2025-03-01 00:00:00", "%Y-%m-%d %H:%M:%S")
| where action="protected_branch.bypass" OR action="protected_branch.policy_override"
| stats count by actor, repo_name, branch_name, action
| sort count desc
| rename count as protection_bypasses
| eval compliance_status="VIOLATION"
Key Management & Key protection
To ensure that the SSH key is used by the SSH agent and Git processes while preventing the developer from reading the key, the organisation can follow these steps on macOS and Windows:
macOS
Provision the SSH key to the system with restricted permissions:
The private key should be owned by
root
and have restrictive permissions so that only the SSH agent can access it.The public key should be readable by the SSH agent but not by the user directly.
Configure the SSH agent to use the restricted key:
The SSH agent is started by the system and the restricted key is loaded into the agent.
Commands to execute on macOS:
# Copy the private key to a secure location
sudo cp /path/to/provisioned/private_key /var/root/.ssh/id_rsa
sudo chmod 600 /var/root/.ssh/id_rsa
sudo chown root:wheel /var/root/.ssh/id_rsa
# Copy the public key to the user's ssh directory for Git operations
sudo cp /path/to/provisioned/public_key /Users/developer/.ssh/id_rsa.pub
sudo chmod 644 /Users/developer/.ssh/id_rsa.pub
sudo chown developer:staff /Users/developer/.ssh/id_rsa.pub
# Start the SSH agent and add the private key (run as root)
sudo ssh-agent sh -c 'ssh-add /var/root/.ssh/id_rsa'
# Configure Git to use the SSH agent
sudo -u developer git config --global core.sshCommand "ssh -o IdentitiesOnly=yes -o IdentityFile=/var/root/.ssh/id_rsa"
Windows
Provision the SSH key to the system with restricted permissions:
The private key should be placed in a directory that only the SYSTEM account or an administrative account can access.
The public key should be accessible for SSH and Git operations but not directly readable by the user.
Configure the SSH agent to use the restricted key:
The SSH agent is started by the system and the restricted key is loaded into the agent.
Commands to execute on Windows:
# Copy the private key to a secure location
Copy-Item -Path "C:\path\to\provisioned\private_key" -Destination "C:\ProgramData\ssh\id_rsa"
icacls "C:\ProgramData\ssh\id_rsa" /inheritance:r
icacls "C:\ProgramData\ssh\id_rsa" /grant SYSTEM:F
icacls "C:\ProgramData\ssh\id_rsa" /grant Administrators:F
# Copy the public key to the user's ssh directory for Git operations
Copy-Item -Path "C:\path\to\provisioned\public_key" -Destination "C:\Users\developer\.ssh\id_rsa.pub"
icacls "C:\Users\developer\.ssh\id_rsa.pub" /grant "developer:(R)"
# Start the SSH agent and add the private key (run as SYSTEM or Administrator)
Start-Service ssh-agent
ssh-add "C:\ProgramData\ssh\id_rsa"
# Configure Git to use the SSH agent
git config --global core.sshCommand "C:\\Windows\\System32\\OpenSSH\\ssh.exe -o IdentitiesOnly=yes -o IdentityFile=C:\\ProgramData\\ssh\\id_rsa"
Provisioning the Key:
The private key is copied to a secure location (
/var/root/.ssh/id_rsa
on macOS,C:\ProgramData\ssh\id_rsa
on Windows) with restrictive permissions so that only the system or administrative accounts can access it.The public key is copied to the user's
.ssh
directory with read permissions for the user.
SSH Agent Configuration:
The SSH agent is started by the system or an administrative account, and the restricted private key is added to the agent.
Git is configured to use the SSH agent for authentication, ensuring the key is used without being directly accessible by the user.
Developer identity to commit identity correlation:
source=github_audit_logs
| where _time > strptime("2025-03-01 00:00:00", "%Y-%m-%d %H:%M:%S")
| where action="git.push"
| join type=left commit_id [
source=github_commit_logs
| parse field=commit_author "* <*>" as author_name, author_email
| fields commit_id, author_email
]
| eval expected_email=actor + "@yourdomain.com"
| where author_email != expected_email
| stats count by actor, author_email, expected_email, repo_name
| rename count as identity_mismatch_count
| eval compliance_status="VIOLATION"
Configure Branch Protection Policies
Why it matters: Prevents unauthorised changes to critical branches and enforces code review requirements.
Go to the repository settings
Select "Branches" from the left sidebar
Click "Add branch protection rule"
Enter branch pattern (e.g.,
main
orproduction
)Configure these recommended settings:
Require pull request reviews before merging
Require approval of the most recent push
Dismiss stale pull request approvals when new commits are pushed
Require status checks to pass before merging
Require signed commits
Include administrators
Save changes
curl -X PUT \
https://api.github.com/repos/YOUR-ORG/YOUR-REPO/branches/main/protection \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer YOUR-TOKEN" \
-d '{
"required_status_checks": null,
"enforce_admins": true,
"required_pull_request_reviews": null,
"restrictions": null,
"required_signatures": true
}'
Centralise and Monitor Logs
Why it matters: Provides a consolidated view of all actions across repositories for monitoring and auditing.
Implementation:
Set up audit log streaming
Configure GitHub Advanced Security alerts:
Enable Dependabot alerts
Enable code scanning with CodeQL
Enable secret scanning
Configure notifications to a security team channel
Implement audits:
Create a script to check for unsigned commits and message the committer
Verify branch protection settings are intact periodically
Review SSH key management in the onboarding process
Audit organisation members and permissions periodically
Spot check with GitHub API:
# Example script to check for unsigned commits
curl -H "Authorization: Bearer YOUR_TOKEN" \
"https://api.github.com/repos/YOUR_ORGANIZATION/REPO_NAME/commits?per_page=100" | \
jq '.[] | select(.commit.verification.verified == false) | {sha: .sha, author: .commit.author.name, date: .commit.author.date}'
GitHub log alerting and monitoring
These queries assume you're using a SIEM solution like Splunk, that can process SPL2 (Search Processing Language 2) against GitHub audit logs streamed to your centralised logging system.
Physical Access Risk Indicators
Attackers with physical access to developer machines could make signed commits with authorised keys.
Key Indicators:
Commits or authentications from unusual IP addresses
Activity outside normal working hours
Bursts of activity after periods of inactivity
Multiple rapid-fire actions that deviate from a user's normal patterns
// Detect unusual IP addresses for user authentication
source="github_audit_logs"
| where action IN ("git.clone", "git.push", "git.pull")
| stats count BY actor, src_ip, _time span=1h
| join actor [
source="github_audit_logs"
| stats count BY actor, src_ip
| stats dc(src_ip) AS usual_ip_count BY actor
]
| where count > 10 AND dc(src_ip) > usual_ip_count
| sort -count
// Detect off-hours commit activity
source="github_audit_logs"
| where action IN ("git.push", "repo.commit")
| eval hour=strftime(_time, "%H")
| eval is_business_hours=if(hour >= 9 AND hour <= 17, "yes", "no")
| where is_business_hours="no"
| stats count BY actor, repository, _time span=1d
| where count > 10
Key Management Issues
SSH keys may be compromised, improperly rotated, or retained by former employees.
Key Indicators:
SSH keys older than your rotation policy (e.g., 90 days)
Keys used by multiple IPs simultaneously
Keys used after employee offboarding dates
Unused keys with access to sensitive repositories
// Identify SSH keys that haven't been rotated
source="github_audit_logs"
| where action="ssh_key.create"
| stats latest(_time) AS last_rotation BY actor, key_fingerprint
| eval days_since_rotation=round((now() - last_rotation) / 86400)
| where days_since_rotation > 90
| sort -days_since_rotation
// Detect SSH keys used from multiple geographic locations quickly
source="github_audit_logs"
| where action IN ("git.push", "git.pull", "git.clone") AND auth_method="ssh_key"
| lookup geo_data src_ip OUTPUT country, city
| stats dc(country) AS country_count, values(country) AS countries, values(city) AS cities BY actor, key_fingerprint, _time span=6h
| where country_count > 1
| sort -_time
Administrator Override Monitoring
Organisation owners can bypass restrictions, potentially introducing security gaps.
Key Indicators:
Admin overrides of branch protection rules
Configuration changes to security settings
Permission changes by administrators
Direct commits to protected branches
// Monitor administrator overrides of branch protections
source="github_audit_logs"
| where action="protected_branch.policy_override" OR
(action="git.push" AND bypass_type="admin_override")
| stats count BY actor, repository, ref, _time span=1d
| sort -_time
// Track security configuration changes
source="github_audit_logs"
| where action IN ("org.update", "repo.config", "actions.set_permissions",
"dependabot_alerts.enable", "secret_scanning.enable",
"code_scanning.enable", "protected_branch.update")
AND actor_is_admin=true
| table _time, actor, action, repository, changes
| sort -_time
Third-Party Integration Risks
OAuth applications or GitHub Apps with extensive permissions could circumvent controls.
Key Indicators:
New OAuth applications being authorised
Changes in OAuth application permissions
High volumes of API access from third-party integrations
Applications accessing sensitive repositories
// Monitor OAuth app authorisations
source="github_audit_logs"
| where action IN ("oauth_app.create", "oauth_authorization.create", "integration.create", "integration_installation.create")
| table _time, actor, action, oauth_app_name, repository, scopes_requested
| sort -_time
// Track high-volume API calls from integrations
source="github_audit_logs"
| where auth_method IN ("oauth", "github_app", "installation_token")
| stats count BY actor, oauth_app_name, repository, _time span=1h
| where count > 1000
| sort -count
Historical Unsigned Commits
Legacy commits made before implementing signing requirements remain unverified.
Key Indicators:
Changes to older parts of the codebase
References to or modifications of files with unsigned commit history
Merge commits that incorporate unsigned historical commits
// Detect changes to files with historical unsigned commit history
source="github_audit_logs"
| where action="repo.commit"
| join repository [
source="unsigned_commits_inventory"
| fields repository, file_path
]
| stats count BY actor, repository, file_path, _time span=1d
| sort -_time
// Monitor merges that incorporate unsigned commits
source="github_audit_logs"
| where action="repo.merge"
| join merge_commit_sha [
source="github_commits"
| where verification_status!="verified"
| fields merge_commit_sha
]
| table _time, actor, repository, merge_commit_sha, parent_commits
Timing Attack Indicators
Commits could be backdated using Git's author date field to misrepresent when changes were made.
Key Indicators:
Discrepancies between commit author date and server receipt time
Large batches of commits with suspiciously ordered dates
Commits with dates in the distant past or future
// Detect suspiciously backdated commits
source="github_audit_logs"
| where action="repo.commit"
| eval server_time=_time
| eval author_time=strptime(commit_author_date, "%Y-%m-%dT%H:%M:%S")
| eval time_difference=abs(server_time - author_time)
| where time_difference > 86400 // More than 1 day difference
| sort -time_difference
| table _time, actor, repository, commit_sha, author_time, server_time, time_difference
// Find clusters of commits with sequential author dates but irregular server timestamps
source="github_audit_logs"
| where action="repo.commit"
| sort actor, repository, commit_author_date
| streamstats window=5 range=actor,repository avg(strptime(commit_author_date, "%Y-%m-%dT%H:%M:%S")) AS avg_author_time
| eval author_time_variance=abs(strptime(commit_author_date, "%Y-%m-%dT%H:%M:%S") - avg_author_time)
| where author_time_variance < 3600 AND server_time_variance > 86400
| stats count BY actor, repository, _time span=1d
User Account Compromise
If a user's GitHub account is compromised, attackers could make signed commits via GitHub's web interface.
Key Indicators:
Login attempts from new devices or locations
Password changes or recovery actions
Multiple failed authentication attempts
Unusual patterns in web UI vs Git client usage
// Detect potential account compromise
source="github_audit_logs"
| where action IN ("user.login", "user.failed_login")
| lookup geo_data src_ip OUTPUT country, city
| stats count AS login_attempts, dc(src_ip) AS unique_ips, values(country) AS countries, values(user_agent) AS user_agents BY actor, _time span=1d
| where (unique_ips > 3 OR mvcount(countries) > 1)
| sort -_time
// Identify sudden shifts from Git client to web UI usage patterns
source="github_audit_logs"
| where action IN ("git.push", "repo.commit")
| eval method=if(match(user_agent, "GitHub-Hookshot|GitHub Web Flow"), "web_ui", "git_client")
| stats count BY actor, method, _time span=1h
| eventstats sum(count) AS total_actions BY actor, _time span=24h
| eval percentage=round((count/total_actions) * 100)
| where percentage > 80
| sort -_time
Other Best Practices
Start with higher thresholds and gradually adjust to reduce false positives.
Develop multi-vector (chained) rules that combine multiple indicators for higher-confidence alerts.
For critical repositories, implement automated token revocation when suspicious patterns are detected.
Regularly review and refine these queries as your GitHub usage patterns and security requirements evolve.
Remember that these technical controls should be accompanied by clear policies, training, and regular audits to verify effectiveness which maintains the integrity of your non-repudiation assurance.
As GitHub continues to evolve its security features, stay updated on new capabilities that can further strengthen your organisation's stance on accountability and verification.