A build log that prints a secret sends it to every developer with log access, every artifact store, and every SIEM ingesting build events - before any scanner that reads source files can fire.
When a build step prints an environment variable - intentionally for debugging or on an unhandled error dump - that secret reaches stdout, the CI artifact store, every log aggregator ingesting build events, and the browser of every developer with log access. A git-resident secret can be scrubbed with a history rewrite and rotated once; a CI log secret may exist in a dozen independent copies before the run finishes. Detection has to reach the log stream, not just the source tree.
Secrets enter CI logs through at least five distinct paths, each with a different owner and a different fix. A debug print left in after local development is the most visible, but framework startup dumps are subtler: Spring Boot Actuator, Rails configuration logging in a misconfigured CI environment, and Django settings debug output each print key-value pairs that may include credentials read from the environment at startup. Build arguments passed with --build-arg land in docker build output and sometimes in the image layer history. Test runners with verbose flags (pytest -s, npm test -- --verbose) can print environment-interpolated strings that include secrets the test expected to read. And an unhandled exception traceback may include the environment dictionary if the framework serializes process state on crash.
Every path ends at the same artifact: a log line in the CI platform's storage, often retained for 90 days or more and readable by any developer with pipeline access - a broader audience than most repository permission models.
Comparing the two exposure profiles makes clear why log-resident secrets deserve a distinct detection track and a tighter remediation SLA than a source-tree finding.
| Attribute | File / git secret | CI log secret |
|---|---|---|
| Who reads it | Anyone who clones or forks the repo | All devs with pipeline access + SIEM + S3 artifact bucket |
| Copies at risk | Each clone, fork and CI cache | CI store + log aggregator + browser cache for every viewer |
| Scrub path | History rewrite + force push + notify all forks | Depends on platform; many CI log stores are append-only |
| Time to spread | Hours - next clone or fork pull | Seconds - log stream ingest fires during the run |
| Detection point | Source tree or git blob scan | Log output stream - source scanner cannot see it |
CI platforms provide secret masking - GitHub Actions, GitLab CI and Jenkins each substitute registered secrets with *** in log output. But masking applies only to secrets that have been explicitly registered. An ad-hoc credential hardcoded into a curl invocation, a token passed as an unregistered build argument, or a secret interpolated into a test fixture does not appear on the mask list and prints in full.
Detection on log output follows the same evidence bar as any other finding: a confirmed pattern match with entropy validation, not a keyword flag. A log line containing a high-entropy string that matches a known token format - an AWS temporary credential, a GitHub PAT, a Slack bot token - is a candidate. The confirmation step checks that the match meets both the format regex and an entropy threshold before the finding is raised. Live-validity checking (calling the issuing service's validation endpoint) is available but off by default, keeping the scan air-gap-safe.
# Pseudocode: scan a log stream for high-entropy token patterns import re, math PATTERNS = [ ("github_pat", re.compile(r'ghp_[A-Za-z0-9]{36}')), ("aws_access_key", re.compile(r'AKIA[0-9A-Z]{16}')), ("slack_bot", re.compile(r'xoxb-[0-9]+-[A-Za-z0-9-]+')), ("generic", None), # entropy > 4.5 bits/char, length >= 20 ] def shannon(s: str) -> float: freq = {c: s.count(c) / len(s) for c in set(s)} return -sum(p * math.log2(p) for p in freq.values()) def scan_line(line: str) -> list: hits = [] for name, pat in PATTERNS: if pat and (m := pat.search(line)): hits.append({"type": name, "masked": mask(m.group())}) elif pat is None: for token in line.split(): if len(token) >= 20 and shannon(token) > 4.5: hits.append({"type": "generic_high_entropy", "masked": mask(token)}) return hits
The value is masked immediately before any finding is written. The artifact attached to the finding carries the log line number, the step name, the token type, and the masked form - enough to confirm that rotation is needed without creating a second unmasked copy in the report.
Severity follows two facts: is the token live, and how broad is the log's read access. A live GitHub PAT in a log readable by all developers in the organization is critical - the credential is compromised the moment the run ends and the log is written. A token that the platform reports as already revoked or expired at detection time is rated medium: the immediate risk is lower, but the detection gap that allowed it to reach the log is unchanged. Both findings share the same required first step: rotate the secret. Deleting the log line or re-running the job does not revoke access for anyone who already viewed it, and it does not purge copies that a SIEM ingested in the first seconds of the run.
CI platform masking is a defense-in-depth control, not a primary one. Register every secret that enters the build environment, but do not rely on registration to catch credentials that were never registered. A dedicated log-scan step running against the artifact log after the build step - and before the log is written to long-term storage - closes the gap for unregistered secrets within the same job. Findings from that step feed the same posture as source-tree and container findings: one record per credential, not a separate dashboard for pipeline risk.
A log-resident secret and a git-resident secret belong to the same severity class but require separate detection paths - source-tree scanning never sees a print statement that runs at build time. The finding is only as trusted as the evidence: a confirmed pattern match with entropy validation and the value masked, not a keyword alert against a file that may not hold the secret when the build fires.