Ken Muse

Masking Sensitive Information on GitHub Runner Custom Images


Finishing up this week’s series on GitHub custom images, I want to discuss how to handle sensitive information when using custom images. GitHub Actions provides a workflow command called add-mask that registers a value for automatic redaction in logs. When you mask a value, Actions scans all subsequent log output and replaces any occurrence of that value with ***. This is the same mechanism that automatically protects values accessed through the secrets context in your workflows.

The syntax is straightforward:

1echo "::add-mask::$SECRET_VALUE"

Once executed, any appearance of $SECRET_VALUE in future log lines in the same job will be automatically redacted. This matters because unlike GitHub Secrets (which GitHub masks automatically when you use them in workflows), any values you generate, retrieve, or expose during custom image creation or in pre-job scripts need explicit protection.

Build-time masking: protecting secrets during image creation

When you create a custom image, you might need to use sensitive information as part of the build process. For example, you might:

  • Get an access token via OIDC to download private packages
  • Use credentials to download images from private container registries
  • Configure certificates or authentication files that include sensitive data

The challenge is that anyone with access to the image could potentially extract these values. Masking doesn’t stop values from ending up in the image itself, but it does keep them out of build logs where someone can discover them much more easily.

Here’s how you might mask a dynamically generated token during image creation:

 1jobs:
 2  bake-image:
 3    runs-on: larger-runner-demo
 4    snapshot:
 5      image-name: my-custom-image
 6      version: "1"
 7    steps:
 8      - name: Generate temporary credentials
 9        run: |
10          # Generate or retrieve a temporary token
11          TEMP_TOKEN=$(./generate-token.sh)
12          
13          # Immediately mask it
14          echo "::add-mask::$TEMP_TOKEN"
15          
16          # Now use it safely
17          curl -H "Authorization: Bearer $TEMP_TOKEN" \
18            https://api.example.com/download-tools
19      
20          # Remove any files that might contain the token
21          rm -f ~/.config/tokens
22          rm -f /tmp/credentials

A few important considerations for build-time masking:

  • Clean up thoroughly: Masking only affects logs. You still need to remove sensitive files from the image before the snapshot is taken.
  • Layer awareness: If you’re working with Docker, remember that those images are built in layers. Even if you delete a file in a later step, it may still exist in an earlier layer. Consider using multi-stage builds or cleaning up in the same RUN command where you created the sensitive data. If possible, build the image on a separate runner before pulling the contents into the generated image.
  • Image access: Anyone who can use a runner with your custom image might be able to extract data from it. Don’t put anything on the image that you wouldn’t want others to access. If you need something sensitive for the job, retrieve it in the pre-job script instead.

Run-time masking: protecting secrets in pre-job scripts

The other common scenario is when you need to retrieve or work with sensitive information in a pre-job script. This is particularly useful when:

  • Retrieving credentials from a key vault or secrets manager
  • Configuring authentication for private registries
  • Generating temporary tokens or API keys for that job
  • Accessing sensitive systems or data for a job

Pre-job scripts run before any workflow steps execute, giving you the opportunity to mask values before they could potentially appear in user-controlled workflow code. Any masks created in the pre-job script will apply to all steps in the job, and users won’t have to take any additional steps in their workflows. If you’re using OIDC to retrieve tokens, this is an ideal place to do so since you have the full context of the job that wants to use the token. This means you can use the job’s claims to determine if it should have access, following a zero-trust model.

Best practices for masking

Here are some key principles to keep in mind when you work with sensitive data in custom images:

  1. Mask immediately: As soon as you generate, retrieve, or expose a sensitive value, mask it. Don’t wait until later in your script.

  2. Mask all variations: If a secret might appear in different formats (base64-encoded, URL-encoded, etc.), mask all variations.

  3. Consider substring matches: The masking mechanism looks for exact string matches. If your secret is “mypassword123” and a log line contains “password: mypassword123”, Actions will mask the secret portion but not the prefix. This is why you should never treat entire JSON objects as secrets; instead, extract and mask the individual sensitive values.

  4. Test your masking: After implementing masking, review your workflow logs to verify that sensitive values are actually being redacted. Try to output the values intentionally in a test workflow to confirm the masking is working.

  5. Clean up thoroughly: In image builds, remember to remove sensitive files and clear history. In pre-job scripts, avoid creating sensitive files if the steps on the runner won’t require them.

Summary

Masking gives you control over what appears in your workflow logs. By combining build-time masking during image creation with run-time masking in pre-job scripts, you can ensure that sensitive information stays protected throughout the image lifecycle.

Remember that masking is just one part of a comprehensive security strategy. Combine it with proper access controls, secrets management, OIDC authentication, and regular secrets rotation to keep your custom images and workflows secure.