-
Notifications
You must be signed in to change notification settings - Fork 0
Description
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:
- π READ THE FILE FIRST: Always examine the existing formatting style before making any changes
- Root-Level permissions MUST be limited to either
read-all
orcontents: read
- If the existing root-level permission is
read-all
then leave it unchanged - 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) - Standard format: New root-level permissions should be:
permissions: contents: read
- Placement: Insert immediately after the root-level
on:
YAML block (no other root-level YAML blocks in between) - Don't reorder existing root-level YAML blocks - only add the permissions block
- Preserve formatting: Match the existing blank line style (see detailed rules below)
- 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:
- Save the script as
check_formatting.py
- Run:
python check_formatting.py
(then callanalyze_yaml_formatting('/path/to/workflow.yml')
) - Follow the output to determine which rule applies
- 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:
- Add the function to your
check_formatting.py
file - After making changes, run:
verify_root_permissions('/path/to/workflow.yml')
- 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
orgithub.token
- If the step calls a script, analyze that script to see what permissions are needed
- Steps that use
actions/github-script
implicitly usegithub.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
-
If only read permissions are needed then do not insert a new permissions block.
-
Placement: Insert the
permissions:
YAML block at the very top of the job YAML block, or directly under aneeds:
block if one existsExample 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
-
Don't reorder existing YAML blocks
-
Only add write permissions
-
Preserve existing read permissions if they are already present
-
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#
. -
DO NOT add any comment for write permissions which were already present
-
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
β needscontents: write
- Writing to repository β needs
contents: write
- Creating releases β needs
contents: write
- Posting comments β needs
issues: write
orpull-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
- 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 aneeds:
block then add thepermissions:
block directly after that node. - If you add a new permissions block, "contents: read" should be the first permission listed under it.
- Don't reorder existing YAML blocks
- 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. - 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
-
Verify all workflows in current directory:
python verify_all_workflow_permissions.py
-
Verify workflows in a specific directory:
python verify_all_workflow_permissions.py /path/to/your/repo
-
Quiet mode (summary only):
python verify_all_workflow_permissions.py --quiet
What the Script Checks
- Root-level permissions presence: Ensures every workflow has a
permissions:
block at the root level - Valid permission formats: Accepts:
permissions: read-all
(recommended)permissions: { contents: read }
(recommended)permissions: write-all
(valid but warns about over-permissive)
- Invalid configurations: Detects missing, empty, or malformed permissions blocks
- 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)