I trusted masking, but the token leaked into the log as base64
Goal
You confirm by running the precedence of where variables come in and the environment scope, narrow the job that will receive the production secret down to main, see how a secret remains in a log without masking, and then build a log-leak checker and a reference-pinning checker.
Why it matters
A pipeline is a place that runs arbitrary code holding the strongest privileges in the repository. When a variable with the same name comes in from several places, you must know which value wins to prevent the incident of the production value being overwritten, and you must reduce the number of jobs that receive secrets with environment scope and branch rules. Masking hides only a string identical to the value, so output with a changed shape remains as it is, and if you reference include and images by a label, on the day someone moves that label our pipeline runs someone else's code.
Steps
- Make
/root/glci-secretsa git repository and, in.gitignore, put.gitlab-ci-local/and.gitlab-ci-local-variables.yml..gitlab-ci.ymlhas stages[build, deploy], the global variablesREGION: yaml-globalandLOG_LEVEL: yaml-global, and a jobshow(build; job variablesLOG_LEVEL: yaml-job;echo "region=$REGION log=$LOG_LEVEL"). In the project variable file.gitlab-ci-local-variables.yml, putREGION: project-var,LOG_LEVEL: project-var, and aDEPLOY_TOKENwhose value differs by environment (productionprod-tok-7f3a9c, stagingstg-tok-41be02,review/*review-tok-9d0c11). Commit only the configuration. The show log must beregion=project-var log=project-var. - Add three jobs (all in the deploy stage).
deploy-staginghas environmentstaging,deploy-prodhasproduction, andreview-apphasreview/$CI_COMMIT_REF_SLUG, and each prints not the token value but only its length withecho "<staging|prod|review> token-len=${#DEPLOY_TOKEN}". When you commit and run, the three jobs must each print the token length of their own environment (staging 14, prod 15, review 17). - To
deploy-prod, addrules: - if: $CI_COMMIT_BRANCH == "main"and commit. The grader checks that on a feature branch in a copy, deploy-prod is not in the list, so the job that would receive the production token is not even created. - Add a job
bad-debug(deploy, environment production, only on main) that runsecho "debugging with $DEPLOY_TOKEN"andecho -n "$DEPLOY_TOKEN" | base64, and commit. After running, copy this job's log (.gitlab-ci-local/output/bad-debug.log) to/root/glci-secrets/leak-evidence.log, and add this file to.gitignoreso that it is not committed. - Create
/root/glci-secrets/leak-scan.sh <저장소>(the placeholder stands for the repository). It makes a temporary copy of the repository, commits, runs the pipeline, and then, if.gitlab-ci-local/output/*.logcontains, as it is or in base64, the value (8 characters or more, including per-environment values) of a variable from the project variable file whose name containsTOKEN,PASSWORD,SECRET, orKEY, it printsLEAK <잡> <변수>(job and variable) one per line (sorted) and ends with 3, if there is none, withOKand 0, and if the pipeline fails, withERRORand 1. It leaves no trace in the original repository. When you run it on this repository,LEAK bad-debug DEPLOY_TOKENmust appear. - Delete
bad-debugand instead add a jobcheck-token(deploy, environment production, only on main) that runs onlytest -n "$DEPLOY_TOKEN" && echo token-present. After committing,leak-scan.shmust give OK. - Create
/root/glci-secrets/pin-audit.sh <설정파일>(the placeholder stands for the configuration file). If the ref of an include's project entry is not a 40-character commit SHA, or it is a remote include, or a component does not end with@<40자리 SHA>(a 40-character SHA), it printsUNPINNED include <값>(the value), and if a job's image has no@sha256:digest, it printsUNPINNED image <값>, one per line (sorted, without duplicates) and ends with 3, and if there is none, it ends withOKand 0. The value of a project entry is written as<project>@<ref>. Put a sample configuration (with the same content as the file example under the task text) at/root/glci-secrets/pin-sample.ymland save the result to/root/glci-secrets/pin-report.txt.
Notes
- This VM has no GitLab server or runner, and gitlab-ci-local 4.75.1 interprets .gitlab-ci.yml by the same rules as GitLab and runs jobs with the shell. If you write
image:, it tries to run with Docker, so do not use it. Protected variables, masking, CI_JOB_TOKEN, runner tags, and merge request pipeline creation are server features and are not reproduced here. - Run: at the repository root,
gitlab-ci-local --shell-isolation --no-artifacts-to-source(a separate working directory per job, artifacts not written back to the repository), job list:gitlab-ci-local --list-csv-all, interpreted configuration:gitlab-ci-local --preview. gitlab-ci-local passes only files tracked by git to jobs, sogit adda file after creating it. The grader copies the repository, commits all files, and runs it again with the same tool. .gitlab-ci-local-variables.ymlstands in for the CI/CD variables of the GitLab project settings (environment-scope values are supported). GitLab's protected variables, masking, hidden variables, and CI_JOB_TOKEN are server features and are not reproduced. For reference, when we actually brought up GitLab CE 19.3.2 (2026-09-15), a masked variable was printed in the log as [MASKED] and CI_JOB_TOKEN as a value starting with glcbt-.- Common mistake: committing the variable file. Common mistake: printing set -x or the entire env for debugging.
- GitLab CI/CD variables (precedence, protection, masking) · CI/CD job token · include · Environments · Protected branches
Four variables with the same name — who wins
Make /root/glci-secrets a git repository and, in .gitignore, put .gitlab-ci-local/ and .gitlab-ci-local-variables.yml. .gitlab-ci.yml has stages [build, deploy], the global variables REGION: yaml-global and LOG_LEVEL: yaml-global, and a job show (build; job variables LOG_LEVEL: yaml-job; echo "region=$REGION log=$LOG_LEVEL"). In the project variable file .gitlab-ci-local-variables.yml, put REGION: project-var, LOG_LEVEL: project-var, and a DEPLOY_TOKEN whose value differs by environment (production prod-tok-7f3a9c, staging stg-tok-41be02, review/* review-tok-9d0c11). Commit only the configuration. The show log must be region=project-var log=project-var.
GitLab gives project-settings variables precedence over the YAML's job variables and global variables. A value given directly when running the pipeline (here, --variable) comes before that. A variable file holds secrets, so you do not put it in the repository — check with git check-ignore.
A different secret goes in for each environment
Add three jobs (all in the deploy stage). deploy-staging has environment staging, deploy-prod has production, and review-app has review/$CI_COMMIT_REF_SLUG, and each prints not the token value but only its length with echo "<staging|prod|review> token-len=${#DEPLOY_TOKEN}". When you commit and run, the three jobs must each print the token length of their own environment (staging 14, prod 15, review 17).
If you put an environment scope on a variable, the value goes only into jobs declared with that environment. A wildcard scope (review/*) matches dynamic environment names. To check whether a value is needed, print only its length or presence instead of the value.
Create the job that will receive the production token only on main
To deploy-prod, add rules: - if: $CI_COMMIT_BRANCH == "main" and commit. The grader checks that on a feature branch in a copy, deploy-prod is not in the list, so the job that would receive the production token is not even created.
GitLab passes protected variables only to pipelines of protected branches. In this environment, which lacks that feature, the same intent is expressed as the rule "create the production job only on main". Both are devices that reduce "who can make a job that holds this secret run".
What remains in the log without masking
Add a job bad-debug (deploy, environment production, only on main) that runs echo "debugging with $DEPLOY_TOKEN" and echo -n "$DEPLOY_TOKEN" | base64, and commit. After running, copy this job's log (.gitlab-ci-local/output/bad-debug.log) to /root/glci-secrets/leak-evidence.log, and add this file to .gitignore so that it is not committed.
gitlab-ci-local does not mask, so the value is printed as it is. GitLab's masking hides only a string exactly identical to the value, so if you change the shape, as with base64, it remains as it is. Logs are stored and seen by many people.
Have a machine check whether a secret was printed in the log
Create /root/glci-secrets/leak-scan.sh <저장소> (the placeholder stands for the repository). It makes a temporary copy of the repository, commits, runs the pipeline, and then, if .gitlab-ci-local/output/*.log contains, as it is or in base64, the value (8 characters or more, including per-environment values) of a variable from the project variable file whose name contains TOKEN, PASSWORD, SECRET, or KEY, it prints LEAK <잡> <변수> (job and variable) one per line (sorted) and ends with 3, if there is none, with OK and 0, and if the pipeline fails, with ERROR and 1. It leaves no trace in the original repository. When you run it on this repository, LEAK bad-debug DEPLOY_TOKEN must appear.
The result of base64 differs depending on whether a newline is attached to the end of the value. Look for both shapes. The copy must also include the variable file (a file that was not committed) for the pipeline to receive the same values.
Do not print the secret; only check that it is there
Delete bad-debug and instead add a job check-token (deploy, environment production, only on main) that runs only test -n "$DEPLOY_TOKEN" && echo token-present. After committing, leak-scan.sh must give OK.
What you need for debugging is usually not the value but "did it come in". If you leave only information that cannot be reversed, such as presence, length, and the first few characters of a hash, it is safe even if you share the log. set -x also expands and prints variable values, so avoid it in deployment jobs.
Find references that can move
Create /root/glci-secrets/pin-audit.sh <설정파일> (the placeholder stands for the configuration file). If the ref of an include's project entry is not a 40-character commit SHA, or it is a remote include, or a component does not end with @<40자리 SHA> (a 40-character SHA), it prints UNPINNED include <값> (the value), and if a job's image has no @sha256: digest, it prints UNPINNED image <값>, one per line (sorted, without duplicates) and ends with 3, and if there is none, it ends with OK and 0. The value of a project entry is written as <project>@<ref>. Put a sample configuration (with the same content as the file example under the task text) at /root/glci-secrets/pin-sample.yml and save the result to /root/glci-secrets/pin-report.txt.
A branch name or a tag is a label a person can move, so the same configuration can bring in different code yesterday and today. A commit SHA or an image digest is the address of the content itself and cannot be moved. There is no way to pin a remote file (remote), so it is better to bring it into the repository.