logger.info(f"calling API with {token}")のようなログを書くと、本番のログにAPIキーがそのまま残る。値を文字列にする経路で必ず伏せ字になる型を作っておくと、うっかり出力しても漏れない。
f-stringの{x:spec}のspecの部分は、そのままx.__format__(spec)に渡される。書式指定の中身は型が自由に決めてよいので、{token:last4}のような独自の指定も作れる。
作り方
文字列にする経路は__format__(f-stringとstr.format)、__str__(str()と%s)、__repr__(!rやコンテナの中、f"{x=}")の3つある。全部ふさぐ。
class Secret:
__slots__ = ("_value",)
def __init__(self, value: str) -> None:
self._value = value
def __format__(self, spec: str) -> str:
if spec == "last4":
return "********" + self._value[-4:]
if spec == "":
return "********"
raise ValueError(f"unknown format spec for Secret: {spec!r}")
def __repr__(self) -> str:
return "Secret('********')"
__str__ = __repr__
def reveal(self) -> str:
return self._value本物の値はreveal()を明示的に呼んだときだけ取り出せる。HTTPヘッダーに入れるなど、本当に必要な場所でだけ呼ぶ。
どの経路で伏せられるか
f"{token}" -> ********
f"{token:last4}" -> ********abcd
f"{token!s}" -> Secret('********')
f"{token=}" -> token=Secret('********')
"%s" % token -> Secret('********')
"{}".format(token) -> ********
str(token) -> Secret('********')
[token] -> [Secret('********')]
dataclass repr -> Req(url='https://api', token=Secret('********'))
logging.warning("token=%r", token) -> token=Secret('********')
json.dumps -> TypeError: Object of type Secret is not JSON serializable
token.reveal() -> sk-live-1234567890abcdSecretを属性に持つdataclassのreprも、中でrepr(token)を呼ぶので伏せ字になる。json.dumpsは知らない型として例外になるので、APIのレスポンスに紛れ込んだときも気づける。
ハマりどころ
__format__を定義せずに__str__だけ定義した場合、f"{x}"はobject.__format__経由でstr(x)になるので伏せられる。ただし{x:last4}のように書式指定を付けるとTypeError: unsupported format string passed to OnlyStr.__format__になる。空でない書式指定を受け付けるかどうかは、__format__を自分で書いて決める必要がある。
上の実装では、知らない書式指定をValueErrorにしている。f"{token:>20}"のような幅の指定も通らない。format(str(self), spec)に任せると幅揃えはできるようになるが、{token:raw}のような書き間違いがValueErrorにならず素通りするので、ここでは厳しくした。
pickleは伏せ字にならない。pickle.dumps(token)の中には生の値がそのまま入る。プロセス間で受け渡したりキャッシュに保存したりする場合は、__reduce__で禁止するか、受け渡す前にreveal()して扱いを決める。