はじめに
近年、国内でも個人情報や認証情報の漏洩に関する報道が続いています。原因は高度な攻撃だけとは限りません。アプリ開発の現場では、実装上の「うっかり」が漏洩の入口になることもあります。
- デバッグ用に出力したログに、メールアドレスやトークンが残ってしまう
- 暗号化されていない形でローカルに保存してしまう
- 証明書ピンニングなどの共通対策を通さない通信を、誰かが直接書いてしまう
アプリ開発者が意識すべき要求も増えています。たとえば個人情報保護法の安全管理措置や、利用者情報を外部に送信する際の電気通信事業法の外部送信規律があります(法的な解釈は、各社の法務・セキュリティ部門にご確認ください)。
そこで多くのチームは「ログは必ず共通の AppLogger を通す」「通信は共通のHTTPクライアントを使う」といった共通Wrapperクラスを用意します。しかし、Wrapperを用意しても、ドキュメントやコードレビューだけでは直接呼び出しを防げません。新しく入ったメンバーが Log.d を書き、レビューをすり抜けてしまうのは時間の問題です。
本記事では、「ルールを文書ではなくビルドで強制する」ために、detekt を使って Wrapper 以外からの直接呼び出しをビルド失敗にする方法を紹介します。実際に動かして確認した結果も載せています。
-
importを検出して禁止する(基本) -
importを使わず、パッケージ名を全部書いた直接呼び出し(完全修飾名)も禁止する - detekt だけでは防げない抜け道と、その塞ぎ方
動作確認環境: detekt-cli 1.23.8(JDK 21)。型解決は CLI の
--classpathで指定して確認しました。Gradle プラグイン経由の設定は公式ドキュメントに基づいており、実際の Android プロジェクトでは動かしていません。
結論を先に
防御は1つの手段に頼らず、3層で組みます。
| 層 | 手段 | 防げるもの | 弱点 |
|---|---|---|---|
| 1 | Gradleモジュール分離(implementation) |
サードパーティライブラリ(OkHttpなど)への直接参照を本物のコンパイルエラーにできる |
android.util.Log のような Android 標準APIは classpath から消せない |
| 2 | detekt ForbiddenImport
|
import による参照(別名importも) |
ワイルドカードimportや、importなしの完全修飾名は見逃す |
| 2 | detekt ForbiddenMethodCall(型解決あり) |
完全修飾名の直接呼び出し、関数参照、コンストラクタ、サブクラス経由の呼び出し | 型だけの参照や @Suppress による回避は防げない |
| 3 | grepゲート(CI) | 型参照、Java、文字列、@Suppress
|
コメント内の記述も拾う(誤検知) |
なお、detekt は Kotlin コンパイラそのものではないため、厳密には「コンパイルエラー」ではなく「check タスクの失敗(ビルド失敗)」になります。本物のコンパイルエラーにできるのは第1層(モジュール分離)です。
最大の落とし穴: Gradle の
checkに自動で組み込まれるのは、型解決なしのdetektタスクだけです(公式ドキュメント)。完全修飾名の検出に必要なForbiddenMethodCallは型解決が必須なので、そのままだと黙って素通りします。後述の「checkに型解決タスクを組み込む」まで設定して初めて、本記事の対策が成立します。
題材: 共通Wrapperと、禁止したい直接呼び出し
以下の3つを例に進めます。
| 禁止する直接呼び出し | 使うべきWrapper | Wrapperで一箇所に集約する対策 |
|---|---|---|
android.util.Log / println
|
AppLogger |
リリースビルドでは出力しない、個人情報をマスクする |
OkHttpClient / OkHttpClient.Builder の直接生成 |
SecureHttpClientFactory |
証明書ピンニング、平文HTTPの禁止、共通ヘッダー |
Context.getSharedPreferences |
SecureStorage |
暗号化して保存する |
Wrapperは com.example.app.core.security.wrapper パッケージに置く前提です。
package com.example.app.core.security.wrapper
import android.util.Log
object AppLogger {
/** Application.onCreate で BuildConfig.DEBUG などを設定する */
@Volatile
var enabled: Boolean = false
fun d(tag: String, message: String) {
if (enabled) Log.d(tag, PiiMasker.mask(message))
}
fun e(tag: String, message: String, throwable: Throwable? = null) {
if (enabled) Log.e(tag, PiiMasker.mask(message), throwable)
}
}
internal object PiiMasker {
private val email = Regex("""[\w.+-]+@[\w-]+\.[\w.-]+""")
fun mask(text: String): String = email.replace(text, "***@***")
}
package com.example.app.core.security.wrapper
import okhttp3.CertificatePinner
import okhttp3.ConnectionSpec
import okhttp3.OkHttpClient
object SecureHttpClientFactory {
private val pinner = CertificatePinner.Builder()
.add("api.example.com", "sha256/<ピン留めするハッシュ>")
.build()
fun create(): OkHttpClient = OkHttpClient.Builder()
.connectionSpecs(listOf(ConnectionSpec.MODERN_TLS)) // 平文HTTPを許可しない
.certificatePinner(pinner)
.build()
}
package com.example.app.core.security.wrapper
import android.content.Context
import android.content.SharedPreferences
class SecureStorage(private val context: Context) {
private val prefs: SharedPreferences
get() = context.getSharedPreferences("secure_prefs", Context.MODE_PRIVATE)
fun putString(key: String, value: String) {
// 実際の実装では EncryptedSharedPreferences などで暗号化して保存する
prefs.edit().putString(key, value).apply()
}
}
Wrapper の内部では、当然 Log や OkHttpClient.Builder、getSharedPreferences を直接使います。この Wrapper だけを禁止ルールの例外にするのが、以降の設計の肝です。
第1層: モジュール分離で、本物のコンパイルエラーにする
OkHttp のようなサードパーティライブラリは、依存関係の宣言の仕方で「そもそも他のモジュールから見えなくする」ことができます。
// core/security-wrapper/build.gradle.kts
dependencies {
// api ではなく implementation にする
implementation("com.squareup.okhttp3:okhttp:<バージョン>")
}
implementation で宣言した依存は、このモジュールを利用する側のコンパイル用クラスパスには含まれません。そのため app モジュールで import okhttp3.OkHttpClient と書くと、Unresolved reference という本物のコンパイルエラーになります。
ただし、次の2点に注意が必要です。
-
android.util.LogやContext.getSharedPreferencesのような Android 標準APIは、どのモジュールからも見えるため、この方法では防げません。ここから先の detekt が必要になります。 - 他のモジュールが同じライブラリを自分で依存宣言すると見えてしまいます。依存宣言の追加も、レビュー対象にしてください。
第2層: detekt で import と完全修飾名の直接呼び出しを検知する
導入
// build.gradle.kts(各モジュールに適用。subprojects や convention plugin 経由でも可)
plugins {
id("io.gitlab.arturbosch.detekt") version "1.23.8"
}
detekt {
buildUponDefaultConfig = true
config.setFrom(rootProject.file("config/detekt/detekt.yml"))
}
バージョンについて: 公式ドキュメントでは、1.23.8 は「もうアクティブにはメンテナンスされていない」とされ、最新版は 2.0.0-alpha 系(プラグインIDは
dev.detekt)と案内されています。2.0系ではForbiddenImportの設定キーがforbiddenImports(理由reasonを付けられる)とallowedImportsに変わります。本記事の設定は 1.23.8 のものです。2.0系を使う場合は読み替えてください。
設定: detekt.yml
build:
maxIssues: 0 # 既定値だが、指摘があればビルドを失敗させる意図を明示する
style:
# ① ワイルドカードimportを禁止する(後述: ForbiddenImportはワイルドカードを見逃すため)
WildcardImport:
active: true
excludeImports: []
# ② import を禁止する(基本)
ForbiddenImport:
active: true
imports:
- 'android.util.Log'
forbiddenPatterns: '^okhttp3\..*' # パッケージごと禁止したい場合は正規表現
excludes: ['**/core/security/wrapper/**']
# ③ import を使わない完全修飾名の直接呼び出しも禁止する(型解決が必須)
ForbiddenMethodCall:
active: true
methods:
# ログ(名前だけ指定すると同名の呼び出しがすべて対象になる)
- 'android.util.Log.v'
- 'android.util.Log.d'
- 'android.util.Log.i'
- 'android.util.Log.w'
- 'android.util.Log.e'
- 'android.util.Log.wtf'
- 'kotlin.io.println'
- 'kotlin.io.print'
# 通信(コンストラクタは <init> と書く)
- 'okhttp3.OkHttpClient.<init>'
- 'okhttp3.OkHttpClient.Builder.<init>'
# ローカル保存
- 'android.content.Context.getSharedPreferences'
excludes: ['**/core/security/wrapper/**']
ポイントは次の通りです。
-
ForbiddenImportはimports(完全一致のリスト)とforbiddenPatterns(正規表現)の2通りで指定できます。 -
ForbiddenMethodCallは、android.util.Log.dのように名前だけを書くと同名の呼び出しがすべて対象になります。java.util.Date.<init>のようにコンストラクタも指定できます。 -
excludesはルール単位のファイルパス(グロブ)による除外で、Wrapper の中だけを例外にします。
実際に何が検知されるか
次のようなコードを用意して、型解決あり・なしで実行しました。android.util.Log などは、検証用のスタブクラスに置き換えています。
// ① 通常のimport
import android.util.Log
fun a() { Log.d("TAG", "user=taro@example.com") }
// ② 別名import
import android.util.Log as L
fun b() { L.d("TAG", "alias") }
// ③ ワイルドカードimport
import android.util.*
fun c() { Log.d("TAG", "wildcard") }
// ④ importなしの完全修飾名
fun d() { android.util.Log.d("TAG", "fully qualified") }
// ⑤ 完全修飾名の関数参照
fun e() { val f = android.util.Log::d; f("T", "ref") }
| パターン | ForbiddenImport |
ForbiddenMethodCall(型解決あり) |
WildcardImport |
|---|---|---|---|
| ① 通常のimport | 検知 | 検知 | ― |
| ② 別名import | 検知 | 検知 | ― |
| ③ ワイルドカードimport | 見逃す | 検知 | 検知 |
| ④ importなしの完全修飾名 | 見逃す | 検知 | ― |
| ⑤ 完全修飾名の関数参照 | 見逃す | 検知 | ― |
okhttp3.OkHttpClient.Builder() を完全修飾名で生成 |
見逃す | 検知 | ― |
Activity のサブクラスから getSharedPreferences
|
― | 検知(Context を指定すれば、オーバーライド経由でも検知) |
― |
型解決なしで実行すると、ForbiddenMethodCall は何も報告せず素通りしました(上表の「検知」は型解決ありの場合のみ)。import 行は ForbiddenImport が拾えますが、④⑤のような完全修飾名の直接呼び出しはすり抜けます。型解決ありで実行したときの出力は次の通りです(CLI の出力、パスは省略)。
BadImport.kt:3:1: The import `android.util.Log` has been forbidden in the detekt config. [ForbiddenImport]
BadImport.kt:6:21: The method `android.util.Log.d` has been forbidden in the detekt config. [ForbiddenMethodCall]
BadFqn.kt:4:34: The method `android.util.Log.d` has been forbidden in the detekt config. [ForbiddenMethodCall]
BadFqn.kt:5:43: The method `android.util.Log.d` has been forbidden in the detekt config. [ForbiddenMethodCall]
BadWildcard.kt:3:1: android.util.* is a wildcard import. Replace it with fully qualified imports. [WildcardImport]
Analysis failed with 8 weighted issues.
指摘があると終了コードは 2 になり、ビルドが失敗します。Wrapper 内(excludes 対象)の Log や android.util.Log.d は、import でも完全修飾名でも指摘されませんでした。
check に型解決タスクを組み込む
前述の通り、check に自動で含まれるのは型解決なしの detekt だけです。ForbiddenMethodCall を効かせるには、型解決つきのタスクを明示的に check に組み込みます。
// Androidモジュールの場合: バリアント名つきのタスクが生成される
tasks.named("check") {
dependsOn("detektDebug")
}
JVM モジュールなら detektMain です。Android では detekt<Variant>(detektDebug、detektRelease など)が型解決つきのタスクです。CI では、./gradlew detektDebug を必須チェックとして明示的に実行しておくと安全です。
ローカルのビルド(Android Studio の Run)でも即座に落としたい場合は、
preBuildに依存させる方法もあります。ただし、環境によっては実行時間や依存関係の問題が出る可能性があり、私は検証していません。まずはcheckと CI で確実に止めるところから始めるのがおすすめです。
Wrapper の除外は、緩いパターンにしない
# 悪い例: 誰かが "wrapper" という名前のパッケージを作れば抜けられる
excludes: ['**/wrapper/**']
# 良い例: フルパスで限定する
excludes: ['**/core/security/wrapper/**']
例外にしたパスは、後述の CODEOWNERS で守ります。
NG例とOK例: こう呼ぶとビルドエラー、この実装なら通る
ここまでの設定(第1層・第2層)を前提に、呼び出し方ごとの結果を整理します。ここでいう「ビルドエラー」は、detekt が指摘して check が失敗することを指します。
以下はすべて、上記の detekt.yml と Wrapper(core/security/wrapper/ 配下)で実際に実行した結果です(型解決あり)。出力はパスを省略しています。
NG: こう呼ぶとビルドエラーになる
NG-1: Log を import して呼ぶ
import android.util.Log
class NgLog {
fun onLogin(email: String) {
Log.d("Login", "email=$email")
}
}
NgLog.kt:3:1: The import `android.util.Log` has been forbidden in the detekt config. [ForbiddenImport]
NgLog.kt:7:13: The method `android.util.Log.d` has been forbidden in the detekt config. [ForbiddenMethodCall]
import と呼び出しの両方が指摘されます。
NG-2: import せず、パッケージ名を全部書いて呼ぶ
class NgLogFqn {
fun onLogin(email: String) {
android.util.Log.d("Login", "email=$email")
}
}
NgLogFqn.kt:5:26: The method `android.util.Log.d` has been forbidden in the detekt config. [ForbiddenMethodCall]
import がないため ForbiddenImport では検知できませんが、ForbiddenMethodCall が捕まえます。
NG-3: println で出力する
class NgPrintln {
fun onLoaded(count: Int) {
println("count=$count")
}
}
NgPrintln.kt:5:9: The method `kotlin.io.println` has been forbidden in the detekt config. [ForbiddenMethodCall]
NG-4: OkHttp を import して直接生成する
import okhttp3.OkHttpClient
class NgHttp {
val client: OkHttpClient = OkHttpClient.Builder().build()
}
NgHttp.kt:3:1: The import `okhttp3.OkHttpClient` has been forbidden in the detekt config. [ForbiddenImport]
NgHttp.kt:6:45: The method `okhttp3.OkHttpClient.Builder.<init>` has been forbidden in the detekt config. [ForbiddenMethodCall]
NG-5: OkHttp を import せず、パッケージ名を全部書いて生成する
class NgHttpFqn {
val client: okhttp3.OkHttpClient = okhttp3.OkHttpClient()
}
NgHttpFqn.kt:4:48: The method `okhttp3.OkHttpClient.<init>` has been forbidden in the detekt config. [ForbiddenMethodCall]
NG-6: Activity の中で getSharedPreferences を直接呼ぶ
import android.app.Activity
class NgPrefs : Activity() {
fun save() {
getSharedPreferences("user", MODE_PRIVATE)
}
}
NgPrefs.kt:7:9: The method `android.content.Context.getSharedPreferences` has been forbidden in the detekt config. [ForbiddenMethodCall]
Activity は Context のサブクラスですが、Context.getSharedPreferences を指定しておけば、サブクラス経由の呼び出しも検知されます。
OK: この実装ならビルドエラーにならない
NG の呼び出しを、Wrapper 経由に置き換えると通ります。
| NG(直接呼び出し) | OK(Wrapper経由) |
|---|---|
Log.d("Login", "email=$email") |
AppLogger.d("Login", "email=$email") |
println("count=$count") |
AppLogger.d("List", "count=$count") |
OkHttpClient.Builder().build() |
SecureHttpClientFactory.create() |
getSharedPreferences("user", MODE_PRIVATE) |
SecureStorage(context).putString(key, value) |
OK-1: ログは AppLogger を通す
import com.example.app.core.security.wrapper.AppLogger
class OkLog {
fun onLogin(email: String) {
AppLogger.d("Login", "email=$email")
}
}
OK-2: HTTPクライアントは SecureHttpClientFactory から取得する
import com.example.app.core.security.wrapper.SecureHttpClientFactory
class OkHttp {
val client = SecureHttpClientFactory.create()
}
OK-3: ローカル保存は SecureStorage を通す
import android.content.Context
import com.example.app.core.security.wrapper.SecureStorage
class OkStorage(context: Context) {
private val storage = SecureStorage(context)
fun save(token: String) = storage.putString("token", token)
}
上の3つは、指摘ゼロで終了コード0でした。
OK-4: Wrapper の内部では、直接呼び出しても指摘されない
AppLogger の中の Log.d(...)、SecureHttpClientFactory の中の OkHttpClient.Builder()、SecureStorage の中の getSharedPreferences(...) は、excludes の対象パス(core/security/wrapper/)にあるため指摘されません。同じコードをこのパスの外にコピーすると、NG になります。
OK-5: 自作クラスのメソッド名が d や println でも指摘されない
class MyLogger {
private val lines = mutableListOf<String>()
fun d(tag: String, message: String) {
lines += "$tag: $message"
}
fun println(message: String) {
lines += message
}
}
class OkNameCollision {
private val logger = MyLogger()
fun run() {
logger.d("TAG", "ok")
logger.println("ok")
}
}
ForbiddenMethodCall は、メソッド名の文字列ではなく型解決で特定した完全修飾名で判定します。自作の MyLogger.d や MyLogger.println は、android.util.Log.d や kotlin.io.println とは別物なので、誤検知されません(検証済み)。これも、型解決が必須である理由のひとつです。
なお、この OK-5 の
MyLoggerは「検知されない」だけで、「ログを一元管理する」という Wrapper の目的は果たしません。直接呼び出しの禁止は、あくまで抜け道を塞ぐための仕組みです。ログの出力先やマスク処理といった実際の対策は、Wrapper 側に実装してください。
第3層: detekt で防げない抜け道を、grep で塞ぐ
detekt は便利ですが、次の抜け道は防げません。いずれも実際に確認しました。
// 型参照だけで、メソッド呼び出しではない
fun typeOnly(x: android.util.Log?) = x
fun klass() = android.util.Log::class
// ファイル全体で検知を無効化する
@file:Suppress("ForbiddenImport", "ForbiddenMethodCall")
package com.example.app.sup
import android.util.Log
class Suppressed { fun run() { Log.d("T", "bypass?") } }
-
ForbiddenMethodCallはメソッド呼び出しとコンストラクタが対象で、型の参照は検知しません。 -
@Suppressを付けると、detekt は完全に黙ります(検証では指摘ゼロ、終了コード0)。セキュリティ目的では、これが最も大きな穴になります。 - detekt は Kotlin しか見ません。Java のコードは対象外です。
そこで、ソースをテキストとして検索する単純なゲートを CI に追加します。detekt の仕組みに依存しないので、@Suppress の影響を受けません。
#!/usr/bin/env bash
# usage: forbidden-grep.sh <src-root> <許可するパスの正規表現>
set -u
ROOT="$1"; ALLOW="$2"
PATTERN='android\.util\.Log|okhttp3\.|android\.content\.SharedPreferences|Suppress\([^)]*Forbidden'
hits=$(grep -rnE "$PATTERN" --include='*.kt' --include='*.java' "$ROOT" | grep -vE "$ALLOW" || true)
if [ -n "$hits" ]; then echo "$hits"; exit 1; fi
./forbidden-grep.sh app/src 'core/security/wrapper/'
検証用のコードに対して実行した結果、型参照のみ(x: android.util.Log?、Log::class)や @Suppress を使ったファイルも含めて検知できました(抜粋)。許可したパスだけを対象にした場合は、何も出力されず終了コード0になります。
src/.../ui/BadFqn.kt:4: fun run() { android.util.Log.d("TAG", "fully qualified, no import") }
src/.../data/Gap.kt:6: fun typeOnly(x: android.util.Log?) = x
src/.../data/Gap.kt:7: fun klass() = android.util.Log::class
src/.../sup/Suppressed.kt:1:@file:Suppress("ForbiddenImport", "ForbiddenMethodCall")
src/.../sup/Suppressed.kt:4:import android.util.Log
Gradle タスクとして書く場合の例です(こちらは動かして確認していません)。
val forbiddenRegex = Regex("""android\.util\.Log|okhttp3\.|android\.content\.SharedPreferences|Suppress\([^)]*Forbidden""")
val baseDir = projectDir
tasks.register("checkForbiddenApi") {
group = "verification"
val sources = fileTree("src") {
include("**/*.kt", "**/*.java")
exclude("**/core/security/wrapper/**")
}
inputs.files(sources)
doLast {
val violations = sources.files.flatMap { file ->
file.readLines().mapIndexedNotNull { index, line ->
if (forbiddenRegex.containsMatchIn(line)) {
"${file.relativeTo(baseDir)}:${index + 1}: ${line.trim()}"
} else null
}
}
if (violations.isNotEmpty()) {
throw GradleException("禁止APIの直接参照を検出しました:\n" + violations.joinToString("\n"))
}
}
}
tasks.named("check") { dependsOn("checkForbiddenApi") }
コメント内の言及も拾うため誤検知は出ますが、セキュリティ目的の最後のゲートとしては、「厳しめに倒す」方が安全です。
運用で気をつけること
既存の違反は baseline に寄せ、新規だけ必ず落とす
既存コードに大量の違反がある場合は、detekt の baseline 機能で既存分を一旦除外し、新規の違反だけを失敗させる運用が現実的です。ただし、baseline に入れた違反は「見えなくなった負債」なので、件数を記録して計画的に Wrapper へ移行してください。
設定ファイルと Wrapper を CODEOWNERS で守る
ルールを守らせる仕組みも、書き換えられては意味がありません。次の変更は、セキュリティ担当などの承認を必須にします。
-
config/detekt/detekt.yml(禁止リストとexcludes) -
core/security/wrapper/配下(信頼境界そのもの) - 依存関係の追加(
build.gradle.kts) -
@Suppress("Forbidden...")を含む差分(第3層のgrepが検知)
テストコードの扱いを決めておく
テストコードで println などを使いたい場合は、excludes に **/test/** を加えるかどうかを、チームで明確に決めてください。
型解決つきの解析は、通常より時間がかかる傾向がある
一般に、型解決つきの解析は通常の detekt より時間がかかります。CI では必須チェックとして実行し、ローカルでは必要なときに ./gradlew detektDebug を実行する運用が扱いやすいです。
限界
最後に、この仕組みで防げないことも整理しておきます。
-
リフレクションや動的ロード:
Class.forName("android.util.Log")のように文字列が固定なら grep で拾えますが、動的に組み立てられると検知できません。 - Wrapper そのものの不備: Wrapper の中に脆弱な実装があれば、どれだけ呼び出しを集約しても意味がありません。Wrapper は信頼境界なので、レビューとテストを手厚くしてください。
- サードパーティSDKの内部動作: 自分たちのソースコードしか対象にできません。SDKが勝手にログ出力や通信をする場合は、別の対策(SDKの選定、通信の監視)が必要です。
-
ビルド成果物の検査: ソースを解析するだけで、実際に出力されたログや APK の中身は検査しません。リリースビルドで
android.util.Logの呼び出しを R8 で削除するといった、成果物側の対策と組み合わせると安心です。
まとめ
- 共通Wrapperを用意しても、ルールを文書やレビューだけに頼ると直接呼び出しは防げない。ビルドで機械的に強制する
- サードパーティライブラリは、
implementationによるモジュール分離で本物のコンパイルエラーにできる - Android標準APIは、detekt の
ForbiddenImport(import)とForbiddenMethodCall(完全修飾名の直接呼び出し)で検知する。ワイルドカードimportはWildcardImportで別途禁止する -
ForbiddenMethodCallは型解決が必須。checkに自動で含まれるのは型解決なしのdetektだけなので、detektDebugなどを明示的に組み込まないと素通りする -
@Suppressや型参照だけの使い方は detekt をすり抜けるため、grep ベースのゲートと CODEOWNERS で最後の穴を塞ぐ - ルールだけでなく、Wrapper の品質と、成果物側の対策(R8によるログ削除など)も併せて運用する
「書かないでください」というルールを、「書いたらビルドが通らない」仕組みに変える。それが、うっかりを減らす現実的な一歩です。