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:

  1. DO NOT open a public issue

  2. Email security concerns to the maintainers

  3. Include a detailed description and reproduction steps

  4. Allow time for a fix before public disclosure

See SECURITY.md for more details.

Additional Resources