A protected password, a base64 leak, and a variable that will not mask
The payments team keeps PAYMENTS_DB_PASSWORD, a 22-character value containing !, as a masked and protected project variable scoped to the production environment. Three incidents land in the same sprint. (a) A hotfix pipeline on branch release/2.4 fails at the migrate step with password authentication failed for user "payments"; the same job on main works, and the variable is empty in the failing job. (b) A well-meaning engineer base64-encodes the password to build a Kubernetes Secret and echoes the encoded value while debugging; the raw password is masked in the job log, but the base64 string is printed in full. (c) A new cloud token that starts with glpat- and contains ! cannot be saved with the Mask variable checkbox enabled, so someone saves it unmasked and pastes it into a snippet. Separately, deploy:prod sets DEPLOY_ENV: staging in its own variables: block, yet the job still deploys to production.
1. Explain why the variable is empty on `release/2.4`, what other restrictions the protected flag imposes, and which fix you would apply.
Read the worked answer
Two independent filters gate this variable. The protected flag means the value is only injected into pipelines running for a protected branch or a protected tag. release/2.4 is not protected, so the variable is never defined there - the job sees an empty string and the driver reports an authentication failure rather than a missing-variable error, which is what makes it confusing. The environment scope (production) is a second filter: the job must also declare a matching environment: to receive the value, and environment scope does not bypass protection.
Other consequences of the protected flag worth knowing: merge request pipelines from forks never receive protected variables, and jobs with an empty variable usually fail late and obscurely rather than at config time.
Fix options, best first:
- Protect the release branches (Settings > Repository > Protected branches, pattern
release/*) so hotfix pipelines on those branches can read the secret. Choose who may merge/push deliberately. - Move the credential out of CI variables entirely: use OIDC
id_tokensto obtain short-lived cloud credentials, orsecrets:with an external vault keyed by environment. - Only as a last resort, unprotect the variable - that widens exposure to every branch and every fork-driven pipeline, so pair it with masked and scoped access if you do it.
2. What are the masking requirements, and why did the base64 value appear in the log in full? Recommend a safer pattern for using the secret.
Read the worked answer
Masking is log scrubbing, not a security control. A value can be masked only if it is a single line, at least 8 characters, contains no whitespace, and uses characters from the Base64 alphabet plus a small set of permitted specials. ! is not in that set, which is exactly why the glpat- token in incident (c) cannot be saved as masked: the checkbox is rejected, not silently ignored.
Incident (b) is the more important lesson: GitLab masks only the exact stored string. Base64, URL-encoding, JSON-escaping, a checksum, or printing half of it defeats the scrubber - and job logs are visible to everyone with report access, are archived, and are searchable.
Safer patterns:
- Never
echothe value; write it to a file in$CI_PROJECT_DIR(never toartifacts:or adotenvreport) and pass the path:kubectl create secret generic db --from-file=password=./.secret. set +xaround the command, and avoid tools that echo their arguments (kubectlwith--from-literalshows the value in a failed command's error).- Prefer federated/short-lived credentials:
id_tokens: { VAULT_ID_TOKEN: { aud: https://vault.example.com } }plussecrets:vault, so no long-lived password exists in project settings at all. - Rotate anything that has already been printed; assume the log is compromised.
3. The job sets `DEPLOY_ENV: staging` in its own `variables:` block, but the deployment goes to production. Explain the precedence rule and how to make a one-off override safely.
Read the worked answer
Variables defined in the GitLab UI/API (project, group, instance) outrank variables defined in .gitlab-ci.yml, including a job's own variables: block. The order, highest first, is roughly: pipeline variables (trigger API, scheduled pipeline, manual run), project variables, group variables, instance variables, variables from dotenv report artifacts, job-level YAML variables, global YAML variables, deployment variables, predefined variables.
So the project-level DEPLOY_ENV=production beats the job's staging value. inherit: variables: false will not help either - it only stops the job inheriting globally defined YAML variables, not UI-defined ones.
Options that actually work:
- Pass the override as a pipeline variable when starting the pipeline (
POST /projects/:id/pipelinewithvariables[][key]=DEPLOY_ENV), which sits at the top of the precedence order. - Remove
DEPLOY_ENVfrom project settings and derive it in YAML, or define environment-scoped variables forstagingandproductionand select them byenvironment: name. - Use
rulesto gate the job on the intended environment so a stray value cannot reach production:- if: $CI_COMMIT_BRANCH == "main"plus a protected environment with required approvals.
For a production-affecting variable, the value should carry a guard rather than relying on whoever edited the job's YAML last.