Clang AST Matchersによる要塞の構築:特定の関数呼び出しをコンパイル時粉砕するカスタムプラグインの全実装
コンパイルとは、単なる機械語への翻訳作業ではない。それは、プロジェクトが定めたセキュリティガバナンスとアーキテクチャの境界線を、物理的かつ不可逆に強制する「最後の防壁」である。
世の中には、Linterや静的解析ツール(SonarQube, Clang-Tidy等)があふれている。しかし、CI/CDのパイプライン上でLinterの指摘がスルーされたり、開発者のローカル環境で警告が無効化されたりした苦い経験はないだろうか?
「警告」ではない。私たちは「エラー」を求めている。
本稿では、LLVM/Clangの強靭な内部機構である AST Matchers と PluginASTAction を用い、特定の危険な関数(例: `gets`, `strcpy`, あるいは社内規約で禁止されたレガシー関数)の呼び出しを検知した瞬間、コンパイルそのものを強制停止させるカスタムプラグインの開発手法を、実務の最前線で通用するレベルの完全コードとアーキテクチャ解説とともに暴く。
単なる「おもちゃのプラグイン」ではない。Dockerによる環境完全再現、Clangの内部メモリモデルのハック、そしてCI/CDパイプラインへのゼロフリクション統合まで、DevOpsアーキテクトが知るべきすべてをここに記述する。
—
1. 内部アーキテクチャ:Clangプラグインのデータフロー
カスタムプラグインを書く前に、Clangのコンパイルパイプラインの中で、プラグインがどのようにフックされるのかを正確に把握しなければならない。
[Source Code (.c/.cpp)]
│
▼
[Lexer / Preprocessor]
│
▼
[Parser (AST 生成)] ◄─── Clang Plugin (AST Matchers)
│ │
│ ▼
│ [Callback 実行]
│ (違反検知で DiagnosticsEngine にエラーを投函)
│ │
▼ ▼
[Code Generation] ──────> [Compilation Aborted (Exit Code != 0)]
Clangは、ソースコードをパースして AST(抽象構文木: Abstract Syntax Tree) をメモリ上に構築する。通常のコンパイルであれば、このASTをそのままコード生成フェーズに送る。
しかし、我々が作成するプラグインは、ASTの構築直後(`ParseAST` の完了時、あるいはカスタムConsumerの介入時)に割り込み、AST全体あるいは特定のノード群を走査する。ここで AST Matchers を用いることで、複雑な構文木の中から「特定の関数呼び出し」というパターンを高精度かつ高速にマッチングさせ、`DiagnosticsEngine` を経由してコンパイルエラーを強制発生させる。
—
2. 開発環境の構築:Dockerによる完全再現
LLVM/Clangのプラグイン開発において最大の障壁は、「ホストOSのLLVMバージョンと、開発するプラグインが依存するLLVMライブラリのバージョンの不整合」である。わずかコンパイラのマイナーバージョンの違いで、ASTの内部構造(API)が変わり、リンクエラーやセグメンテーション違反の温床となる。
この地獄を回避するため、ビルド環境と実行環境を完全にコンテナ化する。以下の `Dockerfile` は、LLVM 17の開発環境を数分で構築するための要塞である。
Dockerfile (LLVM/Clang 17 Plugin Development Environment)
ベースイメージとしてUbuntu 22.04 LTSを採用
FROM ubuntu:22.04
非対話モードの設定とタイムゾーンの固定
ENV DEBIAN_FRONTEND=noninteractive
ENV TZ=Asia/Tokyo
LLVM 17および開発に必要なツールチェインのインストール
RUN apt-get update && apt-get install -y –no-install-recommends \
software-properties-common \
gnupg \
wget \
lsb-release \
build-essential \
cmake \
ninja-build \
git \
&& rm -rf /var/lib/apt/lists/
LLVM公式のAPTリポジトリを追加(最新の安定したLLVM/Clangツールチェインを取得するため)
RUN wget https://apt.llvm.org/llvm.sh && \
chmod +x llvm.sh && \
./llvm.sh 17 && \
rm llvm.sh
LLVM 17のヘッダ、ライブラリ、Clangツール群を環境パスに設定
ENV LLVM_DIR=/usr/lib/llvm-17
ENV PATH=”/usr/lib/llvm-17/bin:${PATH}”
開発用ワークディレクトリの設定
WORKDIR /workspace
デフォルトのシェルを指定
CMD [“/bin/bash”]
このコンテナ内では、`clang-17`, `llvm-17-dev`, `libclang-17-dev` が完璧に統合されており、プラグインのコンパイルに必要なShared Libraryへのリンクパスが完全に保証される。
—
3. 実装:禁止関数を検知し粉砕するプラグインコード
ここからが本番だ。特定の関数名(例: `gets`, `strcpy`)の呼び出しをASTから検知し、コンパイルエラー(Diagnostic)を吐くC++プラグインの実装を行う。
ソースファイル名は `ForbiddenCallPlugin.cpp` とする。
ForbiddenCallPlugin.cpp
include “clang/AST/AST.h”
include “clang/AST/ASTConsumer.h”
include “clang/ASTMatchers/ASTMatchFinder.h”
include “clang/ASTMatchers/ASTMatchers.h”
include “clang/Frontend/CompilerInstance.h”
include “clang/Frontend/FrontendPluginRegistry.h”
include “clang/Basic/Diagnostic.h”
include “llvm/Support/raw_ostream.h”
using namespace clang;
using namespace clang::ast_matchers;
using namespace llvm;
namespace {
// ==========================================================================
// 1. AST Matcherのコールバックハンドラクラス
// マッチしたノード(関数呼び出し)を発見した際に実行されるロジックを定義
// ==========================================================================
class ForbiddenFunctionHandler : public MatchFinder::MatchCallback {
private:
CompilerInstance &CI;
public:
explicit ForbiddenFunctionHandler(CompilerInstance &CI) : CI(CI) {}
void run(const MatchFinder::MatchResult &Result) override {
// Matcherで定義したバインド名 “call” から関数呼び出しノードを取得
if (const CallExpr Call = Result.Nodes.getNodeAs
// 呼び出されている関数の宣言を取得
if (const FunctionDecl Callee = Call->getDirectCallee()) {
std::string FuncName = Callee->getNameAsString();
// 禁止リストの定義(実務では外部設定ファイルやマクロから読み込むことも可能)
if (FuncName == “gets” || FuncName == “strcpy” || FuncName == “sprintf”) {
// ClangのDiagnosticsEngineを取得し、カスタムエラーを発生させる
DiagnosticsEngine &Diags = CI.getDiagnostics();
// エラーIDを動的に取得(またはカスタムDiagnosticを利用)
// ここでは組み込みの警告/エラー機構を利用してメッセージを出力
unsigned DiagID = Diags.getCustomDiagID(
DiagnosticsEngine::Error,
“Security Policy Violation: Forbidden function ‘%0’ is called. This function is strictly prohibited due to buffer overflow risks.”
);
// 該当するソースコードの位置情報とともにエラーを発行
Diags.Report(Call->getExprLoc(), DiagID) << FuncName;
}
}
}
}
};
// ==========================================================================
// 2. ASTConsumerクラス
// パースされたASTを受け取り、Matcherを走査する起点を提供する
// ==========================================================================
class ForbiddenCallASTConsumer : public ASTConsumer {
private:
MatchFinder Finder;
ForbiddenFunctionHandler Handler;
public:
explicit ForbiddenCallASTConsumer(CompilerInstance &CI) : Handler(CI) {
// AST Matcherの構築:
// 任意の関数呼び出し(callExpr)かつ、そのターゲットが特定の名前を持つものをキャプチャする
Finder.addMatcher(
callExpr(
callee(
functionDecl(
anyOf(
hasName("gets"),
hasName("strcpy"),
hasName("sprintf")
)
)
)
).bind("call"),
&Handler
);
}
void HandleTranslationUnit(ASTContext &Context) override {
// 翻訳単位(ソースファイル全体)のASTに対してMatcherを実行
Finder.matchAST(Context);
}
};
// ==========================================================================
// 3. FrontendPluginRegistryへの登録を行うメインクラス
// Clangのコマンドライン引数からプラグインとしてロードされた際に呼び出される
// ==========================================================================
class ForbiddenCallPluginAction : public PluginASTAction {
protected:
std::unique_ptr
return std::make_unique
}
bool ParseArgs(const CompilerInstance &CI, const std::vector
// プラグイン引数のパース(今回は特に引数なしでも動作するため常にtrue)
for (const auto &arg : args) {
llvm::outs() << "ForbiddenCallPlugin arg: " << arg << "\n";
}
return true;
}
};
} // namespace
// Clangのプラグインレジストリにこのプラグインを登録
// コンパイル時に -Xclang -load -Xclang libForbiddenCallPlugin.so -Xclang -plugin -Xclang forbidden-call
static FrontendPluginRegistry::Add
X(“forbidden-call”, “Reject compilation if forbidden functions are called”);
—
4. ビルドと検証:プラグインのコンパイルとテスト
プラグイン自体もLLVMのライブラリにリンクされた動的ライブラリ(`.so`)としてビルドする必要がある。以下の `CMakeLists.txt` を用いてビルドを自動化する。
CMakeLists.txt
cmake_minimum_required(VERSION 3.13)
project(ForbiddenCallPlugin)
C++17標準を指定(LLVM 17の開発には必須)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
LLVM/ClangのCMakeパッケージを探す
find_package(LLVM REQUIRED CONFIG)
find_package(Clang REQUIRED CONFIG)
include_directories(${LLVM_INCLUDE_DIRS} ${CLANG_INCLUDE_DIRS})
add_definitions(${LLVM_DEFINITIONS})
プラグインを共有ライブラリ(モジュール)としてビルド
add_library(ForbiddenCallPlugin MODULE
ForbiddenCallPlugin.cpp
)
LLVMとClangのコンポーネントライブラリをリンク
target_link_libraries(ForbiddenCallPlugin PRIVATE
clangAST
clangASTMatchers
clangBasic
clangFrontend
clangLex
)
macOS等でのシンボル未解決エラーを防ぐための設定
set_target_properties(ForbiddenCallPlugin PROPERTIES
CXX_VISIBILITY_PRESET hidden
C_VISIBILITY_PRESET hidden
)
ビルド実行コマンド
Dockerコンテナ内で以下のコマンドを実行し、プラグインの `.so` ファイルを生成する。
ビルドディレクトリの作成
mkdir build && cd build
CMakeによる設定(LLVMのパスを明示)
cmake -DCMAKE_PREFIX_PATH=/usr/lib/llvm-17 ..
並列ビルドの実行
ninja
成功すると、`build/ForbiddenCallPlugin.so` が生成される。
—
5. 実戦テスト:悪意あるコードをコンパイルしてみる
このプラグインが正しく機能するか、わざと危険な関数を集めたテスト用のCソースコードを用意して検証しよう。
test.c
include
include
void secure_function() {
// 安全な関数:これはスルーされるべき
char buffer[100];
snprintf(buffer, sizeof(buffer), “Hello, World!”);
printf(“%s\n”, buffer);
}
void insecure_function() {
char buffer[10];
// 禁止された関数:コンパイルエラーになるべき
strcpy(buffer, “This is way too long for the buffer”);
}
int main() {
secure_function();
insecure_function();
return 0;
}
実行コマンド(Clangによるプラグインのロードと適用)
clang-17 -fno-discard-value-names \
-Xclang -load -Xclang ./build/ForbiddenCallPlugin.so \
-Xclang -plugin -Xclang forbidden-call \
-c test.c
コンソール出力(期待される結果)
error: Security Policy Violation: Forbidden function ‘strcpy’ is called. This function is strictly prohibited due to buffer overflow risks.
1 error generated.
見事にコンパイルが停止した。 バイナリの生成は一切行われず、ビルドプロセスは非ゼロの終了コード(Exit Code)を返す。これにより、不良コードがCIパイプラインをすり抜けることは物理的に不可能になる。
—
6. CI/CDパイプラインとの高度な統合(GitHub Actions)
このカスタムプラグインを全開発者のローカル環境や、GitHub ActionsなどのCI環境でシームレスに強制適用するためのパイプライン設計を示す。
通常、プロジェクトのMakefileやCMakeプロジェクトに対してコンパイラフラグやプラグインパスを動的に注入する必要がある。ここでは、環境変数を用いてプラグインをラップするGCC/Clang互換のラッパースクリプト、またはCMakeのビルドオプションとして統合するアプローチが効果的である。
以下は、GitHub Actions上でDockerコンテナを用いてビルドとプラグインによる静的検証を完全自動化するワークフロー定義である。
`.github/workflows/security_compile_check.yml`
name: Compile-Time Security Guard
on:
pull_request:
branches: [ main, develop ]
push:
branches: [ main, develop ]
jobs:
secure-build:
runs-on: ubuntu-22.04
container:
image: ghcr.io/your-org/llvm17-plugin-env:latest # 事前にビルドしたプラグイン開発用コンテナ
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Build Clang AST Plugin
run: |
mkdir -p build-plugin
cd build-plugin
cmake -DCMAKE_PREFIX_PATH=/usr/lib/llvm-17 ${{ github.workspace }}/plugin-source
ninja
- name: Compile Project with Security Plugin Enforcement
run: |
echo “=== 独自のコンパイルガードを適用してビルドを開始 ===”
# プラグインを適用したコンパイルの実行
# プロジェクト全体のMakefileやCMakeがある場合は、CC/CXX変数を上書きする
clang-17 \
-Xclang -load -Xclang ${{ github.workspace }}/build-plugin/ForbiddenCallPlugin.so \
-Xclang -plugin -Xclang forbidden-call \
-c src/main.c -o main.o
- name: Pipeline Failure Handling
if: failure()
run: |
echo “=========================================================”
echo “CRITICAL: セキュリティポリシー違反が検出されました。”
echo “禁止された関数呼び出しが含まれているため、マージが拒否されました。”
echo “=========================================================”
exit 1
—
7. パフォーマンス最適化と大規模コードベースへの適用ハック
数百万行規模の大規模コードベースにおいて、全ファイルに対してClangプラグインを走査させると、コンパイル時間が肥大化する懸念がある。真のDevOpsアーキテクトは、パフォーマンスのボトルネックを徹底的に排除しなければならない。
1. AST Matcherの最適化(Early Exit):
Matcherの条件式は、重い条件(正規表現による関数名マッチ `matchesName` など)を避け、可能な限り軽量な完全一致 (`hasName`) をツリーの根元に近い位置で評価させること。無駄なASTトラバーサルを防ぐことができる。
2. 依存関係のキャッシュ(CCacheの活用):
プラグインのソース自体や、プラグインを通したオブジェクトファイルのコンパイル結果を `ccache` でキャッシュさせる場合、プラグインバイナリ自体のハッシュ変更をccacheのコンテキストに含める必要がある。
3. インクリメンタルな適用:
既存のレガシーコードベースに突然このプラグインを導入すると、数千件のエラーが出て開発現場が麻痺する。まずは新規追加されるコード(Git Diffで検知されたファイル、あるいは特定のディレクトリ)に対してのみプラグインを有効化するプロキシ・ラッパー(Python等で書かれたCLIツール)を挟むことで、段階的なガバナンス移行(Gradual Enforcement)を実現せよ。
結び
Linterは「お説教」をするが、Clangプラグインは「実力行使」に出る。
人間は疲労し、コードレビューは形骸化し、規約文書は誰にも読まれなくなる。しかし、コンパイラは決して眠らず、決して妥協せず、規約を破ったコードを一撃で葬り去る。
AST Matchersを掌握した者にとって、ソースコードの構造はすべて手に取るようにコントロール可能なキャンバスとなる。組織のセキュリティポリシーをコードそのものに埋め込み、強靭なエンジニアリングの要塞を築き上げろ。