Skip to content

Add minimum token permissions for all github workflow filesΒ #16

@opentelemetrybot

Description

@opentelemetrybot

Add minimum token permissions for all GitHub workflow files to improve security posture according to OpenSSF Scorecard recommendations.

Context

This addresses the Token-Permissions check from https://github.com/ossf/scorecard/blob/ab2f6e92482462fe66246d9e32f642855a691dc1/docs/checks.md#token-permissions.

Step 1: Add Root-Level Permissions

Every workflow file MUST have a root-level permissions: YAML block.

Rules for Root-Level Permission YAML Block:

  1. πŸ” READ THE FILE FIRST: Always examine the existing formatting style before making any changes
  2. Root-Level permissions MUST be limited to either read-all or contents: read
  3. If the existing root-level permission is read-all then leave it unchanged
  4. If existing root-level permissions have more than read permissions, then move the root-level permission down to the job level where it's needed
    (follow Step 2 for rules about adding job level permissions)
  5. Standard format: New root-level permissions should be:
    permissions:
      contents: read
  6. Placement: Insert immediately after the root-level on: YAML block (no other root-level YAML blocks in between)
  7. Don't reorder existing root-level YAML blocks - only add the permissions block
  8. Preserve formatting: Match the existing blank line style (see detailed rules below)
  9. Do not add any comments to the root-level permissions YAML block

πŸ” CRITICAL: Blank Line Formatting Rules

STEP 1: Look at the original file. Find the on: root-level YAML block and see what comes immediately after it.

STEP 2: Apply the matching rule:

Rule A - If there is NO blank line between the on: root-level YAML block and the next root-level YAML block:

Insert permissions immediately after the `on:` root-level YAML block with NO blank lines above or below

Rule B - If there IS a blank line between on: root-level YAML block and the next root-level YAML block:

Insert permissions with blank lines both above and below

πŸ§ͺ MANDATORY: Run This Verification Script

BEFORE making any changes, copy and run this Python script to understand the blank line pattern:

import sys

def analyze_yaml_formatting(file_path):
    """
    Analyzes the blank line pattern after the 'on:' block in a YAML workflow file.
    Use this to determine which formatting rule (A or B) to apply.
    """
    try:
        with open(file_path, 'r', encoding='utf-8') as f:
            lines = f.readlines()
    except Exception as e:
        print(f"Error reading file: {e}")
        return
    
    # Find the 'on:' root-level block
    on_block_end = -1
    in_on_block = False
    next_block_line = -1
    
    for i, line in enumerate(lines):
        stripped = line.strip()
        
        # Found root-level 'on:' block
        if stripped == 'on:' and not line.startswith(' '):
            in_on_block = True
            print(f"Found 'on:' block at line {i+1}")
            continue
            
        # We're in the on block, look for the end
        if in_on_block:
            # If this line has content and is indented, it's part of the on block
            if stripped and line.startswith(' '):
                on_block_end = i
            # If line starts with non-space and isn't empty, we've found the next root-level block
            elif stripped and not line.startswith(' '):
                next_block_line = i
                break
    
    if on_block_end == -1:
        print("Could not find complete 'on:' block structure")
        return
    
    # Check if there's a blank line between on block and next block
    has_blank_line = (on_block_end + 1 < len(lines) and 
                     lines[on_block_end + 1].strip() == '')
    
    print(f"On block ends at line {on_block_end + 1}")
    print(f"Next root-level block starts at line {next_block_line + 1}: '{lines[next_block_line].strip()}'")
    print(f"Blank line between them: {has_blank_line}")
    print()
    
    if has_blank_line:
        print("🟒 RULE B APPLIES: Insert permissions with blank lines above and below")
        print("Format should be:")
        print("on:")
        print("  # on block content")
        print("")
        print("permissions:")
        print("  contents: read")
        print("")
        print("next-block:")
    else:
        print("🟒 RULE A APPLIES: Insert permissions with NO blank lines")
        print("Format should be:")
        print("on:")
        print("  # on block content")
        print("permissions:")
        print("  contents: read")
        print("next-block:")

# Usage: analyze_yaml_formatting('/path/to/workflow.yml')

How to use this script:

  1. Save the script as check_formatting.py
  2. Run: python check_formatting.py (then call analyze_yaml_formatting('/path/to/workflow.yml'))
  3. Follow the output to determine which rule applies
  4. Make your changes accordingly

βœ… FINAL VERIFICATION: Check Root-Level Permissions

AFTER making changes, run this verification script to ensure the root-level permissions block is correct:

def verify_root_permissions(file_path):
    """
    Verifies that the root-level permissions block is correctly formatted.
    Must be either 'permissions: read-all' or 'permissions:\\n  contents: read'
    """
    try:
        with open(file_path, 'r', encoding='utf-8') as f:
            lines = f.readlines()
    except Exception as e:
        print(f"Error reading file: {e}")
        return False
    
    # Find the root-level permissions block
    permissions_line = -1
    for i, line in enumerate(lines):
        stripped = line.strip()
        # Found root-level 'permissions:' block (not indented)
        if stripped.startswith('permissions:') and not line.startswith(' '):
            permissions_line = i
            break
    
    if permissions_line == -1:
        print("❌ ERROR: No root-level 'permissions:' block found")
        return False
    
    perm_line = lines[permissions_line].strip()
    
    # Check for valid formats
    if perm_line == 'permissions: read-all':
        print("βœ… VALID: Root-level permissions is 'permissions: read-all'")
        return True
    elif perm_line == 'permissions:':
        # Check if next line is '  contents: read' and there are no additional permissions
        if permissions_line + 1 >= len(lines):
            print(f"❌ ERROR: Found 'permissions:' but no content following it")
            return False
        
        contents_line = lines[permissions_line + 1]
        if (contents_line.strip() == 'contents: read' and 
            contents_line.startswith('  ')):
            
            # Check that there are no additional permissions after contents: read
            next_line_idx = permissions_line + 2
            if (next_line_idx < len(lines) and 
                lines[next_line_idx].strip() != '' and 
                lines[next_line_idx].startswith('  ')):
                print(f"❌ ERROR: Found additional permissions after 'contents: read'")
                print(f"Additional line: '{lines[next_line_idx].strip()}'")
                print("Root-level permissions must contain ONLY 'contents: read'")
                return False
            
            print("βœ… VALID: Root-level permissions is 'permissions:\\n  contents: read'")
            return True
        else:
            print(f"❌ ERROR: Found 'permissions:' but next line is not '  contents: read'")
            print(f"Next line: '{contents_line.strip()}'")
            return False
    else:
        print(f"❌ ERROR: Invalid root-level permissions format: '{perm_line}'")
        print("Must be either 'permissions: read-all' or 'permissions:' followed by '  contents: read'")
        return False

# Usage: verify_root_permissions('/path/to/workflow.yml')

How to use this verification:

  1. Add the function to your check_formatting.py file
  2. After making changes, run: verify_root_permissions('/path/to/workflow.yml')
  3. Ensure it returns βœ… VALID before moving to the next file

Step 2: For Regular Workflow Jobs, apply appropriate Job-Level Permissions

Regular Workflow Jobs are defined here as workflow jobs that DO NOT have a uses: node directly under the job node and DO have a steps: node.

Check these and add job-specific permissions for any that need more than read permission.

When to Add Job Permissions for Regular Workflow Jobs

  • Steps explicitly using secrets.GITHUB_TOKEN or github.token
    • If the step calls a script, analyze that script to see what permissions are needed
  • Steps that use actions/github-script implicitly use github.token
    • Analyze the script it is executing to see what permissions are needed
  • Steps that call a script: Analyze the script to see what permissions are needed

Job Permission Rules for Regular Workflow Jobs

  1. If only read permissions are needed then do not insert a new permissions block.

  2. Placement: Insert the permissions: YAML block at the very top of the job YAML block, or directly under a needs: block if one exists

    Example A - Job without needs: block:

    jobs:
      my-job:
        permissions:
          contents: write  # required for pushing changes
        name: My Job
        runs-on: ubuntu-latest
        steps:
          - name: Checkout
            uses: actions/checkout@v3

    Example B - Job with needs: block:

    jobs:
      my-job:
        needs: [setup, build]
        permissions:
          contents: write  # required for pushing changes
          pull-requests: write  # required for commenting on PRs
        name: My Job
        runs-on: ubuntu-latest
        steps:
          - name: Checkout
            uses: actions/checkout@v3
  3. Don't reorder existing YAML blocks

  4. Only add write permissions

  5. Preserve existing read permissions if they are already present

  6. Add a trailing comment for each write permission that you add, explaining briefly why it's needed, e.g. # required for assigning reviewers to PRs. Trailing line comments should only have a single space before the #.

  7. DO NOT add any comment for write permissions which were already present

  8. DO NOT add any additional comment for write permissions moved down from the root-level permissions block

Common Permission Patterns:

  • JamesIves/github-pages-deploy-action β†’ needs contents: write
  • Writing to repository β†’ needs contents: write
  • Creating releases β†’ needs contents: write
  • Posting comments β†’ needs issues: write or pull-requests: write

⚠️ Important Exceptions:

  • Steps which use other tokens such as OPENTELEMETRYBOT_GITHUB_TOKEN: Custom tokens don't need workflow permissions
  • Steps which use actions/cache/save: Doesn't require special permissions
  • Don't add unnecessary permissions: Only add what's actually needed

Step 3: For Workflow Jobs that call a local reusable workflow, apply appropriate Job-Level Permissions

After applying Step 2 to all workflows, check each workflow job that calls a local reusable workflow.
These are workflow jobs that have a uses: node directly under the job node and have NO steps: node.

Read the local reusable workflow file and gather all of the permissions that it requires
(from its root-level permission block workflow AND all job-specific permission blocks).
This may include one or more read permissions in addition to write permissions.

This is the set of permissions required by the job that calls the local reusable workflow.

If the local reusable workflow only requires "contents: read" permissions, then do not add a job-specific permission block. Otherwise apply these rules when adding the job-specific permission block:

Notes that only apply to Jobs that call a local reusable workflow

  1. Placement: If a job-level permissions: block already exists, do not reorder it.
    If one does not already exist and you are going to add one, it should be the first line under the job name node. With one exception, if the job has a needs: block then add the permissions: block directly after that node.
  2. If you add a new permissions block, "contents: read" should be the first permission listed under it.
  3. Don't reorder existing YAML blocks
  4. Add a trailing comment only to the line with text permissions:: # required by the reusable workflow. Trailing line comments should only have a single space before the #. Don't add any comments to any other lines in this case.
  5. DO NOT add any comments to any of the read or write permissions in this case. Only add the above comment above as a trailing comment specifically on the permissions: line

πŸ” Comprehensive Workflow Permissions Verification Script

Use this Python script to verify that all workflows have proper root-level permissions defined. This script uses the PyYAML library for accurate YAML parsing and provides detailed analysis of each workflow file.

Installation Requirements

First, install the required Python package:

pip install PyYAML

Complete Verification Script

Save this as verify_all_workflow_permissions.py:

#!/usr/bin/env python3
"""
Comprehensive GitHub Workflow Permissions Verification Script

This script verifies that all GitHub workflow files have proper root-level permissions
defined according to OpenSSF Scorecard recommendations.

Requirements:
- PyYAML library: pip install PyYAML

Usage:
    python verify_all_workflow_permissions.py [directory]
    
If no directory is provided, it will scan the current directory recursively.
"""

import os
import sys
import yaml
import glob
from pathlib import Path
from typing import Dict, List, Tuple, Optional, Any

class WorkflowPermissionsVerifier:
    """Verifies GitHub workflow permissions compliance."""
    
    def __init__(self):
        self.valid_root_permissions = {
            'read-all',
            'write-all',  # Not recommended but valid
            'contents: read'
        }
        
        # Statistics
        self.total_files = 0
        self.files_with_errors = 0
        self.files_with_warnings = 0
        self.files_passed = 0
        
    def find_workflow_files(self, directory: str = '.') -> List[str]:
        """Find all GitHub workflow files in the given directory."""
        workflow_patterns = [
            '**/.github/workflows/*.yml',
            '**/.github/workflows/*.yaml'
        ]
        
        workflow_files = []
        for pattern in workflow_patterns:
            workflow_files.extend(glob.glob(os.path.join(directory, pattern), recursive=True))
        
        return sorted(workflow_files)
    
    def load_yaml_file(self, file_path: str) -> Tuple[Optional[Dict], Optional[str]]:
        """Load and parse a YAML file safely."""
        try:
            with open(file_path, 'r', encoding='utf-8') as f:
                content = yaml.safe_load(f)
                return content, None
        except yaml.YAMLError as e:
            return None, f"YAML parsing error: {e}"
        except Exception as e:
            return None, f"File reading error: {e}"
    
    def check_root_permissions(self, workflow_data: Dict, file_path: str) -> Tuple[bool, List[str], List[str]]:
        """
        Check if the workflow has proper root-level permissions.
        
        Returns:
            (is_valid, errors, warnings)
        """
        errors = []
        warnings = []
        
        # Check if permissions key exists at root level
        if 'permissions' not in workflow_data:
            errors.append("Missing root-level 'permissions' block")
            return False, errors, warnings
        
        permissions = workflow_data['permissions']
        
        # Handle different permission formats
        if permissions is None:
            errors.append("Root-level 'permissions' block is empty")
            return False, errors, warnings
        
        if isinstance(permissions, str):
            # Single string permission like 'read-all'
            if permissions == 'read-all':
                return True, errors, warnings
            elif permissions == 'write-all':
                warnings.append("Root-level 'write-all' permission is overly permissive - consider using 'read-all' or 'contents: read'")
                return True, errors, warnings
            else:
                errors.append(f"Invalid root-level permissions string: '{permissions}'")
                return False, errors, warnings
        
        elif isinstance(permissions, dict):
            # Dictionary permissions like {'contents': 'read'}
            if len(permissions) == 1 and permissions.get('contents') == 'read':
                return True, errors, warnings
            elif len(permissions) == 0:
                errors.append("Root-level permissions block is empty")
                return False, errors, warnings
            else:
                # Check if it has more than just contents: read
                perm_items = list(permissions.items())
                if len(perm_items) > 1 or (len(perm_items) == 1 and perm_items[0] != ('contents', 'read')):
                    warnings.append(f"Root-level permissions should be limited to 'contents: read' or 'read-all'. Found: {permissions}")
                    warnings.append("Consider moving specific permissions to job level if needed")
                    return True, errors, warnings
                else:
                    return True, errors, warnings
        
        else:
            errors.append(f"Invalid root-level permissions format: {type(permissions)}")
            return False, errors, warnings
    
    def analyze_job_permissions(self, workflow_data: Dict) -> Dict[str, Any]:
        """Analyze job-level permissions for informational purposes."""
        job_analysis = {}
        
        if 'jobs' not in workflow_data:
            return job_analysis
        
        jobs = workflow_data['jobs']
        if not isinstance(jobs, dict):
            return job_analysis
        
        for job_name, job_data in jobs.items():
            if not isinstance(job_data, dict):
                continue
                
            analysis = {
                'has_permissions': False,
                'permissions': None,
                'is_reusable_workflow': False,
                'has_steps': False
            }
            
            # Check if job has permissions
            if 'permissions' in job_data:
                analysis['has_permissions'] = True
                analysis['permissions'] = job_data['permissions']
            
            # Check if it's a reusable workflow call
            if 'uses' in job_data and 'steps' not in job_data:
                analysis['is_reusable_workflow'] = True
            
            # Check if it has steps
            if 'steps' in job_data:
                analysis['has_steps'] = True
            
            job_analysis[job_name] = analysis
        
        return job_analysis
    
    def verify_workflow_file(self, file_path: str) -> Dict[str, Any]:
        """Verify a single workflow file."""
        result = {
            'file_path': file_path,
            'is_valid': False,
            'errors': [],
            'warnings': [],
            'job_analysis': {}
        }
        
        # Load the YAML file
        workflow_data, load_error = self.load_yaml_file(file_path)
        if load_error:
            result['errors'].append(load_error)
            return result
        
        if not isinstance(workflow_data, dict):
            result['errors'].append("Workflow file is not a valid YAML dictionary")
            return result
        
        # Check root-level permissions
        is_valid, errors, warnings = self.check_root_permissions(workflow_data, file_path)
        result['is_valid'] = is_valid
        result['errors'].extend(errors)
        result['warnings'].extend(warnings)
        
        # Analyze job-level permissions
        result['job_analysis'] = self.analyze_job_permissions(workflow_data)
        
        return result
    
    def print_file_result(self, result: Dict[str, Any]) -> None:
        """Print the verification result for a single file."""
        file_path = result['file_path']
        relative_path = os.path.relpath(file_path)
        
        if result['is_valid']:
            if result['warnings']:
                print(f"⚠️  {relative_path}")
                self.files_with_warnings += 1
            else:
                print(f"βœ… {relative_path}")
                self.files_passed += 1
        else:
            print(f"❌ {relative_path}")
            self.files_with_errors += 1
        
        # Print errors
        for error in result['errors']:
            print(f"   ERROR: {error}")
        
        # Print warnings
        for warning in result['warnings']:
            print(f"   WARNING: {warning}")
        
        # Print job analysis if there are jobs with permissions
        job_analysis = result['job_analysis']
        jobs_with_permissions = [name for name, analysis in job_analysis.items() if analysis['has_permissions']]
        
        if jobs_with_permissions:
            print(f"   πŸ“ Jobs with permissions: {', '.join(jobs_with_permissions)}")
        
        print()  # Empty line for readability
    
    def verify_all_workflows(self, directory: str = '.') -> Dict[str, Any]:
        """Verify all workflow files in the given directory."""
        workflow_files = self.find_workflow_files(directory)
        
        if not workflow_files:
            print(f"No GitHub workflow files found in {directory}")
            return {'summary': 'No workflows found'}
        
        print(f"Found {len(workflow_files)} workflow file(s) to verify:\n")
        
        results = []
        for file_path in workflow_files:
            self.total_files += 1
            result = self.verify_workflow_file(file_path)
            results.append(result)
            self.print_file_result(result)
        
        # Print summary
        print("=" * 60)
        print("VERIFICATION SUMMARY")
        print("=" * 60)
        print(f"Total files checked: {self.total_files}")
        print(f"βœ… Passed: {self.files_passed}")
        print(f"⚠️  Warnings: {self.files_with_warnings}")
        print(f"❌ Errors: {self.files_with_errors}")
        
        if self.files_with_errors > 0:
            print(f"\n❌ {self.files_with_errors} file(s) have errors that need to be fixed")
            sys.exit(1)
        elif self.files_with_warnings > 0:
            print(f"\n⚠️  {self.files_with_warnings} file(s) have warnings - review recommended")
        else:
            print(f"\nπŸŽ‰ All workflow files have proper root-level permissions!")
        
        return {
            'total_files': self.total_files,
            'files_passed': self.files_passed,
            'files_with_warnings': self.files_with_warnings,
            'files_with_errors': self.files_with_errors,
            'results': results
        }

def main():
    """Main function to run the verification."""
    import argparse
    
    parser = argparse.ArgumentParser(
        description="Verify GitHub workflow permissions compliance",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Examples:
    python verify_all_workflow_permissions.py
    python verify_all_workflow_permissions.py /path/to/repo
    python verify_all_workflow_permissions.py --help
        """
    )
    
    parser.add_argument(
        'directory',
        nargs='?',
        default='.',
        help='Directory to scan for workflow files (default: current directory)'
    )
    
    parser.add_argument(
        '--quiet',
        action='store_true',
        help='Only show summary (suppress individual file results)'
    )
    
    args = parser.parse_args()
    
    if not os.path.exists(args.directory):
        print(f"Error: Directory '{args.directory}' does not exist")
        sys.exit(1)
    
    print("GitHub Workflow Permissions Verification")
    print("=" * 60)
    print(f"Scanning directory: {os.path.abspath(args.directory)}")
    print()
    
    verifier = WorkflowPermissionsVerifier()
    
    # Temporarily suppress individual file output if quiet mode
    if args.quiet:
        original_print_file_result = verifier.print_file_result
        verifier.print_file_result = lambda result: None
    
    try:
        summary = verifier.verify_all_workflows(args.directory)
        
        if args.quiet:
            # Restore original method and print summary
            verifier.print_file_result = original_print_file_result
    
    except KeyboardInterrupt:
        print("\n\nVerification interrupted by user")
        sys.exit(1)
    except Exception as e:
        print(f"Unexpected error: {e}")
        sys.exit(1)

if __name__ == '__main__':
    main()

Quick Usage Examples

  1. Verify all workflows in current directory:

    python verify_all_workflow_permissions.py
  2. Verify workflows in a specific directory:

    python verify_all_workflow_permissions.py /path/to/your/repo
  3. Quiet mode (summary only):

    python verify_all_workflow_permissions.py --quiet

What the Script Checks

  1. Root-level permissions presence: Ensures every workflow has a permissions: block at the root level
  2. Valid permission formats: Accepts:
    • permissions: read-all (recommended)
    • permissions: { contents: read } (recommended)
    • permissions: write-all (valid but warns about over-permissive)
  3. Invalid configurations: Detects missing, empty, or malformed permissions blocks
  4. Job-level analysis: Provides informational output about job-level permissions

Script Output

The script provides:

  • βœ… Pass: Workflow has proper root-level permissions
  • ⚠️ Warning: Workflow has valid but potentially over-permissive permissions
  • ❌ Error: Workflow missing or has invalid root-level permissions
  • πŸ“ Info: Lists jobs that have their own permission blocks

Integration with CI/CD

You can integrate this script into your CI/CD pipeline:

# Example GitHub Actions step
- name: Verify Workflow Permissions
  run: |
    pip install PyYAML
    python verify_all_workflow_permissions.py

πŸ“ Implementation Guidelines:

  • Read each file completely before making changes
  • Only modify what's necessary for security compliance
  • Maintain existing code style and formatting
  • Don't add comments to the workflow files other than trailing line comments explaining the write permissions
  • No need to test locally (workflows don't run in local builds)

Metadata

Metadata

Assignees

Labels

No labels
No labels

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions