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-1234567890abcd

Secretを属性に持つ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()して扱いを決める。

References