fix(cli): Redact masked variable values from include-resolution output
What this MR does and why?
GitLab expands $VAR / ${VAR} in include: entries before resolving them, and glci does the same. Every warning and error then quoted the expanded entry, so a variable holding a secret reached stderr in clear text — across lint, doctor, show, jobs, variables, merged and run, plus the daemon log and the traces of child and cross-project trigger jobs.
Redaction happens at the output boundary rather than at each call site, because it has to. httpGet wraps the error from client.Do, and Go's *url.Error carries the full URL — net/http strips a userinfo password but never a query parameter. A remote include printed the secret twice, once from glci's own fetching %s wrapper and once from inside the wrapped error, so no change to glci's format strings could have prevented the second.
What gets masked
A value is redacted when the variable is marked, and never because its value looks secret. This mirrors Gitlab::Ci::Config::External::Context#mask_variables_from, which masks a variable if and only if variable[:masked] is set.
Variables fetched from the API already carry GitLab's masked flag and are honoured unconditionally. The local layers (--env, --env-file, preset env:, .glci.env) have no such flag — that is the gap #138 (closed) identifies — so this adds one:
glci merged --mask PRIVATE_TOKEN[pipelines.deploy]
env = { PRIVATE_TOKEN = "glpat-xxxxxxxxxxxx" }
masked = ["PRIVATE_TOKEN"]--mask names a variable rather than a value, so it applies whichever layer supplied it, and it is the local equivalent of ticking Mask variable in the UI. A project .glciconfig.toml may only add to a global preset's masked list, never remove from it, so a cloned repository cannot unhide a variable the user marked — the same restriction [token] already carries.
Why nothing is masked automatically
An earlier revision redacted any unmarked value that matched Ci::Maskable::REGEX. That is dropped, because being more protective than GitLab is its own failure: a value hidden locally and then printed by a real pipeline teaches the user their secret was protected when it never was. Local output now agrees with what the server does.
For the same reason glci refuses to mask a value GitLab would reject, and says so instead of hiding it:
glci: warning: DEPLOY_PHRASE left visible: GitLab would not accept this value for masking, so hiding it
locally would misreport what a real pipeline does. It must be a single line with no spaces, at least 8
characters, and use only letters, digits and _ : @ - + . ~ = /Both of GitLab's rules are applied: REGEX for an expanded variable, and MASK_AND_RAW_REGEX for one marked raw:.
Coverage
Each masked value is matched in every form it can take on the way to a message — raw, percent-encoded, and %q-quoted — because an include path is escaped before it reaches the API URL and several fetchers quote it. --unmask turns redaction off, and when anything is redacted glci says so once on stderr and names the flag, so a masked path is never mistaken for a failed one.
The value redactor moves from pkg/daemon to pkg/variables so the CLI, the daemon log and a trigger job's trace share one implementation and one redact-before-sanitize ordering.
This retracts the caveat documented in !154 (merged).
Steps to reproduce
# .glci.env
PRIVATE_TOKEN=glpat-FAKEFAKEFAKE1234# .gitlab-ci.yml
include:
- remote: https://gitlab.example.invalid/ci.yml?private_token=$PRIVATE_TOKEN
job:
script: [true]Before, on main, the token is printed twice — once by glci and once by the wrapped *url.Error:
$ glci merged 2>&1 >/dev/null
parsing CI config: resolving includes: fetching https://gitlab.example.invalid/ci.yml?private_token=glpat-FAKEFAKEFAKE1234: Get "https://gitlab.example.invalid/ci.yml?private_token=glpat-FAKEFAKEFAKE1234": dial tcp: ...After, marking the variable hides both:
$ glci merged --mask PRIVATE_TOKEN 2>&1 >/dev/null
glci: values in include: paths were redacted; re-run with --unmask to see them
parsing CI config: resolving includes: fetching https://gitlab.example.invalid/ci.yml?private_token=[MASKED]: Get "https://gitlab.example.invalid/ci.yml?private_token=[MASKED]": dial tcp: ...glci merged --unmask restores the original output. Without --mask the value is still printed, which is what GitLab does with an unmasked variable.
Two more shapes worth checking: swapping the include for project: "$GROUP/ci" covers the skipping project include warning and the percent-encoded API URL, and glci run covers the daemon path, where the same error previously reached both the terminal and ~/.glci/daemon.log.
Relevant issues and other links
- Closes #138 (closed)
- !154 (merged) documented the caveat this removes
- GitLab masking requirements: https://docs.gitlab.com/ci/variables/
Ci::Maskable(REGEX,MASK_AND_RAW_REGEX): https://gitlab.com/gitlab-org/gitlab/-/blob/master/app/models/concerns/ci/maskable.rbCi::MaskSecret: https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/gitlab/ci/mask_secret.rb