前の記事では、開発用コマンドを隔離して動かし、ファイルアクセスや通信を記録するIzanagiを紹介しました。今回はそこへ追加した、Jevを使う行動分析の話です。

既存の検知ルールは、一つの操作を見て警告を出します。機密ファイルを開こうとした、許可リスト外へ接続した、想定外のコマンドを起動した、といった判定です。これに加えて、複数の操作を時系列で結び付けて扱えるようにしました。

最初に対象にしたのは、資格情報に関係するファイルへのアクセス試行と、その後に関連するプロセスが行うHTTP POSTです。その系列をRust側で組み立て、限定した特徴量をJevへ渡します。ただし、現在は監査用のPoCで、自動遮断には使っていません。

実装はPR #47で追加しました。この記事は10月9日時点の0c17c601に合わせて書いています。試験ごとのリビジョンや環境は検証記録に残しています。以下の数値はその記録に基づくもので、記事を書くためにAPIやVMの試験をやり直した結果ではありません。

Jevへ何を聞くか

Jevへの問い合わせには、TypeSafe AIのChoiceを使います。あらかじめ用意した候補から一つを選び、候補ごとの確率分布とconfidenceを返す形式です。公式のChoice仕様では、評価する内容をstate、質問をinstructions、選択肢の定義をcriteriaとして渡します。

Izanagiでは、一つの観測系列に対して、一つの観点で質問します。候補はnormal、access_post_suspected、unknownの三つです。

normalは、渡した観測範囲では通常の作業と整合しているという意味です。プログラム全体が安全だという保証にはしません。access_post_suspectedは、資格情報へのアクセス試行とPOSTの関係が疑わしいという判定です。unknownは、与えられた情報では判断できない場合です。

今回の目的は、攻撃の説明文を書かせることではありません。選択結果と分布を受け取り、アプリケーション側で扱います。根拠となるイベントIDもJevに生成させず、相関処理で確定した参照を別に保存します。

質問には、件数や時刻の前後関係、プロセスの関連はすでにホスト側で計算していることを明記しました。欠けている値を「その操作はなかった」と扱わないこと、アクセス試行やPOSTだけで情報の読み取り・持ち出しを証明できないことも、候補の条件に含めています。

一つの操作から系列へ

たとえば、パッケージのインストール中に資格情報ファイルへのアクセスがあり、その直後にPOSTが発生したとします。個別のルールでは、ファイルアクセスと通信が別々のイベントになります。

プロセスA: 資格情報に関係するファイルをopen
プロセスA: 接続を開始
その接続: HTTP POSTを転送

この関係が確認できれば、一つの系列として扱う材料になります。ただし、近い時刻に起きたというだけでは、同じ処理の一部とは限りません。別のプロセスが同時にファイルへ触り、正当なビルド処理がPOSTを送ることもあります。

そこで最初の実装では、QEMU上のLinux、eBPFによる観測、ゲスト内のHTTP/1.1明示プロキシという範囲に限定しました。プロセス、ファイル、ソケットのイベントと、プロキシが観測したリクエストを結び付けます。TLSやQUIC、任意のバックエンドを最初から同じ精度で扱う設計にはしていません。

観測と推論の境界

まず区別したのは、「試した」と「成功した」です。syscallの入口を観測しただけでは、OSがその操作を許可したか分かりません。openat()を呼んでも、ファイルがなくてENOENTになる場合も、権限でEACCESになる場合もあります。

今回の観測では、ファイルへのアクセス試行とopenの完了結果を分けて記録します。従来の入口だけのイベントを、戻り値0の成功として行動分析へ流すことは避けています。取れていない結果はUnknownです。

openが成功しても、ファイルの内容を読んだことまでは分かりません。さらに、その後のPOSTにその内容が含まれていたかも、今回の観測からは分かりません。HTTP本文と資格情報の内容を照合する処理を入れていないためです。

アクセス試行
  → open成功または失敗
  → ファイル内容の読み取り
  → 読んだ内容を含むリクエストの作成
  → 外部への転送

HTTPについても、宣言されたContent-Length、クライアントから受信した量、上流へ実際に書き込んだ量、応答として受信した量を分けました。ヘッダーに書かれた長さを実送信量にしたり、ソケットへの書き込み成功を受信先での処理成功にしたりすると、同じように証拠の範囲を越えます。

PIDだけでは足りない

系列を作るために、プロセスの識別情報を増やしました。PIDは同じOSの中でも再利用されるので、長く記録する場合にはPIDだけで同じプロセスと判断できません。

IzanagiのProcessKeyは、セッション、ゲストの起動、PID namespace、TGID、プロセスの起動を識別する情報を組み合わせます。スレッドのTIDと、実行ファイルを切り替えるexecの世代も分けて扱います。型の定義はschema.rsにあります。

通信側には、さらに別の問題があります。接続を開始したプロセスが、その接続へHTTPリクエストを書いたとは限りません。forkで子がソケットを引き継ぐことも、ファイルディスクリプタを別のプロセスへ渡すこともできます。

そこで接続開始元であるconnectorと、書き込み元を確認したconfirmed writerを区別しました。コネクションのtuple、接続の世代、プロセスの情報を照合できても、writerまで確認できなければ、その不足を残します。送信元のIPやport、時刻が近いというだけで、確定したwriterへ格上げはしません。

Rust側で特徴量を作る

POSTを候補系列の基点にして、同じプロセス、または証拠のある系譜の直前30秒を集計します。遅れて届くイベントを待つ初期値は2秒です。これは現在のPoCの設定で、任意の攻撃を捉える時間幅として検証した値ではありません。

保存の順番と発生の順番も区別します。collectorとホストの間にはキューや非同期処理があるので、ホストが先に受け取ったイベントが、先に起きたとは限りません。表示用のwall clockと、順序比較に使うmonotonic clockを分け、時計域や品質を記録します。

相関結果はFeatureSnapshotにします。アクセス試行の件数、openの成功・失敗、POSTまでの間隔、転送結果、観測品質、根拠イベントへの参照などを保持します。correlation.rsが、この集計と系列の判定を担当します。

宛先については、ポリシーで許可されているかと、正常なベースラインに出現したことがあるかも別です。初めての宛先でも、明示的に許可したテスト受信先かもしれません。逆に、既知の宛先だから今回の使い方も正常だとは限りません。PolicyAllowedとDestinationNoveltyに分けて持たせました。

外部へ送る情報を絞る

ローカルで持つsnapshotと、Jevへ送るデータは別の型です。FeatureProjectionには、外へ渡すことを許可したフィールドだけを定義しています。

パスは資格情報、ビルド、キャッシュといった役割へ分類し、宛先も許可・既知・新規などの区分で扱います。APIキー、環境変数の値、argv、HTTP本文、ヘッダーやqueryの値、生のパスやドメイン名は送信しません。ローカルのプロセスIDやイベントIDもprojectionには含めません。

汎用JSONをそのまま通す入口を設けると、後からフィールドを増やしたときに送信範囲も広がってしまいます。そのため、型からprojectionを組み立て、未知のフィールドを拒否する形にしています。必要な情報を追加する場合は、その型と送信方針を見直す変更になります。

生のパスに「この警告を無視せよ」のような文字列が入っていても、それを質問のinstructionsへ連結しません。アプリケーションが観測した任意の文字列と、分類器へ与える指示を分けるためです。今回の分類に必要な役割や件数へ変換してから送ります。

MCPを経由して呼ぶ

データの経路は次のようにしました。

flowchart TD
    K[ゲストeBPFの観測] --> A[agent collector]
    H[ゲストHTTPプロキシの観測] --> A
    A --> T[ホストのtelemetry]
    T --> R[既存ルールとログ]
    T --> C[Rustで相関と特徴抽出]
    C --> D[決定論的な系列ルール]
    C --> P[外部送信用projection]
    P --> J[Jev MCP]
    J --> V[Rustで応答を検証]
    D --> S[監査JSONL]
    V --> S

ホスト側のbehavior_classifier.rsでjev-mcpをstdioの子プロセスとして起動し、MCPの初期化、ツール確認、jev.choiceの呼び出しを行います。

子プロセスはcommandとargsを指定して起動し、シェル展開は使いません。Jevの認証情報はホストの限定した環境から渡し、ゲストには渡しません。IzanagiのHMACキーやセッショントークンも、そのままMCPへ継承しないようにしています。

評価モデルはjev-1.13.0に固定しました。モデルの公式文書には版指定とaliasの違いがあるため、評価中にaliasの中身が変わる条件は混ぜません。返されたmodelも要求した版と一致するか確認します。

行動分析は既定で無効です。有効にしても分類器はmockが既定で、実Jevへの送信にはallow_exportを別に明示します。無効時にsidecarやMCP、外部APIを起動しないこともテストしています。

confidenceを計算し直す

Choiceの応答は、候補を選んだというだけで採用しません。質問ID、type、候補集合、モデル、確率の範囲、分布の合計、選択結果と最大確率の一致、confidenceとの整合をRust側で検証します。

公式のconfidenceの定義では、Choiceの候補数をn、最大確率をp_maxとすると、次の式を使います。

confidence = (p_max - 1/n) / (1 - 1/n)

三候補なら、すべてに1/3ずつ分かれた分布で0、一つに1が集中した分布で1になります。これは分布から計算する指標です。実世界で攻撃が起きた確率や、その回答の正解率を直接表すものとしては扱いません。

今回の応答検証では、分布の合計の許容差を0.001、confidenceの丸め許容差を0.01にしています。採用の閾値は0.6で、実応答を見る前に固定しました。三候補の式でconfidenceが0.6になる最大確率は約0.7333です。選択確率0.6とconfidence 0.6は同じ条件ではありません。

実試験には、最大確率が0.53、返されたconfidenceが0.28という応答がありました。式から計算すると0.295で、差は0.015です。設定した許容差を越えるため、この応答はInvalidResponseにしました。値を補正して採用したり、通る回答が出るまで再試行したりはしていません。

棄権と失敗を分ける

分類結果は、Classified、Abstained、Failed、Skippedの四状態にしました。

状態扱い
Classified応答検証とホスト側の品質条件を満たして採用
Abstainedunknown、低confidence、必要な観測の不足で棄権
FailedAPI・MCP・timeout・応答検証などの失敗
Skipped無効、送信不可、キュー満杯、期限切れなどで実行しない

必要なwriterや観測情報がないと分かっている場合は、APIへ問い合わせる前に棄権できます。モデルに不足を推測させても、カーネルから得られなかった証拠は増えません。

高いconfidenceでunknownが返ってきても、正常へ変換しません。「情報不足という選択に分布が集中した」と「通常の処理と判断した」は別の結果です。timeoutや429、529、不正なJSONも正常判定にはしません。

逆に、高confidenceで採用した回答が誤っている場合は、評価上の誤りとして数えます。confidenceで受け入れたというだけで正解にする仕組みではありません。独立したラベルに対して見逃しや誤警告を計数するテストを入れています。

分類器の障害は行動分析の品質を下げますが、既存のsyscallルールは引き続き動かします。もともとの監視接続自体が失われた場合に、サンドボックスを停止する方針も残しています。追加の推論が使えないことと、監視全体が使えないことは、異なる障害です。

推論待ちで監視を止めない

既存Detectorの同期的なRule::checkの中では、Jevを呼びません。行動分析用のworkerへ有限長のキューで送る構成です。APIが遅いときに、ファイルや通信の観測まで待たせないためです。

現在の上限は、分類キュー32系列、同時実行1件、キューの最大待機15秒、リクエストのdeadline 65秒、結果の最大age 90秒です。終了時のdrainまたはキャンセルは2秒に制限しています。設定の実装はbehavior_config.rsにあります。

送信projectionはUTF-8のJSONで8KiB、要求全体は16KiB、MCP応答は64KiBが上限です。大きすぎる入力を文字列の途中で切り、元と違う情報を送ることはしません。上限超過として記録します。

stdoutとstderrは同時に読み出し、両方に上限を設けます。子プロセスがstderrを大量に出して止まる場合や、応答せず待ち続ける場合も、主監視を巻き込まないようにしています。エラー文字列を、そのまま監査ファイルへ複製することも避けています。

終了したセッションへの遅い回答や、別の系列の回答を取り違えないために、session、window、input digestで結果を照合します。遅れて到着した観測で評価を更新する場合も、元の結果を無言で書き換えず、revisionとsupersedesを残します。

同じ入力で四方式を比較する

Jevを入れた効果を確かめるため、同じ収集イベントを四つの方式へ渡す評価を作りました。

方式入力と判定
A既存の単一イベントルール
B通信の特徴量をJevへ渡す
Cプロセス・アクセスとの相関特徴量をJevへ渡す
DCと同じ相関特徴量を決定論的な系列ルールで判定

BとCを比べれば、相関情報を加えた差を調べられます。CとDを比べれば、同じ特徴量に対してモデルを使う差を調べられます。既存警告と追加の系列警告は別に数えます。相関機能を加えた改善と、Jevを使った改善を分けるためです。

最初のfixtureは、通常POST、資格情報へのアクセス試行と関連POST、同じような系列で観測が欠損するケースの三系列です。シナリオの仕様からラベルを固定し、分類器の回答から正解を作りません。入力ファイルのdigestもmanifestへ保存します。

観測欠損で棄権した系列や、API失敗の系列も評価の分母に残します。都合よく判定できた系列だけを残すと、必要なときに答えられない分類器の問題が結果から消えてしまいます。

実Jevの応答

三系列の固定試験では、question version 1、モデルjev-1.13.0、閾値0.6、confidence許容差0.01を使いました。通常系列と疑わしい系列のB・Cで、実APIは計4回呼んでいます。欠損系列は必要な品質を満たさず、呼ぶ前に棄権しました。

方式系列数追加系列警告棄権失敗Recall
A30000
B:通信特徴と実応答30210
C:相関特徴と実応答30200
D:決定論的ルール31100.5

Cの通常系列はnormal、confidence 0.95でした。疑わしい系列はaccess_post_suspectedを選びましたが、confidence 0.3なので棄権しました。選択結果だけを見れば疑わしい候補ですが、採用の条件は満たしていません。

Bの通常系列は、前述したconfidenceと分布の不整合で失敗になりました。保存した応答はjev-responses.jsonにあります。修正してから評価するのではなく、返された値を同じ検証器へ再生できるようにしています。

この三系列では、Cから追加警告は出ず、Dは一件を警告しました。Jevが決定論的ルールを改善した証拠は得られていません。一方、この小さなfixtureだけで、Jevが他の行動分析にも使えないと結論付けることもできません。現在の質問・特徴・採用条件で得た結果として扱います。

最初の4呼び出しのusageは、入力2499、出力177 tokensでした。後述する境界ケースへの1呼び出しを加えると、実APIは計5回、入力3131、出力220 tokensです。これは呼び出した試験の量で、日常作業一回あたりの費用や処理量を測ったものではありません。

mockと境界ケース

同じ三系列へmockを使った試験では、CとDは一件警告し、一件棄権しました。これは特徴抽出から判定・保存までの経路と、評価の算術を確認する結果です。mockの良い数値をJevの性能としては扱いません。

さらに21系列の境界fixtureを追加しました。正常なinstall/build、正当な資格情報付きupload、open失敗、open成功だがread未確認、POST失敗、無関係PID、PIDやnamespaceの再利用、確認済み子プロセス、keep-alive、並行接続、時計の変化、イベント欠損などを含めています。

この21系列では、Cのmockは8件警告、5件棄権、Precision 0.875でした。Dは7件警告、5件棄権、Precision 1です。どちらもRecallは1、分類coverageは0.762でした。Dの良い値も合成契約に対する結果で、実際のパッケージインストールの精度ではありません。

Cの単純なmockは、既知の宛先への正当な資格情報付きuploadも警告しました。この一件を実Jevへ追加で渡すと、normal、confidence 0.71、選択確率0.81となり、固定した検証条件で採用できました。

この境界ケースはdevelopmentとして元のheld-out評価と分けています。追加呼び出しのMCP往復は427ms、入力632、出力43 tokensでした。測定は一件なので、速度の分布を示す材料にはなりません。MCPやネットワークを含むホスト側の往復時間で、モデルだけの推論時間でもありません。

実VMで確認したこと

実VMでは、Debian 13、aarch64、Linux 6.12.74、QEMU HVF、2 CPU、2GiB、専用qcow2 overlay、guest UID 1000という環境で試験しました。観測コードの基準は3ab9d3fdで、認証付きwire v3を使っています。イメージ、バイナリ、fixtureのhashと測定値はvm-validation-summary.jsonに保存しています。

通常POST二件と、資格情報アクセス後のPOST二件を発生させました。後者にはopen成功一回とENOENT一回を含め、同じ接続のkeep-aliveも扱います。各POSTでは上流への書き込み192bytes、応答113bytes、status 200を観測しました。

監査JSONLには、稼働中に四つのwindowと判定が保存されました。ただし、writerとnamespaceの完全な証明ができず、MissingWriterとSocketAmbiguousを残したため、四件とも棄権です。このVM試験の分類器はmockで、実Jevの5呼び出しとは別の試験です。

ここで確認したのは、観測が不足した実入力を正常や攻撃へ押し込まないこと、系列が停止前に保存されること、必要な識別と不足理由が残ることです。HTTP本文、header、query、pathへ入れたcanary文字列は、行動監査JSONLに存在しませんでした。

通常のdownでは終了コード0となり、session、PID、lockを削除しました。shellの入力・出力、端末サイズ変更、終了コード7、端末モードとフラグの復元も確認しています。専用QEMUを停止させた異常試験では、待機中のexec、入力待ちshell、upが約1.206秒で終了し、状態と端末を片付けました。

負荷で系列が確定しなかった

実VMの試験中には、HTTPのtelemetryが滞留し、windowがなかなか確定しない問題も見つかりました。Issue #48です。

一つ目の原因は、kernelイベントとHTTPイベントで送出の優先枠を共有していたことです。kernel側が多いと、HTTP側が待ち続けました。二つ目は、遅れて届くkernelイベントでライブ時計の基点が巻き戻り、確定の時刻を先送りしていたことです。

修正ではkernel毎秒96件、HTTP毎秒32件の独立枠を用意し、syscallを含む中継の優先順を交替させました。Stopとhealthを優先し、ホスト側の定期確定処理もbacklogに押し流されないようにしています。遅着イベントで時計の推定を巻き戻さない処理も入れました。

修正後は、同じVM負荷で四つのwindowを停止前に保存できました。ただし、この試験ではsource dropが976件あります。欠損はObservationGapとして記録し、保存失敗やstorage gapが0だったことと区別しています。監査ファイルへ書けたことは、入力イベントを一件も落とさなかった証明にはなりません。

リソース計測では、ホストIzanagiのpeak RSSが20.219MiB、QEMUが773.422MiBでした。0.5秒間隔のpsで、それぞれ150、149 samplesを取った値です。HTTP往復のp50は2.767ms、p95は44.460msでしたが、対象は四リクエスト、nearest-rankによる計算です。Jevの推論待ち時間や、通常機能に対するoverheadの計測ではありません。

記録から再評価する

Jevの応答だけを保存しても、どの入力と設定で得た結果か分からなければ、後から比較できません。そこで、snapshotのdigest、質問・特徴・ホスト方針のversion、要求と応答のmodel、全分布、confidence、usageを監査に残します。

保存した実応答を検証し直す場合は、次のコマンドを使えます。

cargo run --locked -- behavior evaluate \
  --manifest tests/fixtures/behavior/manifest.json \
  --classifier recorded \
  --recorded-responses tests/fixtures/behavior/jev-responses.json

これは外部APIを再度呼びません。保存した応答を、同じホスト検証へ通します。この実行時間は応答ファイルの検証時間で、元のAPI推論時間として扱わないようにしています。

イベントからのreplayは、保存された発生時刻を使います。今日の時計で古いイベントを期限切れにするのではなく、当時の系列を仮想時計で再構成します。一方、APIの実行deadlineはreplayでも残します。

監査データは、ディレクトリ0700、ファイル0600で保存し、保持期間と容量上限を設けています。初期値は七日、一セッション100MiBです。元イベントが削除された場合、snapshotを再生できることと、元の根拠イベントへ戻れることを区別し、根拠の期限切れを表示します。

現在の使い方と残作業

設定例はconfigs/default.tomlにあります。ライブPoCにはQEMUとvm-agent、同じ版のLinux agent・eBPF・guest HTTP sidecarが必要です。通信形式だけでなくeBPFのraw ABIも確認し、古いobjectやagentを準備完了として受け入れないようにしています。

[behavior]
enabled = false

[behavior.classifier]
provider = "mock"
model = "jev-1.13.0"
min_confidence = 0.6
allow_export = false

この抜粋は既定の無効状態です。動作を試すときも、行動分析の有効化と、実モデルへの送信許可を別に設定します。APIキーをTOMLへ直接書くのではなく、ホスト側の認証情報を渡す環境変数名を指定します。

実装済みなのは、限定した観測系列を組み立て、特徴を送信し、回答と欠損・障害を検証して、四方式を比較できる監査経路です。広い実作業でのprecision・recall、writerとnamespaceの証明、確認済みファイルread、TLS・QUICや別バックエンドへの拡張は残っています。

現在の結果では、実Jevが決定論的ルールを改善した証拠はなく、運用判定へ昇格させていません。既存の検知と通信ポリシーを維持し、Jevの結果は監査として保存します。自動遮断を行う前提もありません。これが、今回追加した行動分析の実装範囲です。