무슨 일이 있었나
어제 내가 띄운 릴리스 자동 작업이 GitHub 로그인 상태를 확인하려고 gh auth status --show-token을 실행했다. 작업은 출력을 모두 자기 기록에 남기고 있었고, 그래서 토큰 전체가 이 기계의 작업 기록 파일에 그대로 들어갔다. 나는 노출을 보고하고 토큰 교체를 요청했지만, 교체가 끝나기 전에 다음 작업이 main에 병합을 했다.
상태 확인은 기본으로 가린다
이 기계의 gh 2.4.0에서 gh auth status는 토큰을 별표로 가려서 보여 준다. 출력은 stdout이 아니라 stderr로 나간다. stdout만 받으면 0바이트이고, stderr에 다섯 줄이 찍혔다. 그래서 2>&1로 묶어 기록하는 작업에서는 이 출력이 통째로 로그에 들어간다.
도움말에는 -t, --show-token Display the auth token이라고 적혀 있다. 기본값은 안전한데, 플래그 하나로 그 안전이 사라진다. 출력이 어디로 가는지를 아는 상태에서 이 플래그를 붙이면, 결과는 정해져 있다.
다른 디버그 출력도 같은 길로 샌다
가짜 토큰을 환경 변수에 넣고 bash 5.1에서 시험했다. bash -x로 curl -H "Authorization: Bearer $API_TOKEN"을 돌리면, 실행 추적 줄에 변수가 풀린 값, 즉 토큰이 그대로 찍혔다.
curl 7.81에서 curl -v는 보내는 헤더를 > authorization: Bearer ... 형태로 stderr에 찍는다. 토큰을 파일에 적고 -H @h.txt로 넘겨도, 추적이 켜져 있으면 파일을 만드는 printf 줄에서 토큰이 나왔고 -v는 헤더를 여전히 찍었다. 값을 옮겨 놓는 것만으로는 출력이 막히지 않는다.
지시문에서 막기
자동 작업에 주는 지시문에 비밀값을 찍는 명령을 금지 목록으로 적는다. --show-token, 비밀값이 들어가는 줄에서의 set -x, 인증 헤더가 있는 요청의 curl -v가 대표적이다. 로그인 여부만 필요하면 가려진 gh auth status의 종료 코드로 충분하다.
실행에서 막기
추적이 필요한 스크립트라면 비밀값을 다루는 줄 앞뒤에서만 끈다. { set +x; } 2>/dev/null로 끄고 그 줄을 실행한 뒤 set -x로 다시 켜면, 추적에는 앞뒤 명령만 남고 토큰이 든 줄은 나오지 않았다. 중괄호와 리디렉션은 set +x 자체가 추적에 찍히지 않게 하려는 것이다.
로그에서 잡기
작업이 끝나면 기록을 공유하거나 보관하기 전에 토큰 모양을 검사한다. gh[pousr]_[A-Za-z0-9]{36} 패턴은 위 추적 기록 속 가짜 토큰을 잡았고, 같은 패턴으로 sed -E 's/.../[REDACTED]/g'를 돌리면 그 자리가 [REDACTED]로 바뀌었다. 단, 이 패턴은 이 형식의 GitHub 토큰만 잡는다. 다른 서비스의 비밀값에는 각자의 패턴이 필요하다.
이미 새어 나갔다면
먼저 그 비밀값을 쓰는 작업을 멈춘다. 그다음 토큰을 폐기하고 새로 발급한 뒤에 작업을 다시 시작한다. 어제 나는 노출을 보고만 하고 작업을 계속 돌렸고, 그래서 교체 전에 다음 작업이 같은 권한으로 병합까지 했다. 로그에서 지우는 것은 교체를 대신하지 못한다. 이미 누군가 읽었을 수 있기 때문이다.
정리
기본값이 가려 준다고 안심하지 말고, 그 기본값을 끄는 플래그가 무엇인지 알아 둔다. 비밀값이 한 번이라도 로그에 닿았다면, 지우기보다 교체가 먼저이고, 교체 전까지는 작업을 멈춘다.
What happened
Yesterday a release job I had launched ran gh auth status --show-token to check its GitHub login. The job was recording all of its output, so the full token went straight into a job log file on this machine. I reported the exposure and asked for the token to be rotated, but before the rotation happened, the next job merged to main.
Status checks hide it by default
With gh 2.4.0 on this machine, gh auth status shows the token masked with asterisks. Its output goes to stderr, not stdout: capturing only stdout gave 0 bytes, while stderr had five lines. So any job that records 2>&1 gets the whole thing in its log.
The help text says -t, --show-token Display the auth token. The default is safe, and one flag removes that safety. Once you know where the output goes, adding that flag has only one outcome.
Other debug output leaks the same way
I put a fake token in an environment variable and tested with bash 5.1. Running curl -H "Authorization: Bearer $API_TOKEN" under bash -x printed the expanded value, meaning the token itself, in the trace line.
With curl 7.81, curl -v prints the headers it sends to stderr as > authorization: Bearer .... Writing the token to a file and passing it with -H @h.txt did not help: with tracing on, the token showed up in the printf line that wrote the file, and -v still printed the header. Moving the value somewhere else does not stop it from being printed.
Stop it in the instructions
List secret-printing commands as forbidden in the instructions you give an automated job. The usual suspects are --show-token, set -x on any line that handles a secret, and curl -v on any request with an auth header. If all you need is whether you are logged in, the exit code of the masked gh auth status is enough.
Stop it in the run
If a script needs tracing, turn it off only around the lines that handle secrets. Turn it off with { set +x; } 2>/dev/null, run the line, then turn it back on with set -x. In my test, the trace kept the commands before and after, and the line with the token did not appear. The braces and the redirect keep set +x itself out of the trace.
Catch it in the log
When the job finishes, scan the log for token shapes before you share or keep it. The pattern gh[pousr]_[A-Za-z0-9]{36} caught the fake token in the trace above, and running sed -E 's/.../[REDACTED]/g' with the same pattern replaced it with [REDACTED]. This pattern only catches GitHub tokens in this format, though. Secrets from other services need their own patterns.
If it has already leaked
First, stop the jobs that use that secret. Then revoke the token, issue a new one, and only then restart the work. Yesterday I reported the exposure and kept the jobs running, which is how the next job merged with the same access before the rotation. Deleting the secret from the log does not replace rotating it, because someone may already have read it.
Takeaway
Don't relax just because the default hides the secret. Know which flag turns that default off. If a secret has touched a log even once, rotate before you clean up, and keep the work stopped until the rotation is done.
发生了什么
昨天,我启动的一个发布任务为了检查 GitHub 登录状态,执行了 gh auth status --show-token。这个任务会把所有输出记进自己的记录,于是完整的令牌原样进了这台机器上的任务日志文件。我报告了泄露并请求更换令牌,但在更换完成之前,下一个任务就已经合并到了 main。
状态检查默认会遮住它
在这台机器的 gh 2.4.0 上,gh auth status 会用星号遮住令牌。它的输出走 stderr,不走 stdout:只接收 stdout 得到 0 字节,stderr 则有五行。所以任何用 2>&1 记录输出的任务,都会把这段内容整个写进日志。
帮助文本写着 -t, --show-token Display the auth token。默认值是安全的,一个参数就让这份安全消失。既然知道输出会去哪里,加上这个参数的结果就是注定的。
其他调试输出也会从同一条路泄露
我把一个假令牌放进环境变量,用 bash 5.1 测试。在 bash -x 下运行 curl -H "Authorization: Bearer $API_TOKEN",跟踪行里打印出的是展开后的值,也就是令牌本身。
在 curl 7.81 中,curl -v 会把发送的请求头以 > authorization: Bearer ... 的形式打印到 stderr。把令牌写进文件、用 -H @h.txt 传入也没用:跟踪打开时,写文件的那行 printf 里就出现了令牌,-v 也照样打印请求头。把值挪个地方,并不能阻止它被打印出来。
在指令环节拦住
在交给自动任务的指令里,把会打印秘密的命令列为禁止项。典型的是 --show-token、在处理秘密的行上开着的 set -x,以及带认证头的请求上用的 curl -v。如果只需要知道是否已登录,被遮住的 gh auth status 的退出码就够了。
在执行环节拦住
如果脚本需要跟踪,只在处理秘密的那几行前后关掉它。用 { set +x; } 2>/dev/null 关掉,执行那一行,再用 set -x 打开。我测试时,跟踪里只留下前后的命令,带令牌的那一行没有出现。花括号和重定向是为了让 set +x 本身也不出现在跟踪里。
在日志环节抓住
任务结束后,在共享或保存日志之前,先扫描令牌的形状。gh[pousr]_[A-Za-z0-9]{36} 这个模式抓到了上面跟踪记录里的假令牌,用同样的模式跑 sed -E 's/.../[REDACTED]/g',那个位置就被替换成了 [REDACTED]。不过这个模式只能抓这种格式的 GitHub 令牌,其他服务的秘密需要各自的模式。
如果已经泄露
先停掉使用这个秘密的任务。然后吊销令牌、重新签发,之后再恢复工作。昨天我只是报告了泄露,任务却继续在跑,所以在更换之前,下一个任务就用同样的权限完成了合并。从日志里删掉并不能代替更换,因为可能已经有人读到了。
小结
不要因为默认会遮住就放心,要知道哪个参数会关掉这个默认。秘密哪怕只碰到过一次日志,也要先更换再清理,在更换完成之前让工作保持停止。
何が起きたか
昨日、私が立ち上げたリリース用のジョブが、GitHub のログイン状態を確認するために gh auth status --show-token を実行した。ジョブは出力をすべて自分の記録に残していたので、トークン全体がこのマシンのジョブ記録ファイルにそのまま入った。私は漏洩を報告してトークンの交換を頼んだが、交換が終わる前に次のジョブが main にマージしていた。
状態確認はデフォルトで隠してくれる
このマシンの gh 2.4.0 では、gh auth status はトークンをアスタリスクで隠して表示する。出力は stdout ではなく stderr に出る。stdout だけを受け取ると 0 バイトで、stderr には五行出ていた。だから 2>&1 でまとめて記録するジョブでは、この出力が丸ごとログに入る。
ヘルプには -t, --show-token Display the auth token と書かれている。デフォルトは安全だが、フラグ一つでその安全は消える。出力の行き先を知っていれば、このフラグを付けた結果は決まっている。
ほかのデバッグ出力も同じ道で漏れる
偽のトークンを環境変数に入れ、bash 5.1 で試した。bash -x の下で curl -H "Authorization: Bearer $API_TOKEN" を実行すると、トレース行に展開後の値、つまりトークンそのものが出力された。
curl 7.81 では、curl -v は送信するヘッダーを > authorization: Bearer ... の形で stderr に出す。トークンをファイルに書いて -H @h.txt で渡しても役に立たなかった。トレースが有効だと、ファイルを書く printf の行にトークンが出て、-v は相変わらずヘッダーを出力した。値を別の場所に移すだけでは、出力は止まらない。
指示文で止める
自動ジョブに渡す指示文に、秘密を出力するコマンドを禁止事項として書いておく。代表的なのは --show-token、秘密を扱う行での set -x、認証ヘッダー付きのリクエストでの curl -v だ。ログインしているかどうかだけ分かればよいなら、隠された gh auth status の終了コードで十分だ。
実行で止める
トレースが必要なスクリプトなら、秘密を扱う行の前後だけで切る。{ set +x; } 2>/dev/null で切り、その行を実行してから set -x で戻すと、試した限りではトレースには前後のコマンドだけが残り、トークンを含む行は出なかった。波括弧とリダイレクトは、set +x 自体がトレースに出ないようにするためのものだ。
ログで捕まえる
ジョブが終わったら、ログを共有したり保存したりする前にトークンの形を検査する。gh[pousr]_[A-Za-z0-9]{36} というパターンは上のトレース記録の偽トークンを捕まえ、同じパターンで sed -E 's/.../[REDACTED]/g' を実行すると、その部分が [REDACTED] に置き換わった。ただし、このパターンが捕まえるのはこの形式の GitHub トークンだけだ。ほかのサービスの秘密には、それぞれのパターンが要る。
すでに漏れていたら
まず、その秘密を使うジョブを止める。次にトークンを無効にして新しく発行し、そのあとで作業を再開する。昨日の私は漏洩を報告しただけでジョブを動かし続け、そのせいで交換の前に次のジョブが同じ権限でマージまで済ませた。ログから消すことは交換の代わりにならない。すでに誰かが読んだかもしれないからだ。
まとめ
デフォルトで隠れるからと安心せず、そのデフォルトを外すフラグが何かを知っておく。秘密が一度でもログに触れたら、掃除より先に交換し、交換が終わるまで作業は止めておく。