【PHP】【Laravel】dump,ddなどの特定の名前の関数の使用を検出する nikic/php-parser を使ったスクリプトの紹介

 実際にPHPで作ったwebサイトをインターネット上で公開する際に注意すべき点のひとつにデバッグコードの残留があります。意図せず残ったデバッグコードはデザインを損ねるような出力をする上、時には脆弱性にもつながります(デバッグ用機能がONになっていて裏道の様に使われるなど)。この対策の一つとして自動検出をする方法があります。この記事では自動検出方法の一つとして nikic/php-parser を用いた任意の関数の使用箇所の検出コードを紹介します。

 実際のコードが次です。

<?php

use PhpParser\Error;
use PhpParser\Lexer\Emulative;
use PhpParser\Node;
use PhpParser\NodeTraverser;
use PhpParser\NodeVisitorAbstract;
use PhpParser\Parser\Php8;
use PhpParser\PhpVersion;

require __DIR__.'/vendor/autoload.php';

/**
 * dd や dump 関数の使用を検出するための NodeVisitor
 * NodeVisitor は、PHP-Parser ライブラリの一部であり、AST(抽象構文木)を走査するために使用されます。
 */
class DebugFunctionDetector extends NodeVisitorAbstract
{
    /**
     * 検出結果を格納する配列
     * @var array
     */
    private array $results = [];

    public function __construct(private readonly string $file)
    {
    }

    /**
     * ノードを訪問する際に呼び出されるメソッド
     *
     * @param  Node  $node  訪問するノード
     * @return void
     */
    public function enterNode(Node $node): void
    {
        // dd(), dump() 関数呼び出しを検出
        // もし Node が関数呼び出しであり、その関数の名前が dd または dump の場合
        if ($node instanceof Node\Expr\FuncCall
            && $node->name instanceof Node\Name
            && in_array(strtolower($node->name->toString()), ['dd', 'dump'])
        ) {
            // ノードに付随するコメントを取得し、許容コメントがあるか確認する
            $comments = $node->getAttribute('comments', []);
            foreach ($comments as $comment) {
                // コメントを行単位で分割し
                $lines = preg_split("/\r\n|\r|\n/", $comment->getText());
                foreach ($lines as $line) {
                    // コメント中に @forbidden-dump-ignore を含むものは許容とみなして結果を追加
                    if (str_contains($line, '@forbidden-dump-ignore')) {
                        $this->results[] = [
                            'file'     => $this->file,
                            'line'     => $node->getLine(),
                            'function' => $node->name->toString(),
                            'ignored'  => true,
                            'comment'  => mb_trim(preg_replace('#.*@forbidden-dump-ignore#', '', $line)),
                        ];
                        return;
                    }
                }
            }

            // 許容されていないデバッグ関数の使用を結果に追加
            $this->results[] = [
                'file'     => $this->file,
                'line'     => $node->getLine(),
                'function' => $node->name->toString(),
                'ignored'  => false,
            ];
        }
    }

    /**
     * 解析結果を取得
     *
     * @return array 検出された dd()/dump() の使用結果
     */
    public function getResults(): array
    {
        return $this->results;
    }
}

/**
 * 指定されたPHPファイルを解析し、dd()/dump()の使用を検出する
 * @param  string  $filePath
 * @return array
 */
function analyzeFile(string $filePath): array
{
    $lexer  = new Emulative(PhpVersion::getHostVersion());
    $parser = new Php8($lexer);

    // ファイル読み込み
    $code = @file_get_contents($filePath);
    if ($code === false) {
        fwrite(STDERR, "❌ ファイルが読み込めません: $filePath\n");
        return [];
    }

    try {
        // パースして各ノードを走査して dd()/dump() の使用を検出
        $ast       = $parser->parse($code);
        $traverser = new NodeTraverser();
        $detector  = new DebugFunctionDetector($filePath);
        $traverser->addVisitor($detector);
        $traverser->traverse($ast);

        return $detector->getResults();
    } catch (Error $e) {
        fwrite(STDERR, "⚠️ Parse error in $filePath: {$e->getMessage()}\n");
        return [];
    }
}

/**
 * 指定されたパスからPHPファイルを再帰的に収集する
 * @param  array  $paths
 * @param  array  $ignorePaths
 * @return array
 */
function collectPhpFiles(array $paths, array $ignorePaths): array
{
    $phpFiles = [];

    foreach ($paths as $path) {
        if (! file_exists($path)) {
            fwrite(STDERR, "❌ 指定されたパスが存在しません: $path\n");
            continue;
        }

        // ファイルの場合:PHPファイルであり無視リストに含まれていなければ追加
        if (is_file($path)) {
            if (pathinfo($path, PATHINFO_EXTENSION) === 'php' && ! shouldIgnore($path, $ignorePaths)) {
                $phpFiles[] = $path;
            }
        }
        // ディレクトリの場合:再帰的にすべてのPHPファイルを探索
        elseif (is_dir($path)) {
            $iterator = new RecursiveIteratorIterator(
                new RecursiveDirectoryIterator($path)
            );
            foreach ($iterator as $file) {
                if ($file->isFile()
                    && pathinfo($file, PATHINFO_EXTENSION) === 'php'
                    && ! shouldIgnore($file->getRealPath(), $ignorePaths)
                ) {
                    $phpFiles[] = $file->getRealPath();
                }
            }
        }
    }

    return array_unique($phpFiles);
}

/**
 * 指定されたファイルパスが無視リストに含まれているかどうかをチェック
 * @param  string  $filePath
 * @param  array   $ignorePaths
 * @return bool
 */
function shouldIgnore(string $filePath, array $ignorePaths): bool
{
    $realFilePath = realpath($filePath);
    if ($realFilePath === false) {
        return false;
    }

    foreach ($ignorePaths as $ignore) {
        $realIgnorePath = realpath($ignore);
        if ($realIgnorePath !== false && str_starts_with($realFilePath, $realIgnorePath)) {
            return true;
        }
    }

    return false;
}

/**
 * ヘルプメッセージを出力
 */
function printHelp(): void
{
    echo <<<EOT
dd()/dump() 使用検出ツール

使い方:
  php forbidden_dump.php [paths...] [--ignore path1 --ignore path2 ...]

Examples:
  php forbidden_dump.php app
  php forbidden_dump.php app routes --ignore app/Console

EOT;
}

/**
 * 絶対パスをプロジェクトルートからの相対パスに変換
 * @param  string  $filePath
 * @return string
 */
function toRelativePath(string $filePath): string
{
    global $hasVendorDir;
    $projectRoot = realpath($hasVendorDir);

    return str_replace($projectRoot.DIRECTORY_SEPARATOR, '', realpath($filePath));
}

// ─────────────────────────────
// メイン処理
// ─────────────────────────────
$args        = array_slice($argv, 1);
$paths       = [];
$ignorePaths = [];

// コマンドライン引数を解析
for ($i = 0; $i < count($args); ++$i) {
    if ($args[$i] === '--ignore' && isset($args[$i + 1])) {
        $ignorePaths[] = $args[++$i];
    } elseif ($args[$i] === '--help') {
        printHelp();
        exit(0);
    } else {
        $paths[] = $args[$i];
    }
}

// パスが指定されていない場合はエラー
if (empty($paths)) {
    fwrite(STDERR, "⚠️ パスが指定されていません。--help を参照してください。\n");
    exit(1);
}

// ファイルを収集して解析を実行
$files      = collectPhpFiles($paths, $ignorePaths);
$violations = [];
$warnings   = [];

// 各ファイルに対して違反と警告を振り分け
foreach ($files as $file) {
    foreach (analyzeFile($file) as $result) {
        if (! empty($result['ignored'])) {
            $warnings[] = $result;
        } else {
            $violations[] = $result;
        }
    }
}

// 違反レポート出力
if ($violations) {
    echo "❌ dd()/dump() の未許可使用が検出されました:\n";
    foreach ($violations as $v) {
        echo toRelativePath($v['file']).":{$v['line']} - {$v['function']}()\n";
    }
}

// 許容コメント付きの警告出力
if ($warnings) {
    echo "⚠️ 許容コメント付きの dd()/dump() 使用箇所:\n";
    foreach ($warnings as $w) {
        echo toRelativePath($w['file']).":{$w['line']} - {$w['function']}() {$w['comment']}\n";
    }
}

// 結果が何もなければ安心メッセージ
if (! $violations && ! $warnings) {
    echo "✅ dd()/dump() の使用は検出されませんでした。\n";
}

// 終了コード:違反がある場合は 1、なければ 0
exit($violations ? 1 : 0);

 これは次のように検出対象のファイルらを持つディレクトリを指定して使います。

# /app 以下を全て探索する場合
php forbidden_dump.php app
# /app/Consoleを除いた /app と /routes 以下を探索する場合
php forbidden_dump.php app routes --ignore app/Console

 これを実行すると

$ php forbidden_dump.php app
❌ dd()/dump() の未許可使用が検出されました:
app/Providers/AppServiceProvider.php:40 - dump()

 のようにdump,ddがあればその使用箇所を教えてくれます。

 もしコンソール出力などでdumpやddを使うのが適切な場合は次のように@forbidden-dump-ignoreコメントでそれを示せます。

if (self::$dumpDebugMode) {
    // @forbidden-dump-ignore dump結果を見たいデバッグモード限定なのでOK
    dump('HTTPリクエストが送信されました', [
        'Method'      => $method,
        'URL'         => $url,
        'Headers'     => $headers,
        'RequestBody' => $body,
    ]);
}

 こんな感じで nikic/php-parser を利用してソースコード内の中を走査して、正確に内容を把握して様々なアクションをプログラムにさせられます。dump,dd以外を探したり他の何かをしたい場合は最近だとLLMに上記のコードをコピペして「このコードを元に○○な××を作ってください」や「このコードを元にして次の要件を満たすコードを作ってください。(以下詳細な要件定義)」といった命令をするとパパっとそれっぽいものができます。

 またこれをGitのコミットフックに組み込むなどして自動実行させるようになれば予期せぬddやdumpといったデバッグ用関数の使用は概ね防げます。

>株式会社シーポイントラボ

株式会社シーポイントラボ

TEL:053-543-9889
営業時間:9:00~18:00(月〜金)
住所:〒432-8003
   静岡県浜松市中央区和地山3-1-7
   浜松イノベーションキューブ 315
※ご来社の際はインターホンで「316」をお呼びください

CTR IMG