Security Best Practices¶
Guidelines for building secure GitHub Actions using github-action-toolkit.
Table of Contents¶
Input Validation¶
Always Validate Inputs¶
Never trust user inputs directly. Validate, sanitize, and constrain all inputs:
from github_action_toolkit import get_user_input, error
import re
def get_safe_branch_name() -> str:
"""Get and validate branch name input."""
branch = get_user_input('branch') or 'main'
# Only allow alphanumeric, dash, underscore, and forward slash
if not re.match(r'^[a-zA-Z0-9/_-]+$', branch):
error(
f"Invalid branch name: {branch}. "
"Only alphanumeric characters, dashes, underscores, "
"and forward slashes are allowed.",
title="Security: Invalid Input"
)
raise SystemExit(1)
return branch
Limit Input Length¶
Prevent resource exhaustion with length limits:
from github_action_toolkit import get_user_input, error
MAX_DESCRIPTION_LENGTH = 1000
description = get_user_input('description') or ''
if len(description) > MAX_DESCRIPTION_LENGTH:
error(
f"Description too long: {len(description)} characters "
f"(max: {MAX_DESCRIPTION_LENGTH})",
title="Security: Input Too Long"
)
raise SystemExit(1)
Path Traversal Prevention¶
Prevent path traversal attacks when handling file paths:
from pathlib import Path
from github_action_toolkit import get_user_input, error
def get_safe_file_path(base_dir: Path) -> Path:
"""Get validated file path within base directory."""
file_path = get_user_input('file-path')
if not file_path:
raise ValueError("file-path is required")
# Resolve to absolute path
requested_path = (base_dir / file_path).resolve()
# Ensure it's within base directory
if not requested_path.is_relative_to(base_dir):
error(
f"Invalid file path: {file_path}. "
"Path must be within the workspace.",
title="Security: Path Traversal Attempt"
)
raise SystemExit(1)
return requested_path
Secrets Management¶
Masking Secrets¶
Always mask secrets in logs:
from github_action_toolkit import add_mask, info
# Mask before any use
api_key = get_secret('api_key')
add_mask(api_key)
# Now safe to log
info(f"Using API key: {api_key}") # Will appear as "***"
Derived Secrets¶
Mask any values derived from secrets:
from github_action_toolkit import add_mask
import hashlib
api_key = get_secret('api_key')
add_mask(api_key)
# Hash or transform
key_hash = hashlib.sha256(api_key.encode()).hexdigest()
add_mask(key_hash) # Mask derived values too!
# Concatenations
combined = f"Bearer {api_key}"
add_mask(combined)
Never Log Secrets¶
# ❌ NEVER DO THIS
api_key = get_secret('api_key')
print(f"API Key: {api_key}") # Exposed in logs!
# ✅ DO THIS
api_key = get_secret('api_key')
add_mask(api_key)
info("API key configured successfully") # Safe
Temporary Files with Secrets¶
Clean up temporary files containing secrets:
from pathlib import Path
from github_action_toolkit import add_mask
import os
def use_secret_file(secret: str):
"""Safely use secret in a temporary file."""
add_mask(secret)
temp_file = Path('/tmp/secret.txt')
try:
# Use restrictive permissions (owner read/write only)
temp_file.write_text(secret)
temp_file.chmod(0o600)
# Use the file
process_secret_file(temp_file)
finally:
# Always clean up
if temp_file.exists():
temp_file.unlink()
Dependency Security¶
Pin Versions¶
Always pin dependency versions in your action:
# ❌ Unpinned - risky
runs:
using: 'composite'
steps:
- run: pip install github-action-toolkit
shell: bash
# ✅ Pinned - safer
runs:
using: 'composite'
steps:
- run: pip install github-action-toolkit==0.7.0
shell: bash
Verify Checksums¶
For critical dependencies, verify checksums:
import hashlib
from pathlib import Path
def verify_file_checksum(file_path: Path, expected_hash: str):
"""Verify file integrity with SHA-256."""
sha256_hash = hashlib.sha256()
with open(file_path, 'rb') as f:
for byte_block in iter(lambda: f.read(4096), b''):
sha256_hash.update(byte_block)
actual_hash = sha256_hash.hexdigest()
if actual_hash != expected_hash:
raise SecurityError(
f"Checksum mismatch for {file_path}!\n"
f"Expected: {expected_hash}\n"
f"Actual: {actual_hash}"
)
Scan Dependencies¶
Use tools to scan for known vulnerabilities:
# In your workflow
- name: Scan dependencies
run: |
pip install safety
safety check --json
Code Injection Prevention¶
Command Injection¶
Never use user input directly in shell commands:
from github_action_toolkit import get_user_input
import subprocess
import shlex
# ❌ DANGEROUS - Command injection risk
branch = get_user_input('branch')
subprocess.run(f"git checkout {branch}", shell=True) # NEVER DO THIS!
# ✅ SAFE - Use list and avoid shell
branch = get_user_input('branch')
subprocess.run(['git', 'checkout', branch], shell=False, check=True)
# ✅ SAFE - Quote if shell=True is absolutely needed
branch = get_user_input('branch')
quoted_branch = shlex.quote(branch)
subprocess.run(f"git checkout {quoted_branch}", shell=True, check=True)
SQL Injection Prevention¶
Use parameterized queries:
import sqlite3
# ❌ DANGEROUS
user_input = get_user_input('username')
cursor.execute(f"SELECT * FROM users WHERE username = '{user_input}'")
# ✅ SAFE - Use parameters
user_input = get_user_input('username')
cursor.execute("SELECT * FROM users WHERE username = ?", (user_input,))
Template Injection¶
Be careful with user input in templates:
from github_action_toolkit import get_user_input
from string import Template
# ❌ DANGEROUS - f-string with user input
name = get_user_input('name')
message = f"Hello {name}" # Could be "Hello ${SECRET}"
# ✅ SAFER - Use Template with safe substitution
template = Template("Hello $name")
message = template.safe_substitute(name=name)
API Token Security¶
Minimal Permissions¶
Request only the permissions you need:
# In your workflow
permissions:
contents: read # Only what's needed
pull-requests: write # No more than necessary
Token Scoping¶
Use scoped tokens when possible:
from github_action_toolkit import GitHubAPIClient
# Use installation token with scoped permissions
client = GitHubAPIClient(
github_token=get_installation_token(), # Scoped token
)
# Not personal access token with full access
Token Expiry¶
For long-running operations, handle token expiry:
from github_action_toolkit import GitHubAPIClient, warning
import time
def operation_with_token_refresh():
"""Handle token expiry in long operations."""
client = GitHubAPIClient()
start_time = time.time()
for item in large_dataset:
# Refresh token every hour (GitHub tokens last ~1 hour)
if time.time() - start_time > 3000: # 50 minutes
warning("Token may expire soon, consider refreshing")
# Refresh logic here
start_time = time.time()
process_item(client, item)
Environment Variable Safety¶
Avoid Exporting Secrets¶
Never export secrets as environment variables unless absolutely necessary:
from github_action_toolkit import add_mask
import os
# ❌ RISKY - Exported to environment
api_key = get_secret('api_key')
os.environ['API_KEY'] = api_key # Now visible to all subprocesses
# ✅ BETTER - Pass directly as needed
api_key = get_secret('api_key')
add_mask(api_key)
make_api_call(api_key=api_key)
Clean Environment¶
Remove secrets from environment after use:
from github_action_toolkit import with_env
# Automatically cleaned up
with with_env(TEMP_SECRET=secret_value):
# Use secret here
pass
# Secret removed from environment here
Artifact Security¶
Validate Artifact Contents¶
Don’t trust artifact contents without validation:
from github_action_toolkit import GitHubArtifacts
from pathlib import Path
import zipfile
def safe_extract_artifact(artifact_id: int, dest: Path):
"""Safely extract artifact with validation."""
artifacts = GitHubArtifacts()
# Download to temp location
temp_zip = Path('/tmp/artifact.zip')
artifacts.download_artifact(artifact_id, str(temp_zip))
# Validate before extracting
with zipfile.ZipFile(temp_zip, 'r') as zf:
# Check for path traversal in zip
for name in zf.namelist():
if name.startswith('/') or '..' in name:
raise SecurityError(
f"Malicious path in artifact: {name}"
)
# Check compressed size
total_size = sum(info.file_size for info in zf.infolist())
if total_size > 100 * 1024 * 1024: # 100 MB
raise SecurityError("Artifact too large")
# Safe to extract
zf.extractall(dest)
Artifact Retention¶
Don’t keep sensitive artifacts forever:
from github_action_toolkit import GitHubArtifacts
artifacts = GitHubArtifacts()
# Set short retention for sensitive data
artifacts.upload_artifact(
name='sensitive-logs',
paths=['logs/'],
retention_days=1 # Delete after 1 day
)
Network Security¶
HTTPS Only¶
Always use HTTPS for external requests:
import requests
def safe_api_call(url: str):
"""Make API call with security checks."""
# Ensure HTTPS
if not url.startswith('https://'):
raise SecurityError(
f"Only HTTPS URLs allowed, got: {url}"
)
response = requests.get(
url,
timeout=30, # Prevent hanging
verify=True, # Verify SSL certificates
)
return response
Verify SSL Certificates¶
Never disable certificate verification in production:
import requests
# ❌ NEVER DO THIS IN PRODUCTION
response = requests.get(url, verify=False)
# ✅ Always verify certificates
response = requests.get(url, verify=True)
Rate Limiting¶
Implement rate limiting for external calls:
import time
from collections import deque
class RateLimiter:
"""Simple rate limiter."""
def __init__(self, max_calls: int, period: float):
self.max_calls = max_calls
self.period = period
self.calls = deque()
def wait_if_needed(self):
"""Wait if rate limit would be exceeded."""
now = time.time()
# Remove old calls
while self.calls and self.calls[0] < now - self.period:
self.calls.popleft()
# Wait if at limit
if len(self.calls) >= self.max_calls:
sleep_time = self.period - (now - self.calls[0])
if sleep_time > 0:
time.sleep(sleep_time)
self.calls.append(now)
# Use it
limiter = RateLimiter(max_calls=10, period=60) # 10 calls per minute
for url in urls:
limiter.wait_if_needed()
fetch(url)
Security Checklist¶
Use this checklist when reviewing your action:
Input Handling¶
[ ] All inputs validated and sanitized
[ ] Input length limits enforced
[ ] Path traversal prevented
[ ] Command injection prevented
[ ] SQL injection prevented (if using DB)
Secrets Management¶
[ ] All secrets masked with
add_mask()[ ] Secrets never logged or printed
[ ] Derived values from secrets are masked
[ ] Temporary files with secrets cleaned up
[ ] Secrets not exported to environment unnecessarily
Dependencies¶
[ ] All dependency versions pinned
[ ] Known vulnerabilities checked
[ ] Minimal dependencies used
[ ] Dependencies from trusted sources only
API Security¶
[ ] Minimal token permissions requested
[ ] Token expiry handled
[ ] Rate limits respected
[ ] HTTPS enforced
[ ] SSL certificates verified
Artifacts¶
[ ] Artifact contents validated before use
[ ] Sensitive artifacts have short retention
[ ] No secrets in artifact names or metadata
Code Quality¶
[ ] No hardcoded secrets
[ ] Error messages don’t expose sensitive info
[ ] Logging doesn’t expose sensitive data
[ ] File permissions restrictive for sensitive files
Reporting Security Issues¶
If you discover a security vulnerability in github-action-toolkit:
DO NOT open a public issue
Email security concerns to the maintainers
Include a detailed description and reproduction steps
Allow time for a fix before public disclosure
See SECURITY.md for more details.