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
📝 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)