#[\SensitiveParameter]でスタックトレースにパスワードを載せない

#[\SensitiveParameter] は PHP 8.2 で追加された属性で、付けた引数の値をバックトレースから SensitiveParameterValue オブジェクトに置き換え、ログや例外表示への漏洩を防ぎます。

PHPのスタックトレースは、フレームごとに「どの関数にどんな引数を渡したか」まで出してくれます。デバッグでは本当にありがたいのですが、これが厄介な場面がひとつあって、パスワードやAPIキーがそのままトレースに載ってしまうんですね。

しかも今どきのアプリは、例外をキャッチしてSentryなりCloudWatchなりに投げます。つまり平文のパスワードが外部SaaSのログに保存される、ということが普通に起こる。私も過去に、エラーログを見ていて「あ、これ本番の認証情報だ」と青くなったことがあります。

なぜトレースに秘密が載るのか

まずは起きることを見ておきます。認証処理の途中で例外が飛ぶ、というだけのコードです。

<?php

function login(string $email, string $password): void
{
    throw new RuntimeException('認証基盤に接続できません');
}

login('nori@ex.jp', 'p@ssw0rd!');

これを実行すると、トレースはこうなります。

Fatal error: Uncaught RuntimeException: 認証基盤に接続できません in /app/login.php:5
Stack trace:
#0 /app/login.php(8): login('nori@ex.jp', 'p@ssw0rd!')
#1 {main}
  thrown in /app/login.php on line 5

パスワードが丸見えです。例外を投げたのは login() の中ですが、トレースには「login() を呼んだときの引数」が記録されるので、関数の実装をどう直しても消えません。呼び出し側の問題でもない。引数として渡した時点で載る、という構造の話です。

これはPHP標準の関数でも同じで、RFCが例に挙げているのが PDO::__construct() です。コンストラクタの中で即座に接続しにいく作りなので、DBがダウンしていると PDOException のトレースにDBパスワードが並びます。

属性をひとつ付けるだけ

PHP 8.2以降は、隠したい引数に #[\SensitiveParameter] を付けます。グローバル名前空間のクラスなので、名前空間付きのファイルでも先頭のバックスラッシュを忘れないようにします。

<?php

function login(string $email, #[\SensitiveParameter] string $password): void
{
    throw new RuntimeException('認証基盤に接続できません');
}

login('nori@ex.jp', 'p@ssw0rd!');
Fatal error: Uncaught RuntimeException: 認証基盤に接続できません in /app/login.php:5
Stack trace:
#0 /app/login.php(8): login('nori@ex.jp', Object(SensitiveParameterValue))
#1 {main}
  thrown in /app/login.php on line 5

嬉しいのは、メールアドレスのほうは残っていることです。zend.exception_ignore_args のような全部消す設定と違って、デバッグに必要な情報は残したまま、危ないものだけ落とせる。ここが実務では効きます。

置換はバックトレースを生成する瞬間に行われるので、例外の getTrace() でも debug_backtrace() でも debug_print_backtrace() でも、同じように隠れます。自前の例外ハンドラを書いていても、そこを通ってくる値はすでに置換済みです。

置き換わったオブジェクトはどう振る舞う?

SensitiveParameterValue は final クラスで、元の値を private プロパティに抱えています。ただし覗こうとしても出てきません。

<?php

function login(string $email, #[\SensitiveParameter] string $password): void
{
    $frame = debug_backtrace()[0];

    var_dump($frame['args'][1] instanceof \SensitiveParameterValue); // bool(true)
    var_dump($frame['args'][1]);            // プロパティが空のオブジェクトとして出る
    var_dump($frame['args'][1]->getValue()); // string(9) "p@ssw0rd!"
}

login('nori@ex.jp', 'p@ssw0rd!');

__debugInfo() が空配列を返す実装なので、var_dump() しても中身が出てきません。それでも正当な理由で元の値が要るときは getValue() で取り出せる、という逃げ道が用意されています。

もうひとつ、地味に重要なのが serialize() が禁止されている点です。トレースごとシリアライズしてログに流す実装だと、ここで例外が飛びます。

<?php

try {
    login('nori@ex.jp', 'p@ssw0rd!');
} catch (RuntimeException $e) {
    try {
        $payload = serialize($e->getTrace());
    } catch (\Exception $ex) {
        // Serialization of 'SensitiveParameterValue' is not allowed
        echo $ex->getMessage();
    }
}

「隠したつもりが、ログ送信処理ごと落ちる」というのは実際に踏みやすいところです。ログ基盤にトレース配列をそのまま渡している箇所がある人は、属性を入れる前に一度確認しておくといいと思います。instanceof \SensitiveParameterValue で判定して文字列に置き換える、くらいの前処理を入れておくのが無難でしょう。

秘密は自動では伝播しない

ここがいちばんの落とし穴です。属性は「その引数」にしか効きません。受け取った値を別の関数に渡したら、渡した先のフレームでは普通に表示されます。

<?php

function login(string $email, #[\SensitiveParameter] string $password): void
{
    verify($password); // 渡した先には効かない
}

function verify(string $password): void
{
    throw new RuntimeException('NG');
}

login('nori@ex.jp', 'p@ssw0rd!');
#0 /app/login.php(5): verify('p@ssw0rd!')
#1 /app/login.php(13): login('nori@ex.jp', Object(SensitiveParameterValue))
#2 {main}

RFCでも「トレースを遡って伝播させる」案は明示的に却下されています。なので、秘密が通る経路にある関数には、そのつど属性を付けて回る必要があります。面倒に見えますが、逆に言えば「どこを秘密が流れているか」がコードに書かれることになるので、私はそれほど悪くない設計だと思っています。

可変長引数と名前付き引数、コンストラクタ

可変長引数に付けた場合は、展開された全要素がそれぞれ置換されます。名前付き引数で呼んでも、省略してデフォルト値が使われても、位置ではなく宣言に紐づくので取りこぼしません。

実務でいちばん使うのはコンストラクタでしょうか。プロモーテッドプロパティにも書けます。

<?php

final class DbConfig
{
    public function __construct(
        public readonly string $dsn,
        public readonly string $user,
        #[\SensitiveParameter] public readonly string $password,
    ) {}
}

ただし勘違いしやすいのですが、これで守られるのは「コンストラクタ呼び出しのトレース」だけです。出来上がった $configvar_dump() すれば、password プロパティの中身は普通に出ます。オブジェクトの中身まで隠したいなら、別途 __debugInfo() を自分で定義する必要があります。

既存の設定との住み分け

zend.exception_ignore_args は例外トレースから引数を丸ごと落とす設定で、php.ini-production では On、php.ini-development では Off が推奨値になっています。本番はこれを On にしておけばとりあえず漏れません。

ただ、これは効き方が乱暴です。障害調査のときにいちばん見たい「何を渡したら壊れたのか」まで消えてしまう。開発環境やCIでは Off にしている人が多いはずで、そこで出たトレースをSlackに貼る、みたいな運用まで考えると穴が残ります。

個人的には、iniで全体を締めるのは最後の保険と考えて、コード側に #[\SensitiveParameter] を打っておくのが本筋だという気がしています。属性は環境設定に依存せず、どのマシンで動かしても同じように効きますから。

まとめ

秘密を受け取る引数に属性をひとつ付けるだけで、例外トレースにもデバッグバックトレースにも値が出なくなります。コストがほぼゼロなのに、事故ったときの被害は大きく減る、という珍しい種類の機能です。認証、決済、外部APIのトークン、このあたりを触る関数のシグネチャを一度眺めてみて、素の string $password が残っていたら付けて回るのがおすすめです。伝播しないという性質だけ頭に入れておけば、あとは素直に効きます。

よくある質問

Q. #[\SensitiveParameter] を付けると、その変数をechoやログ出力しても隠れますか?
A. 隠れません。効くのはバックトレースを生成するときの引数の記録だけです。自分で error_log($password) と書けば普通に出力されます。

Q. PHP 8.1以前で同じことをするには?
A. 属性そのものはポリフィルできますが、PHPが自動で置換してくれるわけではないので効果はありません。8.1以前では zend.exception_ignore_args を On にするか、例外ハンドラ側でトレースを自前でスクラブすることになります。

Q. PHP標準の関数には最初から付いていますか?
A. 付いています。password_hash()password_verify() のパスワード引数、PDO::__construct() のパスワード引数などは PHP 8.2 で属性が適用されました。

類似投稿

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です