添付ファイルのウイルススキャン
本体の標準機能ではありません
プリザンター 1.5.8.1 にはアップロードされたファイルの中身を検査する仕組みがありません。このページは組み込む場合の設計メモです。ClamAV を使う実装コード(IVirusScanService などの全文)と clamd のセットアップは添付ファイルのレシピの「ウイルススキャンを追加する(本体改変)」にあります。
前提にした現行実装
アップロード時の検査は次のものだけです(1.5.8.1)。
| 検査 | 場所 | 内容 |
|---|---|---|
| 件数・サイズ | BinaryValidators.OnUploading() | 項目の LimitQuantity・LimitSize・TotalLimitSize、ローカル保存時の LocalFolderLimitSize・LocalFolderTotalLimitSize、テナントの StorageSize |
| ファイル名・拡張子 | BinaryValidators.OnValidatingFormUpload() | IsValidFileName()(NUL・..・/・\・:・255 文字超を拒否)と IsAllowedExtension()(Form.json の AttachmentExcludedExtensions) |
| ハッシュ | BinaryUtilities.ValidateFileHash() | 送られたハッシュと保存したファイルの一致確認(改ざん・欠損の検出で、マルウェア検出ではない) |
| 画像 | 画像アップロード時 | ImageSharp で画像として読めるか |
OnValidatingFormUpload() は先頭で if (!context.IsForm) return Error.Types.None; としているので、ファイル名と拡張子の検査は公開フォームからのアップロードだけに掛かります(BinaryValidators.cs#L636-L689)。拡張子の検査の詳細は添付ファイルの拡張子制限を参照してください。
どちらにしても、.docx・.pdf・.zip のように許可された拡張子に偽装したファイルや、マクロ付きの Office 文書は通ります。
スキャンエンジンの比較
| 手法 | Windows | Linux | 無償 | 呼び出し方 | 向き・不向き |
|---|---|---|---|---|---|
ClamAV(clamd デーモン) | 可 | 可 | 可(GPL v2) | TCP / Unix ソケットでストリーム送信。.NET は nClam(MIT) | 常駐するので速い。公式 Docker イメージ clamav/clamav がある |
ClamAV(clamscan コマンド) | 可 | 可 | 可 | プロセス起動 | 毎回エンジンを初期化するため 1 ファイル数秒〜十数秒かかる。高頻度には不向き(clamdscan なら clamd 経由で速い) |
| Windows Defender(AMSI) | 可 | 不可 | 可(OS 標準) | amsi.dll を P/Invoke(AmsiScanBuffer) | 追加ソフト不要。AMSI 対応のサードパーティ製 AV が入っていればそのエンジンが使われる |
Windows Defender(MpCmdRun.exe) | 可 | 不可 | 可 | -Scan -ScanType 3 -File をプロセス起動。終了コード 2 が検出 | 手軽だがプロセス起動のコストがある |
| YARA | 可 | 可 | 可(BSD) | ルールでパターンマッチ(.NET は dnYara) | AV の代わりではなく補完。ルールの保守に専門知識が要る |
| VirusTotal API | 可 | 可 | 無償枠は 1 日 500 件・1 分 4 件 | REST API | 70 以上のエンジンで判定できるが、アップロードしたファイルは共有されるので機密文書には使えない。SHA-256 のハッシュだけを照会する使い方なら本体は送らない |
Windows と Linux の両方で動かすなら ClamAV(clamd)が現実的な第一候補です。Windows だけの環境なら AMSI が追加ソフトなしで済みます。OS を判別して Windows は AMSI、Linux は ClamAV に振り分ける構成も取れますが、プラットフォームごとのコードを保守することになります。
組み込み方の比較
| 方式 | 本体改修 | 確実さ | バージョンアップの影響 | 内容 |
|---|---|---|---|---|
| A. 本体コードに直接入れる | 要 | 高い | 大きい | BinaryUtilities.UploadFile() などにスキャンを差し込む。全経路を確実に押さえられる |
| B. プラグインにする | 要 | 中 | 中 | ExtendedLibraries のプラグイン機構に拡張点を足す。ただし 1.5.8.1 の PluginTypes は Pdf だけで(ExtendedPlugin.cs#L10-L13)、スキャン用の拡張点とアップロード処理からの呼び出しを本体に足す必要がある |
| C. リバースプロキシでスキャン | 不要 | 中 | なし | nginx + ModSecurity などでマルチパートのファイル部分を ClamAV に回す。マルチパートの解析とチャンクアップロードへの対応が難しい |
| D. サーバースクリプトから呼ぶ | 不要 | 低い | なし | BeforeCreate・BeforeUpdate で外部のスキャン API を呼ぶ。一時ファイルの実体やバイナリにスクリプトから届くかが課題 |
本体を触らずに済むのは C、確実さを取るなら A です。以下は A の設計です。
本体に組み込む場合の設計
設定:ScanMode で対象を切り替える
設定は App_Data/Parameters/VirusScan.json に置き、ScanMode でスキャンする経路を選びます。既定は Disabled で、ファイルを置かなければ(Read<VirusScan>(required: false) で null)スキャンしません。項目の一覧は添付ファイルのレシピにあります。
| アップロード経路 | All | FormOnly | Disabled |
|---|---|---|---|
BinariesController.Upload(認証ユーザーの添付ファイル) | スキャン | - | - |
BinariesController.UploadImage(認証ユーザーの画像) | スキャン | - | - |
FormBinariesController.Upload(公開フォームの添付ファイル) | スキャン | スキャン | - |
FormBinariesController.UploadImage(公開フォームの画像) | スキャン | スキャン | - |
Api/BinariesController.Upload(API) | スキャン | - | - |
| 使い方 | モード |
|---|---|
| 社内だけで使う | Disabled |
| 公開フォームがあり、社内ユーザーは信頼する | FormOnly |
| 公開フォームと API を不特定多数に開いている | All |
| ClamAV サーバーの資源が限られる | FormOnly |
改修するファイル
| 区分 | ファイル | 内容 |
|---|---|---|
| 新規 | Implem.ParameterAccessor/Parts/VirusScan.cs | パラメータクラス |
| 新規 | App_Data/Parameters/VirusScan.json | 既定値(ScanMode: "Disabled") |
| 新規 | Libraries/Security/IVirusScanService.cs ほか 3 つ | インターフェース・ClamAV 実装・無効時の実装・結果クラス |
| 新規 | App_Data/Displays/VirusDetected.json・VirusScanError.json | エラーメッセージ |
| 変更 | Implem.ParameterAccessor/Parameters.cs | public static VirusScan VirusScan; |
| 変更 | Implem.DefinitionAccessor/Initializer.cs | SetParameters() に Read<VirusScan>(required: false) |
| 変更 | Startup.cs | ConfigureServices で DI 登録 |
| 変更 | Implem.Pleasanter.csproj | nClam の参照 |
| 変更 | Models/Binaries/BinaryUtilities.cs | スキャンの呼び出し |
| 変更 | Controllers/Api/BinariesController.cs | API 経路のスキャン |
| 自動生成 | Libraries/General/Error.cs・Libraries/Responses/Messages.cs | Displays を足して CodeDefiner を実行すると再生成される |
Parameters は Startup のコンストラクタで Initializer.Initialize() が読み込むので、ConfigureServices の時点で参照できます。
挿入箇所
BinaryUtilities は static クラスなので DI を直接受けられません。AspNetCoreHttpContext.Current.RequestServices.GetService<IVirusScanService>() でサービスを取り出すヘルパーを用意し、見つからなければ無効時の実装を返すようにします。呼び出し元がフォームかどうかは引数(isFormUpload)で渡します。
添付ファイル(UploadFile):1.5.8.1 では、OnValidatingFormUpload() の検査の後、一時保存先(TemporaryBinaryStorageProvider)で Rds とファイルシステムに分岐する直前に入れます(BinaryUtilities.cs#L823-L896)。
図を読み込み中…
保存前にスキャンする場合、PostedFile のストリームを MemoryStream に写してからスキャンし、保存処理にはコピーしたバイト列を渡すかストリームを巻き戻します(入力ストリームは一方向なので、読み切ると保存できなくなります)。保存後にスキャンするなら、ファイルシステム保存では保存したファイルを読み直し、検出したら削除します。
画像(UploadImage):1.5.8.1 では実際の保存を行うオーバーロードがバイト列(byte[] bin)を受け取るので、その先頭でスキャンできます(BinaryUtilities.cs#L509-L514)。画像を対象にするかは ScanImages で切り替えます。
API(Api/BinariesController.Upload):一時ファイルを App_Data/Temp に保存して ValidateFileHash を通した後、CreateAttachment の前でファイルを読んでスキャンし、検出したら一時ファイルを削除してエラーを返します(Api/BinariesController.cs#L95-L250)。API は isFormUpload = false なので All のときだけスキャンします。
チャンクアップロード
Content-Range で分割して送られた場合、途中のチャンクだけではスキャンできません。ファイル全体が揃った時点(contentRange == null || contentRange.To + 1 == contentRange.Length)でスキャンします。ファイルシステム保存では追記で完成したファイルを、Rds 保存では完成した Bin を対象にします。分割してシグネチャ検出を逃れる攻撃もこれで防げます。
エラーメッセージ
Error.Types と Messages は CodeDefiner が App_Data/Displays/*.json から生成するので、直接編集しません。Displays の JSON を足して CodeDefiner を実行します。
{
"Id": "VirusDetected",
"Type": 240,
"Languages": [
{
"Body": "A virus was detected in the uploaded file: \"{0}\". The upload has been rejected."
},
{
"Language": "ja",
"Body": "アップロードされたファイルからウイルスが検出されました: 「{0}」。アップロードは拒否されました。"
}
]
}Type は本体のエラーメッセージ(OverLimitApi.json・RateLimitExceeded.json など)と同じ 240 にします。スキャン自体の失敗用に VirusScanError.json も同じ形で作ります。Displays が残っていれば、バージョンアップ後も CodeDefiner の再実行で反映されます。
ログ
検出とスキャンエラーは SysLogModel に Warning で記録します(method に VirusScan.Detected / VirusScan.Error、message にウイルス名とファイル名)。ウイルス名やエラー文言はそのまま出すとログインジェクションの余地があるので整形してから書きます。
画面の表示
添付ファイルのアップロードは、ファイルごとのプログレスバー(attachments.js)で進捗を出しています(#LoaderContainer の全画面ローダーはレコード保存などの Ajax 用で、アップロード中は出ません)。スキャンはサーバー側で同期的に終わってから応答が返るので、ポーリングは要りません。表示の案は次のとおりです。
| 案 | 内容 | 評価 |
|---|---|---|
| プログレスバーを「スキャン中」にする | 送信が 100% になったらバーの色と文言を変え、応答を待つ | ファイルごとの状態が分かる |
全画面ローダー($p.loading()) | 送信前に出して応答で消す | 実装は 1 行だが画面全体が止まる |
| バーの下に文言を出す | 「ウイルススキャン中...」とスピナーを出す | バーの改修より独立して作れる |
大きなファイルはスキャンに時間がかかるので、TimeoutMs とクライアント側の Ajax のタイムアウトを揃えます。
運用上のリスク
| リスク | 内容 | 対策 |
|---|---|---|
clamd との通信の改ざん | clamd の TCP 通信は暗号化されない | clamd を localhost か専用ネットワークに置く(TCPAddr 127.0.0.1) |
| サービス拒否 | 大きなファイルの連続アップロードで clamd を枯渇させる | MaxFileSizeMB、clamd の接続数上限、アップロードのレート制限(レート制限。公開フォームは PublicForm ポリシー) |
| 圧縮爆弾 | 展開すると巨大になるアーカイブ | clamd.conf の MaxScanSize・MaxFileSize・MaxRecursion・MaxFiles |
| タイムアウトによるすり抜け | RejectOnScanError: false だとスキャンが失敗したファイルが通る | 本番は RejectOnScanError: true |
| 定義ファイルの陳腐化 | 定義が古いと新しいマルウェアを見逃す | freshclam の自動更新と監視 |
clamd 自体の脆弱性 | 既知の CVE が踏み台になる | 定期的な更新 |
| 過信 | 「スキャン済み」で他の対策を怠る | 拡張子制限・サイズ制限と組み合わせる多層防御の一部と位置付ける |
動作確認には EICAR テストファイルを使います。.docx などに名前を変えても検出されるかで、拡張子偽装への耐性も確かめられます。確認すべき組み合わせは、ScanMode 3 種 × 経路 3 種、clamd 停止時の RejectOnScanError の両方、サイズ超過、画像の ScanImages の両方、チャンクアップロード、ログ出力です。