diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index 0e292f6..e34d9ae 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -74,7 +74,13 @@ jobs: - name: Build SDK AAR and demo APK run: | ./gradlew \ + :riskengine-sdk:test \ + :riskengine-sdk:lint \ :riskengine-sdk:assembleRelease \ + :riskengine-sdk:sourceReleaseJar \ + :riskengine-sdk:generatePomFileForReleasePublication \ + :integration-test:assembleDebug \ + :demo:lintDebug \ :demo:assembleDebug \ -PreleaseVersionName="${{ steps.version.outputs.name }}" \ -PreleaseVersionCode="${{ steps.version.outputs.code }}" \ @@ -89,6 +95,8 @@ jobs: mkdir -p release-artifacts sdk_aar="riskengine-sdk/build/outputs/aar/riskengine-sdk-release.aar" + sdk_sources="riskengine-sdk/build/intermediates/source_jar/release/release-sources.jar" + sdk_pom="riskengine-sdk/build/publications/release/pom-default.xml" demo_apk="demo/build/outputs/apk/debug/demo-debug.apk" if [[ ! -f "$sdk_aar" ]]; then @@ -96,16 +104,28 @@ jobs: exit 1 fi + if [[ ! -f "$sdk_sources" ]]; then + echo "SDK sources JAR not found: $sdk_sources" + exit 1 + fi + + if [[ ! -f "$sdk_pom" ]]; then + echo "SDK POM not found: $sdk_pom" + exit 1 + fi + if [[ ! -f "$demo_apk" ]]; then echo "Demo APK not found: $demo_apk" exit 1 fi cp "$sdk_aar" "release-artifacts/RiskEngine-SDK-${VERSION_NAME}.aar" + cp "$sdk_sources" "release-artifacts/RiskEngine-SDK-${VERSION_NAME}-sources.jar" + cp "$sdk_pom" "release-artifacts/RiskEngine-SDK-${VERSION_NAME}.pom" cp "$demo_apk" "release-artifacts/RiskEngine-Demo-${VERSION_NAME}-debug.apk" cd release-artifacts - sha256sum ./*.aar ./*.apk > SHA256SUMS + sha256sum ./*.aar ./*.jar ./*.pom ./*.apk > SHA256SUMS ls -lah @@ -117,4 +137,5 @@ jobs: gh release create "$GITHUB_REF_NAME" release-artifacts/* \ --verify-tag \ --title "RiskEngine ${{ steps.version.outputs.name }}" \ - --generate-notes + --generate-notes \ + --notes "The Demo APK is intentionally signed with a temporary CI debug key and is provided only for sideload testing. It is not intended for production distribution or app stores. A later release may not install as an update because its temporary signing key can differ; uninstall the previous Demo first if Android reports a signature mismatch. No production signing secret is stored by this workflow." diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..741106c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,74 @@ +name: Android CI + +on: + pull_request: + push: + branches: + - "**" + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up JDK + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: "17" + cache: gradle + + - name: Set up Android SDK + uses: android-actions/setup-android@v3 + + - name: Install Android build dependencies + run: | + sdkmanager \ + "platforms;android-36" \ + "build-tools;35.0.0" \ + "cmake;3.22.1" \ + "ndk;27.0.12077973" + + - name: Test, lint and build + run: | + ./gradlew \ + :riskengine-sdk:test \ + :riskengine-sdk:lint \ + :riskengine-sdk:assembleRelease \ + :riskengine-sdk:sourceReleaseJar \ + :riskengine-sdk:generatePomFileForReleasePublication \ + :integration-test:assembleDebug \ + :demo:lintDebug \ + :demo:assembleDebug \ + --no-daemon + + - name: Verify distributable artifacts + run: | + test -s riskengine-sdk/build/outputs/aar/riskengine-sdk-release.aar + test -s riskengine-sdk/build/intermediates/source_jar/release/release-sources.jar + test -s riskengine-sdk/build/publications/release/pom-default.xml + test -s demo/build/outputs/apk/debug/demo-debug.apk + test -s integration-test/build/outputs/apk/debug/integration-test-debug.apk + unzip -t riskengine-sdk/build/outputs/aar/riskengine-sdk-release.aar + unzip -t demo/build/outputs/apk/debug/demo-debug.apk + unzip -t integration-test/build/outputs/apk/debug/integration-test-debug.apk + + - name: Upload CI artifacts + uses: actions/upload-artifact@v6 + with: + name: riskengine-ci-${{ github.sha }} + path: | + riskengine-sdk/build/outputs/aar/riskengine-sdk-release.aar + riskengine-sdk/build/intermediates/source_jar/release/release-sources.jar + riskengine-sdk/build/publications/release/pom-default.xml + demo/build/outputs/apk/debug/demo-debug.apk + if-no-files-found: error diff --git a/.github/workflows/device-tests.yml b/.github/workflows/device-tests.yml new file mode 100644 index 0000000..b59efa3 --- /dev/null +++ b/.github/workflows/device-tests.yml @@ -0,0 +1,37 @@ +name: Android Device Matrix + +on: + workflow_dispatch: + schedule: + - cron: "17 3 * * 1" + +permissions: + contents: read + +jobs: + instrumentation: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + api-level: [30, 33, 35, 36] + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up JDK + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: "17" + cache: gradle + + - name: Run SDK instrumentation tests + uses: ReactiveCircus/android-emulator-runner@v2 + with: + api-level: ${{ matrix.api-level }} + arch: x86_64 + target: google_apis + disable-animations: true + script: ./gradlew :riskengine-sdk:connectedDebugAndroidTest --no-daemon diff --git a/README.md b/README.md index 779a85c..cfd0fc0 100644 --- a/README.md +++ b/README.md @@ -9,11 +9,11 @@ An Android SDK for local device fingerprinting and runtime risk detection. Built This revision focuses on reliable failure semantics, privacy-safe collection, and a redesigned Demo: - Collector outcomes are explicit: `SUCCESS`, `EMPTY`, `ERROR`, or `UNSUPPORTED`. -- Failed, timed-out, or unavailable detectors return `DetectionStatus.UNKNOWN` and `RiskLevel.UNKNOWN` instead of being treated as safe. -- Reports expose risk score capacity and status counts, preserve partial coverage, and become immutable after aggregation. +- Detectors report risk presentation separately from `DetectionExecutionStatus`; failures, timeouts, unavailable checks, and partial execution are never treated as safe. +- Reports expose the fixed severe threshold (18), next threshold, complete/partial coverage, and status counts, and become immutable after aggregation. - Collection uses one bounded deadline, serializes concurrent requests, rejects main-thread synchronous calls, and cancels cleanly across `shutdown()`. -- Raw persistent identifiers are not returned. Values used for correlation are converted to package-scoped SHA-256 hashes. -- The Demo now presents a Chinese-first risk summary, coverage, elapsed time, collector status, expandable English technical evidence, dark mode, accessible touch feedback, and reduced-motion behavior. +- The default `BALANCED` privacy profile keeps only an app-scoped pseudonymous Android ID hash. Boot ID and Widevine hashes require `DIAGNOSTIC` or explicit opt-in. +- The Demo uses localized semantic states, evidence-based summaries, consistent coverage, redacted report copy, expandable raw evidence, dark mode, accessible interaction, and reduced-motion behavior. ## Requirements @@ -37,8 +37,8 @@ Build the SDK and demo: Or use Gradle directly: ```bash -./gradlew :riskengine-sdk:testDebugUnitTest :riskengine-sdk:lintDebug -./gradlew :riskengine-sdk:assembleRelease +./gradlew :riskengine-sdk:test :riskengine-sdk:lint +./gradlew :riskengine-sdk:assembleRelease :riskengine-sdk:sourceReleaseJar ./gradlew :demo:lintDebug :demo:assembleDebug :demo:assembleRelease ``` @@ -47,19 +47,23 @@ Build artifacts: | Artifact | Path | Notes | | --- | --- | --- | | SDK AAR | `riskengine-sdk/build/outputs/aar/riskengine-sdk-release.aar` | Ready for host-app integration | -| Demo Debug APK | `demo/build/outputs/apk/debug/demo-debug.apk` | Debug-signed and directly installable | +| SDK sources | `riskengine-sdk/build/intermediates/source_jar/release/release-sources.jar` | Source archive for distribution | +| Demo Debug APK | `demo/build/outputs/apk/debug/demo-debug.apk` | Temporarily debug-signed for sideload testing only | | Demo Release APK | `demo/build/outputs/apk/release/demo-release-unsigned.apk` | Minified; sign with your release key before distribution | The SDK packages native libraries for `arm64-v8a`, `armeabi-v7a`, `x86_64`, and `x86`. +Tag releases attach the AAR, sources JAR, POM, temporary Debug APK, and `SHA256SUMS`. The temporary APK is not for production or stores. A later release can use a different temporary key; uninstall the older Demo first if Android reports a signature mismatch. + Initialize and collect: ```java RiskEngineConfig config = new RiskEngineConfig.Builder() + .privacyProfile(PrivacyProfile.BALANCED) .collectTimeout(15000) .build(); -RiskEngine.init(context, config); +RiskEngine.initIfNeeded(context, config); RiskEngine.collect(new RiskEngineCallback() { @Override @@ -81,12 +85,13 @@ Synchronous collection: ```java RiskReport report = RiskEngine.collectSync(); -String json = RiskEngine.getReportJson(); +String json = RiskEngine.reportToJson(report); // no second collection +String freshJson = RiskEngine.collectReportJson(); // collect then serialize ``` Synchronous APIs reject calls from the Android main thread to prevent ANRs. -Call `RiskEngine.shutdown()` when the SDK is no longer needed. A second `init` call is rejected until the previous instance has been shut down. +Call `RiskEngine.shutdown()` when the SDK is no longer needed. `init` rejects duplicate initialization; multi-entry applications can use atomic `initIfNeeded`. ## Core Detections @@ -111,9 +116,12 @@ Individual collector or detector failures do not silently become safe results. T | `CollectorResult.Status.EMPTY` | Collection completed but returned no usable value | | `CollectorResult.Status.ERROR` | The collector failed or exceeded the available request time | | `CollectorResult.Status.UNSUPPORTED` | The current device or platform does not expose this signal | -| `DetectionStatus.UNKNOWN` / `RiskLevel.UNKNOWN` | A detector could not produce a reliable conclusion | +| `DetectionStatus` | Risk presentation: `NORMAL` / `WARNING` / `DANGER` / `UNKNOWN` | +| `DetectionExecutionStatus` | Execution: `SAFE` / `RISK` / `PARTIAL` / `UNAVAILABLE` / `DISABLED` / `TIMEOUT` / `ERROR` | + +`UNKNOWN` means insufficient coverage, not “no risk.” `PARTIAL` lowers report coverage even when completed subchecks found no issue. When no warning or danger exists but one or more checks are unknown, report risk is `UNKNOWN`. Multi-source inconsistency raises an otherwise lower result to at least `MEDIUM`. -`UNKNOWN` means insufficient coverage, not “no risk.” When no warning or danger signal exists but one or more checks are unknown, the report-level risk is `UNKNOWN`. Multi-source inconsistency is emitted as a detection signal and raises an otherwise lower result to at least `MEDIUM`. +Only actionable results contribute to `riskScore`. Informational results (the deprecated compatibility name is `warnOnly`) contribute no score but can raise an otherwise safe presentation to `LOW`. Thresholds are `MEDIUM >= 4`, `HIGH >= 10`, and `DEADLY >= 18`; hard triggers can enter `DEADLY` directly. `maxRiskScore` is diagnostic capacity, not the Demo progress denominator. ## Output @@ -127,6 +135,8 @@ Individual collector or detector failures do not silently become safe results. T | `riskScore` / `maxRiskScore` | Actionable score and the maximum score represented by the report | | `warningCount` / `dangerCount` | Number of warning and danger detection results | | `unknownCount` | Number of unavailable, timed-out, or unsupported checks | +| `reportStatus` | `COMPLETE` / `PARTIAL` / `UNAVAILABLE` | +| `checkCount` / `completedCheckCount` / `coveragePercent` | Unified detector + raw-collector coverage without double-counting synthesized SDK fields | | `timestampMs` | Collection timestamp | | `sdkVersion` | SDK version string | @@ -134,11 +144,12 @@ Individual collector or detector failures do not silently become safe results. T The Demo is designed as a local diagnostic console rather than an automatic collector: -- It starts in `READY` and collects only after an explicit tap; results are never uploaded automatically. -- The top card shows the Chinese risk conclusion, English enum code, risk score, coverage percentage, and elapsed time. -- Risk items are sorted first. Detection rows expand to English technical evidence, while collector rows expose source values and `SUCCESS` / `EMPTY` / `ERROR` / `UNSUPPORTED` status. +- It starts in a localized ready state and collects only after an explicit tap; results are never uploaded automatically. +- The top card uses localized semantic status and risk color. Progress uses the severe threshold of 18; collection errors use gray and 0%. +- Risk items are sorted first. Rows explain the actual reason before showing raw evidence, score, and subcheck coverage. Collector fields use localized labels and units, with synthesized SDK fields in a separate section. - `UNKNOWN` is displayed as insufficient coverage and is never styled or described as safe. -- In-flight and completed UI state survives Activity recreation. Press feedback and detail transitions are interruptible, and system reduced-motion settings are respected. +- In-flight and completed UI state survives Activity recreation. Haptics fire only when a new result arrives, not after rotation or foreground return. +- A redacted summary can be copied without identifier hashes, raw system properties, paths, or PIDs. - Semantic light/dark palettes, readable type sizes, accessibility click behavior, and meaningful completion/error haptics are included. ## Public API @@ -146,21 +157,26 @@ The Demo is designed as a local diagnostic console rather than an automatic coll | API | Description | | --- | --- | | `RiskEngine.init(Context, RiskEngineConfig)` | Initialize the SDK | +| `RiskEngine.initIfNeeded(Context, RiskEngineConfig)` | Atomically initialize when needed and report whether this call did it | | `RiskEngine.collect(RiskEngineCallback)` | Run collection asynchronously | | `RiskEngine.collectSync()` | Run collection synchronously | -| `RiskEngine.getReportJson()` | Collect and return the report as JSON | +| `RiskEngine.collectReportJson()` | Collect a fresh report and return JSON | +| `RiskEngine.reportToJson(RiskReport)` | Serialize an existing report without collecting again | +| `RiskEngine.getReportJson()` | Deprecated compatibility alias for `collectReportJson()` | | `RiskEngine.shutdown()` | Release SDK resources | | `RiskEngineConfig.Builder.debugLog(boolean)` | Toggle SDK logs | | `RiskEngineConfig.Builder.collectTimeout(long)` | Set collection timeout (1-120,000 ms) | +| `RiskEngineConfig.Builder.privacyProfile(PrivacyProfile)` | Select `MINIMAL`, `BALANCED`, or `DIAGNOSTIC` | +| `collectAndroidId/collectBootId/collectDrmId(boolean)` | Override individual identifier collection | | `RiskEngineConfig.Builder.enableRoot/enableHookDetection/...` | Enable or disable individual detector groups | The SDK ships with `consumer-rules.pro`; host apps need no extra ProGuard rules for the public API. -The report never exposes raw Android ID, DRM ID, boot ID, IMEI, IMSI, Wi-Fi/Bluetooth MAC, SSID, or BSSID. Identifiers needed for stable correlation are converted to package-scoped SHA-256 values first. The Demo also disables cloud backup and device-transfer extraction. +The report never exposes raw Android ID, DRM ID, boot ID, IMEI, IMSI, Wi-Fi/Bluetooth MAC, SSID, or BSSID. Deterministic package-scoped SHA-256 values are pseudonyms, not encryption, anonymization, or an absolute non-reversibility guarantee. The Demo also disables cloud backup and device-transfer extraction. ## Documentation -See [doc/Implementation_Details.md](./doc/Implementation_Details.md) for the full implementation details. +See [doc/Implementation_Details.md](./doc/Implementation_Details.md) for implementation details and [doc/Pending_Items.md](./doc/Pending_Items.md) for validation work that requires external devices or deployment infrastructure. ## License diff --git a/README_zh.md b/README_zh.md index 3f8a8a4..cf873c7 100644 --- a/README_zh.md +++ b/README_zh.md @@ -9,11 +9,11 @@ Android 本地设备指纹采集与运行环境风险检测 SDK。采用 Java + 本次修改重点完善了失败语义、隐私安全与 Demo 界面: - Collector 结果明确区分 `SUCCESS`、`EMPTY`、`ERROR` 与 `UNSUPPORTED`。 -- Detector 失败、超时或不可用时返回 `DetectionStatus.UNKNOWN` 和 `RiskLevel.UNKNOWN`,不再被误判为安全。 -- 报告增加风险分容量与各状态计数,允许保留部分覆盖结果,并在聚合完成后保持不可变。 +- Detector 同时报告风险状态与独立的 `DetectionExecutionStatus`;失败、超时、不可用、部分完成不再被误判为安全。 +- 报告增加统一的严重阈值(18)、下一阈值、完整/部分覆盖状态与各状态计数,并在聚合完成后保持不可变。 - 采集任务共享一个有界总时限;并发请求串行执行;同步接口禁止主线程调用;`shutdown()` 可安全取消进行中的任务。 -- 不返回原始持久标识;需要关联的值统一转换为包名作用域的 SHA-256 哈希。 -- Demo 改为中文结论优先,展示覆盖率、耗时和采集状态,支持展开英文技术证据、深色模式、无障碍触控反馈与减少动态效果。 +- 默认 `BALANCED` 隐私档位只保留应用范围的 Android ID 假名化哈希;Boot ID 与 Widevine 仅在 `DIAGNOSTIC` 档位或显式开启时采集。 +- Demo 统一使用中文语义状态,展示真实风险依据、覆盖率和耗时,支持脱敏报告复制、原始证据展开、深色模式、无障碍点击与减少动态效果。 ## 环境要求 @@ -37,8 +37,8 @@ Android 本地设备指纹采集与运行环境风险检测 SDK。采用 Java + 或直接使用 Gradle: ```bash -./gradlew :riskengine-sdk:testDebugUnitTest :riskengine-sdk:lintDebug -./gradlew :riskengine-sdk:assembleRelease +./gradlew :riskengine-sdk:test :riskengine-sdk:lint +./gradlew :riskengine-sdk:assembleRelease :riskengine-sdk:sourceReleaseJar ./gradlew :demo:lintDebug :demo:assembleDebug :demo:assembleRelease ``` @@ -47,19 +47,23 @@ Android 本地设备指纹采集与运行环境风险检测 SDK。采用 Java + | 产物 | 路径 | 说明 | | --- | --- | --- | | SDK AAR | `riskengine-sdk/build/outputs/aar/riskengine-sdk-release.aar` | 可集成到宿主应用 | -| Demo Debug APK | `demo/build/outputs/apk/debug/demo-debug.apk` | 已使用 Debug 证书签名,可直接安装 | +| SDK Sources | `riskengine-sdk/build/intermediates/source_jar/release/release-sources.jar` | 发布用源码包 | +| Demo Debug APK | `demo/build/outputs/apk/debug/demo-debug.apk` | 临时 Debug 证书签名,仅供侧载测试 | | Demo Release APK | `demo/build/outputs/apk/release/demo-release-unsigned.apk` | 已混淆压缩,分发前需使用正式证书签名 | SDK 内置 `arm64-v8a`、`armeabi-v7a`、`x86_64` 与 `x86` 四种 ABI 的 Native 库。 +Tag 发布会附带 AAR、sources JAR、POM、临时 Debug APK 与 `SHA256SUMS`。临时 Debug APK 不用于生产或应用商店;不同发布的临时证书可能不同,如升级安装提示签名不一致,请先卸载旧 Demo。 + 初始化并采集: ```java RiskEngineConfig config = new RiskEngineConfig.Builder() + .privacyProfile(PrivacyProfile.BALANCED) .collectTimeout(15000) .build(); -RiskEngine.init(context, config); +RiskEngine.initIfNeeded(context, config); RiskEngine.collect(new RiskEngineCallback() { @Override @@ -81,12 +85,13 @@ RiskEngine.collect(new RiskEngineCallback() { ```java RiskReport report = RiskEngine.collectSync(); -String json = RiskEngine.getReportJson(); +String json = RiskEngine.reportToJson(report); // 不重复采集 +String freshJson = RiskEngine.collectReportJson(); // 重新采集并序列化 ``` 同步接口禁止在 Android 主线程调用,以避免 ANR。 -不再使用 SDK 时调用 `RiskEngine.shutdown()` 释放资源。再次初始化前必须先关闭已有实例,否则重复 `init` 会被拒绝。 +不再使用 SDK 时调用 `RiskEngine.shutdown()` 释放资源。`init` 会拒绝重复初始化;多入口应用可使用原子的 `initIfNeeded`。 ## 核心检测项 @@ -111,9 +116,12 @@ SDK 不声明任何 Android 权限;Manifest 仅为 3 个已知模拟器包声 | `CollectorResult.Status.EMPTY` | 采集正常结束,但没有可用值 | | `CollectorResult.Status.ERROR` | Collector 执行失败或超出本次请求的可用时间 | | `CollectorResult.Status.UNSUPPORTED` | 当前设备或平台不提供该信号 | -| `DetectionStatus.UNKNOWN` / `RiskLevel.UNKNOWN` | Detector 无法给出可靠结论 | +| `DetectionStatus` | 风险展示状态:`NORMAL` / `WARNING` / `DANGER` / `UNKNOWN` | +| `DetectionExecutionStatus` | 执行状态:`SAFE` / `RISK` / `PARTIAL` / `UNAVAILABLE` / `DISABLED` / `TIMEOUT` / `ERROR` | + +`UNKNOWN` 表示覆盖不足,不表示“没有风险”。没有 Warning 或 Danger 信号但存在未知检测项时,报告级风险为 `UNKNOWN`。`PARTIAL` 会降低报告覆盖率,即使已完成部分没有发现异常,也不能解释为全部安全。多来源结果不一致会生成检测信号,并将原本更低的综合结果至少提升至 `MEDIUM`。 -`UNKNOWN` 表示覆盖不足,不表示“没有风险”。没有 Warning 或 Danger 信号但存在未知检测项时,报告级风险为 `UNKNOWN`。多来源结果不一致会生成检测信号,并将原本更低的综合结果至少提升至 `MEDIUM`。 +`riskScore` 只累加可处置信号;提示性(`informational`,旧接口名 `warnOnly`)信号不加分,但可将完全安全的结果展示为 `LOW`。分数阈值为:`MEDIUM >= 4`、`HIGH >= 10`、`DEADLY >= 18`;硬触发信号可直接进入 `DEADLY`。`maxRiskScore` 仅用于技术诊断,不作为 Demo 进度条分母。 ## 输出 @@ -127,6 +135,8 @@ SDK 不声明任何 Android 权限;Manifest 仅为 3 个已知模拟器包声 | `riskScore` / `maxRiskScore` | 可执行风险分与本报告所代表的最大风险分 | | `warningCount` / `dangerCount` | Warning 与 Danger 检测结果数量 | | `unknownCount` | 未执行、超时或不支持的检测数量 | +| `reportStatus` | `COMPLETE` / `PARTIAL` / `UNAVAILABLE` | +| `checkCount` / `completedCheckCount` / `coveragePercent` | 统一的 Detector + 原始 Collector 覆盖口径;不重复计算 SDK 合成项 | | `timestampMs` | 采集时间戳 | | `sdkVersion` | SDK 版本号 | @@ -134,11 +144,12 @@ SDK 不声明任何 Android 权限;Manifest 仅为 3 个已知模拟器包声 Demo 被设计为本地诊断控制台,不会自动开始采集: -- 初始状态为 `READY`,仅在用户主动点击后执行检测,结果不会自动上传。 -- 顶部卡片优先显示中文风险结论,同时展示英文枚举值、风险分、覆盖率和耗时。 -- 风险项优先排列;检测条目可展开英文技术证据,采集条目可查看来源值及 `SUCCESS` / `EMPTY` / `ERROR` / `UNSUPPORTED` 状态。 +- 初始状态为“就绪”,仅在用户主动点击后执行检测,结果不会自动上传。 +- 顶部卡片使用中文语义状态和风险着色,风险进度以严重阈值 18 为分母;错误状态使用灰色和 0% 进度。 +- 风险项优先排列;检测条目先解释真实依据,再展示原始证据、评分和子检查覆盖;采集字段使用中文名称和单位,SDK 合成项独立分区。 - `UNKNOWN` 会被解释为“覆盖不足”,不会使用安全样式或安全描述。 -- 检测进行中及已完成的界面状态可跨 Activity 重建保留;按压反馈和详情动效可以中断,并遵循系统的减少动态效果设置。 +- 检测进行中及已完成的界面状态可跨 Activity 重建保留;触觉反馈只在新结果到达时触发,不会因旋转或切回前台重放。 +- 可复制不含设备标识哈希、原始属性、文件路径与 PID 的脱敏摘要。 - 提供语义化深浅色主题、可读字号、无障碍点击行为,以及有意义的完成/失败触觉反馈。 ## 公开 API @@ -146,21 +157,26 @@ Demo 被设计为本地诊断控制台,不会自动开始采集: | API | 说明 | | --- | --- | | `RiskEngine.init(Context, RiskEngineConfig)` | 初始化 SDK | +| `RiskEngine.initIfNeeded(Context, RiskEngineConfig)` | 原子地按需初始化;返回本次是否执行了初始化 | | `RiskEngine.collect(RiskEngineCallback)` | 异步采集 | | `RiskEngine.collectSync()` | 同步采集 | -| `RiskEngine.getReportJson()` | 采集并返回 JSON 结果 | +| `RiskEngine.collectReportJson()` | 完整采集并返回 JSON 结果 | +| `RiskEngine.reportToJson(RiskReport)` | 序列化已有报告,不重复采集 | +| `RiskEngine.getReportJson()` | 已弃用的兼容接口;等价于 `collectReportJson()` | | `RiskEngine.shutdown()` | 释放 SDK 资源 | | `RiskEngineConfig.Builder.debugLog(boolean)` | 开关 SDK 日志 | | `RiskEngineConfig.Builder.collectTimeout(long)` | 设置采集超时时间(1-120,000 毫秒) | +| `RiskEngineConfig.Builder.privacyProfile(PrivacyProfile)` | 设置 `MINIMAL` / `BALANCED` / `DIAGNOSTIC` 隐私档位 | +| `collectAndroidId/collectBootId/collectDrmId(boolean)` | 覆盖档位中的单项标识采集开关 | | `RiskEngineConfig.Builder.enableRoot/enableHookDetection/...` | 分项启用或关闭检测器 | SDK 已提供 `consumer-rules.pro`,宿主应用无需为公开 API 额外配置 ProGuard 规则。 -SDK 不输出原始 Android ID、DRM ID、Boot ID、IMEI、IMSI、Wi-Fi/Bluetooth MAC、SSID 或 BSSID。需要稳定关联的标识会先转换为包名作用域的 SHA-256 值。Demo 同时禁止云备份和设备迁移数据导出。 +SDK 不输出原始 Android ID、DRM ID、Boot ID、IMEI、IMSI、Wi-Fi/Bluetooth MAC、SSID 或 BSSID。所用 SHA-256 值是确定性的应用范围假名,不是加密、匿名化或不可逆性的绝对保证。Demo 同时禁止云备份和设备迁移数据导出。 ## 文档 -完整实现细节见 [doc/Implementation_Details_zh.md](./doc/Implementation_Details_zh.md)。 +完整实现细节见 [doc/Implementation_Details_zh.md](./doc/Implementation_Details_zh.md);需要外部设备或发布基础设施才能闭环的事项见 [doc/Pending_Items_zh.md](./doc/Pending_Items_zh.md)。 ## 许可证 diff --git a/build.sh b/build.sh index 6c9026f..80a46f6 100755 --- a/build.sh +++ b/build.sh @@ -41,25 +41,44 @@ check_java() { err "Java not found. Install JDK 17+." exit 1 fi - local ver - ver=$(java -version 2>&1 | head -1 | sed 's/.*"\(.*\)".*/\1/' | cut -d. -f1) - if [[ "$ver" -lt 17 ]]; then - err "JDK 17+ required, found version $ver" + local version_line major + version_line=$(java -version 2>&1 | head -1) + if [[ "$version_line" =~ \"1\.([0-9]+)\. ]]; then + major="${BASH_REMATCH[1]}" + elif [[ "$version_line" =~ \"([0-9]+)(\.|\") ]]; then + major="${BASH_REMATCH[1]}" + else + err "Unable to parse Java version: $version_line" + exit 1 + fi + if (( major < 17 )); then + err "JDK 17+ required, found version $major" exit 1 fi - ok "Java $ver" + ok "Java $major" } check_android_sdk() { if [[ -z "${ANDROID_HOME:-}" && -z "${ANDROID_SDK_ROOT:-}" ]]; then - local default_sdk="$HOME/Library/Android/sdk" - if [[ -d "$default_sdk" ]]; then - export ANDROID_HOME="$default_sdk" - warn "ANDROID_HOME not set, using default: $default_sdk" - else - err "ANDROID_HOME or ANDROID_SDK_ROOT not set and SDK not found at default path." + local user_home="${HOME:?HOME is not set}" + local candidates=( + "$user_home/Library/Android/sdk" + "$user_home/Android/Sdk" + "$user_home/android-sdk" + ) + local candidate selected="" + for candidate in "${candidates[@]}"; do + if [[ -d "$candidate" ]]; then + selected="$candidate" + break + fi + done + if [[ -z "$selected" ]]; then + err "ANDROID_HOME or ANDROID_SDK_ROOT not set and no standard SDK path exists." exit 1 fi + export ANDROID_HOME="$selected" + warn "ANDROID_HOME not set, using default: $selected" fi ok "Android SDK: ${ANDROID_HOME:-$ANDROID_SDK_ROOT}" } @@ -74,7 +93,10 @@ build_sdk() { info "Building RiskEngine SDK..." check_java check_android_sdk - run_gradle :riskengine-sdk:assembleRelease + run_gradle :riskengine-sdk:test :riskengine-sdk:lint \ + :riskengine-sdk:assembleRelease :riskengine-sdk:sourceReleaseJar \ + :riskengine-sdk:generatePomFileForReleasePublication \ + :integration-test:assembleDebug local aar="$ROOT_DIR/riskengine-sdk/build/outputs/aar/riskengine-sdk-release.aar" if [[ -f "$aar" ]]; then ok "SDK AAR: $aar" @@ -87,7 +109,7 @@ build_demo() { info "Building Demo APK..." check_java check_android_sdk - run_gradle :demo:assembleDebug + run_gradle :demo:lintDebug :demo:assembleDebug local apk="$ROOT_DIR/demo/build/outputs/apk/debug/demo-debug.apk" if [[ -f "$apk" ]]; then ok "Demo APK: $apk" @@ -124,9 +146,9 @@ clean_all() { build_all() { build_sdk - echo "" + printf '\n' build_demo - echo "" + printf '\n' ok "All builds completed." } diff --git a/demo/build.gradle.kts b/demo/build.gradle.kts index 1b31168..6173177 100644 --- a/demo/build.gradle.kts +++ b/demo/build.gradle.kts @@ -2,7 +2,8 @@ plugins { alias(libs.plugins.android.application) } -val releaseVersionName = providers.gradleProperty("releaseVersionName").orElse("1.0.0") +val releaseVersionName = providers.gradleProperty("releaseVersionName") + .orElse(providers.gradleProperty("riskEngineVersion")) val releaseVersionCode = providers.gradleProperty("releaseVersionCode").map(String::toInt).orElse(1) android { @@ -41,5 +42,4 @@ dependencies { implementation(project(":riskengine-sdk")) implementation(libs.appcompat) implementation(libs.material) - implementation(libs.gson) } diff --git a/demo/src/main/java/com/wsttxm/riskenginesdk/demo/MainActivity.java b/demo/src/main/java/com/wsttxm/riskenginesdk/demo/MainActivity.java index 4e7f7ec..1b322ce 100644 --- a/demo/src/main/java/com/wsttxm/riskenginesdk/demo/MainActivity.java +++ b/demo/src/main/java/com/wsttxm/riskenginesdk/demo/MainActivity.java @@ -1,6 +1,9 @@ package com.wsttxm.riskenginesdk.demo; import android.animation.ValueAnimator; +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Context; import android.graphics.Typeface; import android.graphics.drawable.GradientDrawable; import android.os.Bundle; @@ -8,7 +11,6 @@ import android.util.TypedValue; import android.view.Gravity; import android.view.HapticFeedbackConstants; -import android.view.MotionEvent; import android.view.View; import android.view.animation.DecelerateInterpolator; import android.widget.LinearLayout; @@ -31,7 +33,7 @@ import com.wsttxm.riskenginesdk.model.RiskReport; import java.lang.ref.WeakReference; -import java.text.SimpleDateFormat; +import java.text.DateFormat; import java.util.ArrayList; import java.util.Date; import java.util.List; @@ -50,6 +52,7 @@ public class MainActivity extends AppCompatActivity { private static long activeRequestToken; private MaterialButton btnCollect; + private MaterialButton btnCopyReport; private MaterialCardView cardStatus; private MaterialCardView cardDetections; private MaterialCardView cardInconsistent; @@ -63,7 +66,7 @@ public class MainActivity extends AppCompatActivity { private TextView tvScoreCaption; private TextView tvCoverageSummary; private LinearLayout layoutStats; - private TextView tvStatDetections; + private TextView tvStatRiskScore; private TextView tvStatFingerprints; private TextView tvStatElapsed; private TextView tvDetectionsHeader; @@ -80,7 +83,6 @@ protected void onCreate(Bundle savedInstanceState) { initViews(); initSdk(); - installPressFeedback(btnCollect); btnCollect.setOnClickListener(v -> startCollection()); } @@ -119,6 +121,7 @@ protected void onDestroy() { private void initViews() { btnCollect = findViewById(R.id.btnCollect); + btnCopyReport = findViewById(R.id.btnCopyReport); cardStatus = findViewById(R.id.cardStatus); cardDetections = findViewById(R.id.cardDetections); cardInconsistent = findViewById(R.id.cardInconsistent); @@ -131,7 +134,7 @@ private void initViews() { tvScoreCaption = findViewById(R.id.tvScoreCaption); tvCoverageSummary = findViewById(R.id.tvCoverageSummary); layoutStats = findViewById(R.id.layoutStats); - tvStatDetections = findViewById(R.id.tvStatDetections); + tvStatRiskScore = findViewById(R.id.tvStatRiskScore); tvStatFingerprints = findViewById(R.id.tvStatFingerprints); tvStatElapsed = findViewById(R.id.tvStatElapsed); tvDetectionsHeader = findViewById(R.id.tvDetectionsHeader); @@ -140,17 +143,15 @@ private void initViews() { layoutInconsistent = findViewById(R.id.layoutInconsistent); tvFingerprintHeader = findViewById(R.id.tvFingerprintHeader); layoutFingerprint = findViewById(R.id.layoutFingerprint); + btnCopyReport.setOnClickListener(v -> copyRedactedReport()); } private void initSdk() { - if (RiskEngine.isInitialized()) { - return; - } RiskEngineConfig config = new RiskEngineConfig.Builder() .debugLog(BuildConfig.DEBUG) .collectTimeout(15_000) .build(); - RiskEngine.init(this, config); + RiskEngine.initIfNeeded(this, config); } private void startCollection() { @@ -186,7 +187,7 @@ public void onSuccess(RiskReport report) { latestError = null; collectionInFlight = false; } - deliverSharedState(); + deliverSharedState(true); } @Override @@ -199,18 +200,23 @@ public void onError(Throwable error) { latestError = error; collectionInFlight = false; } - deliverSharedState(); + deliverSharedState(false); } }; } - private static void deliverSharedState() { + private static void deliverSharedState(boolean success) { MainActivity activity; synchronized (STATE_LOCK) { activity = activeActivity.get(); } if (activity != null) { - activity.runOnUiThread(activity::renderSharedState); + activity.runOnUiThread(() -> { + activity.renderSharedState(); + activity.btnCollect.performHapticFeedback(success + ? HapticFeedbackConstants.CONFIRM + : HapticFeedbackConstants.REJECT); + }); } } @@ -230,10 +236,8 @@ private void renderSharedState() { showLoadingState(); } else if (report != null) { displayReport(report, elapsedMs); - btnCollect.performHapticFeedback(HapticFeedbackConstants.CONFIRM); } else if (error != null) { showErrorState(error); - btnCollect.performHapticFeedback(HapticFeedbackConstants.REJECT); } else { showIdleState(); } @@ -242,84 +246,99 @@ private void renderSharedState() { private void showIdleState() { progressCollecting.setVisibility(View.GONE); tvLocalStatus.setText(R.string.status_ready_code); + styleLocalStatus(getString(R.string.status_ready_code), + getColor(R.color.primary), R.color.chip_bg); tvRiskLevel.setText(R.string.status_ready); tvRiskLevel.setTextColor(getColor(R.color.text_primary)); tvStatusInfo.setText(R.string.status_ready_description); tvScoreCaption.setText(R.string.score_idle_description); progressRisk.setIndicatorColor(getColor(R.color.primary)); progressRisk.setProgressCompat(0, false); + progressRisk.setContentDescription(getString(R.string.risk_progress_idle)); layoutStats.setVisibility(View.GONE); tvCoverageSummary.setVisibility(View.GONE); btnCollect.setText(R.string.collect_action); btnCollect.setContentDescription(getString(R.string.collect_action)); hideResultCards(); + btnCopyReport.setVisibility(View.GONE); } private void showLoadingState() { progressCollecting.setVisibility(View.VISIBLE); tvLocalStatus.setText(R.string.status_analyzing_code); + styleLocalStatus(getString(R.string.status_analyzing_code), + getColor(R.color.primary), R.color.chip_bg); tvRiskLevel.setText(R.string.status_analyzing); tvRiskLevel.setTextColor(getColor(R.color.text_primary)); tvStatusInfo.setText(R.string.status_analyzing_description); tvScoreCaption.setText(R.string.score_calculating_description); progressRisk.setIndicatorColor(getColor(R.color.primary)); progressRisk.setProgressCompat(0, false); + progressRisk.setContentDescription(getString(R.string.score_calculating_description)); layoutStats.setVisibility(View.GONE); tvCoverageSummary.setVisibility(View.GONE); btnCollect.setText(R.string.collecting_action); btnCollect.setContentDescription(getString(R.string.collecting_action)); hideResultCards(); + btnCopyReport.setVisibility(View.GONE); } private void showErrorState(Throwable error) { progressCollecting.setVisibility(View.GONE); - tvLocalStatus.setText(R.string.status_error_code); + styleLocalStatus(getString(R.string.status_error_code), + getColor(R.color.risk_unknown), R.color.unknown_bg); tvRiskLevel.setText(R.string.collection_failed); - tvRiskLevel.setTextColor(getColor(R.color.risk_deadly)); + tvRiskLevel.setTextColor(getColor(R.color.risk_unknown)); String message = error.getMessage(); if (message == null || message.isBlank()) { message = getString(R.string.unknown_error); } tvStatusInfo.setText(getString(R.string.collection_failed_description, message)); tvScoreCaption.setText(R.string.score_error_description); - progressRisk.setIndicatorColor(getColor(R.color.risk_deadly)); - progressRisk.setProgressCompat(100, animationsEnabled()); + progressRisk.setIndicatorColor(getColor(R.color.risk_unknown)); + progressRisk.setProgressCompat(0, animationsEnabled()); + progressRisk.setContentDescription(getString(R.string.risk_progress_error)); layoutStats.setVisibility(View.GONE); tvCoverageSummary.setVisibility(View.GONE); btnCollect.setText(R.string.retry_action); btnCollect.setContentDescription(getString(R.string.retry_action)); hideResultCards(); + btnCopyReport.setVisibility(View.GONE); } private void displayReport(RiskReport report, long elapsedMs) { progressCollecting.setVisibility(View.GONE); RiskLevel level = report.getOverallRiskLevel(); int riskColor = getRiskColor(level); - tvLocalStatus.setText(level.name()); + styleLocalStatus(getRiskLabel(level), riskColor, riskBackground(level)); tvRiskLevel.setText(getRiskLabel(level)); tvRiskLevel.setTextColor(riskColor); - tvStatusInfo.setText(getRiskDescription(level)); + tvStatusInfo.setText(buildRiskDescription(report)); int riskPercent = Math.min(100, - Math.round((report.getRiskScore() / 18f) * 100f)); + Math.round((report.getRiskScore() + / (float) report.getDisplayThresholdMaximum()) * 100f)); progressRisk.setIndicatorColor(riskColor); progressRisk.setProgressCompat(riskPercent, animationsEnabled()); + progressRisk.setContentDescription(getString(R.string.risk_progress_value, + report.getRiskScore(), report.getDisplayThresholdMaximum())); - String time = new SimpleDateFormat("HH:mm:ss", Locale.getDefault()) + String time = DateFormat.getDateTimeInstance( + DateFormat.SHORT, DateFormat.MEDIUM, Locale.getDefault()) .format(new Date(report.getTimestampMs())); - tvScoreCaption.setText(String.format(Locale.getDefault(), - "SDK %s · %s · score %d / %d", - report.getSdkVersion(), time, report.getRiskScore(), report.getMaxRiskScore())); + tvScoreCaption.setText(getString(R.string.score_report_description, + report.getSdkVersion(), time, report.getRiskScore(), + report.getDisplayThresholdMaximum())); - Coverage coverage = calculateCoverage(report); layoutStats.setVisibility(View.VISIBLE); - tvStatDetections.setText(String.valueOf(report.getRiskScore())); - tvStatFingerprints.setText(getString(R.string.coverage_percent, coverage.percent)); + tvStatRiskScore.setText(String.valueOf(report.getRiskScore())); + tvStatFingerprints.setText(getString( + R.string.coverage_percent, report.getCoveragePercent())); tvStatElapsed.setText(formatElapsed(elapsedMs)); tvCoverageSummary.setVisibility(View.VISIBLE); - tvCoverageSummary.setText(String.format(Locale.getDefault(), - "完成 %d / %d 项 · 未知 %d 项 · 采集字段 %d 项", - coverage.completed, coverage.total, coverage.unknown, + tvCoverageSummary.setText(getString(R.string.coverage_summary, + report.getCompletedCheckCount(), report.getCheckCount(), + report.getDangerCount(), report.getWarningCount(), report.getIncompleteCheckCount(), report.getFingerprint().getResults().size())); btnCollect.setText(R.string.collect_again_action); @@ -327,28 +346,7 @@ private void displayReport(RiskReport report, long elapsedMs) { displayDetections(report); displayInconsistentFields(report); displayFingerprint(report); - } - - private Coverage calculateCoverage(RiskReport report) { - int detectorChecks = 0; - for (DetectionResult detection : report.getDetections()) { - if (!detection.getDetectorName().startsWith("collector:")) { - detectorChecks++; - } - } - - int collectorChecks = 0; - for (String name : report.getFingerprint().getResults().keySet()) { - if (!"hook_memory_signals".equals(name) - && !"runtime_integrity_score_inputs".equals(name)) { - collectorChecks++; - } - } - int total = Math.max(1, detectorChecks + collectorChecks); - int unknown = Math.min(total, report.getUnknownCount()); - int completed = total - unknown; - int percent = Math.round((completed * 100f) / total); - return new Coverage(total, completed, unknown, percent); + btnCopyReport.setVisibility(View.VISIBLE); } private void displayDetections(RiskReport report) { @@ -369,8 +367,8 @@ private void displayDetections(RiskReport report) { attentionCount++; } } - tvDetectionsHeader.setText(String.format(Locale.getDefault(), - "环境检测 · %d 项 · %d 项需关注", detections.size(), attentionCount)); + tvDetectionsHeader.setText(getString(R.string.detections_header_summary, + detections.size(), attentionCount, countIncompleteDetections(detections))); for (int index = 0; index < detections.size(); index++) { layoutDetections.addView(createDetectionRow(detections.get(index))); @@ -384,43 +382,10 @@ private void displayDetections(RiskReport report) { private View createDetectionRow(DetectionResult detection) { LinearLayout row = createInteractiveRow(); - LinearLayout header = new LinearLayout(this); - header.setGravity(Gravity.CENTER_VERTICAL); - header.setOrientation(LinearLayout.HORIZONTAL); - - View dot = new View(this); - LinearLayout.LayoutParams dotParams = new LinearLayout.LayoutParams(dp(9), dp(9)); - dotParams.rightMargin = dp(11); - dot.setLayoutParams(dotParams); - dot.setBackground(createCircleDrawable(getRiskColor(detection.getRiskLevel()))); - header.addView(dot); - - LinearLayout titleGroup = new LinearLayout(this); - titleGroup.setOrientation(LinearLayout.VERTICAL); - LinearLayout.LayoutParams titleParams = new LinearLayout.LayoutParams( - 0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f); - titleGroup.setLayoutParams(titleParams); - - TextView title = createText(detectorTitle(detection.getDetectorName()), - 14, R.color.text_primary, Typeface.BOLD); - titleGroup.addView(title); - TextView technicalName = createText(detection.getDetectorName(), - 11, R.color.text_tertiary, Typeface.NORMAL); - technicalName.setTypeface(Typeface.MONOSPACE); - titleGroup.addView(technicalName); - header.addView(titleGroup); - - TextView badge = createStatusBadge( - getRiskLabel(detection.getRiskLevel()), - getRiskColor(detection.getRiskLevel()), - riskBackground(detection.getRiskLevel())); - header.addView(badge); - - TextView disclosure = createText(getString(R.string.expand_details), - 11, R.color.text_tertiary, Typeface.NORMAL); - disclosure.setPadding(dp(8), 0, 0, 0); - header.addView(disclosure); - row.addView(header); + TextView disclosure = addRowHeader(row, detectionStatusColor(detection), + detectorTitle(detection.getDetectorName()), detection.getDetectorName(), + detectionStatusLabel(detection), detectionStatusColor(detection), + detectionStatusBackground(detection)); TextView summary = createText(detectionSummary(detection), 12, R.color.text_secondary, Typeface.NORMAL); @@ -433,10 +398,10 @@ private View createDetectionRow(DetectionResult detection) { row.addView(summary); LinearLayout details = createDetailsContainer(); - TextView detailLabel = createText("技术证据 · Technical Evidence", + TextView detailLabel = createText(getString(R.string.technical_evidence), 11, R.color.text_tertiary, Typeface.BOLD); details.addView(detailLabel); - TextView evidence = createText(normalizeEvidence(detection.getEvidence()), + TextView evidence = createText(detectionTechnicalDetails(detection), 11, R.color.text_secondary, Typeface.NORMAL); evidence.setTypeface(Typeface.MONOSPACE); evidence.setTextIsSelectable(true); @@ -448,8 +413,8 @@ private View createDetectionRow(DetectionResult detection) { details.addView(evidence); row.addView(details); - row.setContentDescription(detectorTitle(detection.getDetectorName()) - + "," + getRiskLabel(detection.getRiskLevel()) + ",轻触查看详情"); + row.setContentDescription(getString(R.string.row_content_description, + detectorTitle(detection.getDetectorName()), detectionStatusLabel(detection))); row.setOnClickListener(v -> toggleDetails(details, disclosure)); return row; } @@ -462,8 +427,8 @@ private void displayInconsistentFields(RiskReport report) { return; } - tvInconsistentHeader.setText(String.format(Locale.getDefault(), - "数据一致性提醒 · %d 项", inconsistent.size())); + tvInconsistentHeader.setText(getString( + R.string.inconsistent_header_summary, inconsistent.size())); for (String field : inconsistent) { TextView fieldView = createText( collectorTitle(field) + " · " + field, @@ -491,57 +456,92 @@ private void displayFingerprint(RiskReport report) { return; } + List collected = new ArrayList<>(); + List synthetic = new ArrayList<>(); int unavailable = 0; for (CollectorResult result : results.values()) { + if (isSyntheticCollector(result.getFieldName())) { + synthetic.add(result); + continue; + } + collected.add(result); if (result.getStatus() != CollectorResult.Status.SUCCESS) { unavailable++; } } - tvFingerprintHeader.setText(String.format(Locale.getDefault(), - "数据采集 · %d 项 · %d 项无可用数据", results.size(), unavailable)); + tvFingerprintHeader.setText(getString( + R.string.fingerprint_header_summary, collected.size(), unavailable)); int index = 0; - for (CollectorResult result : results.values()) { + for (CollectorResult result : collected) { layoutFingerprint.addView(createFingerprintRow(result)); - if (index < results.size() - 1) { + if (index < collected.size() - 1) { layoutFingerprint.addView(createDivider()); } index++; } + if (!synthetic.isEmpty()) { + TextView section = createText(getString(R.string.synthetic_analysis_title), + 13, R.color.text_secondary, Typeface.BOLD); + section.setPadding(dp(2), dp(20), dp(2), dp(7)); + layoutFingerprint.addView(section); + for (int syntheticIndex = 0; syntheticIndex < synthetic.size(); syntheticIndex++) { + layoutFingerprint.addView(createFingerprintRow(synthetic.get(syntheticIndex))); + if (syntheticIndex < synthetic.size() - 1) { + layoutFingerprint.addView(createDivider()); + } + } + } revealCard(cardFingerprint, 2); } - private View createFingerprintRow(CollectorResult result) { - LinearLayout row = createInteractiveRow(); - LinearLayout header = new LinearLayout(this); - header.setGravity(Gravity.CENTER_VERTICAL); - header.setOrientation(LinearLayout.HORIZONTAL); + private boolean isSyntheticCollector(String name) { + return "hook_memory_signals".equals(name) + || "runtime_integrity_score_inputs".equals(name); + } - LinearLayout titleGroup = new LinearLayout(this); - titleGroup.setOrientation(LinearLayout.VERTICAL); - LinearLayout.LayoutParams titleParams = new LinearLayout.LayoutParams( - 0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f); - titleGroup.setLayoutParams(titleParams); - titleGroup.addView(createText(collectorTitle(result.getFieldName()), - 14, R.color.text_primary, Typeface.BOLD)); - TextView technicalName = createText(result.getFieldName(), - 11, R.color.text_tertiary, Typeface.NORMAL); - technicalName.setTypeface(Typeface.MONOSPACE); - titleGroup.addView(technicalName); - header.addView(titleGroup); + private void copyRedactedReport() { + RiskReport report; + synchronized (STATE_LOCK) { + report = latestReport; + } + if (report == null) return; + ClipboardManager clipboard = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE); + if (clipboard == null) return; + clipboard.setPrimaryClip(ClipData.newPlainText( + getString(R.string.copy_redacted_report_label), buildRedactedSummary(report))); + Toast.makeText(this, R.string.copy_redacted_report_success, Toast.LENGTH_SHORT).show(); + } + private String buildRedactedSummary(RiskReport report) { + StringBuilder summary = new StringBuilder(); + summary.append(getString(R.string.redacted_report_header, + report.getSdkVersion(), getRiskLabel(report.getOverallRiskLevel()), + report.getRiskScore(), report.getDisplayThresholdMaximum(), + report.getCompletedCheckCount(), report.getCheckCount(), + report.getDangerCount(), report.getWarningCount(), + report.getIncompleteCheckCount())); + for (DetectionResult detection : report.getDetections()) { + if (detection.getDetectorName().startsWith("collector:")) continue; + summary.append(getString(R.string.redacted_report_item, + detectorTitle(detection.getDetectorName()), detectionStatusLabel(detection))); + if (!detection.getDetails().isEmpty()) { + summary.append(getString(R.string.redacted_report_reason, + humanizeEvidence(detection.getDetails().get(0)))); + } + } + summary.append(getString(R.string.redacted_report_footer)); + return summary.toString(); + } + + private View createFingerprintRow(CollectorResult result) { + LinearLayout row = createInteractiveRow(); int statusColor = collectorStatusColor(result.getStatus()); - TextView badge = createStatusBadge( - collectorStatusLabel(result.getStatus()), - statusColor, + TextView disclosure = addRowHeader(row, null, + collectorTitle(result.getFieldName()), getString(R.string.field_meta, + result.getFieldName(), collectorSensitivityLabel(result.getFieldName())), + collectorStatusLabel(result.getStatus()), statusColor, collectorStatusBackground(result.getStatus())); - header.addView(badge); - - TextView disclosure = createText(getString(R.string.expand_details), - 11, R.color.text_tertiary, Typeface.NORMAL); - disclosure.setPadding(dp(8), 0, 0, 0); - header.addView(disclosure); - row.addView(header); TextView summary = createText(collectorSummary(result), 12, R.color.text_secondary, Typeface.NORMAL); @@ -554,7 +554,7 @@ private View createFingerprintRow(CollectorResult result) { row.addView(summary); LinearLayout details = createDetailsContainer(); - TextView detailLabel = createText("来源详情 · Source Values", + TextView detailLabel = createText(getString(R.string.source_values), 11, R.color.text_tertiary, Typeface.BOLD); details.addView(detailLabel); TextView technicalValues = createText(collectorTechnicalDetails(result), @@ -569,8 +569,8 @@ private View createFingerprintRow(CollectorResult result) { details.addView(technicalValues); row.addView(details); - row.setContentDescription(collectorTitle(result.getFieldName()) - + "," + collectorStatusLabel(result.getStatus()) + ",轻触查看详情"); + row.setContentDescription(getString(R.string.row_content_description, + collectorTitle(result.getFieldName()), collectorStatusLabel(result.getStatus()))); row.setOnClickListener(v -> toggleDetails(details, disclosure)); return row; } @@ -590,6 +590,42 @@ private LinearLayout createInteractiveRow() { return row; } + private TextView addRowHeader(LinearLayout row, Integer dotColor, String titleText, + String technicalText, String badgeText, int badgeTextColor, + int badgeBackgroundColor) { + LinearLayout header = new LinearLayout(this); + header.setGravity(Gravity.CENTER_VERTICAL); + header.setOrientation(LinearLayout.HORIZONTAL); + + if (dotColor != null) { + View dot = new View(this); + LinearLayout.LayoutParams dotParams = new LinearLayout.LayoutParams(dp(9), dp(9)); + dotParams.rightMargin = dp(11); + dot.setLayoutParams(dotParams); + dot.setBackground(createCircleDrawable(dotColor)); + header.addView(dot); + } + + LinearLayout titleGroup = new LinearLayout(this); + titleGroup.setOrientation(LinearLayout.VERTICAL); + titleGroup.setLayoutParams(new LinearLayout.LayoutParams( + 0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)); + titleGroup.addView(createText(titleText, 14, R.color.text_primary, Typeface.BOLD)); + TextView technicalName = createText( + technicalText, 11, R.color.text_tertiary, Typeface.NORMAL); + technicalName.setTypeface(Typeface.MONOSPACE); + titleGroup.addView(technicalName); + header.addView(titleGroup); + + header.addView(createStatusBadge(badgeText, badgeTextColor, badgeBackgroundColor)); + TextView disclosure = createText(getString(R.string.expand_details), + 11, R.color.text_tertiary, Typeface.NORMAL); + disclosure.setPadding(dp(8), 0, 0, 0); + header.addView(disclosure); + row.addView(header); + return disclosure; + } + private LinearLayout createDetailsContainer() { LinearLayout details = new LinearLayout(this); details.setOrientation(LinearLayout.VERTICAL); @@ -683,57 +719,6 @@ private void hideImmediately(View view) { view.setScaleY(1f); } - private void installPressFeedback(View view) { - view.setOnTouchListener((target, event) -> { - switch (event.getActionMasked()) { - case MotionEvent.ACTION_DOWN: - target.setPressed(true); - animateScale(target, 0.97f, 80); - break; - case MotionEvent.ACTION_MOVE: - int allowance = dp(10); - boolean inside = event.getX() >= -allowance - && event.getY() >= -allowance - && event.getX() <= target.getWidth() + allowance - && event.getY() <= target.getHeight() + allowance; - target.setPressed(inside); - animateScale(target, inside ? 0.97f : 1f, 80); - break; - case MotionEvent.ACTION_UP: { - boolean shouldClick = target.isPressed(); - target.setPressed(false); - animateScale(target, 1f, 180); - if (shouldClick) { - target.performClick(); - } - break; - } - case MotionEvent.ACTION_CANCEL: - target.setPressed(false); - animateScale(target, 1f, 180); - break; - default: - break; - } - return true; - }); - } - - private void animateScale(View view, float scale, long durationMs) { - view.animate().cancel(); - if (!animationsEnabled()) { - view.setScaleX(scale); - view.setScaleY(scale); - return; - } - view.animate() - .scaleX(scale) - .scaleY(scale) - .setDuration(durationMs) - .setInterpolator(new DecelerateInterpolator()) - .start(); - } - private boolean animationsEnabled() { return ValueAnimator.areAnimatorsEnabled(); } @@ -741,9 +726,9 @@ private boolean animationsEnabled() { private int detectionPriority(DetectionResult detection) { switch (detection.getStatus()) { case DANGER: - return detection.isWarnOnly() ? 1 : 0; + return detection.isInformational() ? 1 : 0; case WARNING: - return detection.isWarnOnly() ? 3 : 2; + return detection.isInformational() ? 3 : 2; case UNKNOWN: return 4; case NORMAL: @@ -752,98 +737,310 @@ private int detectionPriority(DetectionResult detection) { } } + private int countIncompleteDetections(List detections) { + int count = 0; + for (DetectionResult detection : detections) { + switch (detection.getExecutionStatus()) { + case PARTIAL: + case UNAVAILABLE: + case TIMEOUT: + case ERROR: + count++; + break; + default: + break; + } + } + return count; + } + private String detectionSummary(DetectionResult detection) { if (detection.getStatus() == DetectionStatus.UNKNOWN) { - return "检测未完成,已计入覆盖不足;这不代表当前环境安全。"; + return getString(R.string.detection_unavailable_summary, + executionStatusLabel(detection)); } if (detection.getStatus() == DetectionStatus.NORMAL) { - return "未发现异常信号。"; + return detection.getExecutionStatus() + == com.wsttxm.riskenginesdk.model.DetectionExecutionStatus.PARTIAL + ? getString(R.string.detection_partial_safe_summary, + detection.getChecksSucceeded(), detection.getChecksAttempted()) + : getString(R.string.detection_normal_summary); } - if (detection.isWarnOnly()) { - return "发现提示性信号,单独出现时不会提高综合风险等级。"; + String reason = detection.getDetails().isEmpty() + ? getString(R.string.no_specific_evidence) + : humanizeEvidence(detection.getDetails().get(0)); + if (detection.isInformational()) { + return getString(R.string.detection_informational_summary, reason); } if (detection.getStatus() == DetectionStatus.DANGER) { - return "发现高置信度风险信号,建议结合技术证据进一步处置。"; + return getString(R.string.detection_danger_summary, reason); + } + return getString(R.string.detection_warning_summary, reason); + } + + private String detectionTechnicalDetails(DetectionResult detection) { + StringBuilder text = new StringBuilder(); + text.append(getString(R.string.technical_execution_status, + executionStatusLabel(detection))); + text.append('\n').append(getString(R.string.technical_score, + detection.getScore(), detection.getMaxScore(), + detection.isInformational() ? getString(R.string.yes) : getString(R.string.no))); + text.append('\n').append(getString(R.string.technical_check_coverage, + detection.getChecksSucceeded(), detection.getChecksAttempted())); + for (String reason : detection.getFailureReasons()) { + text.append('\n').append(getString(R.string.technical_failure_reason, + humanizeFailureReason(reason))); + } + if (detection.getDetails().isEmpty()) { + text.append('\n').append(getString(R.string.no_abnormal_signal)); + } else { + for (String detail : detection.getDetails()) { + text.append("\n• ").append(humanizeEvidence(detail)); + if (!isInternalExecutionToken(detail)) { + text.append("\n ").append(detail); + } + } + } + return text.toString(); + } + + private String buildRiskDescription(RiskReport report) { + String base = getRiskDescription(report.getOverallRiskLevel()); + if (report.getReportStatus() + != com.wsttxm.riskenginesdk.model.ReportStatus.COMPLETE) { + base += "\n" + getString(R.string.coverage_incomplete_advice, + report.getIncompleteCheckCount()); + } + for (DetectionResult detection : report.getDetections()) { + if (detection.getStatus() != DetectionStatus.NORMAL + && detection.getStatus() != DetectionStatus.UNKNOWN + && !detection.getDetails().isEmpty()) { + return base + "\n" + getString(R.string.primary_reason, + detectorTitle(detection.getDetectorName()), + humanizeEvidence(detection.getDetails().get(0))); + } + } + return base; + } + + private void styleLocalStatus(String label, int textColor, int backgroundColor) { + tvLocalStatus.setText(label); + tvLocalStatus.setTextColor(textColor); + tvLocalStatus.setBackground(createPillDrawable(getColor(backgroundColor))); + } + + private String detectionStatusLabel(DetectionResult detection) { + switch (detection.getExecutionStatus()) { + case UNAVAILABLE: return getString(R.string.execution_unavailable); + case DISABLED: return getString(R.string.execution_disabled); + case TIMEOUT: return getString(R.string.execution_timeout); + case ERROR: return getString(R.string.execution_error); + case PARTIAL: + if (detection.getStatus() == DetectionStatus.NORMAL) { + return getString(R.string.execution_partial); + } + return getString(R.string.risk_partial, + getRiskLabel(detection.getRiskLevel())); + default: + break; + } + if (detection.getStatus() == DetectionStatus.NORMAL) { + return getString(R.string.status_normal); + } + if (detection.isInformational()) { + return getString(R.string.status_information); + } + return getRiskLabel(detection.getRiskLevel()); + } + + private String executionStatusLabel(DetectionResult detection) { + switch (detection.getExecutionStatus()) { + case SAFE: return getString(R.string.execution_complete); + case RISK: return getString(R.string.execution_complete); + case PARTIAL: return getString(R.string.execution_partial); + case UNAVAILABLE: return getString(R.string.execution_unavailable); + case DISABLED: return getString(R.string.execution_disabled); + case TIMEOUT: return getString(R.string.execution_timeout); + case ERROR: + default: return getString(R.string.execution_error); + } + } + + private int detectionStatusColor(DetectionResult detection) { + switch (detection.getExecutionStatus()) { + case UNAVAILABLE: + case DISABLED: + case TIMEOUT: + case ERROR: + return getColor(R.color.risk_unknown); + case PARTIAL: + return detection.getStatus() == DetectionStatus.NORMAL + ? getColor(R.color.risk_unknown) + : getRiskColor(detection.getRiskLevel()); + default: + return getRiskColor(detection.getRiskLevel()); + } + } + + private int detectionStatusBackground(DetectionResult detection) { + switch (detection.getExecutionStatus()) { + case UNAVAILABLE: + case DISABLED: + case TIMEOUT: + case ERROR: + return R.color.unknown_bg; + case PARTIAL: + return detection.getStatus() == DetectionStatus.NORMAL + ? R.color.unknown_bg : riskBackground(detection.getRiskLevel()); + default: + return riskBackground(detection.getRiskLevel()); + } + } + + private String humanizeEvidence(String detail) { + if (detail == null || detail.isBlank()) return getString(R.string.no_specific_evidence); + if (detail.startsWith("detection_unavailable:")) { + return getString(R.string.evidence_check_unavailable, + humanizeFailureReason(detail.substring("detection_unavailable:".length()))); + } + if (isInternalExecutionToken(detail)) { + int separator = detail.indexOf(':'); + return getString(R.string.evidence_check_unavailable, + humanizeFailureReason(separator >= 0 + ? detail.substring(separator + 1) : detail)); } - return "发现需要关注的环境信号,建议结合业务场景复核。"; + if (detail.startsWith("settings_adb_wifi_enabled")) return getString(R.string.evidence_adb_wifi); + if (detail.startsWith("settings_adb_enabled")) return getString(R.string.evidence_adb_usb); + if (detail.startsWith("adb_tcp_port:")) return getString(R.string.evidence_adb_tcp); + if (detail.startsWith("debuggable_flag")) return getString(R.string.evidence_debuggable); + if (detail.startsWith("debugger_connected")) return getString(R.string.evidence_debugger); + if (detail.startsWith("tracer_pid:")) return getString(R.string.evidence_tracer); + if (detail.startsWith("su_found:")) return getString(R.string.evidence_su); + if (detail.startsWith("magisk_found:") || detail.startsWith("native:magisk")) { + return getString(R.string.evidence_magisk); + } + if (detail.startsWith("selinux_permissive")) return getString(R.string.evidence_selinux); + if (detail.startsWith("test_keys")) return getString(R.string.evidence_test_keys); + if (detail.contains("frida")) return getString(R.string.evidence_frida); + if (detail.contains("xposed") || detail.contains("lsposed")) return getString(R.string.evidence_xposed); + if (detail.startsWith("community_rom:")) return getString(R.string.evidence_custom_rom); + if (detail.startsWith("runtime_arch:")) return getString(R.string.evidence_runtime_arch); + if (detail.startsWith("fingerprint:") || detail.startsWith("model:") + || detail.startsWith("hardware:") || detail.startsWith("product:")) { + return getString(R.string.evidence_emulator_build); + } + if (detail.startsWith("inconsistent_fields:")) return getString(R.string.evidence_inconsistent); + return detail.replace('_', ' '); } - private String normalizeEvidence(String evidence) { - if (evidence == null || evidence.isBlank() || "no risk detected".equals(evidence)) { - return "none · 未发现异常"; + private boolean isInternalExecutionToken(String detail) { + return detail.startsWith("unavailable:") + || detail.startsWith("timeout:") + || detail.startsWith("error:") + || detail.startsWith("disabled:") + || detail.startsWith("detection_unavailable:"); + } + + private String humanizeFailureReason(String reason) { + if (reason == null || reason.isBlank()) return getString(R.string.unknown_error); + String lower = reason.toLowerCase(Locale.ROOT); + if (lower.contains("timeout") || lower.contains("deadline")) return getString(R.string.failure_timeout); + if (lower.contains("securityexception")) return getString(R.string.failure_access_denied); + if (lower.contains("native_library_unavailable") || lower.contains("native_unavailable")) { + return getString(R.string.failure_native_unavailable); } - return evidence; + if (lower.contains("unsupported")) return getString(R.string.failure_unsupported); + if (lower.contains("disabled")) return getString(R.string.failure_disabled); + return reason.replace('_', ' '); } private String detectorTitle(String name) { switch (name) { - case "root": return "Root 权限风险"; - case "mount_analysis": return "系统挂载完整性"; - case "hook_framework": return "注入与 Hook"; - case "process_scan": return "可疑进程"; - case "adb": return "ADB 调试桥"; - case "emulator": return "模拟器环境"; - case "sandbox": return "沙箱与虚拟化"; - case "debug": return "调试器连接"; - case "cloud_phone": return "云手机特征"; - case "custom_rom": return "第三方 ROM"; - case "multi_source_validation": return "多源一致性"; + case "root": return getString(R.string.detector_root); + case "mount_analysis": return getString(R.string.detector_mount); + case "hook_framework": return getString(R.string.detector_hook); + case "process_scan": return getString(R.string.detector_process); + case "adb": return getString(R.string.detector_adb); + case "emulator": return getString(R.string.detector_emulator); + case "sandbox": return getString(R.string.detector_sandbox); + case "debug": return getString(R.string.detector_debug); + case "cloud_phone": return getString(R.string.detector_cloud_phone); + case "custom_rom": return getString(R.string.detector_custom_rom); + case "multi_source_validation": return getString(R.string.detector_multi_source); default: if (name.startsWith("collector:")) { - return "采集覆盖 · " + collectorTitle(name.substring("collector:".length())); + return getString(R.string.detector_collector_coverage, + collectorTitle(name.substring("collector:".length()))); } - return "其他检测"; + return getString(R.string.detector_other); } } private String collectorTitle(String name) { switch (name) { - case "android_id": return "Android 标识哈希"; - case "build_props": return "系统构建信息"; - case "screen_info": return "屏幕特征"; - case "apk_signature": return "应用签名"; - case "bluetooth_info": return "蓝牙能力"; - case "wifi_info": return "Wi-Fi 能力"; - case "telephony": return "通信能力"; - case "settings": return "安全设置摘要"; - case "adb_state": return "ADB 状态"; - case "container_signals": return "容器信号"; - case "drm_id": return "DRM 标识哈希"; - case "boot_id": return "启动标识哈希"; - case "system_properties_native": return "系统属性"; - case "cpu_info": return "CPU 信息"; - case "disk_size": return "存储容量"; - case "kernel_info": return "内核信息"; - case "hook_memory_signals": return "Hook 内存信号"; - case "runtime_integrity_score_inputs": return "风险评分输入"; - default: return "其他采集项"; + case "android_id": return getString(R.string.collector_android_id); + case "build_props": return getString(R.string.collector_build_props); + case "screen_info": return getString(R.string.collector_screen); + case "apk_signature": return getString(R.string.collector_signature); + case "bluetooth_info": return getString(R.string.collector_bluetooth); + case "wifi_info": return getString(R.string.collector_wifi); + case "telephony": return getString(R.string.collector_telephony); + case "settings": return getString(R.string.collector_settings); + case "adb_state": return getString(R.string.collector_adb); + case "container_signals": return getString(R.string.collector_container); + case "drm_id": return getString(R.string.collector_drm); + case "boot_id": return getString(R.string.collector_boot); + case "system_properties_native": return getString(R.string.collector_properties); + case "cpu_info": return getString(R.string.collector_cpu); + case "disk_size": return getString(R.string.collector_disk); + case "kernel_info": return getString(R.string.collector_kernel); + case "hook_memory_signals": return getString(R.string.collector_hook_memory); + case "runtime_integrity_score_inputs": return getString(R.string.collector_score_inputs); + default: return getString(R.string.collector_other); + } + } + + private String collectorSensitivityLabel(String name) { + if ("android_id".equals(name) || "drm_id".equals(name) + || "boot_id".equals(name) || "apk_signature".equals(name)) { + return getString(R.string.sensitivity_pseudonymous); + } + if (isSyntheticCollector(name) || "system_properties_native".equals(name) + || "cpu_info".equals(name) || "kernel_info".equals(name)) { + return getString(R.string.sensitivity_technical); } + return getString(R.string.sensitivity_non_sensitive); } private String collectorSummary(CollectorResult result) { switch (result.getStatus()) { case EMPTY: - return "当前环境未返回可展示数据。"; + return getString(R.string.collector_empty); case ERROR: - return "采集失败 · " + safeReason(result.getError()); + return getString(R.string.collector_failed, + humanizeFailureReason(result.getError())); case UNSUPPORTED: - return "当前设备或运行环境不支持此采集项。"; + return getString(R.string.collector_unsupported); case SUCCESS: default: if (result.isCompareSources() && result.getValues().size() > 1) { return result.isConsistent() - ? result.getValues().size() + " 个来源结果一致" - : result.getValues().size() + " 个来源结果不一致"; + ? getString(R.string.collector_sources_consistent, + result.getValues().size()) + : getString(R.string.collector_sources_inconsistent, + result.getValues().size()); } if (result.getValues().isEmpty()) { - return "采集完成,未返回可展示数据。"; + return getString(R.string.collector_no_display_data); } StringBuilder summary = new StringBuilder(); int count = 0; for (Map.Entry entry : result.getValues().entrySet()) { if (count > 0) summary.append(" · "); - summary.append(entry.getKey()).append(" = ") - .append(shortenValue(entry.getValue())); + summary.append(collectorValueLabel(result.getFieldName(), entry.getKey())) + .append(":") + .append(formatCollectorValue(result.getFieldName(), + entry.getKey(), entry.getValue(), true)); if (++count == 2) break; } return summary.toString(); @@ -852,20 +1049,29 @@ private String collectorSummary(CollectorResult result) { private String collectorTechnicalDetails(CollectorResult result) { StringBuilder details = new StringBuilder(); - details.append("status = ").append(result.getStatus()); + details.append(getString(R.string.collector_status_prefix, + collectorStatusLabel(result.getStatus()))); if (result.getError() != null && !result.getError().isBlank()) { - details.append("\nreason = ").append(result.getError()); + details.append('\n').append(getString(R.string.collector_failure_prefix, + humanizeFailureReason(result.getError()))); + } + if (result.getCanonicalValue() != null && !result.getCanonicalValue().isBlank()) { + details.append('\n').append(getString(R.string.canonical_value_prefix, + formatCollectorValue(result.getFieldName(), "canonical", + result.getCanonicalValue(), false))); } for (Map.Entry entry : result.getValues().entrySet()) { - details.append("\n").append(entry.getKey()).append(" = ") - .append(entry.getValue()); + details.append("\n").append(collectorValueLabel( + result.getFieldName(), entry.getKey())).append(":") + .append(formatCollectorValue(result.getFieldName(), + entry.getKey(), entry.getValue(), false)); } return details.toString(); } private String shortenValue(String value) { if (value == null || value.isBlank()) { - return "无数据"; + return getString(R.string.value_none); } String trimmed = value.trim(); if (trimmed.length() <= 52) { @@ -874,17 +1080,53 @@ private String shortenValue(String value) { return trimmed.substring(0, 28) + "…" + trimmed.substring(trimmed.length() - 12); } - private String safeReason(String reason) { - return reason == null || reason.isBlank() ? "unknown" : reason; + private String collectorValueLabel(String field, String key) { + switch (key) { + case "summary": return getString(R.string.value_summary); + case "status": return getString(R.string.value_status); + case "supported": return getString(R.string.value_supported); + case "low_energy_supported": return getString(R.string.value_bluetooth_le); + case "tcp_port": return getString(R.string.value_tcp_port); + case "signals": return getString(R.string.value_signals); + case "count": return getString(R.string.value_count); + case "width_px": return getString(R.string.value_width); + case "height_px": return getString(R.string.value_height); + case "density_dpi": return getString(R.string.value_density); + case "native_data": return getString(R.string.value_data_capacity); + case "native_storage": return getString(R.string.value_storage_capacity); + case "settings_api": return getString(R.string.value_settings_api); + case "content_resolver": return getString(R.string.value_content_resolver); + case "content_query": return getString(R.string.value_content_query); + default: + if (key.startsWith("ro.")) return getString(R.string.value_system_property, key); + return key.replace('_', ' '); + } + } + + private String formatCollectorValue(String field, String key, String value, boolean summary) { + if (value == null || value.isBlank()) return getString(R.string.value_none); + if (("disk_size".equals(field)) && ("native_data".equals(key) + || "native_storage".equals(key))) { + try { + double gib = Long.parseLong(value) / (1024d * 1024d * 1024d); + return String.format(Locale.getDefault(), "%.1f GB", gib); + } catch (NumberFormatException ignored) {} + } + if ("true".equalsIgnoreCase(value)) return getString(R.string.yes); + if ("false".equalsIgnoreCase(value)) return getString(R.string.no); + if (summary && (field.endsWith("_id") || key.contains("hash")) && value.length() > 16) { + return value.substring(0, 8) + "…" + value.substring(value.length() - 4); + } + return summary ? shortenValue(value) : value; } private String collectorStatusLabel(CollectorResult.Status status) { switch (status) { - case SUCCESS: return "已采集"; - case EMPTY: return "无数据"; - case ERROR: return "失败"; - case UNSUPPORTED: return "不支持"; - default: return "未知"; + case SUCCESS: return getString(R.string.collector_status_success); + case EMPTY: return getString(R.string.collector_status_empty); + case ERROR: return getString(R.string.collector_status_error); + case UNSUPPORTED: return getString(R.string.collector_status_unsupported); + default: return getString(R.string.status_unknown); } } @@ -910,31 +1152,31 @@ private int collectorStatusBackground(CollectorResult.Status status) { private String getRiskLabel(RiskLevel level) { switch (level) { - case SAFE: return "安全"; - case LOW: return "低风险"; - case MEDIUM: return "中风险"; - case HIGH: return "高风险"; - case DEADLY: return "严重风险"; + case SAFE: return getString(R.string.risk_safe); + case LOW: return getString(R.string.risk_low); + case MEDIUM: return getString(R.string.risk_medium); + case HIGH: return getString(R.string.risk_high); + case DEADLY: return getString(R.string.risk_deadly); case UNKNOWN: - default: return "覆盖不足"; + default: return getString(R.string.risk_unknown); } } private String getRiskDescription(RiskLevel level) { switch (level) { case SAFE: - return "已完成的检测中未发现需要处置的环境风险。"; + return getString(R.string.risk_description_safe); case LOW: - return "发现少量提示性信号,建议结合当前设备与业务场景判断。"; + return getString(R.string.risk_description_low); case MEDIUM: - return "发现需要关注的环境信号,建议查看检测详情后复核。"; + return getString(R.string.risk_description_medium); case HIGH: - return "发现高置信度风险信号,建议限制敏感操作并进一步检查。"; + return getString(R.string.risk_description_high); case DEADLY: - return "发现严重运行环境风险,建议立即停止敏感流程并处置。"; + return getString(R.string.risk_description_deadly); case UNKNOWN: default: - return "部分检测未完成,当前结果不能被解释为安全。"; + return getString(R.string.risk_description_unknown); } } @@ -1017,17 +1259,4 @@ private int dp(int value) { TypedValue.COMPLEX_UNIT_DIP, value, getResources().getDisplayMetrics()); } - private static final class Coverage { - private final int total; - private final int completed; - private final int unknown; - private final int percent; - - private Coverage(int total, int completed, int unknown, int percent) { - this.total = total; - this.completed = completed; - this.unknown = unknown; - this.percent = percent; - } - } } diff --git a/demo/src/main/res/layout/activity_main.xml b/demo/src/main/res/layout/activity_main.xml index 1141ae2..b3109e0 100644 --- a/demo/src/main/res/layout/activity_main.xml +++ b/demo/src/main/res/layout/activity_main.xml @@ -9,7 +9,10 @@ + + #FF15243A #FFF2F2F7 #FFAEAEB2 - #FF8E8E93 + #FFA1A1A6 #3D545458 #3D787880 #330A84FF diff --git a/demo/src/main/res/values/colors.xml b/demo/src/main/res/values/colors.xml index fa96c45..13f1940 100644 --- a/demo/src/main/res/values/colors.xml +++ b/demo/src/main/res/values/colors.xml @@ -3,9 +3,9 @@ #FFFFFFFF - #FF007AFF - #FF0062CC - #FF007AFF + #FF0066CC + #FF0054A8 + #FF0066CC #FFF2F2F7 #FFF8FAFF #FFFFFFFF @@ -14,13 +14,13 @@ #FFF2F7FF #FF1C1C1E #FF636366 - #FF8E8E93 + #FF6E6E73 #1F3C3C43 #1A787880 #1F007AFF - #FF34C759 + #FF248A3D #FF32ADE6 #FFFF9500 #FFFF7A00 diff --git a/demo/src/main/res/values/strings.xml b/demo/src/main/res/values/strings.xml index eb7f286..c0f5a96 100644 --- a/demo/src/main/res/values/strings.xml +++ b/demo/src/main/res/values/strings.xml @@ -5,15 +5,15 @@ 设备风险概览 一次检测,了解当前运行环境与数据采集覆盖情况。 隐私优先 · Privacy First - 数据仅在本机处理,无需 Android 权限;设备标识会先转换为应用级哈希再展示。 + 数据仅在本机处理,无需危险权限;平衡隐私模式只采集应用范围的 Android ID 假名化哈希,不代表匿名化或加密。 综合风险 准备就绪 - READY + 就绪 轻触下方按钮,开始一次仅在本机执行的安全检测。 检测中 - ANALYZING + 检测中 正在并行检查设备环境与采集覆盖,请稍候。 - ERROR + 未完成 风险分 覆盖率 %1$d%% @@ -22,7 +22,7 @@ 正在计算风险分与检测覆盖率 本次结果未生成,可安全重试 环境检测 - 风险项优先展示;轻触条目可查看英文技术证据。 + 风险项优先展示;轻触条目可查看可读说明、原始证据与执行覆盖。 数据采集 仅展示非敏感属性或应用级哈希;轻触查看来源详情。 开始安全检测 @@ -36,6 +36,151 @@ 检测未完成 发生错误:%1$s 未知错误 - 展开详情 - 收起详情 + 详情 + 收起 + SDK %1$s · %2$s · 风险分 %3$d / %4$d(严重阈值) + 完成 %1$d / %2$d 项 · 高危 %3$d · 提醒 %4$d · 未完成 %5$d · 数据字段 %6$d + 环境检测 · %1$d 项 · %2$d 项需关注 · %3$d 项未完成 + 数据一致性提醒 · %1$d 项 + 数据采集 · %1$d 项 · %2$d 项无可用数据 + 检测依据与原始证据 + 采集来源与原始值 + 检测%1$s,已计入覆盖不足;这不代表当前环境安全。 + 已执行 %1$d / %2$d 个子检查,已完成部分未发现异常;仍需留意覆盖不足。 + 已完成检测,未发现异常信号。 + 提示:%1$s。该信号单独出现时不增加风险分。 + 高置信度依据:%1$s。建议限制敏感操作并进一步处置。 + 关注依据:%1$s。建议结合设备和业务场景复核。 + 未提供具体证据 + 执行状态:%1$s + 评分:%1$d / %2$d · 仅提示:%3$s + 子检查覆盖:%1$d / %2$d + 未完成原因:%1$s + 未发现异常信号 + 主要依据:%1$s · %2$s + 是 + 否 + 已完成 + 部分完成 + 不可用 + 已关闭 + 已超时 + 执行失败 + 正常 + 提示 + 检查不可用:%1$s + 复制脱敏报告 + 脱敏报告已复制 + RiskEngine 脱敏报告 + SDK 合成分析 + 尚未生成风险分 + 风险分 %1$d,严重阈值 %2$d + 本次检测未完成,没有风险分 + 另有 %1$d 项检查未完整执行,因此结果不能解释为“全部安全”。 + 无线 ADB 已开启 + USB 调试已开启 + ADB TCP 端口已开启 + 当前应用允许调试 + 检测到调试器连接 + 进程正在被跟踪 + 发现 su 可执行文件 + 发现 Magisk 相关文件 + SELinux 处于宽松模式 + 系统使用测试签名密钥 + 发现 Frida 注入或服务信号 + 发现 Xposed/LSPosed 信号 + 检测到第三方社区 ROM + 运行架构具有模拟器特征 + 系统构建信息具有模拟器特征 + 不同来源返回的数据不一致 + 执行超时 + 系统拒绝访问 + Native 能力不可用 + 当前环境不支持 + 已由配置关闭 + Root 权限风险 + 系统挂载完整性 + 注入与 Hook + 可疑进程 + ADB 调试桥 + 模拟器环境 + 沙箱与虚拟化 + 调试器连接 + 云手机特征 + 第三方 ROM + 多源一致性 + 采集覆盖 · %1$s + 其他检测 + Android 标识哈希 + 系统构建信息 + 屏幕特征 + 应用签名 + 蓝牙能力 + Wi-Fi 能力 + 通信能力 + 安全设置摘要 + ADB 状态 + 容器信号 + DRM 标识哈希 + 启动标识哈希 + 系统属性 + CPU 信息 + 存储容量 + 内核信息 + Hook 内存信号 + 风险评分输入 + 其他采集项 + 当前环境未返回可展示数据。 + 采集失败 · %1$s + 当前设备或运行环境不支持此采集项。 + %1$d 个来源结果一致 + %1$d 个来源结果不一致 + 采集完成,未返回可展示数据。 + 状态:%1$s + 失败原因:%1$s + 无数据 + 摘要 + 状态 + 是否支持 + 低功耗蓝牙 + TCP 端口 + 相关信号 + 数量 + 屏幕宽度 + 屏幕高度 + 屏幕密度 + 数据分区容量 + 共享存储容量 + 系统设置 API + 内容解析器 + 系统查询命令 + 系统属性 %1$s + 已采集 + 无数据 + 失败 + 不支持 + 未知 + 安全 + 低风险 + 中风险 + 高风险 + 严重风险 + 覆盖不足 + 已完成的检测中未发现需要处置的环境风险。 + 发现少量提示性信号,建议结合当前设备与业务场景判断。 + 发现需要关注的环境信号,建议查看检测详情后复核。 + 发现高置信度风险信号,建议限制敏感操作并进一步检查。 + 发现严重运行环境风险,建议立即停止敏感流程并处置。 + 部分检测未完成,当前结果不能被解释为安全。 + RiskEngine SDK %1$s\n风险等级:%2$s\n风险分:%3$d / %4$d\n覆盖:%5$d / %6$d\n高危/提醒/未完成:%7$d/%8$d/%9$d\n检测结果: + \n- %1$s:%2$s + · %1$s + \n\n已脱敏:未包含设备标识哈希、原始系统属性、文件路径或进程 PID。 + %1$s,%2$s,轻触查看详情 + %1$s · %2$s + 应用范围假名 + 技术诊断数据 + 非敏感属性 + 规范值:%1$s + %1$s · 部分 diff --git a/doc/Implementation_Details.md b/doc/Implementation_Details.md index 6a4cd51..a802d74 100644 --- a/doc/Implementation_Details.md +++ b/doc/Implementation_Details.md @@ -1,142 +1,133 @@ -# RiskEngine SDK +# RiskEngine Implementation Details -RiskEngine is an Android SDK for local device fingerprint collection and runtime risk detection. It uses a Java and C++17 dual-layer implementation to collect device signals, run environment detectors, and return a `RiskReport` to the host app. +This document describes the contracts implemented by the current source. See [README.md](../README.md) for integration guidance. -## Project Layout +## 1. Architecture and collection flow ```text -. -├── build.sh -├── LICENSE -├── build.gradle.kts -├── settings.gradle.kts -├── gradle/ -├── demo/ -│ └── src/main/ -├── riskengine-sdk/ -│ ├── build.gradle.kts -│ └── src/main/ -│ ├── java/com/wsttxm/riskenginesdk/ -│ │ ├── collector/ -│ │ ├── core/ -│ │ ├── detector/ -│ │ ├── model/ -│ │ └── util/ -│ └── cpp/ -└── doc/ - ├── Implementation_Details.md - └── Implementation_Details_zh.md +host call + -> RiskEngine lifecycle snapshot + -> single coordinator serializes requests + -> collectionLock (queue wait is inside the deadline) + -> SignalSnapshot.reset() + -> parallel collectors on worker pool + -> parallel detectors on worker pool + -> DataAggregator + -> immutable RiskReport ``` -## Requirements +- `TaskScheduler` separates one coordinator from four workers so a task waiting for a batch cannot starve that batch. +- The coordinator queue is bounded at 16. Rejected asynchronous requests call `onError`. +- `collectTimeout` starts when the request is queued. Work missing the deadline becomes explicit timeout/error coverage. +- Synchronous collection rejects the Android main thread. Asynchronous callbacks are not moved to the main thread. +- `shutdown()` advances the lifecycle generation, cancels work, and clears registries. Results from older generations are not delivered. -| Item | Version | -| --- | --- | -| JDK | 17+ | -| Android Gradle Plugin | 8.13.1 | -| Compile SDK | 36 | -| Min SDK | 30 | -| CMake | 3.22.1+ | -| C++ | C++17 | +## 2. Lifecycle API -## Build +| API | Behavior | +| --- | --- | +| `init(context, config)` | Strict initialization; duplicate calls throw | +| `initIfNeeded(context, config)` | Atomic initialize-if-absent; returns whether this call initialized | +| `collect(callback)` | Full asynchronous collection on SDK threads | +| `collectSync()` | Full synchronous collection; forbidden on main thread | +| `collectReportJson()` | Collect then serialize JSON | +| `reportToJson(report)` | Serialize an existing report without collecting again | +| `getReportJson()` | Deprecated compatibility alias for `collectReportJson()` | +| `shutdown()` | Cancel work and release resources | -```bash -./build.sh sdk -./build.sh demo -./build.sh install -./build.sh all -./build.sh clean -``` +## 3. Result semantics -Equivalent Gradle commands: +### 3.1 Collectors -```bash -./gradlew :riskengine-sdk:assembleRelease -./gradlew :demo:assembleDebug -./gradlew clean -``` +`CollectorResult.Status` is `SUCCESS`, `EMPTY`, `ERROR`, or `UNSUPPORTED`. `compareSources=true` is used only when all values represent the same semantic field from different sources. Inconsistency is recorded by `DeviceFingerprint` and emitted as `multi_source_validation`. -## Usage +### 3.2 Detectors -```java -RiskEngineConfig config = new RiskEngineConfig.Builder() - .collectTimeout(15000) - .build(); +Risk presentation and execution are independent: -RiskEngine.init(context, config); +| Type | Values | +| --- | --- | +| Risk presentation | `DetectionStatus.NORMAL / WARNING / DANGER / UNKNOWN` | +| Execution | `SAFE / RISK / PARTIAL / UNAVAILABLE / DISABLED / TIMEOUT / ERROR` | -RiskEngine.collect(new RiskEngineCallback() { - @Override - public void onSuccess(RiskReport report) { - Log.d("RiskEngine", "Risk: " + report.getOverallRiskLevel()); - Log.d("RiskEngine", "Score: " + report.getRiskScore()); - } +`DetectionResult` also exposes `checksAttempted`, `checksSucceeded`, `checksFailed`, and `failureReasons`. Multi-check root, hook, emulator, and debug detectors record real subcheck coverage. They do not return safe when every key subcheck failed. - @Override - public void onError(Throwable error) { - Log.e("RiskEngine", "Collect failed", error); - } -}); -``` +`isInformational()` marks context that does not enter `riskScore`. Deprecated `isWarnOnly()` remains for compatibility. -Callbacks run on an SDK background thread. `collectTimeout` is shared by the complete request, including serialized queue wait and the collector/detector pipeline. +### 3.3 Report and scoring -Synchronous collection is also available: +| Level | Rule | +| --- | --- | +| `SAFE` | Score 0, no informational/risk result, complete coverage | +| `LOW` | Score 1–3, or informational signals only | +| `MEDIUM` | Score at least 4, or at least two actionable warnings | +| `HIGH` | Score at least 10, or at least one actionable danger | +| `DEADLY` | Score at least 18, at least three actionable dangers, or a hard trigger | +| `UNKNOWN` | No known risk and key coverage is unavailable | -```java -RiskReport report = RiskEngine.collectSync(); -String json = RiskEngine.getReportJson(); -``` +High-confidence Frida PID/port or maps Frida/Gadget combinations can hard-trigger `DEADLY`. Multi-source inconsistency raises a lower known result to at least `MEDIUM`. -Synchronous APIs must not be called from the Android main thread. +- `riskScore` sums actionable results only. +- `maxRiskScore` is technical capacity, not a UI percentage denominator. +- `displayThresholdMaximum` is the severe threshold, 18. +- `reportStatus` is `COMPLETE`, `PARTIAL`, or `UNAVAILABLE`. +- `checkCount` and `completedCheckCount` combine detectors and raw collectors without double-counting `collector:*` signals or synthesized fields. +- `coveragePercent` is completed checks divided by total checks. -Call `RiskEngine.shutdown()` when the host app no longer needs the SDK. Reinitialization requires shutting down the existing instance first. +Aggregation freezes `RiskReport`, `DeviceFingerprint`, and nested collector results. -## Public API +## 4. Privacy configuration -| API | Description | +| Profile | Default identifier collection | | --- | --- | -| `RiskEngine.init(Context, RiskEngineConfig)` | Initialize the SDK. | -| `RiskEngine.collect(RiskEngineCallback)` | Run collection asynchronously. | -| `RiskEngine.collectSync()` | Run collection synchronously. | -| `RiskEngine.getReportJson()` | Collect and return the report as JSON. | -| `RiskEngine.shutdown()` | Release SDK resources. | -| `RiskEngineConfig.Builder.debugLog(boolean)` | Enable or disable SDK logs. | -| `RiskEngineConfig.Builder.collectTimeout(long)` | Set collection timeout from 1 to 120,000 milliseconds. | -| `RiskEngineConfig.Builder.enableRoot/enableHookDetection/...` | Enable or disable individual detector groups. | +| `MINIMAL` | No Android ID, boot ID, or Widevine | +| `BALANCED` | App-scoped Android ID hash only; default | +| `DIAGNOSTIC` | App-scoped Android ID, boot ID, and Widevine hashes | -## Detection Scope +`collectAndroidId`, `collectBootId`, and `collectDrmId` override a profile. The SDK does not collect raw IMEI, IMSI, MAC, SSID, or BSSID. The package name scopes deterministic hashes, making them pseudonyms rather than encryption, anonymization, or an absolute non-reversibility guarantee. -The SDK includes Java and native checks for common Android runtime risks: +## 5. Shared signals and performance -| Area | Examples | -| --- | --- | -| Root | `su`/Magisk artifacts, SELinux and build-context signals | -| Hooking | Xposed/LSPosed, Frida, suspicious maps and processes | -| Emulator | Build props, QEMU artifacts, native emulator markers | -| Debugging | Debug flags, tracer pid, gdb/lldb/IDA artifacts | -| Sandbox/container | Container files, cgroup markers, virtualized paths | -| Device fingerprint | App-scoped Android ID hash, build props, non-sensitive telephony/Wi-Fi/Bluetooth capabilities, screen, APK signature | +Each report resets one `SignalSnapshot`. Typed `SignalResult` values preserve success, empty, unavailable, and error outcomes. Collectors and detectors share cached ADB state, system properties, `/proc/self/maps`, local listening ports, process snapshots, text files, path existence, container signals, and structured shell results. -The library manifest declares no Android permissions and limits package-visibility queries to three known emulator packages. +This removes duplicate ADB collection and lets Java hook/debug checks share maps and port snapshots. Native hook maps remain an independent raw-syscall read path to reduce libc-hook blind spots. -## Output Model +## 6. Shell and native boundaries -`RiskReport` contains: +`ShellExecutor.Result` distinguishes `SUCCESS`, `INVALID_COMMAND`, `TIMEOUT`, `NON_ZERO_EXIT`, `INTERRUPTED`, and `EXECUTION_ERROR`, and carries stdout, stderr, exit code, duration, and failure reason. String-only helpers are deprecated. -| Field | Description | -| --- | --- | -| `fingerprint` | Aggregated device fingerprint values. | -| `detections` | Detector results and evidence. | -| `overallRiskLevel` | Final risk level; `UNKNOWN` when coverage failed and no other risk is present. | -| `riskScore` | Numeric score derived from detector results. | -| `unknownCount` | Number of unavailable, timed-out, or unsupported checks. | -| `timestampMs` | Collection timestamp. | -| `sdkVersion` | SDK version string. | +Native detector methods expose structured `SignalResult` wrappers so Java can distinguish an unavailable library, JNI error, and valid negative result. + +JNI strings do not pass untrusted bytes to `NewStringUTF`. The implementation caps input length, validates UTF-8, replaces malformed sequences, and constructs UTF-16 with `NewString`; C++ allocation failures and JNI string-access failures do not leave exceptions across the boundary. JNI registration failures clear pending exceptions and log the target class. Native builds explicitly enable strong stack protection, release FORTIFY, format checks, RELRO/NOW, a non-executable stack, 16 KiB page alignment, hidden symbols, and compiler warnings. + +## 7. Demo presentation contract + +The Demo presents: + +1. localized risk conclusion, primary reason, and action; +2. score against the severe threshold of 18, coverage, elapsed time, and counts; +3. detector reason, risk/execution status, score, and subcheck coverage; +4. collector labels, readable units, and source details; +5. synthesized SDK fields in a separate section. + +Collection errors use gray and 0% rather than looking like maximum risk. Internal unavailable tokens are translated. Identifier hashes are shortened in summaries and byte capacities are formatted as GB. Copy produces a redacted summary without identifier hashes, raw properties, paths, or PIDs. Haptics fire only for newly delivered results, not Activity recreation. + +## 8. Build, test, and release + +```bash +./gradlew :riskengine-sdk:test :riskengine-sdk:lint +./gradlew :riskengine-sdk:assembleRelease :riskengine-sdk:sourceReleaseJar +./gradlew :demo:lintDebug :demo:assembleDebug +``` -## ProGuard +- PRs and every branch push run unit tests, lint, AAR/POM/sources, Demo Debug APK, and archive integrity checks. +- `integration-test` depends only on the generated release AAR and verifies that a standalone host compiles and links the public API. +- A weekly/manual API 30, 33, 35, and 36 x86_64 emulator matrix runs instrumentation tests. +- `vMAJOR.MINOR.PATCH` tags publish AAR, sources, POM, temporary Debug APK, and SHA-256 checksums. +- The temporary Debug APK is intentionally for sideload testing only. CI stores no production signing key. Different tags can have different temporary keys, requiring uninstall on signature mismatch. -The SDK ships with `consumer-rules.pro`. Host apps do not need extra keep rules for the public API. +## 9. Verification boundaries -Raw Android ID, DRM ID, boot ID, IMEI, IMSI, MAC, SSID, and BSSID values are never included in reports. Stable values are converted to package-scoped SHA-256 values first. +- The x86_64 emulator matrix does not replace real ARM64 OEM hardware. Native anti-hook policy, anonymous executable-region allowlists, and OEM false-positive rates require continuing physical-device regression. +- Advanced adversaries can tamper with local signals. This SDK complements rather than replaces server risk analysis, hardware attestation, or Play Integrity. +- See [Pending_Items.md](./Pending_Items.md) for concrete owners and acceptance criteria. diff --git a/doc/Implementation_Details_zh.md b/doc/Implementation_Details_zh.md index 7ab5100..6d50d2f 100644 --- a/doc/Implementation_Details_zh.md +++ b/doc/Implementation_Details_zh.md @@ -1,142 +1,149 @@ -# RiskEngine SDK +# RiskEngine 实现说明 -RiskEngine 是一个 Android 本地设备指纹采集与运行环境风险检测 SDK。SDK 采用 Java + C++17 双层实现,负责采集设备信号、执行风险检测,并向宿主应用返回 `RiskReport`。 +本文档描述当前源码中的实际契约。公开用法以 [README_zh.md](../README_zh.md) 为准。 -## 项目结构 +## 1. 架构与采集流程 ```text -. -├── build.sh -├── LICENSE -├── build.gradle.kts -├── settings.gradle.kts -├── gradle/ -├── demo/ -│ └── src/main/ -├── riskengine-sdk/ -│ ├── build.gradle.kts -│ └── src/main/ -│ ├── java/com/wsttxm/riskenginesdk/ -│ │ ├── collector/ -│ │ ├── core/ -│ │ ├── detector/ -│ │ ├── model/ -│ │ └── util/ -│ └── cpp/ -└── doc/ - ├── Implementation_Details.md - └── Implementation_Details_zh.md +宿主调用 + -> RiskEngine 生命周期快照 + -> coordinator 单线程串行请求 + -> collectionLock(总时限包含排队) + -> SignalSnapshot.reset() + -> worker 池并行 Collector + -> worker 池并行 Detector + -> DataAggregator + -> 不可变 RiskReport ``` -## 环境要求 +- `TaskScheduler` 使用独立 coordinator 与 4 线程 worker,避免等待批任务时占满 worker。 +- coordinator 队列上限为 16;队列满或关闭时异步接口调用 `onError`。 +- `collectTimeout` 是从请求入队开始计算的总期限。未在期限内返回的任务会生成明确的超时/错误覆盖项。 +- 同步采集禁止在主线程执行。异步回调不自动切换到主线程。 +- `shutdown()` 增加生命周期代次、取消任务并清空注册表;旧代次结果不会交付。 -| 项目 | 版本 | +## 2. 生命周期 API + +| API | 行为 | | --- | --- | -| JDK | 17+ | -| Android Gradle Plugin | 8.13.1 | -| Compile SDK | 36 | -| Min SDK | 30 | -| CMake | 3.22.1+ | -| C++ | C++17 | +| `init(context, config)` | 严格初始化;重复调用抛出 `IllegalStateException` | +| `initIfNeeded(context, config)` | 原子按需初始化;已初始化时返回 `false` | +| `collect(callback)` | 异步完整采集;回调运行在线程池线程 | +| `collectSync()` | 同步完整采集;主线程调用会被拒绝 | +| `collectReportJson()` | 完整采集后序列化 JSON | +| `reportToJson(report)` | 只序列化已有报告,不重复采集 | +| `getReportJson()` | 已弃用兼容入口,等价于 `collectReportJson()` | +| `shutdown()` | 取消任务并释放 SDK 资源 | -## 构建 +## 3. 结果语义 -```bash -./build.sh sdk -./build.sh demo -./build.sh install -./build.sh all -./build.sh clean -``` +### 3.1 Collector -等价 Gradle 命令: +`CollectorResult.Status`: -```bash -./gradlew :riskengine-sdk:assembleRelease -./gradlew :demo:assembleDebug -./gradlew clean -``` +- `SUCCESS`:至少一个值可用; +- `EMPTY`:执行完成但没有可用值; +- `ERROR`:执行失败或未能在总期限内返回; +- `UNSUPPORTED`:平台或 Native 库不支持该信号。 + +`compareSources=true` 仅用于“同一语义值的多个来源”。来源值不一致时,`DeviceFingerprint` 记录字段名,并由聚合器生成 `multi_source_validation` 信号。 -## 使用方式 +### 3.2 Detector -```java -RiskEngineConfig config = new RiskEngineConfig.Builder() - .collectTimeout(15000) - .build(); +风险表现与执行状态互相独立: -RiskEngine.init(context, config); +| 类型 | 枚举 | +| --- | --- | +| 风险表现 | `DetectionStatus.NORMAL / WARNING / DANGER / UNKNOWN` | +| 执行状态 | `SAFE / RISK / PARTIAL / UNAVAILABLE / DISABLED / TIMEOUT / ERROR` | -RiskEngine.collect(new RiskEngineCallback() { - @Override - public void onSuccess(RiskReport report) { - Log.d("RiskEngine", "Risk: " + report.getOverallRiskLevel()); - Log.d("RiskEngine", "Score: " + report.getRiskScore()); - } +`DetectionResult` 还提供 `checksAttempted`、`checksSucceeded`、`checksFailed` 与 `failureReasons`。Root、Hook、Emulator、Debug 等多子检查 Detector 会记录实际覆盖;所有关键子检查都失败时不会返回安全。 - @Override - public void onError(Throwable error) { - Log.e("RiskEngine", "Collect failed", error); - } -}); -``` +`isInformational()` 表示提示性信号:它可以帮助解释环境,但不进入 `riskScore`。旧的 `isWarnOnly()` 保留兼容并已弃用。 -回调运行在 SDK 后台线程。`collectTimeout` 覆盖串行排队等待以及 Collector/Detector 管线,是整次请求共享的总时限。 +### 3.3 报告与评分 -也可以同步采集: +可处置分数阈值固定为: -```java -RiskReport report = RiskEngine.collectSync(); -String json = RiskEngine.getReportJson(); -``` +| 等级 | 条件 | +| --- | --- | +| `SAFE` | 分数 0、无提示/风险且覆盖完整 | +| `LOW` | 分数 1–3,或仅有提示性信号 | +| `MEDIUM` | 分数至少 4,或至少 2 个可处置 Warning | +| `HIGH` | 分数至少 10,或至少 1 个可处置 Danger | +| `DEADLY` | 分数至少 18、至少 3 个可处置 Danger,或硬触发 | +| `UNKNOWN` | 没有已知风险,但关键覆盖不足 | + +Frida PID/端口、maps 中的 Frida/Gadget 等高置信度组合可硬触发 `DEADLY`。多来源不一致会把更低的已知结果至少提升到 `MEDIUM`。 -同步接口不得在 Android 主线程调用。 +- `riskScore`:只累加非 informational 分数; +- `maxRiskScore`:本报告技术容量,不是 UI 百分比分母; +- `displayThresholdMaximum`:固定为严重阈值 18; +- `reportStatus`:`COMPLETE / PARTIAL / UNAVAILABLE`; +- `checkCount` 与 `completedCheckCount`:Detector 加原始 Collector;不重复计算 `collector:*` 覆盖信号或 SDK 合成字段; +- `coveragePercent`:`completedCheckCount / checkCount`。 -宿主应用不再使用 SDK 时调用 `RiskEngine.shutdown()` 释放资源;如需重新初始化,必须先关闭已有实例。 +聚合后 `RiskReport`、`DeviceFingerprint` 与内部 `CollectorResult` 被冻结。 -## 公开 API +## 4. 隐私配置 -| API | 说明 | +| 档位 | 默认标识采集 | | --- | --- | -| `RiskEngine.init(Context, RiskEngineConfig)` | 初始化 SDK。 | -| `RiskEngine.collect(RiskEngineCallback)` | 异步采集。 | -| `RiskEngine.collectSync()` | 同步采集。 | -| `RiskEngine.getReportJson()` | 采集并返回 JSON 结果。 | -| `RiskEngine.shutdown()` | 释放 SDK 资源。 | -| `RiskEngineConfig.Builder.debugLog(boolean)` | 开关 SDK 日志。 | -| `RiskEngineConfig.Builder.collectTimeout(long)` | 设置 1 至 120,000 毫秒的采集超时时间。 | -| `RiskEngineConfig.Builder.enableRoot/enableHookDetection/...` | 分项启用或关闭检测器。 | +| `MINIMAL` | 不采 Android ID、Boot ID、Widevine | +| `BALANCED` | 仅 Android ID 应用范围哈希;默认档位 | +| `DIAGNOSTIC` | Android ID、Boot ID、Widevine 的应用范围哈希 | -## 检测范围 +`collectAndroidId`、`collectBootId`、`collectDrmId` 可以覆盖档位默认值。SDK 不采集原始 IMEI、IMSI、MAC、SSID 或 BSSID。哈希输入包含宿主包名,因此结果是确定性的应用范围假名;这不等同于加密、匿名化或绝对不可逆。 -SDK 内置 Java 与 Native 检测能力,覆盖常见 Android 运行环境风险: +## 5. 共享信号与性能 -| 方向 | 示例 | -| --- | --- | -| Root | `su`/Magisk 痕迹、SELinux 与构建环境信号 | -| Hook | Xposed/LSPosed、Frida、可疑 maps 与进程 | -| 模拟器 | Build 属性、QEMU 特征、Native 模拟器痕迹 | -| 调试 | Debug 标记、TracerPid、gdb/lldb/IDA 痕迹 | -| 沙箱/容器 | 容器文件、cgroup 标记、虚拟化路径 | -| 设备指纹 | Android ID 应用级哈希、Build 属性、非敏感 Telephony/Wi-Fi/Bluetooth 能力、屏幕、APK 签名 | +每次报告开始前重置一个 `SignalSnapshot`。它以 `SignalResult` 保存成功、空值、不可用或错误状态,并在 Collector/Detector 之间共享: -SDK Manifest 不声明 Android 权限,包可见性查询仅限 3 个已知模拟器包。 +- ADB 状态; +- 系统属性; +- `/proc/self/maps`; +- 本机监听端口; +- 进程快照、文本文件、路径存在性与容器信号; +- 同一命令的结构化 Shell 结果。 -## 输出模型 +因此 ADB Collector 与 Detector 不再各执行一遍完整扫描,Hook 与 Debug 也复用 Java maps/端口快照。Native Hook maps 仍通过 raw-syscall 路径独立读取,以减少 libc hook 造成的盲区。 -`RiskReport` 包含: +## 6. Shell 与 Native 边界 -| 字段 | 说明 | -| --- | --- | -| `fingerprint` | 聚合后的设备指纹值。 | -| `detections` | 检测结果与证据。 | -| `overallRiskLevel` | 综合风险等级;覆盖不足且无其他风险时为 `UNKNOWN`。 | -| `riskScore` | 根据检测结果计算出的风险分。 | -| `unknownCount` | 未执行、超时或不支持的检测数量。 | -| `timestampMs` | 采集时间戳。 | -| `sdkVersion` | SDK 版本号。 | +`ShellExecutor.Result` 区分:`SUCCESS`、`INVALID_COMMAND`、`TIMEOUT`、`NON_ZERO_EXIT`、`INTERRUPTED`、`EXECUTION_ERROR`,并包含 `stdout`、`stderr`、`exitCode`、`durationMs` 与 `failureReason`。兼容的字符串接口已弃用。 + +Native 检测公开结构化的 `SignalResult` 包装,Java Detector 可以区分库不可用、JNI 错误与有效的否定结果。 + +JNI 字符串不会直接把不可信字节交给 `NewStringUTF`:实现会限制输入长度、校验 UTF-8、用替换字符处理非法序列,再通过 UTF-16 `NewString` 创建 Java 字符串;C++ 分配失败与 JNI 字符获取失败也不会跨边界遗留异常。JNI 注册失败会清理挂起异常并记录具体类名。Native 构建显式启用强栈保护、Release FORTIFY、格式检查、RELRO/NOW、不可执行栈、16 KiB page 对齐、隐藏符号及编译警告。 + +## 7. Demo 展示契约 + +Demo 按以下层次呈现: + +1. 中文风险结论、主要依据与处置建议; +2. 风险分(以 18 为严重阈值)、覆盖率、耗时和状态计数; +3. Detector 条目:可读依据、风险/执行状态、评分与子检查覆盖; +4. Collector 条目:中文字段名、可读单位与来源详情; +5. SDK 合成字段独立分区。 + +错误状态使用灰色和 0% 进度,不与最高风险混淆。内部不可用 token 不直接展示。标识哈希在摘要中缩写,字节容量转换为 GB。复制功能只输出脱敏摘要,不包含标识哈希、原始系统属性、路径或 PID。触觉反馈只在新结果到达时触发,Activity 重建不会重放。 + +## 8. 构建、测试与发布 + +```bash +./gradlew :riskengine-sdk:test :riskengine-sdk:lint +./gradlew :riskengine-sdk:assembleRelease :riskengine-sdk:sourceReleaseJar +./gradlew :demo:lintDebug :demo:assembleDebug +``` -## ProGuard +- PR 与所有分支 Push 执行单测、Lint、AAR、POM、sources JAR 和 Demo Debug APK 构建及压缩包完整性检查。 +- `integration-test` 只依赖生成的 Release AAR,用于验证独立宿主可以编译和链接公开 API。 +- 每周/手动设备矩阵在 API 30、33、35、36 x86_64 模拟器执行 instrumentation test。 +- `vMAJOR.MINOR.PATCH` Tag 发布 AAR、sources JAR、POM、临时 Debug APK 与 SHA-256 清单。 +- Debug APK 明确只用于侧载测试。流水线不保存生产签名密钥;不同 Tag 的临时证书可能不同,签名冲突时需卸载旧 Demo。 -SDK 已提供 `consumer-rules.pro`,宿主应用无需为公开 API 额外配置 keep 规则。 +## 9. 已知验证边界 -原始 Android ID、DRM ID、Boot ID、IMEI、IMSI、MAC、SSID 和 BSSID 不进入报告;确需稳定关联的值先转换为包名作用域的 SHA-256。 +- x86_64 模拟器矩阵不能替代真实 ARM64 厂商设备;Native 反 Hook 策略、匿名可执行区白名单和厂商 ROM 误报率仍需持续真实设备回归。 +- 本地风险信号可被高级对手对抗,不能替代服务端风控、硬件证明或 Play Integrity;宿主应将本 SDK 作为多源决策的一部分。 +- 具体待解决项与验收方法见 [Pending_Items_zh.md](./Pending_Items_zh.md)。 diff --git a/doc/Pending_Items.md b/doc/Pending_Items.md new file mode 100644 index 0000000..4d3dcc9 --- /dev/null +++ b/doc/Pending_Items.md @@ -0,0 +1,34 @@ +# RiskEngine Pending Items + +This file contains only work that cannot be safely closed in the current workspace or requires an external device/infrastructure decision. All other remediation is implemented in source, tests, the Demo, or workflows. + +## 1. ARM64/OEM false-positive and adversarial regression + +- Cover Android 30–36 physical devices from major OEMs plus rooted, Magisk, LSPosed, and Frida Gadget/Server positive fixtures. +- Record SDK version, system build, execution coverage, raw evidence, and a human verdict for each run. +- Acceptance: zero strong-risk false positives on clean fixtures and corresponding evidence for every seeded positive fixture. +- Current evidence is an API 36 ARM64 emulator instrumentation and Demo run; it does not replace OEM hardware. + +## 2. Single-source Java/native root paths + +- Java and native lists currently match but remain separately maintained. Passing Java data into native code would weaken the native check's independence under Java-layer hooks. +- Preferred follow-up: generate both a Java class and C++ header from one read-only build-time manifest, with CI drift checks. +- Acceptance: one editable source list and generated outputs that CI refuses to accept when stale. + +## 3. Hook-resistant native task traversal + +- `/proc/self/maps` now uses raw syscalls, while thread directory traversal still uses `opendir/readdir` and can be targeted through libc hooks. +- Evaluate a tested `getdents64` wrapper for every ABI while preserving explicit unavailable/error semantics. +- Acceptance: libc directory hooks cannot silently turn thread checks into safe results; API 30–36 and all four ABIs pass. + +## 4. Hosted workflow and release validation + +- The user explicitly prohibited `git push`, so the new PR CI, scheduled device matrix, and tag Release workflows have not run on GitHub. +- After the first authorized push, use a temporary branch/test tag to validate artifact paths, permissions, Action versions, and release notes, then clean up according to repository policy. +- The temporary Debug-signed APK is intentional and sideload-only. Release notes must continue to state that signatures can change, old builds may need uninstalling, and the APK is not for stores or production. + +## 5. Remote Maven repository selection + +- AAR, sources JAR, POM, and a `maven-publish` publication are ready, but no remote repository or credential policy was selected. GitHub Release files remain the current distribution channel. +- A maintainer must choose GitHub Packages, Maven Central, or an internal repository before credentials and upload tasks can be configured. + diff --git a/doc/Pending_Items_zh.md b/doc/Pending_Items_zh.md new file mode 100644 index 0000000..fd4175e --- /dev/null +++ b/doc/Pending_Items_zh.md @@ -0,0 +1,34 @@ +# RiskEngine 待解决项 + +本文仅记录当前工作区无法安全闭环、或需要外部设备/基础设施决策的事项。其余修复已进入源码、测试、Demo 或工作流。 + +## 1. ARM64/OEM 真机误报与对抗回归 + +- 范围:至少覆盖 Pixel、三星、小米/红米、OPPO/一加、vivo 等 Android 30–36 真机,以及 Root、Magisk、LSPosed、Frida Gadget/Server 的正例设备。 +- 重点:匿名可执行区、AOSP/厂商传感器、通用 fingerprint、容器痕迹、进程可见性和 SELinux 权限差异。 +- 验收:每个机型保存 SDK 版本、系统构建、执行覆盖、原始证据和人工结论;正常设备的强风险误报为 0,预置正例均产生对应证据。 +- 当前证据:本地 API 36 ARM64 模拟器 instrumentation 与 Demo 采集通过;模拟器环境不能替代 OEM 真机。 + +## 2. Java/Native Root 路径名单单源化 + +- 现状:Java 与 Native 名单目前内容一致,但仍各自维护。Native 需要在 Java 层被 Hook 时保持独立检查能力,直接由 Java 传入名单会削弱这一目标。 +- 候选方案:在构建期从一个只读清单生成 Java 类和 C++ header,并增加生成物一致性测试。 +- 验收:源码仓库只有一个可编辑名单;Java/Native 生成物由 CI 校验且不能手工漂移。 + +## 3. Native task 目录遍历抗 Hook + +- 现状:`/proc/self/maps` 已使用 raw syscall 读取;线程目录仍通过 `opendir/readdir` 遍历,libc 被定向 Hook 时可能影响线程名检测。 +- 候选方案:为各 ABI 实现并测试 `getdents64` 包装,同时保留失败状态而非返回安全。 +- 验收:Frida/LSPosed 对 libc 目录 API 的 Hook 不会使线程检测静默变为安全;API 30–36 与 4 ABI 回归通过。 + +## 4. 远程工作流与发布环境验证 + +- 由于本次明确禁止 `git push`,新增 PR CI、周度设备矩阵和 Tag Release 尚未在 GitHub 执行。 +- 首次推送后应在临时分支/测试 Tag 验证 artifact 路径、权限、Action 版本和 Release notes;测试 Tag 完成后按仓库策略清理。 +- 临时 Debug 签名 APK 是既定产物,只用于用户侧载。验收时应确认 Release 页面继续明确签名可能变化、冲突时需卸载旧版、不得用于商店或生产分发。 + +## 5. Maven 远端仓库选择 + +- AAR、sources JAR、POM 和 `maven-publish` publication 已就绪,但未配置远端 Maven 仓库或凭据;当前发布仍以 GitHub Release 文件为准。 +- 需要维护者选择 GitHub Packages、Maven Central 或内部仓库后再增加凭据与发布任务。 + diff --git a/gradle.properties b/gradle.properties index fc46277..5b4c625 100644 --- a/gradle.properties +++ b/gradle.properties @@ -15,3 +15,6 @@ android.useAndroidX=true # Non-transitive R classes android.nonTransitiveRClass=true + +# Local builds are snapshots. Tag release workflows override releaseVersionName. +riskEngineVersion=1.1.0-SNAPSHOT diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3aeb52d..a14295f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,7 +5,7 @@ junitVersion = "1.1.5" espressoCore = "3.5.1" appcompat = "1.6.1" material = "1.10.0" -gson = "2.11.0" +json = "20240303" [libraries] junit = { group = "junit", name = "junit", version.ref = "junit" } @@ -13,7 +13,7 @@ ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitV espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } material = { group = "com.google.android.material", name = "material", version.ref = "material" } -gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" } +json = { group = "org.json", name = "json", version.ref = "json" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } diff --git a/integration-test/build.gradle.kts b/integration-test/build.gradle.kts new file mode 100644 index 0000000..510a0ee --- /dev/null +++ b/integration-test/build.gradle.kts @@ -0,0 +1,30 @@ +plugins { + alias(libs.plugins.android.application) +} + +android { + namespace = "com.wsttxm.riskenginesdk.integrationtest" + compileSdk = 36 + + defaultConfig { + applicationId = "com.wsttxm.riskenginesdk.integrationtest" + minSdk = 30 + targetSdk = 36 + versionCode = 1 + versionName = "1.0" + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } +} + +dependencies { + // Deliberately consume the produced binary rather than project(":riskengine-sdk"). + implementation(files("../riskengine-sdk/build/outputs/aar/riskengine-sdk-release.aar")) +} + +tasks.named("preBuild") { + dependsOn(":riskengine-sdk:assembleRelease") +} diff --git a/integration-test/src/main/AndroidManifest.xml b/integration-test/src/main/AndroidManifest.xml new file mode 100644 index 0000000..7dfcd31 --- /dev/null +++ b/integration-test/src/main/AndroidManifest.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + diff --git a/integration-test/src/main/java/com/wsttxm/riskenginesdk/integrationtest/SmokeActivity.java b/integration-test/src/main/java/com/wsttxm/riskenginesdk/integrationtest/SmokeActivity.java new file mode 100644 index 0000000..fc22210 --- /dev/null +++ b/integration-test/src/main/java/com/wsttxm/riskenginesdk/integrationtest/SmokeActivity.java @@ -0,0 +1,32 @@ +package com.wsttxm.riskenginesdk.integrationtest; + +import android.app.Activity; +import android.os.Bundle; +import android.widget.TextView; + +import com.wsttxm.riskenginesdk.PrivacyProfile; +import com.wsttxm.riskenginesdk.RiskEngine; +import com.wsttxm.riskenginesdk.RiskEngineConfig; + +/** Compile/link smoke test for the standalone release AAR. */ +public final class SmokeActivity extends Activity { + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + RiskEngineConfig config = new RiskEngineConfig.Builder() + .privacyProfile(PrivacyProfile.MINIMAL) + .build(); + RiskEngine.initIfNeeded(this, config); + TextView status = new TextView(this); + status.setText(RiskEngine.isInitialized() ? "RiskEngine AAR loaded" : "Load failed"); + setContentView(status); + } + + @Override + protected void onDestroy() { + if (isFinishing()) { + RiskEngine.shutdown(); + } + super.onDestroy(); + } +} diff --git a/riskengine-sdk/build.gradle.kts b/riskengine-sdk/build.gradle.kts index 17d274c..d1f44c8 100644 --- a/riskengine-sdk/build.gradle.kts +++ b/riskengine-sdk/build.gradle.kts @@ -1,9 +1,12 @@ plugins { alias(libs.plugins.android.library) + id("maven-publish") } -val releaseVersionName = providers.gradleProperty("releaseVersionName").orElse("1.0.0") +val releaseVersionName = providers.gradleProperty("releaseVersionName") + .orElse(providers.gradleProperty("riskEngineVersion")) version = releaseVersionName.get() +group = "com.wsttxm" android { namespace = "com.wsttxm.riskenginesdk" @@ -57,18 +60,33 @@ android { buildConfig = true } - @Suppress("UnstableApiUsage") - testOptions { - unitTests.all { - it.enabled = it.name != "testReleaseUnitTest" + publishing { + singleVariant("release") { + withSourcesJar() } } } -dependencies { - implementation(libs.gson) +afterEvaluate { + publishing { + publications { + create("release") { + from(components["release"]) + groupId = project.group.toString() + artifactId = "riskengine-sdk" + version = project.version.toString() + pom { + name.set("RiskEngine Android SDK") + description.set("On-device Android security risk signal SDK") + } + } + } + } +} +dependencies { testImplementation(libs.junit) + testImplementation(libs.json) androidTestImplementation(libs.ext.junit) androidTestImplementation(libs.espresso.core) } diff --git a/riskengine-sdk/consumer-rules.pro b/riskengine-sdk/consumer-rules.pro index 86b426a..95e8d14 100644 --- a/riskengine-sdk/consumer-rules.pro +++ b/riskengine-sdk/consumer-rules.pro @@ -1,5 +1,5 @@ # Keep the complete source-level SDK contract, including nested builders and -# model fields serialized by Gson. +# Public model accessors and constructors are consumed by host applications. -keep public class com.wsttxm.riskenginesdk.RiskEngine { *; } -keep public class com.wsttxm.riskenginesdk.RiskEngineConfig { *; } -keep public class com.wsttxm.riskenginesdk.RiskEngineConfig$Builder { *; } diff --git a/riskengine-sdk/src/androidTest/java/com/wsttxm/riskenginesdk/RiskEngineIntegrationTest.java b/riskengine-sdk/src/androidTest/java/com/wsttxm/riskenginesdk/RiskEngineIntegrationTest.java index 6cf98fc..4e835bb 100644 --- a/riskengine-sdk/src/androidTest/java/com/wsttxm/riskenginesdk/RiskEngineIntegrationTest.java +++ b/riskengine-sdk/src/androidTest/java/com/wsttxm/riskenginesdk/RiskEngineIntegrationTest.java @@ -1,6 +1,7 @@ package com.wsttxm.riskenginesdk; import android.content.Context; +import android.os.Build; import androidx.test.platform.app.InstrumentationRegistry; import androidx.test.ext.junit.runners.AndroidJUnit4; @@ -9,6 +10,11 @@ import org.junit.runner.RunWith; import static org.junit.Assert.*; +import com.wsttxm.riskenginesdk.model.DetectionResult; +import com.wsttxm.riskenginesdk.model.DetectionStatus; +import com.wsttxm.riskenginesdk.model.RiskReport; + +import java.util.Locale; @RunWith(AndroidJUnit4.class) public class RiskEngineIntegrationTest { @@ -18,20 +24,30 @@ public void sdkInitializesAndCollectsSuccessfully() { Context context = InstrumentationRegistry.getInstrumentation().getTargetContext(); assertNotNull(context); RiskEngineConfig config = new RiskEngineConfig.Builder() - .enableRoot(false) - .enableHookDetection(false) - .enableAdbDetection(false) - .enableEmulatorDetection(false) - .enableSandboxDetection(false) - .enableDebugDetection(false) - .enableCloudPhoneDetection(false) - .enableCustomRomDetection(false) - .collectTimeout(5_000) + .privacyProfile(PrivacyProfile.MINIMAL) + .collectTimeout(15_000) .build(); try { RiskEngine.init(context, config); assertTrue(RiskEngine.isInitialized()); - assertNotNull(RiskEngine.collectSync()); + RiskReport report = RiskEngine.collectSync(); + assertNotNull(report); + assertFalse(report.getDetections().isEmpty()); + assertTrue(report.getCheckCount() > 0); + + String fingerprint = Build.FINGERPRINT.toLowerCase(Locale.ROOT); + if (fingerprint.contains("generic") || fingerprint.contains("emulator")) { + DetectionResult emulator = null; + for (DetectionResult result : report.getDetections()) { + if ("emulator".equals(result.getDetectorName())) { + emulator = result; + break; + } + } + assertNotNull("emulator detector must be registered", emulator); + assertNotEquals("emulator should provide a natural positive signal", + DetectionStatus.NORMAL, emulator.getStatus()); + } } finally { RiskEngine.shutdown(); } diff --git a/riskengine-sdk/src/main/cpp/CMakeLists.txt b/riskengine-sdk/src/main/cpp/CMakeLists.txt index 1158afb..b9eb8fb 100644 --- a/riskengine-sdk/src/main/cpp/CMakeLists.txt +++ b/riskengine-sdk/src/main/cpp/CMakeLists.txt @@ -22,9 +22,22 @@ target_include_directories(riskengine PRIVATE ${CMAKE_CURRENT_SOURCE_DIR} ) +target_compile_options(riskengine PRIVATE + -Wall + -Wextra + -Wformat + -Wformat-security + -fstack-protector-strong + "$<$,$>:-D_FORTIFY_SOURCE=2>" +) + +find_library(android_log_lib log) +target_link_libraries(riskengine PRIVATE ${android_log_lib}) + # Support 4 KiB/16 KiB pages and minimize the exported native attack surface. target_link_options(riskengine PRIVATE "-Wl,-z,max-page-size=16384" "-Wl,-z,relro,-z,now" + "-Wl,-z,noexecstack" "-Wl,--exclude-libs,ALL" ) diff --git a/riskengine-sdk/src/main/cpp/detector/native_hook_detector.cpp b/riskengine-sdk/src/main/cpp/detector/native_hook_detector.cpp index 53f0966..88baed3 100644 --- a/riskengine-sdk/src/main/cpp/detector/native_hook_detector.cpp +++ b/riskengine-sdk/src/main/cpp/detector/native_hook_detector.cpp @@ -2,13 +2,13 @@ #include "../util/syscall_wrapper.h" #include #include +#include #include #include #include #include #include #include -#include #include #include #include @@ -49,7 +49,29 @@ void add_evidence(std::vector &evidence, const std::string &value) std::vector read_maps() { std::vector entries; - std::ifstream maps("/proc/self/maps"); + constexpr size_t kMaxMapsBytes = 2 * 1024 * 1024; + long fd = my_openat(AT_FDCWD, "/proc/self/maps", O_RDONLY, 0); + if (fd < 0) { + return entries; + } + std::string maps_content; + maps_content.reserve(64 * 1024); + char buffer[8192]; + while (maps_content.size() < kMaxMapsBytes) { + size_t remaining = kMaxMapsBytes - maps_content.size(); + long count = my_read(static_cast(fd), buffer, + std::min(sizeof(buffer), remaining)); + if (count < 0 && errno == EINTR) { + continue; + } + if (count <= 0) { + break; + } + maps_content.append(buffer, static_cast(count)); + } + my_close(static_cast(fd)); + + std::istringstream maps(maps_content); std::string line; while (std::getline(maps, line)) { MapEntry entry; diff --git a/riskengine-sdk/src/main/cpp/riskengine_jni.cpp b/riskengine-sdk/src/main/cpp/riskengine_jni.cpp index ac38c6a..4305d37 100644 --- a/riskengine-sdk/src/main/cpp/riskengine_jni.cpp +++ b/riskengine-sdk/src/main/cpp/riskengine_jni.cpp @@ -14,10 +14,99 @@ #include #include +#include #include +#include static jstring toJString(JNIEnv *env, const std::string &str) { - return env->NewStringUTF(str.c_str()); + if (env == nullptr || env->ExceptionCheck()) { + return nullptr; + } + + try { + // NewStringUTF expects JNI modified UTF-8 and may abort under CheckJNI when + // procfs or hooked inputs contain malformed bytes. Decode standard UTF-8 + // ourselves and replace malformed sequences before crossing the JNI edge. + constexpr size_t kMaxInputBytes = 1024 * 1024; + const size_t limit = std::min(str.size(), kMaxInputBytes); + std::vector utf16; + utf16.reserve(limit); + size_t index = 0; + while (index < limit) { + const auto first = static_cast(str[index]); + uint32_t codepoint = 0; + size_t length = 0; + if (first < 0x80) { + codepoint = first; + length = 1; + } else if ((first & 0xE0) == 0xC0) { + codepoint = first & 0x1F; + length = 2; + } else if ((first & 0xF0) == 0xE0) { + codepoint = first & 0x0F; + length = 3; + } else if ((first & 0xF8) == 0xF0) { + codepoint = first & 0x07; + length = 4; + } else { + utf16.push_back(0xFFFD); + ++index; + continue; + } + + bool valid = index + length <= limit; + for (size_t offset = 1; valid && offset < length; ++offset) { + const auto continuation = static_cast(str[index + offset]); + if ((continuation & 0xC0) != 0x80) { + valid = false; + } else { + codepoint = (codepoint << 6) | (continuation & 0x3F); + } + } + const bool overlong = (length == 2 && codepoint < 0x80) + || (length == 3 && codepoint < 0x800) + || (length == 4 && codepoint < 0x10000); + if (!valid || overlong || codepoint > 0x10FFFF + || (codepoint >= 0xD800 && codepoint <= 0xDFFF)) { + utf16.push_back(0xFFFD); + ++index; + continue; + } + + if (codepoint <= 0xFFFF) { + utf16.push_back(static_cast(codepoint)); + } else { + codepoint -= 0x10000; + utf16.push_back(static_cast(0xD800 + (codepoint >> 10))); + utf16.push_back(static_cast(0xDC00 + (codepoint & 0x3FF))); + } + index += length; + } + + jstring result = env->NewString( + utf16.empty() ? nullptr : utf16.data(), + static_cast(utf16.size())); + if (result == nullptr && env->ExceptionCheck()) { + // Native collectors are best-effort. Never leave a pending JNI + // exception that would abort a subsequent framework call. + env->ExceptionClear(); + } + return result; + } catch (...) { + // Never let a C++ allocation failure cross the JNI boundary. + const jchar emptyValue = 0; + jstring empty = env->NewString(&emptyValue, 0); + if (empty == nullptr && env->ExceptionCheck()) { + env->ExceptionClear(); + } + return empty; + } +} + +static void clearPendingException(JNIEnv *env) { + if (env != nullptr && env->ExceptionCheck()) { + env->ExceptionClear(); + } } static bool isAllowedSystemProperty(const std::string &name) { @@ -46,7 +135,10 @@ static jstring jni_getBootId(JNIEnv *env, jclass) { static jstring jni_getSystemProperty(JNIEnv *env, jclass, jstring jname) { if (jname == nullptr) return toJString(env, ""); const char *name = env->GetStringUTFChars(jname, nullptr); - if (name == nullptr) return toJString(env, ""); + if (name == nullptr) { + clearPendingException(env); + return toJString(env, ""); + } std::string propertyName(name); env->ReleaseStringUTFChars(jname, name); if (!isAllowedSystemProperty(propertyName)) { @@ -63,7 +155,10 @@ static jstring jni_getCpuInfo(JNIEnv *env, jclass) { static jlong jni_getDiskSize(JNIEnv *env, jclass, jstring jpath) { if (jpath == nullptr) return static_cast(-1); const char *path = env->GetStringUTFChars(jpath, nullptr); - if (path == nullptr) return static_cast(-1); + if (path == nullptr) { + clearPendingException(env); + return static_cast(-1); + } long long size = get_disk_total_size(path); env->ReleaseStringUTFChars(jpath, path); return (jlong) size; diff --git a/riskengine-sdk/src/main/cpp/util/custom_jni_register.cpp b/riskengine-sdk/src/main/cpp/util/custom_jni_register.cpp index 17ffc1f..95ea98e 100644 --- a/riskengine-sdk/src/main/cpp/util/custom_jni_register.cpp +++ b/riskengine-sdk/src/main/cpp/util/custom_jni_register.cpp @@ -1,12 +1,27 @@ #include "custom_jni_register.h" +#include + +static constexpr const char *kLogTag = "RiskEngine-JNI"; bool custom_register_natives(JNIEnv *env, const char *class_name, const JNINativeMethod *methods, int numMethods) { jclass clazz = env->FindClass(class_name); if (clazz == nullptr) { + if (env->ExceptionCheck()) { + env->ExceptionClear(); + } + __android_log_print(ANDROID_LOG_ERROR, kLogTag, + "FindClass failed for %s", class_name); return false; } jint result = env->RegisterNatives(clazz, methods, numMethods); + if (result != JNI_OK) { + if (env->ExceptionCheck()) { + env->ExceptionClear(); + } + __android_log_print(ANDROID_LOG_ERROR, kLogTag, + "RegisterNatives failed for %s: %d", class_name, result); + } env->DeleteLocalRef(clazz); return result == JNI_OK; } diff --git a/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/PrivacyProfile.java b/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/PrivacyProfile.java new file mode 100644 index 0000000..eb769d5 --- /dev/null +++ b/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/PrivacyProfile.java @@ -0,0 +1,11 @@ +package com.wsttxm.riskenginesdk; + +/** Controls optional identifier collection while leaving risk detection enabled. */ +public enum PrivacyProfile { + /** No persistent or resettable identifiers are collected. */ + MINIMAL, + /** App-scoped Android ID hash only; the default for normal integrations. */ + BALANCED, + /** Adds boot and Widevine hashes for opt-in troubleshooting. */ + DIAGNOSTIC +} diff --git a/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/RiskEngine.java b/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/RiskEngine.java index 2e79d4d..9900d7f 100644 --- a/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/RiskEngine.java +++ b/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/RiskEngine.java @@ -3,18 +3,18 @@ import android.content.Context; import android.os.Looper; -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; import com.wsttxm.riskenginesdk.collector.BaseCollector; import com.wsttxm.riskenginesdk.collector.CollectorRegistry; import com.wsttxm.riskenginesdk.core.DataAggregator; import com.wsttxm.riskenginesdk.core.TaskScheduler; +import com.wsttxm.riskenginesdk.core.SignalSnapshot; import com.wsttxm.riskenginesdk.detector.BaseDetector; import com.wsttxm.riskenginesdk.detector.DetectorRegistry; import com.wsttxm.riskenginesdk.model.CollectorResult; import com.wsttxm.riskenginesdk.model.DetectionResult; import com.wsttxm.riskenginesdk.model.RiskReport; import com.wsttxm.riskenginesdk.util.CLog; +import com.wsttxm.riskenginesdk.util.RiskReportJsonSerializer; import java.util.ArrayList; import java.util.HashSet; @@ -36,8 +36,8 @@ public final class RiskEngine { private CollectorRegistry collectorRegistry; private DetectorRegistry detectorRegistry; private DataAggregator dataAggregator; - private Gson gson; private ReentrantLock collectionLock; + private SignalSnapshot signalSnapshot; private volatile boolean initialized; private volatile long lifecycleGeneration; @@ -58,6 +58,18 @@ public static void init(Context context, RiskEngineConfig config) { getInstance().doInit(context, config); } + /** Atomically initializes the singleton once. Returns true when this call initialized it. */ + public static boolean initIfNeeded(Context context, RiskEngineConfig config) { + RiskEngine engine = getInstance(); + synchronized (engine.lifecycleLock) { + if (engine.initialized) { + return false; + } + engine.doInit(context, config); + return true; + } + } + private void doInit(Context context, RiskEngineConfig requestedConfig) { if (context == null) { throw new IllegalArgumentException("context must not be null"); @@ -77,10 +89,10 @@ private void doInit(Context context, RiskEngineConfig requestedConfig) { config = requestedConfig; CLog.setEnabled(config.isDebugLog()); taskScheduler = new TaskScheduler(); - collectorRegistry = new CollectorRegistry(appContext); - detectorRegistry = new DetectorRegistry(appContext, config); + signalSnapshot = new SignalSnapshot(appContext); + collectorRegistry = new CollectorRegistry(appContext, config, signalSnapshot); + detectorRegistry = new DetectorRegistry(appContext, config, signalSnapshot); dataAggregator = new DataAggregator(); - gson = new GsonBuilder().create(); collectionLock = new ReentrantLock(true); lifecycleGeneration++; initialized = true; @@ -157,6 +169,7 @@ private RiskReport doCollectSync(long startNanos, long expectedGeneration) { } try { ensureCollectionActive(snapshot); + snapshot.signalSnapshot.reset(); List collectors = snapshot.collectorRegistry.getCollectors(); List collectorResults = snapshot.scheduler.submitAllAndWait( @@ -231,8 +244,8 @@ public static void shutdown() { engine.collectorRegistry = null; engine.detectorRegistry = null; engine.dataAggregator = null; - engine.gson = null; engine.collectionLock = null; + engine.signalSnapshot = null; engine.config = null; engine.appContext = null; } @@ -242,17 +255,26 @@ public static void shutdown() { CLog.i("RiskEngine shutdown"); } + /** Collects a fresh report and serializes it. */ + public static String collectReportJson() { + return RiskReportJsonSerializer.serialize(getInstance().doCollectSync()); + } + + /** + * @deprecated The old name obscures that a full collection is performed. + * Use {@link #collectReportJson()}. + */ + @Deprecated public static String getReportJson() { - RiskEngine engine = getInstance(); - Gson serializer; - synchronized (engine.lifecycleLock) { - serializer = engine.gson; - } - if (serializer == null) { - throw new IllegalStateException("RiskEngine not initialized"); + return collectReportJson(); + } + + /** Serializes an existing report without performing another collection. */ + public static String reportToJson(RiskReport report) { + if (report == null) { + throw new IllegalArgumentException("report must not be null"); } - RiskReport report = engine.doCollectSync(); - return serializer.toJson(report); + return RiskReportJsonSerializer.serialize(report); } public static boolean isInitialized() { @@ -266,7 +288,7 @@ private EngineSnapshot snapshotState() { } return new EngineSnapshot( config, taskScheduler, collectorRegistry, detectorRegistry, dataAggregator, - collectionLock, lifecycleGeneration); + collectionLock, signalSnapshot, lifecycleGeneration); } } @@ -309,8 +331,8 @@ private static void addMissingDetectionResults(List expected, } for (BaseDetector detector : expected) { if (!completed.contains(detector.getName())) { - actual.add(DetectionResult.unavailable( - detector.getName(), "timeout_or_execution_failure")); + actual.add(DetectionResult.timeout( + detector.getName(), "collection_deadline_exceeded")); } } } @@ -333,6 +355,7 @@ private static final class EngineSnapshot { private final DetectorRegistry detectorRegistry; private final DataAggregator dataAggregator; private final ReentrantLock collectionLock; + private final SignalSnapshot signalSnapshot; private final long generation; private EngineSnapshot(RiskEngineConfig config, @@ -341,6 +364,7 @@ private EngineSnapshot(RiskEngineConfig config, DetectorRegistry detectorRegistry, DataAggregator dataAggregator, ReentrantLock collectionLock, + SignalSnapshot signalSnapshot, long generation) { this.config = config; this.scheduler = scheduler; @@ -348,6 +372,7 @@ private EngineSnapshot(RiskEngineConfig config, this.detectorRegistry = detectorRegistry; this.dataAggregator = dataAggregator; this.collectionLock = collectionLock; + this.signalSnapshot = signalSnapshot; this.generation = generation; } } diff --git a/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/RiskEngineCallback.java b/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/RiskEngineCallback.java index d8d3fab..8e1249e 100644 --- a/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/RiskEngineCallback.java +++ b/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/RiskEngineCallback.java @@ -3,6 +3,8 @@ import com.wsttxm.riskenginesdk.model.RiskReport; public interface RiskEngineCallback { + /** Invoked on a RiskEngine coordinator thread, never implicitly on the main thread. */ void onSuccess(RiskReport report); + /** Invoked on the calling or coordinator thread depending on where the failure occurs. */ void onError(Throwable error); } diff --git a/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/RiskEngineConfig.java b/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/RiskEngineConfig.java index 7b24255..6c2d5f3 100644 --- a/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/RiskEngineConfig.java +++ b/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/RiskEngineConfig.java @@ -14,6 +14,10 @@ public final class RiskEngineConfig { private final boolean enableAdbDetection; private final boolean debugLog; private final long collectTimeoutMs; + private final PrivacyProfile privacyProfile; + private final boolean collectAndroidId; + private final boolean collectBootId; + private final boolean collectDrmId; private RiskEngineConfig(Builder builder) { this.enableRoot = builder.enableRoot; @@ -26,6 +30,16 @@ private RiskEngineConfig(Builder builder) { this.enableAdbDetection = builder.enableAdbDetection; this.debugLog = builder.debugLog; this.collectTimeoutMs = builder.collectTimeoutMs; + this.privacyProfile = builder.privacyProfile; + this.collectAndroidId = builder.collectAndroidId != null + ? builder.collectAndroidId + : builder.privacyProfile != PrivacyProfile.MINIMAL; + this.collectBootId = builder.collectBootId != null + ? builder.collectBootId + : builder.privacyProfile == PrivacyProfile.DIAGNOSTIC; + this.collectDrmId = builder.collectDrmId != null + ? builder.collectDrmId + : builder.privacyProfile == PrivacyProfile.DIAGNOSTIC; } public static final class Builder { @@ -39,6 +53,10 @@ public static final class Builder { private boolean enableAdbDetection = true; private boolean debugLog; private long collectTimeoutMs = 10_000; + private PrivacyProfile privacyProfile = PrivacyProfile.BALANCED; + private Boolean collectAndroidId; + private Boolean collectBootId; + private Boolean collectDrmId; public Builder enableRoot(boolean value) { enableRoot = value; return this; } public Builder enableHookDetection(boolean value) { enableHookDetection = value; return this; } @@ -49,6 +67,14 @@ public static final class Builder { public Builder enableCustomRomDetection(boolean value) { enableCustomRomDetection = value; return this; } public Builder enableAdbDetection(boolean value) { enableAdbDetection = value; return this; } public Builder debugLog(boolean value) { debugLog = value; return this; } + public Builder privacyProfile(PrivacyProfile value) { + if (value == null) throw new IllegalArgumentException("privacyProfile must not be null"); + privacyProfile = value; + return this; + } + public Builder collectAndroidId(boolean value) { collectAndroidId = value; return this; } + public Builder collectBootId(boolean value) { collectBootId = value; return this; } + public Builder collectDrmId(boolean value) { collectDrmId = value; return this; } public Builder collectTimeout(long timeoutMs) { if (timeoutMs <= 0 || timeoutMs > MAX_COLLECT_TIMEOUT_MS) { @@ -75,4 +101,8 @@ public RiskEngineConfig build() { public boolean isEnableAdbDetection() { return enableAdbDetection; } public boolean isDebugLog() { return debugLog; } public long getCollectTimeoutMs() { return collectTimeoutMs; } + public PrivacyProfile getPrivacyProfile() { return privacyProfile; } + public boolean isCollectAndroidId() { return collectAndroidId; } + public boolean isCollectBootId() { return collectBootId; } + public boolean isCollectDrmId() { return collectDrmId; } } diff --git a/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/collector/CollectorRegistry.java b/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/collector/CollectorRegistry.java index 34ea871..3a548c9 100644 --- a/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/collector/CollectorRegistry.java +++ b/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/collector/CollectorRegistry.java @@ -4,6 +4,8 @@ import com.wsttxm.riskenginesdk.collector.java_layer.*; import com.wsttxm.riskenginesdk.collector.native_layer.NativeCollectorBridge; +import com.wsttxm.riskenginesdk.RiskEngineConfig; +import com.wsttxm.riskenginesdk.core.SignalSnapshot; import java.util.ArrayList; import java.util.Collections; @@ -13,22 +15,36 @@ public class CollectorRegistry { private final List collectors = new ArrayList<>(); public CollectorRegistry(Context context) { + this(context, new RiskEngineConfig.Builder().build(), new SignalSnapshot(context)); + } + + public CollectorRegistry(Context context, RiskEngineConfig config) { + this(context, config, new SignalSnapshot(context)); + } + + public CollectorRegistry(Context context, RiskEngineConfig config, SignalSnapshot signals) { // Java layer collectors - collectors.add(new AndroidIdCollector(context)); + if (config.isCollectAndroidId()) { + collectors.add(new AndroidIdCollector(context, signals)); + } collectors.add(new BuildPropsCollector(context)); collectors.add(new ScreenInfoCollector(context)); collectors.add(new ApkSignatureCollector(context)); - collectors.add(new BluetoothMacCollector(context)); + collectors.add(new BluetoothCapabilityCollector(context)); collectors.add(new WifiInfoCollector(context)); collectors.add(new TelephonyCollector(context)); collectors.add(new SettingsCollector(context)); - collectors.add(new AdbStateCollector(context)); - collectors.add(new ContainerSignalCollector(context)); + collectors.add(new AdbStateCollector(context, signals)); + collectors.add(new ContainerSignalCollector(context, signals)); // Native layer collectors (via JNI bridge) - NativeCollectorBridge nativeBridge = new NativeCollectorBridge(context); - collectors.add(nativeBridge.getDrmCollector()); - collectors.add(nativeBridge.getBootIdCollector()); + NativeCollectorBridge nativeBridge = new NativeCollectorBridge(context, signals); + if (config.isCollectDrmId()) { + collectors.add(nativeBridge.getDrmCollector()); + } + if (config.isCollectBootId()) { + collectors.add(nativeBridge.getBootIdCollector()); + } collectors.add(nativeBridge.getSystemPropertyCollector()); collectors.add(nativeBridge.getCpuInfoCollector()); collectors.add(nativeBridge.getDiskSizeCollector()); diff --git a/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/collector/java_layer/AdbStateCollector.java b/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/collector/java_layer/AdbStateCollector.java index af0c818..3871a31 100644 --- a/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/collector/java_layer/AdbStateCollector.java +++ b/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/collector/java_layer/AdbStateCollector.java @@ -5,11 +5,18 @@ import com.wsttxm.riskenginesdk.collector.BaseCollector; import com.wsttxm.riskenginesdk.model.CollectorResult; import com.wsttxm.riskenginesdk.util.AdbInspector; +import com.wsttxm.riskenginesdk.core.SignalSnapshot; public class AdbStateCollector extends BaseCollector { + private final SignalSnapshot signals; public AdbStateCollector(Context context) { + this(context, new SignalSnapshot(context)); + } + + public AdbStateCollector(Context context, SignalSnapshot signals) { super(context); + this.signals = signals; } @Override @@ -19,8 +26,13 @@ public String getName() { @Override protected void collect(CollectorResult result) { - AdbInspector.Snapshot snapshot = AdbInspector.collect(context); + AdbInspector.Snapshot snapshot = AdbInspector.collect(signals); result.addValue("summary", snapshot.getSummary()); + result.addValue("checks_completed", snapshot.getChecksSucceeded() + + "/" + snapshot.getChecksAttempted()); + if (!snapshot.getFailureReasons().isEmpty()) { + result.addValue("check_failures", String.join(",", snapshot.getFailureReasons())); + } if (snapshot.getTcpPort() > 0) { result.addValue("tcp_port", String.valueOf(snapshot.getTcpPort())); } diff --git a/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/collector/java_layer/AndroidIdCollector.java b/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/collector/java_layer/AndroidIdCollector.java index 4a912fd..2723321 100644 --- a/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/collector/java_layer/AndroidIdCollector.java +++ b/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/collector/java_layer/AndroidIdCollector.java @@ -7,15 +7,24 @@ import android.provider.Settings; import com.wsttxm.riskenginesdk.collector.BaseCollector; +import com.wsttxm.riskenginesdk.core.SignalSnapshot; import com.wsttxm.riskenginesdk.model.CollectorResult; import com.wsttxm.riskenginesdk.util.CLog; import com.wsttxm.riskenginesdk.util.PrivacyUtils; import com.wsttxm.riskenginesdk.util.ShellExecutor; public class AndroidIdCollector extends BaseCollector { + private static final String CONTENT_QUERY_COMMAND = + "content query --uri content://settings/secure --where \"name=\\'android_id\\'\""; + private final SignalSnapshot signals; public AndroidIdCollector(Context context) { + this(context, new SignalSnapshot(context)); + } + + public AndroidIdCollector(Context context, SignalSnapshot signals) { super(context); + this.signals = signals; } @Override @@ -65,15 +74,17 @@ private void collectViaContentResolver(CollectorResult result) { private void collectViaContentQuery(CollectorResult result) { try { - String raw = ShellExecutor.execute( - "content query --uri content://settings/secure --where \"name=\\'android_id\\'\""); - if (raw != null && raw.contains("value=")) { + ShellExecutor.Result query = signals.getShellResult(CONTENT_QUERY_COMMAND); + String raw = query.getStdout(); + if (query.isSuccess() && raw.contains("value=")) { int idx = raw.indexOf("value="); String value = raw.substring(idx + 6).trim(); if (value.contains(",")) { value = value.substring(0, value.indexOf(",")); } - addHashed(result, "content_query", value); + if (!"NULL".equalsIgnoreCase(value) && !"null".equalsIgnoreCase(value)) { + addHashed(result, "content_query", value); + } } } catch (Exception e) { CLog.e("AndroidId content_query failed", e); diff --git a/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/collector/java_layer/BluetoothMacCollector.java b/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/collector/java_layer/BluetoothCapabilityCollector.java similarity index 78% rename from riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/collector/java_layer/BluetoothMacCollector.java rename to riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/collector/java_layer/BluetoothCapabilityCollector.java index 118331b..aef21c0 100644 --- a/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/collector/java_layer/BluetoothMacCollector.java +++ b/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/collector/java_layer/BluetoothCapabilityCollector.java @@ -6,9 +6,9 @@ import com.wsttxm.riskenginesdk.collector.BaseCollector; import com.wsttxm.riskenginesdk.model.CollectorResult; -/** Collects non-sensitive Bluetooth capability information; local MAC is inaccessible to apps. */ -public class BluetoothMacCollector extends BaseCollector { - public BluetoothMacCollector(Context context) { +/** Collects non-sensitive Bluetooth capability information; no MAC is requested. */ +public class BluetoothCapabilityCollector extends BaseCollector { + public BluetoothCapabilityCollector(Context context) { super(context); } diff --git a/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/collector/java_layer/ContainerSignalCollector.java b/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/collector/java_layer/ContainerSignalCollector.java index 6a46e2f..710282f 100644 --- a/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/collector/java_layer/ContainerSignalCollector.java +++ b/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/collector/java_layer/ContainerSignalCollector.java @@ -4,14 +4,21 @@ import com.wsttxm.riskenginesdk.collector.BaseCollector; import com.wsttxm.riskenginesdk.model.CollectorResult; -import com.wsttxm.riskenginesdk.util.ProcfsUtils; +import com.wsttxm.riskenginesdk.core.SignalResult; +import com.wsttxm.riskenginesdk.core.SignalSnapshot; import java.util.List; public class ContainerSignalCollector extends BaseCollector { + private final SignalSnapshot signals; public ContainerSignalCollector(Context context) { + this(context, new SignalSnapshot(context)); + } + + public ContainerSignalCollector(Context context, SignalSnapshot signals) { super(context); + this.signals = signals; } @Override @@ -21,10 +28,15 @@ public String getName() { @Override protected void collect(CollectorResult result) { - List signals = ProcfsUtils.collectContainerSignals(context); - result.addValue("summary", signals.isEmpty() ? "none" : "present"); - if (!signals.isEmpty()) { - result.addValue("signals", String.join(",", signals)); + SignalResult> snapshot = signals.getContainerSignals(); + if (!snapshot.isSuccess() || snapshot.getValue() == null) { + result.markError(snapshot.getFailureReason()); + return; + } + List values = snapshot.getValue(); + result.addValue("summary", values.isEmpty() ? "none" : "present"); + if (!values.isEmpty()) { + result.addValue("signals", String.join(",", values)); } } } diff --git a/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/collector/java_layer/SettingsCollector.java b/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/collector/java_layer/SettingsCollector.java index 37ee671..cb763b6 100644 --- a/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/collector/java_layer/SettingsCollector.java +++ b/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/collector/java_layer/SettingsCollector.java @@ -6,15 +6,9 @@ import com.wsttxm.riskenginesdk.collector.BaseCollector; import com.wsttxm.riskenginesdk.model.CollectorResult; import com.wsttxm.riskenginesdk.util.CLog; -import com.wsttxm.riskenginesdk.util.PrivacyUtils; public class SettingsCollector extends BaseCollector { - private static final String[] SECURE_KEYS = { - "bluetooth_address", - "android_id" - }; - public SettingsCollector(Context context) { super(context); } @@ -26,17 +20,12 @@ public String getName() { @Override protected void collect(CollectorResult result) { - for (String key : SECURE_KEYS) { - try { - String value = Settings.Secure.getString(context.getContentResolver(), key); - if (value != null) { - result.addValue("secure_" + key, - PrivacyUtils.hashIdentifier(context, value)); - } - } catch (Exception e) { - CLog.e("Settings.Secure." + key + " failed", e); - } + try { + int development = Settings.Global.getInt(context.getContentResolver(), + Settings.Global.DEVELOPMENT_SETTINGS_ENABLED, 0); + result.addValue("development_settings_enabled", String.valueOf(development == 1)); + } catch (Exception e) { + CLog.e("Development settings collection failed", e); } - } } diff --git a/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/collector/native_layer/NativeCollectorBridge.java b/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/collector/native_layer/NativeCollectorBridge.java index ba14bb0..8579b07 100644 --- a/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/collector/native_layer/NativeCollectorBridge.java +++ b/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/collector/native_layer/NativeCollectorBridge.java @@ -6,11 +6,14 @@ import com.wsttxm.riskenginesdk.model.CollectorResult; import com.wsttxm.riskenginesdk.util.CLog; import com.wsttxm.riskenginesdk.util.PrivacyUtils; +import com.wsttxm.riskenginesdk.core.SignalResult; +import com.wsttxm.riskenginesdk.core.SignalSnapshot; import java.util.Set; public class NativeCollectorBridge { private final Context context; + private final SignalSnapshot signals; private static final boolean NATIVE_AVAILABLE; private static final Set ALLOWED_PROPERTIES = Set.of( "service.adb.tcp.port", "persist.adb.tcp.port", @@ -34,8 +37,13 @@ public class NativeCollectorBridge { } public NativeCollectorBridge(Context context) { + this(context, null); + } + + public NativeCollectorBridge(Context context, SignalSnapshot signals) { Context application = context == null ? null : context.getApplicationContext(); this.context = application != null ? application : context; + this.signals = signals; } public static boolean isNativeAvailable() { @@ -72,46 +80,81 @@ public static String getSystemProperty(String name) { private static native int nativeGetTracerPidRaw(); public static boolean checkRoot() { - return callNative(false, NativeCollectorBridge::nativeCheckRootRaw); + return valueOr(checkRootResult(), false); + } + + public static SignalResult checkRootResult() { + return callNativeResult(NativeCollectorBridge::nativeCheckRootRaw); } public static String getRootEvidence() { - return callNative("", NativeCollectorBridge::nativeGetRootEvidenceRaw); + return valueOr(getRootEvidenceResult(), ""); + } + + public static SignalResult getRootEvidenceResult() { + return callNativeResult(NativeCollectorBridge::nativeGetRootEvidenceRaw); } public static String getHookEvidence() { - return callNative("", NativeCollectorBridge::nativeGetHookEvidenceRaw); + return valueOr(getHookEvidenceResult(), ""); + } + + public static SignalResult getHookEvidenceResult() { + return callNativeResult(NativeCollectorBridge::nativeGetHookEvidenceRaw); } public static String checkEmulatorFiles() { - return callNative("", NativeCollectorBridge::nativeCheckEmulatorFilesRaw); + return valueOr(checkEmulatorFilesResult(), ""); + } + + public static SignalResult checkEmulatorFilesResult() { + return callNativeResult(NativeCollectorBridge::nativeCheckEmulatorFilesRaw); } public static int getThermalZoneCount() { - return callNative(-1, NativeCollectorBridge::nativeGetThermalZoneCountRaw); + return valueOr(getThermalZoneCountResult(), -1); + } + + public static SignalResult getThermalZoneCountResult() { + return callNativeResult(NativeCollectorBridge::nativeGetThermalZoneCountRaw); } public static String getRuntimeArch() { - return callNative("", NativeCollectorBridge::nativeGetRuntimeArchRaw); + return valueOr(getRuntimeArchResult(), ""); + } + + public static SignalResult getRuntimeArchResult() { + return callNativeResult(NativeCollectorBridge::nativeGetRuntimeArchRaw); } public static int getTracerPid() { - return callNative(-1, NativeCollectorBridge::nativeGetTracerPidRaw); + return valueOr(getTracerPidResult(), -1); } - private static T callNative(T fallback, NativeCall call) { + public static SignalResult getTracerPidResult() { + return callNativeResult(NativeCollectorBridge::nativeGetTracerPidRaw); + } + + private static SignalResult callNativeResult(NativeCall call) { if (!NATIVE_AVAILABLE) { - return fallback; + return SignalResult.unavailable("native_library_unavailable"); } try { T result = call.run(); - return result == null ? fallback : result; + return result == null + ? SignalResult.error("native_returned_null") + : SignalResult.success(result); } catch (Exception | LinkageError e) { CLog.e("Native detector call failed", e); - return fallback; + return SignalResult.error(e.getClass().getSimpleName()); } } + private static T valueOr(SignalResult result, T fallback) { + return result != null && result.isSuccess() && result.getValue() != null + ? result.getValue() : fallback; + } + private interface NativeCall { T run(); } @@ -170,7 +213,10 @@ protected void collect(CollectorResult result) { "persist.sys.timezone", "gsm.version.baseband" }; for (String prop : props) { - String value = getSystemProperty(prop); + SignalResult cached = signals == null + ? null : signals.getSystemProperty(prop); + String value = cached == null || cached.getValue() == null + ? getSystemProperty(prop) : cached.getValue(); if (value != null && !value.isEmpty()) { result.addValue(prop, value); } diff --git a/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/core/DataAggregator.java b/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/core/DataAggregator.java index a30843b..3a79bdb 100644 --- a/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/core/DataAggregator.java +++ b/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/core/DataAggregator.java @@ -113,7 +113,7 @@ private CollectorResult buildRuntimeIntegrityInputs(List detect } else if (detection.getStatus() == DetectionStatus.WARNING) { warningCount++; } - if (!detection.isWarnOnly()) { + if (!detection.isInformational()) { score += detection.getScore(); } statusMap.add(detection.getDetectorName() + ":" + detection.getStatus()); diff --git a/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/core/SignalResult.java b/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/core/SignalResult.java new file mode 100644 index 0000000..a24490f --- /dev/null +++ b/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/core/SignalResult.java @@ -0,0 +1,40 @@ +package com.wsttxm.riskenginesdk.core; + +/** Typed result for a shared low-level signal. */ +public final class SignalResult { + public enum Status { SUCCESS, EMPTY, UNAVAILABLE, ERROR } + + private final Status status; + private final T value; + private final String failureReason; + private final long timestampMs; + + private SignalResult(Status status, T value, String failureReason) { + this.status = status; + this.value = value; + this.failureReason = failureReason; + this.timestampMs = System.currentTimeMillis(); + } + + public static SignalResult success(T value) { + return new SignalResult<>(Status.SUCCESS, value, null); + } + + public static SignalResult empty(T value) { + return new SignalResult<>(Status.EMPTY, value, null); + } + + public static SignalResult unavailable(String reason) { + return new SignalResult<>(Status.UNAVAILABLE, null, reason); + } + + public static SignalResult error(String reason) { + return new SignalResult<>(Status.ERROR, null, reason); + } + + public Status getStatus() { return status; } + public T getValue() { return value; } + public String getFailureReason() { return failureReason; } + public long getTimestampMs() { return timestampMs; } + public boolean isSuccess() { return status == Status.SUCCESS || status == Status.EMPTY; } +} diff --git a/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/core/SignalSnapshot.java b/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/core/SignalSnapshot.java new file mode 100644 index 0000000..63b8556 --- /dev/null +++ b/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/core/SignalSnapshot.java @@ -0,0 +1,230 @@ +package com.wsttxm.riskenginesdk.core; + +import android.content.Context; + +import com.wsttxm.riskenginesdk.collector.native_layer.NativeCollectorBridge; +import com.wsttxm.riskenginesdk.util.AdbInspector; +import com.wsttxm.riskenginesdk.util.ShellExecutor; +import com.wsttxm.riskenginesdk.util.ProcfsUtils; + +import java.io.BufferedReader; +import java.io.FileReader; +import java.io.File; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +/** Per-report cache for expensive signals shared by collectors and detectors. */ +public final class SignalSnapshot { + private final Context context; + private final ConcurrentMap> systemProperties = + new ConcurrentHashMap<>(); + private final ConcurrentMap> textFiles = + new ConcurrentHashMap<>(); + private final ConcurrentMap shellResults = + new ConcurrentHashMap<>(); + private final ConcurrentMap> pathExistence = + new ConcurrentHashMap<>(); + private volatile SignalResult adbState; + private volatile SignalResult> loopbackListeningPorts; + private volatile SignalResult> selfMaps; + private volatile SignalResult> processes; + private volatile SignalResult> containerSignals; + + public SignalSnapshot(Context context) { + Context application = context == null ? null : context.getApplicationContext(); + this.context = application != null ? application : context; + } + + public void reset() { + systemProperties.clear(); + textFiles.clear(); + shellResults.clear(); + pathExistence.clear(); + adbState = null; + loopbackListeningPorts = null; + selfMaps = null; + processes = null; + containerSignals = null; + } + + public SignalResult getAdbState() { + SignalResult cached = adbState; + if (cached != null) return cached; + synchronized (this) { + cached = adbState; + if (cached == null) { + try { + AdbInspector.Snapshot value = AdbInspector.collectUncached(context, this); + cached = SignalResult.success(value); + } catch (Exception | LinkageError e) { + cached = SignalResult.error(e.getClass().getSimpleName()); + } + adbState = cached; + } + } + return cached; + } + + public SignalResult getSystemProperty(String name) { + return systemProperties.computeIfAbsent(name, this::readSystemProperty); + } + + public SignalResult> getLoopbackListeningPorts() { + SignalResult> cached = loopbackListeningPorts; + if (cached != null) return cached; + synchronized (this) { + cached = loopbackListeningPorts; + if (cached == null) { + try { + cached = SignalResult.success(Collections.unmodifiableSet( + ProcfsUtils.findLoopbackListeningPorts())); + } catch (Exception e) { + cached = SignalResult.error(e.getClass().getSimpleName()); + } + loopbackListeningPorts = cached; + } + } + return cached; + } + + public SignalResult> getSelfMaps() { + SignalResult> cached = selfMaps; + if (cached != null) return cached; + synchronized (this) { + cached = selfMaps; + if (cached == null) { + List lines = new ArrayList<>(); + try (BufferedReader reader = new BufferedReader( + new FileReader("/proc/self/maps"))) { + String line; + while ((line = reader.readLine()) != null) { + lines.add(line); + } + cached = SignalResult.success(Collections.unmodifiableList(lines)); + } catch (Exception e) { + cached = SignalResult.error(e.getClass().getSimpleName()); + } + selfMaps = cached; + } + } + return cached; + } + + public SignalResult getTextFile(String path) { + return textFiles.computeIfAbsent(path, key -> { + try { + File file = new File(key); + if (!file.exists() || !file.canRead()) { + return SignalResult.unavailable("file_unreadable"); + } + return SignalResult.success(ProcfsUtils.readFile(key)); + } catch (Exception e) { + return SignalResult.error(e.getClass().getSimpleName()); + } + }); + } + + public ShellExecutor.Result getShellResult(String command) { + return shellResults.computeIfAbsent(command, ShellExecutor::executeResult); + } + + public boolean pathExists(String path) { + SignalResult result = getPathExists(path); + return result.isSuccess() && Boolean.TRUE.equals(result.getValue()); + } + + public SignalResult getPathExists(String path) { + return pathExistence.computeIfAbsent(path, key -> { + try { + return SignalResult.success(new File(key).exists()); + } catch (SecurityException e) { + return SignalResult.error(e.getClass().getSimpleName()); + } + }); + } + + public SignalResult> getProcesses() { + SignalResult> cached = processes; + if (cached != null) return cached; + synchronized (this) { + cached = processes; + if (cached == null) { + try { + List value = ProcfsUtils.snapshotProcesses(); + cached = SignalResult.success(Collections.unmodifiableList(value)); + } catch (Exception e) { + cached = SignalResult.error(e.getClass().getSimpleName()); + } + processes = cached; + } + } + return cached; + } + + public SignalResult> getContainerSignals() { + SignalResult> cached = containerSignals; + if (cached != null) return cached; + synchronized (this) { + cached = containerSignals; + if (cached == null) { + try { + List signals = new ArrayList<>(); + String[] cgroupPaths = {"/proc/1/cgroup", "/proc/self/cgroup"}; + String[] keywords = {"docker", "lxc", "container", "kubepods", "podman"}; + for (String path : cgroupPaths) { + SignalResult file = getTextFile(path); + String content = file.getValue() == null + ? "" : file.getValue().toLowerCase(java.util.Locale.ROOT); + for (String keyword : keywords) { + if (content.contains(keyword)) { + signals.add("cgroup:" + keyword); + break; + } + } + } + SignalResult mounts = getTextFile("/proc/self/mountinfo"); + if (mounts.getValue() != null + && mounts.getValue().toLowerCase(java.util.Locale.ROOT) + .contains(" overlay ")) { + signals.add("mount_overlay"); + } + SignalResult cmdlineFile = getTextFile("/proc/self/cmdline"); + String cmdline = cmdlineFile.getValue() == null ? "" + : cmdlineFile.getValue().replace('\0', ' ').trim(); + if (!cmdline.isEmpty() && context != null + && !cmdline.contains(context.getPackageName())) { + signals.add("cmdline_mismatch"); + } + cached = SignalResult.success(Collections.unmodifiableList(signals)); + } catch (Exception e) { + cached = SignalResult.error(e.getClass().getSimpleName()); + } + containerSignals = cached; + } + } + return cached; + } + + private SignalResult readSystemProperty(String name) { + if (NativeCollectorBridge.isNativeAvailable()) { + try { + String value = NativeCollectorBridge.getSystemProperty(name); + if (value != null && !value.isBlank()) { + return SignalResult.success(value.trim()); + } + } catch (Exception | LinkageError ignored) { + // Use the status-aware shell fallback below. + } + } + ShellExecutor.Result shell = ShellExecutor.executeResult("getprop " + name); + if (!shell.isSuccess()) { + return SignalResult.unavailable("getprop:" + shell.getStatus()); + } + String value = shell.getStdout().trim(); + return value.isEmpty() ? SignalResult.empty("") : SignalResult.success(value); + } +} diff --git a/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/detector/AdbDetector.java b/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/detector/AdbDetector.java index b0451c7..21dbd58 100644 --- a/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/detector/AdbDetector.java +++ b/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/detector/AdbDetector.java @@ -5,14 +5,21 @@ import com.wsttxm.riskenginesdk.model.DetectionStatus; import com.wsttxm.riskenginesdk.model.RiskLevel; import com.wsttxm.riskenginesdk.util.AdbInspector; +import com.wsttxm.riskenginesdk.core.SignalSnapshot; import java.util.ArrayList; import java.util.List; public class AdbDetector extends BaseDetector { + private final SignalSnapshot signals; public AdbDetector(Context context) { + this(context, new SignalSnapshot(context)); + } + + public AdbDetector(Context context, SignalSnapshot signals) { super(context); + this.signals = signals; } @Override @@ -22,10 +29,13 @@ public String getName() { @Override protected com.wsttxm.riskenginesdk.model.DetectionResult detect() { - AdbInspector.Snapshot snapshot = AdbInspector.collect(context); + AdbInspector.Snapshot snapshot = AdbInspector.collect(signals); List details = new ArrayList<>(snapshot.getDetails()); + CheckCoverage coverage = new CheckCoverage(); + for (int i = 0; i < snapshot.getChecksSucceeded(); i++) coverage.success(); + for (String reason : snapshot.getFailureReasons()) coverage.failure(reason); if (!snapshot.isEnabled()) { - return safe(); + return safe(coverage); } int score = snapshot.isWifiEnabled() ? 4 : 2; @@ -37,7 +47,8 @@ protected com.wsttxm.riskenginesdk.model.DetectionResult detect() { 10, true, details, - String.join("; ", details) + String.join("; ", details), + coverage ); } } diff --git a/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/detector/BaseDetector.java b/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/detector/BaseDetector.java index 7be7405..c2ba7cb 100644 --- a/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/detector/BaseDetector.java +++ b/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/detector/BaseDetector.java @@ -3,11 +3,13 @@ import android.content.Context; import com.wsttxm.riskenginesdk.model.DetectionResult; +import com.wsttxm.riskenginesdk.model.DetectionExecutionStatus; import com.wsttxm.riskenginesdk.model.DetectionStatus; import com.wsttxm.riskenginesdk.model.RiskLevel; import com.wsttxm.riskenginesdk.util.CLog; import java.util.Collections; +import java.util.ArrayList; import java.util.List; import java.util.concurrent.Callable; @@ -29,7 +31,7 @@ public DetectionResult call() { return detect(); } catch (Exception | LinkageError e) { CLog.e("Detector [" + getName() + "] failed", e); - return DetectionResult.unavailable(getName(), e.getClass().getSimpleName()); + return DetectionResult.error(getName(), e.getClass().getSimpleName()); } } @@ -62,4 +64,64 @@ protected DetectionResult result(RiskLevel level, String evidence) { return new DetectionResult(getName(), level, status, score, maxScore, warnOnly, details, evidence); } + + protected DetectionResult result(RiskLevel level, + DetectionStatus status, + int score, + int maxScore, + boolean informational, + List details, + String evidence, + CheckCoverage coverage) { + if (coverage == null || coverage.attempted == 0) { + return DetectionResult.unavailable(getName(), "no_checks_attempted"); + } + if (coverage.succeeded == 0) { + return DetectionResult.unavailable(getName(), coverage.summaryReason()); + } + DetectionExecutionStatus executionStatus; + if (coverage.failed > 0) { + executionStatus = DetectionExecutionStatus.PARTIAL; + } else if (level == RiskLevel.SAFE) { + executionStatus = DetectionExecutionStatus.SAFE; + } else { + executionStatus = DetectionExecutionStatus.RISK; + } + return new DetectionResult( + getName(), level, status, score, maxScore, informational, + executionStatus, coverage.attempted, coverage.succeeded, coverage.failed, + coverage.failureReasons, details, evidence); + } + + protected DetectionResult safe(CheckCoverage coverage) { + return result(RiskLevel.SAFE, DetectionStatus.NORMAL, 0, 10, false, + Collections.emptyList(), "no risk detected", coverage); + } + + /** Tracks whether each logical detector check was actually executed. */ + protected static final class CheckCoverage { + private int attempted; + private int succeeded; + private int failed; + private final List failureReasons = new ArrayList<>(); + + public CheckCoverage() {} + + public void success() { + attempted++; + succeeded++; + } + + public void failure(String reason) { + attempted++; + failed++; + failureReasons.add(reason == null || reason.isBlank() ? "unknown" : reason); + } + + private String summaryReason() { + return failureReasons.isEmpty() + ? "all_checks_failed" + : String.join(",", failureReasons); + } + } } diff --git a/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/detector/CloudPhoneDetector.java b/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/detector/CloudPhoneDetector.java index 44e4084..d033500 100644 --- a/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/detector/CloudPhoneDetector.java +++ b/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/detector/CloudPhoneDetector.java @@ -34,16 +34,23 @@ protected DetectionResult detect() { boolean batteryAvailable = checkBatteryAnomaly(evidence); boolean cameraAvailable = checkCameraCount(evidence); boolean sensorsAvailable = checkSensorCount(evidence); + CheckCoverage coverage = new CheckCoverage(); + if (batteryAvailable) coverage.success(); + else coverage.failure("battery_unavailable"); + if (cameraAvailable) coverage.success(); + else coverage.failure("camera_unavailable"); + if (sensorsAvailable) coverage.success(); + else coverage.failure("sensors_unavailable"); if (!evidence.isEmpty()) { RiskLevel level = evidence.size() >= 3 ? RiskLevel.MEDIUM : RiskLevel.LOW; return result(level, DetectionStatus.WARNING, 1, 10, true, - evidence, String.join("; ", evidence)); + evidence, String.join("; ", evidence), coverage); } if (!batteryAvailable && !cameraAvailable && !sensorsAvailable) { return unavailable("hardware_signals_unavailable"); } - return safe(); + return safe(coverage); } private boolean checkBatteryAnomaly(List evidence) { diff --git a/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/detector/CustomRomDetector.java b/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/detector/CustomRomDetector.java index a832a7e..2312bd5 100644 --- a/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/detector/CustomRomDetector.java +++ b/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/detector/CustomRomDetector.java @@ -2,16 +2,17 @@ import android.content.Context; -import com.wsttxm.riskenginesdk.collector.native_layer.NativeCollectorBridge; import com.wsttxm.riskenginesdk.model.DetectionResult; import com.wsttxm.riskenginesdk.model.DetectionStatus; import com.wsttxm.riskenginesdk.model.RiskLevel; -import com.wsttxm.riskenginesdk.util.ShellExecutor; +import com.wsttxm.riskenginesdk.core.SignalResult; +import com.wsttxm.riskenginesdk.core.SignalSnapshot; import java.util.ArrayList; import java.util.List; public class CustomRomDetector extends BaseDetector { + private final SignalSnapshot signals; // Stock OEM Android distributions are intentionally excluded. private static final String[][] COMMUNITY_ROM_PROPS = { {"ro.lineage.version", "LineageOS"}, @@ -23,7 +24,12 @@ public class CustomRomDetector extends BaseDetector { }; public CustomRomDetector(Context context) { + this(context, new SignalSnapshot(context)); + } + + public CustomRomDetector(Context context, SignalSnapshot signals) { super(context); + this.signals = signals; } @Override @@ -34,11 +40,20 @@ public String getName() { @Override protected DetectionResult detect() { List evidence = new ArrayList<>(); - if (readProperty("ro.build.fingerprint").isEmpty()) { + SignalResult fingerprint = signals.getSystemProperty("ro.build.fingerprint"); + if (!fingerprint.isSuccess() || valueOf(fingerprint).isEmpty()) { return unavailable("system_properties_unavailable"); } + CheckCoverage coverage = new CheckCoverage(); + coverage.success(); for (String[] romProperty : COMMUNITY_ROM_PROPS) { - String value = readProperty(romProperty[0]); + SignalResult property = signals.getSystemProperty(romProperty[0]); + if (!property.isSuccess()) { + coverage.failure(romProperty[0] + ":" + property.getFailureReason()); + continue; + } + coverage.success(); + String value = valueOf(property); if (!value.isEmpty()) { evidence.add("community_rom:" + romProperty[1] + "=" + value); } @@ -47,22 +62,12 @@ protected DetectionResult detect() { if (!evidence.isEmpty()) { // A community ROM is context, not proof of compromise/root. return result(RiskLevel.LOW, DetectionStatus.WARNING, 1, 10, true, - evidence, String.join("; ", evidence)); + evidence, String.join("; ", evidence), coverage); } - return safe(); + return safe(coverage); } - private String readProperty(String name) { - if (NativeCollectorBridge.isNativeAvailable()) { - try { - String value = NativeCollectorBridge.getSystemProperty(name); - if (value != null && !value.isBlank()) { - return value.trim(); - } - } catch (Exception | LinkageError ignored) { - // Fall through to the bounded shell fallback. - } - } - return ShellExecutor.execute("getprop " + name).trim(); + private static String valueOf(SignalResult result) { + return result.getValue() == null ? "" : result.getValue().trim(); } } diff --git a/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/detector/DebugDetector.java b/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/detector/DebugDetector.java index b2bd284..faafa69 100644 --- a/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/detector/DebugDetector.java +++ b/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/detector/DebugDetector.java @@ -9,7 +9,8 @@ import com.wsttxm.riskenginesdk.model.DetectionResult; import com.wsttxm.riskenginesdk.model.RiskLevel; import com.wsttxm.riskenginesdk.util.CLog; -import com.wsttxm.riskenginesdk.util.ProcfsUtils; +import com.wsttxm.riskenginesdk.core.SignalResult; +import com.wsttxm.riskenginesdk.core.SignalSnapshot; import java.io.BufferedReader; import java.io.FileReader; @@ -20,9 +21,15 @@ import java.util.Set; public class DebugDetector extends BaseDetector { + private final SignalSnapshot signals; public DebugDetector(Context context) { + this(context, new SignalSnapshot(context)); + } + + public DebugDetector(Context context, SignalSnapshot signals) { super(context); + this.signals = signals; } @Override @@ -36,38 +43,43 @@ protected DetectionResult detect() { boolean strongSignal = false; boolean mediumSignal = false; boolean weakSignal = false; + CheckCoverage coverage = new CheckCoverage(); - strongSignal |= checkTracerPid(details); - weakSignal |= checkDebuggable(details); - weakSignal |= checkIdaPort(details); - mediumSignal |= checkDebuggerConnection(details); - mediumSignal |= checkMapsExecPath(details); + strongSignal |= checkTracerPid(details, coverage); + weakSignal |= checkDebuggable(details, coverage); + weakSignal |= checkIdaPort(details, coverage); + mediumSignal |= checkDebuggerConnection(details, coverage); + mediumSignal |= checkMapsExecPath(details, coverage); if (!details.isEmpty()) { List detailList = new ArrayList<>(details); if (strongSignal) { return result(RiskLevel.HIGH, DetectionStatus.DANGER, 8, 10, false, - detailList, String.join("; ", detailList)); + detailList, String.join("; ", detailList), coverage); } if (mediumSignal) { return result(RiskLevel.MEDIUM, DetectionStatus.WARNING, 4, 10, false, - detailList, String.join("; ", detailList)); + detailList, String.join("; ", detailList), coverage); } return result(RiskLevel.LOW, DetectionStatus.WARNING, 1, 10, weakSignal, - detailList, String.join("; ", detailList)); + detailList, String.join("; ", detailList), coverage); } - return safe(); + return safe(coverage); } - private boolean checkTracerPid(Set details) { + private boolean checkTracerPid(Set details, CheckCoverage coverage) { if (NativeCollectorBridge.isNativeAvailable()) { try { - int tracerPid = NativeCollectorBridge.getTracerPid(); + SignalResult nativeTracer = NativeCollectorBridge.getTracerPidResult(); + int tracerPid = nativeTracer.isSuccess() && nativeTracer.getValue() != null + ? nativeTracer.getValue() : -1; if (tracerPid > 0) { details.add("tracer_pid:" + tracerPid); + coverage.success(); return true; } if (tracerPid == 0) { + coverage.success(); return false; } } catch (Exception | LinkageError e) { @@ -81,57 +93,81 @@ private boolean checkTracerPid(Set details) { int pid = Integer.parseInt(line.split(":")[1].trim()); if (pid > 0) { details.add("tracer_pid:" + pid); + coverage.success(); return true; } - break; + coverage.success(); + return false; } } } catch (Exception e) { CLog.e("TracerPid check failed", e); + coverage.failure("tracer_pid:" + e.getClass().getSimpleName()); + return false; } + coverage.failure("tracer_pid:missing_field"); return false; } - private boolean checkDebuggable(Set details) { + private boolean checkDebuggable(Set details, CheckCoverage coverage) { try { ApplicationInfo ai = context.getApplicationInfo(); if ((ai.flags & ApplicationInfo.FLAG_DEBUGGABLE) != 0) { details.add("debuggable_flag"); + coverage.success(); return true; } + coverage.success(); } catch (Exception e) { CLog.e("Debuggable check failed", e); + coverage.failure("debuggable:" + e.getClass().getSimpleName()); } return false; } - private boolean checkIdaPort(Set details) { + private boolean checkIdaPort(Set details, CheckCoverage coverage) { try { - if (!ProcfsUtils.findLoopbackListeningPorts().contains(23946)) { + SignalResult> ports = signals.getLoopbackListeningPorts(); + if (!ports.isSuccess() || ports.getValue() == null) { + coverage.failure("ida_port:" + ports.getFailureReason()); + return false; + } + if (!ports.getValue().contains(23946)) { + coverage.success(); return false; } details.add("ida_port_open:23946"); + coverage.success(); return true; - } catch (Exception ignored) {} + } catch (Exception e) { + coverage.failure("ida_port:" + e.getClass().getSimpleName()); + } return false; } - private boolean checkDebuggerConnection(Set details) { + private boolean checkDebuggerConnection(Set details, CheckCoverage coverage) { try { if (Debug.isDebuggerConnected() || Debug.waitingForDebugger()) { details.add("debugger_connected"); + coverage.success(); return true; } + coverage.success(); } catch (Exception e) { CLog.e("Debugger connection check failed", e); + coverage.failure("debugger_connection:" + e.getClass().getSimpleName()); } return false; } - private boolean checkMapsExecPath(Set details) { - try (BufferedReader br = new BufferedReader(new FileReader("/proc/self/maps"))) { - String line; - while ((line = br.readLine()) != null) { + private boolean checkMapsExecPath(Set details, CheckCoverage coverage) { + try { + SignalResult> maps = signals.getSelfMaps(); + if (!maps.isSuccess() || maps.getValue() == null) { + coverage.failure("maps_exec_path:" + maps.getFailureReason()); + return false; + } + for (String line : maps.getValue()) { String lower = line.toLowerCase(Locale.ROOT); if (!lower.contains(" r-x") && !lower.contains(" r--p")) { continue; @@ -141,11 +177,14 @@ private boolean checkMapsExecPath(Set details) { || lower.contains("lldb") || lower.contains("frida")) { details.add("maps_exec_path:" + matchingTool(lower)); + coverage.success(); return true; } } + coverage.success(); } catch (Exception e) { CLog.e("Maps exec path check failed", e); + coverage.failure("maps_exec_path:" + e.getClass().getSimpleName()); } return false; } diff --git a/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/detector/DetectorRegistry.java b/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/detector/DetectorRegistry.java index db0d4ca..0e37039 100644 --- a/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/detector/DetectorRegistry.java +++ b/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/detector/DetectorRegistry.java @@ -3,6 +3,7 @@ import android.content.Context; import com.wsttxm.riskenginesdk.RiskEngineConfig; +import com.wsttxm.riskenginesdk.core.SignalSnapshot; import java.util.ArrayList; import java.util.Collections; @@ -12,31 +13,35 @@ public class DetectorRegistry { private final List detectors = new ArrayList<>(); public DetectorRegistry(Context context, RiskEngineConfig config) { + this(context, config, new SignalSnapshot(context)); + } + + public DetectorRegistry(Context context, RiskEngineConfig config, SignalSnapshot signals) { if (config.isEnableRoot()) { - detectors.add(new RootDetector(context)); - detectors.add(new MountAnalysisDetector(context)); + detectors.add(new RootDetector(context, signals)); + detectors.add(new MountAnalysisDetector(context, signals)); } if (config.isEnableHookDetection()) { - detectors.add(new HookFrameworkDetector(context)); - detectors.add(new ProcessScanDetector(context)); + detectors.add(new HookFrameworkDetector(context, signals)); + detectors.add(new ProcessScanDetector(context, signals)); } if (config.isEnableAdbDetection()) { - detectors.add(new AdbDetector(context)); + detectors.add(new AdbDetector(context, signals)); } if (config.isEnableEmulatorDetection()) { - detectors.add(new EmulatorDetector(context)); + detectors.add(new EmulatorDetector(context, signals)); } if (config.isEnableSandboxDetection()) { detectors.add(new SandboxDetector(context)); } if (config.isEnableDebugDetection()) { - detectors.add(new DebugDetector(context)); + detectors.add(new DebugDetector(context, signals)); } if (config.isEnableCloudPhoneDetection()) { detectors.add(new CloudPhoneDetector(context)); } if (config.isEnableCustomRomDetection()) { - detectors.add(new CustomRomDetector(context)); + detectors.add(new CustomRomDetector(context, signals)); } } diff --git a/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/detector/EmulatorDetector.java b/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/detector/EmulatorDetector.java index 9a5825e..7f50156 100644 --- a/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/detector/EmulatorDetector.java +++ b/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/detector/EmulatorDetector.java @@ -11,7 +11,8 @@ import com.wsttxm.riskenginesdk.model.DetectionResult; import com.wsttxm.riskenginesdk.model.RiskLevel; import com.wsttxm.riskenginesdk.util.CLog; -import com.wsttxm.riskenginesdk.util.ProcfsUtils; +import com.wsttxm.riskenginesdk.core.SignalResult; +import com.wsttxm.riskenginesdk.core.SignalSnapshot; import java.net.Inet4Address; import java.net.InetAddress; @@ -22,9 +23,15 @@ import java.util.Locale; public class EmulatorDetector extends BaseDetector { + private final SignalSnapshot signals; public EmulatorDetector(Context context) { + this(context, new SignalSnapshot(context)); + } + + public EmulatorDetector(Context context, SignalSnapshot signals) { super(context); + this.signals = signals; } @Override @@ -35,16 +42,17 @@ public String getName() { @Override protected DetectionResult detect() { List evidence = new ArrayList<>(); + CheckCoverage coverage = new CheckCoverage(); - checkBuildProperties(evidence); - checkHardwareFeatures(evidence); - checkEmulatorFiles(evidence); - checkThermalZones(evidence); - checkRuntimeArch(evidence); - checkSensors(evidence); - checkEmulatorIp(evidence); - checkEmulatorPackages(evidence); - checkContainerSignals(evidence); + checkBuildProperties(evidence, coverage); + checkHardwareFeatures(evidence, coverage); + checkEmulatorFiles(evidence, coverage); + checkThermalZones(evidence, coverage); + checkRuntimeArch(evidence, coverage); + checkSensors(evidence, coverage); + checkEmulatorIp(evidence, coverage); + checkEmulatorPackages(evidence, coverage); + checkContainerSignals(evidence, coverage); int strongSignals = 0; for (String signal : evidence) { @@ -56,15 +64,15 @@ protected DetectionResult detect() { if (strongSignals >= 2 || (strongSignals >= 1 && weakSignals >= 2)) { return result(RiskLevel.HIGH, DetectionStatus.DANGER, 8, 10, false, - evidence, String.join("; ", evidence)); + evidence, String.join("; ", evidence), coverage); } else if (strongSignals == 1) { return result(RiskLevel.MEDIUM, DetectionStatus.WARNING, 4, 10, false, - evidence, String.join("; ", evidence)); + evidence, String.join("; ", evidence), coverage); } else if (!evidence.isEmpty()) { return result(RiskLevel.LOW, DetectionStatus.WARNING, 1, 10, true, - evidence, String.join("; ", evidence)); + evidence, String.join("; ", evidence), coverage); } - return safe(); + return safe(coverage); } private boolean isStrongSignal(String signal) { @@ -78,13 +86,14 @@ private boolean isStrongSignal(String signal) { || signal.startsWith("emu_pkg:"); } - private void checkBuildProperties(List evidence) { - String[] fingerprintKeywords = {"generic", "vbox"}; - for (String kw : fingerprintKeywords) { - if (Build.FINGERPRINT.toLowerCase(Locale.ROOT).contains(kw)) { - evidence.add("fingerprint:" + Build.FINGERPRINT); - break; - } + private void checkBuildProperties(List evidence, CheckCoverage coverage) { + String fingerprint = Build.FINGERPRINT.toLowerCase(Locale.ROOT); + if (fingerprint.contains("vbox")) { + evidence.add("fingerprint:" + Build.FINGERPRINT); + } else if (fingerprint.contains("generic")) { + // "generic" is also used by legitimate AOSP-derived and embedded + // builds, so it must be correlated with stronger evidence. + evidence.add("generic_fingerprint:" + Build.FINGERPRINT); } String[] modelKeywords = {"google_sdk", "emulator", "android sdk built for", "droid4x"}; @@ -118,9 +127,10 @@ private void checkBuildProperties(List evidence) { if (Build.BOARD.toLowerCase(Locale.ROOT).contains("nox")) { evidence.add("board:" + Build.BOARD); } + coverage.success(); } - private void checkHardwareFeatures(List evidence) { + private void checkHardwareFeatures(List evidence, CheckCoverage coverage) { try { PackageManager pm = context.getPackageManager(); String[] features = { @@ -137,54 +147,80 @@ private void checkHardwareFeatures(List evidence) { if (missing > 0) { evidence.add("limited_hardware_features:" + missing); } + coverage.success(); } catch (Exception e) { CLog.e("Hardware feature check failed", e); + coverage.failure("hardware_features:" + e.getClass().getSimpleName()); } } - private void checkEmulatorFiles(List evidence) { + private void checkEmulatorFiles(List evidence, CheckCoverage coverage) { if (!NativeCollectorBridge.isNativeAvailable()) { + coverage.failure("emulator_files:native_unavailable"); return; } try { - String found = NativeCollectorBridge.checkEmulatorFiles(); + SignalResult nativeResult = NativeCollectorBridge.checkEmulatorFilesResult(); + if (!nativeResult.isSuccess()) { + coverage.failure("emulator_files:" + nativeResult.getFailureReason()); + return; + } + String found = nativeResult.getValue(); if (found != null && !found.isEmpty()) { evidence.add("emu_file:" + found); } - } catch (Exception e) { + coverage.success(); + } catch (Exception | LinkageError e) { CLog.e("Emulator file check failed", e); + coverage.failure("emulator_files:" + e.getClass().getSimpleName()); } } - private void checkThermalZones(List evidence) { + private void checkThermalZones(List evidence, CheckCoverage coverage) { if (!NativeCollectorBridge.isNativeAvailable()) { + coverage.failure("thermal_zones:native_unavailable"); return; } try { - int count = NativeCollectorBridge.getThermalZoneCount(); + SignalResult nativeResult = NativeCollectorBridge.getThermalZoneCountResult(); + if (!nativeResult.isSuccess() || nativeResult.getValue() == null) { + coverage.failure("thermal_zones:" + nativeResult.getFailureReason()); + return; + } + int count = nativeResult.getValue(); if (count == 0) { evidence.add("no_thermal_zones"); } - } catch (Exception e) { + coverage.success(); + } catch (Exception | LinkageError e) { CLog.e("Thermal zone check failed", e); + coverage.failure("thermal_zones:" + e.getClass().getSimpleName()); } } - private void checkRuntimeArch(List evidence) { + private void checkRuntimeArch(List evidence, CheckCoverage coverage) { if (!NativeCollectorBridge.isNativeAvailable()) { + coverage.failure("runtime_arch:native_unavailable"); return; } try { - String arch = NativeCollectorBridge.getRuntimeArch(); + SignalResult nativeResult = NativeCollectorBridge.getRuntimeArchResult(); + if (!nativeResult.isSuccess()) { + coverage.failure("runtime_arch:" + nativeResult.getFailureReason()); + return; + } + String arch = nativeResult.getValue(); if ("X86_64".equals(arch) || "I386".equals(arch)) { evidence.add("runtime_arch:" + arch); } - } catch (Exception e) { + coverage.success(); + } catch (Exception | LinkageError e) { CLog.e("Runtime architecture check failed", e); + coverage.failure("runtime_arch:" + e.getClass().getSimpleName()); } } - private void checkSensors(List evidence) { + private void checkSensors(List evidence, CheckCoverage coverage) { try { SensorManager sm = (SensorManager) context.getSystemService(Context.SENSOR_SERVICE); if (sm != null) { @@ -199,16 +235,20 @@ private void checkSensors(List evidence) { break; } } + coverage.success(); + } else { + coverage.failure("sensors:service_unavailable"); } } catch (Exception e) { CLog.e("Sensor check failed", e); + coverage.failure("sensors:" + e.getClass().getSimpleName()); } } - private void checkEmulatorIp(List evidence) { + private void checkEmulatorIp(List evidence, CheckCoverage coverage) { try { Enumeration interfaces = NetworkInterface.getNetworkInterfaces(); - while (interfaces.hasMoreElements()) { + while (interfaces != null && interfaces.hasMoreElements()) { NetworkInterface ni = interfaces.nextElement(); Enumeration addresses = ni.getInetAddresses(); while (addresses.hasMoreElements()) { @@ -221,31 +261,45 @@ private void checkEmulatorIp(List evidence) { } } } + coverage.success(); } catch (Exception e) { CLog.e("Emulator IP check failed", e); + coverage.failure("network_interfaces:" + e.getClass().getSimpleName()); } } - private void checkEmulatorPackages(List evidence) { + private void checkEmulatorPackages(List evidence, CheckCoverage coverage) { String[] emulatorPackages = { "com.google.android.launcher.layouts.genymotion", "com.bluestacks", "com.bignox.app" }; - PackageManager pm = context.getPackageManager(); - for (String pkg : emulatorPackages) { - try { - pm.getPackageInfo(pkg, 0); - evidence.add("emu_pkg:" + pkg); - } catch (PackageManager.NameNotFoundException ignored) {} + try { + PackageManager pm = context.getPackageManager(); + for (String pkg : emulatorPackages) { + try { + pm.getPackageInfo(pkg, 0); + evidence.add("emu_pkg:" + pkg); + } catch (PackageManager.NameNotFoundException ignored) {} + } + coverage.success(); + } catch (Exception e) { + coverage.failure("emulator_packages:" + e.getClass().getSimpleName()); } } - private void checkContainerSignals(List evidence) { + private void checkContainerSignals(List evidence, CheckCoverage coverage) { try { - evidence.addAll(ProcfsUtils.collectContainerSignals(context)); + SignalResult> container = signals.getContainerSignals(); + if (!container.isSuccess() || container.getValue() == null) { + coverage.failure("container_signals:" + container.getFailureReason()); + return; + } + evidence.addAll(container.getValue()); + coverage.success(); } catch (Exception e) { CLog.e("Container signal check failed", e); + coverage.failure("container_signals:" + e.getClass().getSimpleName()); } } } diff --git a/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/detector/HookFrameworkDetector.java b/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/detector/HookFrameworkDetector.java index d22f52e..e116d25 100644 --- a/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/detector/HookFrameworkDetector.java +++ b/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/detector/HookFrameworkDetector.java @@ -8,6 +8,8 @@ import com.wsttxm.riskenginesdk.model.RiskLevel; import com.wsttxm.riskenginesdk.util.CLog; import com.wsttxm.riskenginesdk.util.ProcfsUtils; +import com.wsttxm.riskenginesdk.core.SignalResult; +import com.wsttxm.riskenginesdk.core.SignalSnapshot; import java.io.BufferedReader; import java.io.FileReader; @@ -22,9 +24,15 @@ public class HookFrameworkDetector extends BaseDetector { private static final int DEFAULT_FRIDA_PORT = 27042; + private final SignalSnapshot signals; public HookFrameworkDetector(Context context) { + this(context, new SignalSnapshot(context)); + } + + public HookFrameworkDetector(Context context, SignalSnapshot signals) { super(context); + this.signals = signals; } @Override @@ -36,36 +44,36 @@ public String getName() { protected DetectionResult detect() { LinkedHashSet details = new LinkedHashSet<>(); SignalScore score = new SignalScore(); + CheckCoverage coverage = new CheckCoverage(); - checkXposed(details, score); - checkFrida(details, score); - checkNativeHooks(details, score); + checkXposed(details, score, coverage); + checkFrida(details, score, coverage); + checkNativeHooks(details, score, coverage); if (!details.isEmpty()) { List detailList = new ArrayList<>(details); if (score.strong >= 2 || (score.strong >= 1 && score.medium >= 2)) { return result(RiskLevel.DEADLY, DetectionStatus.DANGER, 10, 10, false, - detailList, String.join("; ", detailList)); + detailList, String.join("; ", detailList), coverage); } if (score.strong >= 1 || score.medium >= 2) { return result(RiskLevel.HIGH, DetectionStatus.DANGER, 8, 10, false, - detailList, String.join("; ", detailList)); + detailList, String.join("; ", detailList), coverage); } if (score.medium >= 1) { return result(RiskLevel.MEDIUM, DetectionStatus.WARNING, 2, 10, true, - detailList, String.join("; ", detailList)); + detailList, String.join("; ", detailList), coverage); } return result(RiskLevel.LOW, DetectionStatus.WARNING, 1, 10, true, - detailList, String.join("; ", detailList)); + detailList, String.join("; ", detailList), coverage); } - return safe(); + return safe(coverage); } - private void checkXposed(Set details, SignalScore score) { + private void checkXposed(Set details, SignalScore score, CheckCoverage coverage) { // Check for Xposed's sHookedMethodCallbacks try { - ClassLoader cl = ClassLoader.getSystemClassLoader(); - Class xposedBridge = cl.loadClass("de.robv.android.xposed.XposedBridge"); + Class xposedBridge = loadXposedBridge(); if (xposedBridge != null) { addMedium(details, score, "xposed_class_found"); Field field = xposedBridge.getDeclaredField("sHookedMethodCallbacks"); @@ -75,10 +83,13 @@ private void checkXposed(Set details, SignalScore score) { addStrong(details, score, "xposed_hooks_active:" + ((Map) callbacks).size()); } } + coverage.success(); } catch (ClassNotFoundException ignored) { // Xposed not present + coverage.success(); } catch (Exception e) { CLog.e("Xposed check error", e); + coverage.failure("xposed_class:" + e.getClass().getSimpleName()); } // Check stack trace for Xposed @@ -92,28 +103,66 @@ private void checkXposed(Set details, SignalScore score) { break; } } - } catch (Exception ignored) {} + coverage.success(); + } catch (Exception e) { + coverage.failure("xposed_stack:" + e.getClass().getSimpleName()); + } } - private void checkFrida(Set details, SignalScore score) { + private Class loadXposedBridge() throws ClassNotFoundException { + String className = "de.robv.android.xposed.XposedBridge"; + ClassLoader[] loaders = { + context == null ? null : context.getClassLoader(), + HookFrameworkDetector.class.getClassLoader(), + Thread.currentThread().getContextClassLoader(), + ClassLoader.getSystemClassLoader() + }; + for (ClassLoader loader : loaders) { + if (loader == null) { + continue; + } + try { + return Class.forName(className, false, loader); + } catch (ClassNotFoundException ignored) { + // Try the next loader. Injected frameworks are commonly visible + // from the application loader but not the system loader. + } + } + throw new ClassNotFoundException(className); + } + + private void checkFrida(Set details, SignalScore score, CheckCoverage coverage) { // Check /proc/self/maps for Frida - try (BufferedReader br = new BufferedReader(new FileReader("/proc/self/maps"))) { - String line; - while ((line = br.readLine()) != null) { + try { + SignalResult> maps = signals.getSelfMaps(); + if (!maps.isSuccess() || maps.getValue() == null) { + coverage.failure("frida_maps:" + maps.getFailureReason()); + } else { + for (String line : maps.getValue()) { String lower = line.toLowerCase(Locale.ROOT); if (lower.contains("frida") || lower.contains("libgadget.so")) { addStrong(details, score, "frida_maps"); break; } } - } catch (Exception ignored) {} + coverage.success(); + } + } catch (Exception e) { + coverage.failure("frida_maps:" + e.getClass().getSimpleName()); + } // Observe the TCP table without actively connecting to local services. try { - if (ProcfsUtils.findLoopbackListeningPorts().contains(DEFAULT_FRIDA_PORT)) { + SignalResult> ports = signals.getLoopbackListeningPorts(); + if (!ports.isSuccess() || ports.getValue() == null) { + coverage.failure("frida_ports:" + ports.getFailureReason()); + } else if (ports.getValue().contains(DEFAULT_FRIDA_PORT)) { addMedium(details, score, "frida_port_open:" + DEFAULT_FRIDA_PORT); } - } catch (Exception ignored) {} + if (ports.isSuccess()) coverage.success(); + } catch (Exception e) { + coverage.failure("frida_ports:" + e.getClass().getSimpleName()); + } // Check threads for Frida try { @@ -135,18 +184,33 @@ private void checkFrida(Set details, SignalScore score) { } } } - } catch (Exception ignored) {} + if (tasks == null) { + coverage.failure("frida_threads:procfs_unavailable"); + } else { + coverage.success(); + } + } catch (Exception e) { + coverage.failure("frida_threads:" + e.getClass().getSimpleName()); + } try { - List pids = ProcfsUtils.findPidsByNameFragments("frida-server", "frida_helper"); + SignalResult> processes = signals.getProcesses(); + if (!processes.isSuccess() || processes.getValue() == null) { + coverage.failure("frida_processes:" + processes.getFailureReason()); + return; + } + List pids = ProcfsUtils.findPidsByNameFragments( + processes.getValue(), "frida-server", "frida_helper"); for (Integer pid : pids) { addStrong(details, score, "frida_pid:" + pid); for (Integer port : ProcfsUtils.findPidLoopbackListeningPorts(pid)) { addStrong(details, score, "frida_pid_port:" + port); } } + coverage.success(); } catch (Exception e) { CLog.e("Frida pid correlation failed", e); + coverage.failure("frida_processes:" + e.getClass().getSimpleName()); } } @@ -156,12 +220,18 @@ private String matchingThreadMarker(String threadName) { return "gmain"; } - private void checkNativeHooks(Set details, SignalScore score) { + private void checkNativeHooks(Set details, SignalScore score, CheckCoverage coverage) { if (!NativeCollectorBridge.isNativeAvailable()) { + coverage.failure("native_hook:unavailable"); return; } try { - String nativeEvidence = NativeCollectorBridge.getHookEvidence(); + SignalResult nativeResult = NativeCollectorBridge.getHookEvidenceResult(); + if (!nativeResult.isSuccess()) { + coverage.failure("native_hook:" + nativeResult.getFailureReason()); + return; + } + String nativeEvidence = nativeResult.getValue(); if (nativeEvidence != null && !nativeEvidence.isEmpty()) { for (String item : nativeEvidence.split(",")) { String token = item.trim(); @@ -171,18 +241,23 @@ private void checkNativeHooks(Set details, SignalScore score) { if (token.startsWith("maps:frida") || token.startsWith("maps:gadget")) { addStrong(details, score, token); - } else if (token.startsWith("anon_exec:") - || token.startsWith("thread:") + } else if (token.startsWith("thread:") || token.startsWith("maps:xposed") || token.startsWith("maps:substrate")) { addMedium(details, score, token); + } else if (token.startsWith("anon_exec:")) { + // ART and OEM runtimes can legitimately create anonymous + // executable regions. Keep it as correlating context only. + addWeak(details, score, token); } else { addWeak(details, score, token); } } } + coverage.success(); } catch (Exception | LinkageError e) { CLog.e("Native hook check failed", e); + coverage.failure("native_hook:" + e.getClass().getSimpleName()); } } diff --git a/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/detector/MountAnalysisDetector.java b/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/detector/MountAnalysisDetector.java index f8d60eb..376d5d4 100644 --- a/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/detector/MountAnalysisDetector.java +++ b/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/detector/MountAnalysisDetector.java @@ -4,18 +4,25 @@ import com.wsttxm.riskenginesdk.model.DetectionResult; import com.wsttxm.riskenginesdk.model.RiskLevel; +import com.wsttxm.riskenginesdk.model.DetectionStatus; import com.wsttxm.riskenginesdk.util.CLog; +import com.wsttxm.riskenginesdk.core.SignalResult; +import com.wsttxm.riskenginesdk.core.SignalSnapshot; -import java.io.BufferedReader; -import java.io.FileReader; import java.util.ArrayList; import java.util.List; import java.util.Locale; public class MountAnalysisDetector extends BaseDetector { + private final SignalSnapshot signals; public MountAnalysisDetector(Context context) { + this(context, new SignalSnapshot(context)); + } + + public MountAnalysisDetector(Context context, SignalSnapshot signals) { super(context); + this.signals = signals; } @Override @@ -29,20 +36,27 @@ protected DetectionResult detect() { boolean mountsAvailable = checkMounts(evidence); boolean mountInfoAvailable = checkMountInfo(evidence); + CheckCoverage coverage = new CheckCoverage(); + if (mountsAvailable) coverage.success(); + else coverage.failure("proc_mounts_unavailable"); + if (mountInfoAvailable) coverage.success(); + else coverage.failure("proc_mountinfo_unavailable"); if (!evidence.isEmpty()) { - return risk(RiskLevel.MEDIUM, String.join("; ", evidence)); + return result(RiskLevel.MEDIUM, DetectionStatus.WARNING, 4, 10, false, + evidence, String.join("; ", evidence), coverage); } if (!mountsAvailable && !mountInfoAvailable) { return unavailable("procfs_mounts_unavailable"); } - return safe(); + return safe(coverage); } private boolean checkMounts(List evidence) { - try (BufferedReader br = new BufferedReader(new FileReader("/proc/mounts"))) { - String line; - while ((line = br.readLine()) != null) { + try { + SignalResult mounts = signals.getTextFile("/proc/mounts"); + if (!mounts.isSuccess() || mounts.getValue() == null) return false; + for (String line : mounts.getValue().split("\\n")) { String lower = line.toLowerCase(Locale.ROOT); // Magisk overlay if (lower.contains("magisk") || lower.contains("tmpfs /system") || @@ -68,9 +82,10 @@ private boolean checkMounts(List evidence) { } private boolean checkMountInfo(List evidence) { - try (BufferedReader br = new BufferedReader(new FileReader("/proc/self/mountinfo"))) { - String line; - while ((line = br.readLine()) != null) { + try { + SignalResult mountInfo = signals.getTextFile("/proc/self/mountinfo"); + if (!mountInfo.isSuccess() || mountInfo.getValue() == null) return false; + for (String line : mountInfo.getValue().split("\\n")) { if (line.contains("magisk") || line.contains("core/mirror")) { addUnique(evidence, "mountinfo:magisk"); break; diff --git a/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/detector/ProcessScanDetector.java b/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/detector/ProcessScanDetector.java index 23c48f3..35e4597 100644 --- a/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/detector/ProcessScanDetector.java +++ b/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/detector/ProcessScanDetector.java @@ -4,15 +4,20 @@ import com.wsttxm.riskenginesdk.model.DetectionResult; import com.wsttxm.riskenginesdk.model.RiskLevel; +import com.wsttxm.riskenginesdk.model.DetectionStatus; import com.wsttxm.riskenginesdk.util.CLog; import com.wsttxm.riskenginesdk.util.ShellExecutor; +import com.wsttxm.riskenginesdk.core.SignalSnapshot; import java.util.ArrayList; import java.util.List; import java.util.Locale; import java.util.regex.Pattern; +import java.util.LinkedHashMap; +import java.util.Map; public class ProcessScanDetector extends BaseDetector { + private final SignalSnapshot signals; private static final String[] SUSPICIOUS_PROCESSES = { "frida", "frida-server", "frida-agent", @@ -24,9 +29,15 @@ public class ProcessScanDetector extends BaseDetector { "radare2", "substrate", "cydia", }; + private static final Map PROCESS_PATTERNS = buildPatterns(); public ProcessScanDetector(Context context) { + this(context, new SignalSnapshot(context)); + } + + public ProcessScanDetector(Context context, SignalSnapshot signals) { super(context); + this.signals = signals; } @Override @@ -41,9 +52,10 @@ protected DetectionResult detect() { boolean serviceListAvailable = false; try { - String psOutput = ShellExecutor.execute("ps -ef"); - if (psOutput != null && !psOutput.isEmpty()) { + ShellExecutor.Result ps = signals.getShellResult("ps -ef"); + if (ps.isSuccess()) { processListAvailable = true; + String psOutput = ps.getStdout(); String lower = psOutput.toLowerCase(Locale.ROOT); for (String proc : SUSPICIOUS_PROCESSES) { if (containsProcessToken(lower, proc)) { @@ -57,9 +69,10 @@ protected DetectionResult detect() { // Also check service list try { - String serviceOutput = ShellExecutor.execute("service list"); - if (serviceOutput != null && !serviceOutput.isEmpty()) { + ShellExecutor.Result services = signals.getShellResult("service list"); + if (services.isSuccess()) { serviceListAvailable = true; + String serviceOutput = services.getStdout(); String lower = serviceOutput.toLowerCase(Locale.ROOT); if (lower.contains("xposed") || lower.contains("edxposed")) { evidence.add("suspicious_service:xposed"); @@ -70,16 +83,37 @@ protected DetectionResult detect() { } if (!evidence.isEmpty()) { - return risk(RiskLevel.HIGH, String.join("; ", evidence)); + CheckCoverage coverage = processCoverage( + processListAvailable, serviceListAvailable); + return result(RiskLevel.HIGH, DetectionStatus.DANGER, 8, 10, false, + evidence, String.join("; ", evidence), coverage); } if (!processListAvailable && !serviceListAvailable) { return unavailable("process_and_service_lists_unavailable"); } - return safe(); + return safe(processCoverage(processListAvailable, serviceListAvailable)); + } + + private CheckCoverage processCoverage(boolean processAvailable, boolean serviceAvailable) { + CheckCoverage coverage = new CheckCoverage(); + if (processAvailable) coverage.success(); + else coverage.failure("process_list_unavailable"); + if (serviceAvailable) coverage.success(); + else coverage.failure("service_list_unavailable"); + return coverage; } private boolean containsProcessToken(String output, String processName) { - String boundary = "(^|[\\s/:])" + Pattern.quote(processName) + "($|[\\s:])"; - return Pattern.compile(boundary, Pattern.MULTILINE).matcher(output).find(); + Pattern pattern = PROCESS_PATTERNS.get(processName); + return pattern != null && pattern.matcher(output).find(); + } + + private static Map buildPatterns() { + Map patterns = new LinkedHashMap<>(); + for (String processName : SUSPICIOUS_PROCESSES) { + String boundary = "(^|[\\s/:])" + Pattern.quote(processName) + "($|[\\s:])"; + patterns.put(processName, Pattern.compile(boundary, Pattern.MULTILINE)); + } + return patterns; } } diff --git a/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/detector/RootDetector.java b/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/detector/RootDetector.java index 5363480..e802db7 100644 --- a/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/detector/RootDetector.java +++ b/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/detector/RootDetector.java @@ -7,13 +7,14 @@ import com.wsttxm.riskenginesdk.model.DetectionStatus; import com.wsttxm.riskenginesdk.model.RiskLevel; import com.wsttxm.riskenginesdk.util.CLog; -import com.wsttxm.riskenginesdk.util.ShellExecutor; +import com.wsttxm.riskenginesdk.core.SignalResult; +import com.wsttxm.riskenginesdk.core.SignalSnapshot; -import java.io.File; import java.util.ArrayList; import java.util.List; public class RootDetector extends BaseDetector { + private final SignalSnapshot signals; private static final String[] SU_PATHS = { "/system/bin/su", "/system/xbin/su", "/sbin/su", @@ -29,7 +30,12 @@ public class RootDetector extends BaseDetector { }; public RootDetector(Context context) { + this(context, new SignalSnapshot(context)); + } + + public RootDetector(Context context, SignalSnapshot signals) { super(context); + this.signals = signals; } @Override @@ -41,60 +47,90 @@ public String getName() { protected DetectionResult detect() { List strongEvidence = new ArrayList<>(); List weakEvidence = new ArrayList<>(); + CheckCoverage coverage = new CheckCoverage(); // Check su binary paths - for (String path : SU_PATHS) { - if (new File(path).exists()) { - strongEvidence.add("su_found:" + path); + try { + boolean pathsAvailable = true; + for (String path : SU_PATHS) { + SignalResult exists = signals.getPathExists(path); + if (!exists.isSuccess()) pathsAvailable = false; + if (Boolean.TRUE.equals(exists.getValue())) { + strongEvidence.add("su_found:" + path); + } } + if (pathsAvailable) coverage.success(); + else coverage.failure("su_paths:access_failed"); + } catch (SecurityException e) { + coverage.failure("su_paths:" + e.getClass().getSimpleName()); } // Check Magisk paths - for (String path : MAGISK_PATHS) { - if (new File(path).exists()) { - strongEvidence.add("magisk_found:" + path); + try { + boolean pathsAvailable = true; + for (String path : MAGISK_PATHS) { + SignalResult exists = signals.getPathExists(path); + if (!exists.isSuccess()) pathsAvailable = false; + if (Boolean.TRUE.equals(exists.getValue())) { + strongEvidence.add("magisk_found:" + path); + } } + if (pathsAvailable) coverage.success(); + else coverage.failure("magisk_paths:access_failed"); + } catch (SecurityException e) { + coverage.failure("magisk_paths:" + e.getClass().getSimpleName()); } // Check SELinux status - try { - String enforcing = ShellExecutor.execute("getenforce"); - if (enforcing != null && enforcing.trim().equalsIgnoreCase("Permissive")) { + com.wsttxm.riskenginesdk.util.ShellExecutor.Result selinux = + signals.getShellResult("getenforce"); + if (selinux.isSuccess()) { + coverage.success(); + if (selinux.getStdout().trim().equalsIgnoreCase("Permissive")) { weakEvidence.add("selinux_permissive"); } - } catch (Exception e) { - CLog.e("SELinux check failed", e); + } else { + coverage.failure("selinux:" + selinux.getStatus()); } // Native root check - try { - if (NativeCollectorBridge.isNativeAvailable() - && NativeCollectorBridge.checkRoot()) { - String nativeEvidence = NativeCollectorBridge.getRootEvidence(); - if (nativeEvidence != null && !nativeEvidence.isEmpty()) { - strongEvidence.add("native:" + nativeEvidence); + SignalResult nativeRoot = NativeCollectorBridge.checkRootResult(); + if (nativeRoot.isSuccess() && nativeRoot.getValue() != null) { + coverage.success(); + if (nativeRoot.getValue()) { + SignalResult nativeDetails = NativeCollectorBridge.getRootEvidenceResult(); + if (nativeDetails.isSuccess() && nativeDetails.getValue() != null + && !nativeDetails.getValue().isEmpty()) { + strongEvidence.add("native:" + nativeDetails.getValue()); + } else { + strongEvidence.add("native:root_check_positive"); } } - } catch (Exception e) { - CLog.e("Native root check failed", e); + } else { + coverage.failure("native_root:" + nativeRoot.getFailureReason()); } // Check build tags - String tags = android.os.Build.TAGS; - if (tags != null && tags.contains("test-keys")) { - weakEvidence.add("test_keys"); + try { + String tags = android.os.Build.TAGS; + if (tags != null && tags.contains("test-keys")) { + weakEvidence.add("test_keys"); + } + coverage.success(); + } catch (RuntimeException e) { + coverage.failure("build_tags:" + e.getClass().getSimpleName()); } List allEvidence = new ArrayList<>(strongEvidence); allEvidence.addAll(weakEvidence); if (!strongEvidence.isEmpty()) { return result(RiskLevel.HIGH, DetectionStatus.DANGER, 8, 10, false, - allEvidence, String.join("; ", allEvidence)); + allEvidence, String.join("; ", allEvidence), coverage); } if (!weakEvidence.isEmpty()) { return result(RiskLevel.LOW, DetectionStatus.WARNING, 1, 10, true, - weakEvidence, String.join("; ", weakEvidence)); + weakEvidence, String.join("; ", weakEvidence), coverage); } - return safe(); + return safe(coverage); } } diff --git a/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/detector/SandboxDetector.java b/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/detector/SandboxDetector.java index da7d0e9..cd91aa3 100644 --- a/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/detector/SandboxDetector.java +++ b/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/detector/SandboxDetector.java @@ -28,17 +28,20 @@ protected DetectionResult detect() { List evidence = new ArrayList<>(); boolean fdInspectionAvailable = checkFdCount(evidence); + CheckCoverage coverage = new CheckCoverage(); + if (fdInspectionAvailable) coverage.success(); + else coverage.failure("procfs_fd_unavailable"); if (!evidence.isEmpty()) { // A virtualized path is useful context, but one path alone is not // proof that the current app is executing inside a sandbox. return result(RiskLevel.LOW, DetectionStatus.WARNING, 1, 10, true, - evidence, String.join("; ", evidence)); + evidence, String.join("; ", evidence), coverage); } if (!fdInspectionAvailable) { return unavailable("procfs_fd_unavailable"); } - return safe(); + return safe(coverage); } private boolean checkFdCount(List evidence) { diff --git a/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/model/DetectionExecutionStatus.java b/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/model/DetectionExecutionStatus.java new file mode 100644 index 0000000..c6caa15 --- /dev/null +++ b/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/model/DetectionExecutionStatus.java @@ -0,0 +1,16 @@ +package com.wsttxm.riskenginesdk.model; + +/** + * Describes whether a detector was actually able to execute its checks. + * This is deliberately independent from {@link DetectionStatus}, which + * describes the risk presentation of the signals that were found. + */ +public enum DetectionExecutionStatus { + SAFE, + RISK, + PARTIAL, + UNAVAILABLE, + DISABLED, + TIMEOUT, + ERROR +} diff --git a/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/model/DetectionResult.java b/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/model/DetectionResult.java index 15b1282..0c20662 100644 --- a/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/model/DetectionResult.java +++ b/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/model/DetectionResult.java @@ -3,14 +3,20 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.Locale; public class DetectionResult { private final String detectorName; private final RiskLevel riskLevel; private final DetectionStatus status; + private final DetectionExecutionStatus executionStatus; private final int score; private final int maxScore; private final boolean warnOnly; + private final int checksAttempted; + private final int checksSucceeded; + private final int checksFailed; + private final List failureReasons; private final List details; private final String evidence; private final long timestampMs; @@ -23,6 +29,11 @@ public DetectionResult(String detectorName, RiskLevel riskLevel, String evidence defaultScoreFor(riskLevel), 10, false, + defaultExecutionStatusFor(riskLevel), + 1, + riskLevel == RiskLevel.UNKNOWN ? 0 : 1, + riskLevel == RiskLevel.UNKNOWN ? 1 : 0, + Collections.emptyList(), deriveDetails(evidence), evidence, System.currentTimeMillis() @@ -50,14 +61,62 @@ public DetectionResult(String detectorName, List details, String evidence, long timestampMs) { + this(detectorName, riskLevel, status, score, maxScore, warnOnly, + defaultExecutionStatusFor(riskLevel), 1, + riskLevel == RiskLevel.UNKNOWN ? 0 : 1, + riskLevel == RiskLevel.UNKNOWN ? 1 : 0, + Collections.emptyList(), details, evidence, timestampMs); + } + + public DetectionResult(String detectorName, + RiskLevel riskLevel, + DetectionStatus status, + int score, + int maxScore, + boolean informational, + DetectionExecutionStatus executionStatus, + int checksAttempted, + int checksSucceeded, + int checksFailed, + List failureReasons, + List details, + String evidence) { + this(detectorName, riskLevel, status, score, maxScore, informational, + executionStatus, checksAttempted, checksSucceeded, checksFailed, + failureReasons, details, evidence, System.currentTimeMillis()); + } + + private DetectionResult(String detectorName, + RiskLevel riskLevel, + DetectionStatus status, + int score, + int maxScore, + boolean informational, + DetectionExecutionStatus executionStatus, + int checksAttempted, + int checksSucceeded, + int checksFailed, + List failureReasons, + List details, + String evidence, + long timestampMs) { this.detectorName = detectorName == null || detectorName.isBlank() ? "unknown" : detectorName; this.riskLevel = riskLevel == null ? RiskLevel.UNKNOWN : riskLevel; this.status = status != null ? status : defaultStatusFor(this.riskLevel); + this.executionStatus = executionStatus != null + ? executionStatus + : defaultExecutionStatusFor(this.riskLevel); this.score = Math.max(0, score); this.maxScore = Math.max(this.score, Math.max(1, maxScore)); - this.warnOnly = warnOnly; + this.warnOnly = informational; + this.checksAttempted = Math.max(0, checksAttempted); + this.checksSucceeded = Math.min(this.checksAttempted, Math.max(0, checksSucceeded)); + this.checksFailed = Math.min( + Math.max(0, this.checksAttempted - this.checksSucceeded), + Math.max(0, checksFailed)); + this.failureReasons = sanitizeDetails(failureReasons); this.details = sanitizeDetails(details); this.evidence = (evidence == null || evidence.isEmpty()) ? String.join("; ", this.details) @@ -68,15 +127,41 @@ public DetectionResult(String detectorName, public String getDetectorName() { return detectorName; } public RiskLevel getRiskLevel() { return riskLevel; } public DetectionStatus getStatus() { return status; } + public DetectionExecutionStatus getExecutionStatus() { return executionStatus; } public int getScore() { return score; } public int getMaxScore() { return maxScore; } + /** @deprecated Use {@link #isInformational()} for clearer semantics. */ + @Deprecated public boolean isWarnOnly() { return warnOnly; } + public boolean isInformational() { return warnOnly; } + public int getChecksAttempted() { return checksAttempted; } + public int getChecksSucceeded() { return checksSucceeded; } + public int getChecksFailed() { return checksFailed; } + public List getFailureReasons() { return failureReasons; } public List getDetails() { return details; } public String getEvidence() { return evidence; } public long getTimestampMs() { return timestampMs; } public static DetectionResult unavailable(String detectorName, String reason) { - String detail = "detection_unavailable:" + return executionFailure(detectorName, DetectionExecutionStatus.UNAVAILABLE, reason); + } + + public static DetectionResult timeout(String detectorName, String reason) { + return executionFailure(detectorName, DetectionExecutionStatus.TIMEOUT, reason); + } + + public static DetectionResult error(String detectorName, String reason) { + return executionFailure(detectorName, DetectionExecutionStatus.ERROR, reason); + } + + public static DetectionResult disabled(String detectorName) { + return executionFailure(detectorName, DetectionExecutionStatus.DISABLED, "disabled_by_config"); + } + + private static DetectionResult executionFailure(String detectorName, + DetectionExecutionStatus executionStatus, + String reason) { + String detail = executionStatus.name().toLowerCase(Locale.ROOT) + ":" + ((reason == null || reason.isBlank()) ? "unknown" : reason); return new DetectionResult( detectorName, @@ -85,11 +170,25 @@ public static DetectionResult unavailable(String detectorName, String reason) { 0, 1, true, + executionStatus, + 1, + 0, + 1, + Collections.singletonList(detail), Collections.singletonList(detail), detail ); } + private static DetectionExecutionStatus defaultExecutionStatusFor(RiskLevel riskLevel) { + if (riskLevel == null || riskLevel == RiskLevel.UNKNOWN) { + return DetectionExecutionStatus.UNAVAILABLE; + } + return riskLevel == RiskLevel.SAFE + ? DetectionExecutionStatus.SAFE + : DetectionExecutionStatus.RISK; + } + private static DetectionStatus defaultStatusFor(RiskLevel riskLevel) { if (riskLevel == null || riskLevel == RiskLevel.UNKNOWN) { return DetectionStatus.UNKNOWN; diff --git a/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/model/ReportStatus.java b/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/model/ReportStatus.java new file mode 100644 index 0000000..ada8e1a --- /dev/null +++ b/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/model/ReportStatus.java @@ -0,0 +1,8 @@ +package com.wsttxm.riskenginesdk.model; + +/** Overall data-collection completeness for a report. */ +public enum ReportStatus { + COMPLETE, + PARTIAL, + UNAVAILABLE +} diff --git a/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/model/RiskReport.java b/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/model/RiskReport.java index c9c05af..4464561 100644 --- a/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/model/RiskReport.java +++ b/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/model/RiskReport.java @@ -8,6 +8,9 @@ import com.wsttxm.riskenginesdk.BuildConfig; public class RiskReport { + public static final int MEDIUM_THRESHOLD = 4; + public static final int HIGH_THRESHOLD = 10; + public static final int DEADLY_THRESHOLD = 18; private final DeviceFingerprint fingerprint; private final List detections; private final long timestampMs; @@ -18,6 +21,10 @@ public class RiskReport { private final int dangerCount; private final int unknownCount; private final RiskLevel overallRiskLevel; + private final ReportStatus reportStatus; + private final int availableDetectionCount; + private final int checkCount; + private final int completedCheckCount; public RiskReport(DeviceFingerprint fingerprint, List detections) { this.fingerprint = Objects.requireNonNull(fingerprint, "fingerprint must not be null"); @@ -37,6 +44,10 @@ public RiskReport(DeviceFingerprint fingerprint, List detection this.dangerCount = computeCount(DetectionStatus.DANGER); this.unknownCount = computeCount(DetectionStatus.UNKNOWN); this.overallRiskLevel = computeOverallRisk(); + this.availableDetectionCount = computeAvailableDetectionCount(); + this.checkCount = computeCheckCount(); + this.completedCheckCount = computeCompletedCheckCount(); + this.reportStatus = computeReportStatus(); } private RiskLevel computeOverallRisk() { @@ -45,15 +56,15 @@ private RiskLevel computeOverallRisk() { RiskLevel level; if (hasHardTrigger()) { level = RiskLevel.DEADLY; - } else if (riskScore >= 18 || actionableDangers >= 3) { + } else if (riskScore >= DEADLY_THRESHOLD || actionableDangers >= 3) { level = RiskLevel.DEADLY; - } else if (riskScore >= 10 || actionableDangers >= 1) { + } else if (riskScore >= HIGH_THRESHOLD || actionableDangers >= 1) { level = RiskLevel.HIGH; - } else if (riskScore >= 4 || actionableWarnings >= 2) { + } else if (riskScore >= MEDIUM_THRESHOLD || actionableWarnings >= 2) { level = RiskLevel.MEDIUM; } else if (riskScore > 0 || warningCount >= 1 || dangerCount >= 1) { level = RiskLevel.LOW; - } else if (unknownCount > 0) { + } else if (unknownCount > 0 || hasIncompleteDetectorExecution()) { level = RiskLevel.UNKNOWN; } else { level = RiskLevel.SAFE; @@ -77,6 +88,26 @@ private RiskLevel computeOverallRisk() { public int getDangerCount() { return dangerCount; } public int getUnknownCount() { return unknownCount; } public RiskLevel getOverallRiskLevel() { return overallRiskLevel; } + public ReportStatus getReportStatus() { return reportStatus; } + public int getAvailableDetectionCount() { return availableDetectionCount; } + public int getDetectionCount() { return detections.size(); } + public int getCheckCount() { return checkCount; } + public int getCompletedCheckCount() { return completedCheckCount; } + public int getIncompleteCheckCount() { return Math.max(0, checkCount - completedCheckCount); } + public int getCoveragePercent() { + return checkCount == 0 ? 100 + : Math.round(completedCheckCount * 100f / checkCount); + } + public int getDisplayThresholdMaximum() { return DEADLY_THRESHOLD; } + public int getNextRiskThreshold() { + if (riskScore < MEDIUM_THRESHOLD) return MEDIUM_THRESHOLD; + if (riskScore < HIGH_THRESHOLD) return HIGH_THRESHOLD; + if (riskScore < DEADLY_THRESHOLD) return DEADLY_THRESHOLD; + return DEADLY_THRESHOLD; + } + public int getScoreToNextRiskThreshold() { + return Math.max(0, getNextRiskThreshold() - riskScore); + } public List getDetectionsByLevel(RiskLevel minLevel) { Objects.requireNonNull(minLevel, "minLevel must not be null"); @@ -92,7 +123,7 @@ public List getDetectionsByLevel(RiskLevel minLevel) { private int computeRiskScore() { long total = 0; for (DetectionResult detection : detections) { - if (!detection.isWarnOnly()) { + if (!detection.isInformational()) { total += detection.getScore(); } } @@ -120,7 +151,7 @@ private int computeCount(DetectionStatus status) { private int computeActionableCount(DetectionStatus status) { int count = 0; for (DetectionResult detection : detections) { - if (!detection.isWarnOnly() && detection.getStatus() == status) { + if (!detection.isInformational() && detection.getStatus() == status) { count++; } } @@ -129,7 +160,7 @@ private int computeActionableCount(DetectionStatus status) { private boolean hasHardTrigger() { for (DetectionResult detection : detections) { - if (detection.isWarnOnly()) { + if (detection.isInformational()) { continue; } if (detection.getRiskLevel().getValue() < RiskLevel.HIGH.getValue()) { @@ -145,6 +176,21 @@ private boolean hasHardTrigger() { return false; } + private boolean hasIncompleteDetectorExecution() { + for (DetectionResult detection : detections) { + switch (detection.getExecutionStatus()) { + case PARTIAL: + case UNAVAILABLE: + case TIMEOUT: + case ERROR: + return true; + default: + break; + } + } + return false; + } + private boolean containsAny(List details, String... prefixes) { for (String detail : details) { for (String prefix : prefixes) { @@ -155,4 +201,73 @@ private boolean containsAny(List details, String... prefixes) { } return false; } + + private int computeAvailableDetectionCount() { + int count = 0; + for (DetectionResult detection : detections) { + switch (detection.getExecutionStatus()) { + case SAFE: + case RISK: + case PARTIAL: + count++; + break; + default: + break; + } + } + return count; + } + + private int computeCheckCount() { + int count = 0; + for (DetectionResult detection : detections) { + if (!detection.getDetectorName().startsWith("collector:")) { + count++; + } + } + for (String fieldName : fingerprint.getResults().keySet()) { + if (!isSyntheticCollector(fieldName)) { + count++; + } + } + return count; + } + + private int computeCompletedCheckCount() { + int count = 0; + for (DetectionResult detection : detections) { + if (detection.getDetectorName().startsWith("collector:")) { + continue; + } + switch (detection.getExecutionStatus()) { + case SAFE: + case RISK: + count++; + break; + default: + break; + } + } + for (CollectorResult result : fingerprint.getResults().values()) { + if (!isSyntheticCollector(result.getFieldName()) + && result.getStatus() == CollectorResult.Status.SUCCESS) { + count++; + } + } + return count; + } + + private boolean isSyntheticCollector(String fieldName) { + return "hook_memory_signals".equals(fieldName) + || "runtime_integrity_score_inputs".equals(fieldName); + } + + private ReportStatus computeReportStatus() { + if (checkCount == 0 || completedCheckCount == checkCount) { + return ReportStatus.COMPLETE; + } + return completedCheckCount == 0 && availableDetectionCount == 0 + ? ReportStatus.UNAVAILABLE + : ReportStatus.PARTIAL; + } } diff --git a/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/util/AdbInspector.java b/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/util/AdbInspector.java index 29d94f2..1cde007 100644 --- a/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/util/AdbInspector.java +++ b/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/util/AdbInspector.java @@ -5,6 +5,8 @@ import android.provider.Settings; import com.wsttxm.riskenginesdk.collector.native_layer.NativeCollectorBridge; +import com.wsttxm.riskenginesdk.core.SignalResult; +import com.wsttxm.riskenginesdk.core.SignalSnapshot; import java.util.ArrayList; import java.util.Collections; @@ -22,6 +24,9 @@ public static final class Snapshot { private boolean enabled; private boolean wifiEnabled; private int tcpPort = -1; + private int checksAttempted; + private int checksSucceeded; + private final List failureReasons = new ArrayList<>(); public List getDetails() { return Collections.unmodifiableList(new ArrayList<>(details)); @@ -30,33 +35,78 @@ public List getDetails() { public boolean isEnabled() { return enabled; } public boolean isWifiEnabled() { return wifiEnabled; } public int getTcpPort() { return tcpPort; } + public int getChecksAttempted() { return checksAttempted; } + public int getChecksSucceeded() { return checksSucceeded; } + public List getFailureReasons() { + return Collections.unmodifiableList(new ArrayList<>(failureReasons)); + } + private void success() { checksAttempted++; checksSucceeded++; } + private void failure(String reason) { + checksAttempted++; + failureReasons.add(reason); + } } public static Snapshot collect(Context context) { + return collectUncached(context, null); + } + + public static Snapshot collect(SignalSnapshot signals) { + SignalResult result = signals.getAdbState(); + if (!result.isSuccess() || result.getValue() == null) { + throw new IllegalStateException("ADB signals unavailable: " + result.getFailureReason()); + } + return result.getValue(); + } + + public static Snapshot collectUncached(Context context, SignalSnapshot signals) { Snapshot snapshot = new Snapshot(); ContentResolver resolver = context.getContentResolver(); - int adbEnabled = Settings.Global.getInt(resolver, Settings.Global.ADB_ENABLED, 0); - if (adbEnabled == 1) { - snapshot.enabled = true; - snapshot.details.add("settings_adb_enabled"); + try { + int adbEnabled = Settings.Global.getInt(resolver, Settings.Global.ADB_ENABLED, 0); + if (adbEnabled == 1) { + snapshot.enabled = true; + snapshot.details.add("settings_adb_enabled"); + } + snapshot.success(); + } catch (Exception e) { + snapshot.failure("settings_adb:" + e.getClass().getSimpleName()); } - int adbWifiEnabled = Settings.Global.getInt(resolver, "adb_wifi_enabled", 0); - if (adbWifiEnabled == 1) { - snapshot.enabled = true; - snapshot.wifiEnabled = true; - snapshot.details.add("settings_adb_wifi_enabled"); + try { + int adbWifiEnabled = Settings.Global.getInt(resolver, "adb_wifi_enabled", 0); + if (adbWifiEnabled == 1) { + snapshot.enabled = true; + snapshot.wifiEnabled = true; + snapshot.details.add("settings_adb_wifi_enabled"); + } + snapshot.success(); + } catch (Exception e) { + snapshot.failure("settings_adb_wifi:" + e.getClass().getSimpleName()); + } + + String tcpPortProp; + if (signals != null) { + SignalResult servicePort = signals.getSystemProperty("service.adb.tcp.port"); + SignalResult persistPort = signals.getSystemProperty("persist.adb.tcp.port"); + tcpPortProp = firstNonBlank(valueOf(servicePort), valueOf(persistPort)); + if (servicePort.isSuccess() || persistPort.isSuccess()) { + snapshot.success(); + } else { + snapshot.failure("adb_properties:" + servicePort.getStatus() + + "/" + persistPort.getStatus()); + } + } else { + tcpPortProp = firstNonBlank( + getNativeProperty("service.adb.tcp.port"), + getNativeProperty("persist.adb.tcp.port")); + snapshot.success(); } - - String tcpPortProp = firstNonBlank( - getNativeProperty("service.adb.tcp.port"), - getNativeProperty("persist.adb.tcp.port") - ); - if (tcpPortProp.isEmpty()) { + if (tcpPortProp.isEmpty() && signals == null) { tcpPortProp = firstNonBlank( - ShellExecutor.execute("getprop service.adb.tcp.port"), - ShellExecutor.execute("getprop persist.adb.tcp.port")); + ShellExecutor.executeResult("getprop service.adb.tcp.port").getStdout(), + ShellExecutor.executeResult("getprop persist.adb.tcp.port").getStdout()); } Integer port = parsePort(tcpPortProp); if (port != null && port > 0) { @@ -68,7 +118,18 @@ public static Snapshot collect(Context context) { // A listening port alone is not proof of ADB: any app can bind 5555. // Only correlate sockets that are owned by an actual adbd process. - List adbdPids = ProcfsUtils.findPidsByProcessNames("adbd"); + List adbdPids; + if (signals == null) { + adbdPids = ProcfsUtils.findPidsByProcessNames("adbd"); + snapshot.success(); + } else { + SignalResult> processes = signals.getProcesses(); + adbdPids = processes.isSuccess() && processes.getValue() != null + ? ProcfsUtils.findPidsByProcessNames(processes.getValue(), "adbd") + : Collections.emptyList(); + if (processes.isSuccess()) snapshot.success(); + else snapshot.failure("process_snapshot_unavailable"); + } if (!adbdPids.isEmpty()) { snapshot.enabled = true; snapshot.details.add("adbd_process:" + adbdPids.get(0)); @@ -101,6 +162,11 @@ public static Snapshot collect(Context context) { return snapshot; } + private static String valueOf(SignalResult result) { + return result != null && result.isSuccess() && result.getValue() != null + ? result.getValue() : ""; + } + private static boolean isConfiguredState(String value) { String lower = value.toLowerCase(Locale.ROOT); return lower.contains("configured") diff --git a/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/util/PrivacyUtils.java b/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/util/PrivacyUtils.java index 9506e1b..0c9f07d 100644 --- a/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/util/PrivacyUtils.java +++ b/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/util/PrivacyUtils.java @@ -8,7 +8,10 @@ public final class PrivacyUtils { private PrivacyUtils() {} - /** Returns a per-application, non-reversible representation of an identifier. */ + /** + * Returns a deterministic, app-scoped pseudonymous representation. + * This limits direct disclosure but is not encryption or anonymization. + */ public static String hashIdentifier(Context context, String value) { if (value == null || value.isBlank()) { return ""; diff --git a/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/util/ProcfsUtils.java b/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/util/ProcfsUtils.java index 54e29aa..69f28ce 100644 --- a/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/util/ProcfsUtils.java +++ b/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/util/ProcfsUtils.java @@ -46,6 +46,22 @@ public boolean isLoopback() { } } + public static final class ProcessInfo { + private final int pid; + private final String comm; + private final String cmdline; + + public ProcessInfo(int pid, String comm, String cmdline) { + this.pid = pid; + this.comm = comm == null ? "" : comm; + this.cmdline = cmdline == null ? "" : cmdline; + } + + public int getPid() { return pid; } + public String getComm() { return comm; } + public String getCmdline() { return cmdline; } + } + public static List readTcpTable(String path) { List entries = new ArrayList<>(); File file = new File(path); @@ -109,32 +125,24 @@ public static Set findPidLoopbackListeningPorts(int pid) { } public static List findPidsByNameFragments(String... keywords) { + return findPidsByNameFragments(snapshotProcesses(), keywords); + } + + public static List findPidsByNameFragments(List processes, + String... keywords) { List pids = new ArrayList<>(); if (keywords == null || keywords.length == 0) { return pids; } - File proc = new File("/proc"); - File[] dirs = proc.listFiles(); - if (dirs == null) { - return pids; - } - for (File dir : dirs) { - if (!dir.isDirectory()) { - continue; - } - String name = dir.getName(); - if (!name.matches("\\d+")) { - continue; - } - String comm = readFirstLine(new File(dir, "comm").getAbsolutePath()); - String cmdline = readCmdline(Integer.parseInt(name)); - String haystack = (comm + " " + cmdline).toLowerCase(Locale.ROOT); + for (ProcessInfo process : processes) { + String haystack = (process.getComm() + " " + process.getCmdline()) + .toLowerCase(Locale.ROOT); for (String keyword : keywords) { if (keyword == null || keyword.isBlank()) { continue; } if (haystack.contains(keyword.toLowerCase(Locale.ROOT))) { - pids.add(Integer.parseInt(name)); + pids.add(process.getPid()); break; } } @@ -143,6 +151,11 @@ public static List findPidsByNameFragments(String... keywords) { } public static List findPidsByProcessNames(String... processNames) { + return findPidsByProcessNames(snapshotProcesses(), processNames); + } + + public static List findPidsByProcessNames(List processes, + String... processNames) { LinkedHashSet expected = new LinkedHashSet<>(); if (processNames == null || processNames.length == 0) { return new ArrayList<>(); @@ -157,28 +170,31 @@ public static List findPidsByProcessNames(String... processNames) { if (expected.isEmpty()) { return pids; } - File[] dirs = new File("/proc").listFiles(); - if (dirs == null) { - return pids; + for (ProcessInfo process : processes) { + if (matchesProcessName(process.getComm(), process.getCmdline(), expected)) { + pids.add(process.getPid()); + } } + return pids; + } + + public static List snapshotProcesses() { + List processes = new ArrayList<>(); + File[] dirs = new File("/proc").listFiles(); + if (dirs == null) return processes; for (File dir : dirs) { String name = dir.getName(); - if (!dir.isDirectory() || !name.matches("\\d+")) { - continue; - } - int pid; + if (!dir.isDirectory() || !name.matches("\\d+")) continue; try { - pid = Integer.parseInt(name); + int pid = Integer.parseInt(name); + processes.add(new ProcessInfo(pid, + readFirstLine(new File(dir, "comm").getAbsolutePath()), + readCmdline(pid))); } catch (NumberFormatException ignored) { - continue; - } - String comm = readFirstLine(new File(dir, "comm").getAbsolutePath()); - String cmdline = readCmdline(pid); - if (matchesProcessName(comm, cmdline, expected)) { - pids.add(pid); + // PID disappeared or overflowed during the scan. } } - return pids; + return processes; } public static String readCmdline(int pid) { diff --git a/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/util/RiskReportJsonSerializer.java b/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/util/RiskReportJsonSerializer.java new file mode 100644 index 0000000..ebee2ec --- /dev/null +++ b/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/util/RiskReportJsonSerializer.java @@ -0,0 +1,94 @@ +package com.wsttxm.riskenginesdk.util; + +import com.wsttxm.riskenginesdk.model.CollectorResult; +import com.wsttxm.riskenginesdk.model.DetectionResult; +import com.wsttxm.riskenginesdk.model.RiskReport; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.Map; + +/** Self-contained JSON serialization that adds no runtime dependency to the AAR. */ +public final class RiskReportJsonSerializer { + private RiskReportJsonSerializer() {} + + public static String serialize(RiskReport report) { + try { + JSONObject root = new JSONObject(); + root.put("timestampMs", report.getTimestampMs()); + root.put("sdkVersion", report.getSdkVersion()); + root.put("riskScore", report.getRiskScore()); + root.put("maxRiskScore", report.getMaxRiskScore()); + root.put("displayThresholdMaximum", report.getDisplayThresholdMaximum()); + root.put("warningCount", report.getWarningCount()); + root.put("dangerCount", report.getDangerCount()); + root.put("unknownCount", report.getUnknownCount()); + root.put("availableDetectionCount", report.getAvailableDetectionCount()); + root.put("detectionCount", report.getDetectionCount()); + root.put("checkCount", report.getCheckCount()); + root.put("completedCheckCount", report.getCompletedCheckCount()); + root.put("incompleteCheckCount", report.getIncompleteCheckCount()); + root.put("coveragePercent", report.getCoveragePercent()); + root.put("reportStatus", report.getReportStatus().name()); + root.put("overallRiskLevel", report.getOverallRiskLevel().name()); + + JSONObject fingerprint = new JSONObject(); + for (Map.Entry entry + : report.getFingerprint().getResults().entrySet()) { + fingerprint.put(entry.getKey(), collectorToJson(entry.getValue())); + } + root.put("fingerprint", fingerprint); + + JSONArray detections = new JSONArray(); + for (DetectionResult detection : report.getDetections()) { + detections.put(detectionToJson(detection)); + } + root.put("detections", detections); + return root.toString(); + } catch (JSONException e) { + throw new IllegalStateException("Unable to serialize risk report", e); + } + } + + private static JSONObject collectorToJson(CollectorResult result) throws JSONException { + JSONObject json = new JSONObject(); + json.put("fieldName", result.getFieldName()); + json.put("status", result.getStatus().name()); + json.put("compareSources", result.isCompareSources()); + json.put("consistent", result.isConsistent()); + json.put("timestampMs", result.getTimestampMs()); + if (result.getCanonicalValue() != null) { + json.put("canonicalValue", result.getCanonicalValue()); + } + if (result.getError() != null) { + json.put("error", result.getError()); + } + JSONObject values = new JSONObject(); + for (Map.Entry entry : result.getValues().entrySet()) { + values.put(entry.getKey(), entry.getValue()); + } + json.put("values", values); + return json; + } + + private static JSONObject detectionToJson(DetectionResult result) throws JSONException { + JSONObject json = new JSONObject(); + json.put("detectorName", result.getDetectorName()); + json.put("riskLevel", result.getRiskLevel().name()); + json.put("status", result.getStatus().name()); + json.put("executionStatus", result.getExecutionStatus().name()); + json.put("score", result.getScore()); + json.put("maxScore", result.getMaxScore()); + json.put("informational", result.isInformational()); + json.put("checksAttempted", result.getChecksAttempted()); + json.put("checksSucceeded", result.getChecksSucceeded()); + json.put("checksFailed", result.getChecksFailed()); + json.put("failureReasons", new JSONArray(result.getFailureReasons())); + json.put("details", new JSONArray(result.getDetails())); + json.put("evidence", result.getEvidence()); + json.put("timestampMs", result.getTimestampMs()); + return json; + } +} diff --git a/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/util/ShellExecutor.java b/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/util/ShellExecutor.java index 2e7eceb..98280df 100644 --- a/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/util/ShellExecutor.java +++ b/riskengine-sdk/src/main/java/com/wsttxm/riskenginesdk/util/ShellExecutor.java @@ -2,6 +2,7 @@ import java.io.BufferedReader; import java.io.IOException; +import java.io.InputStream; import java.io.InputStreamReader; import java.nio.charset.StandardCharsets; import java.util.concurrent.TimeUnit; @@ -11,65 +12,134 @@ public final class ShellExecutor { private static final int MAX_OUTPUT_CHARS = 256 * 1024; private static final long READER_JOIN_TIMEOUT_MS = 500; + public enum Status { + SUCCESS, + INVALID_COMMAND, + TIMEOUT, + NON_ZERO_EXIT, + INTERRUPTED, + EXECUTION_ERROR + } + + /** Immutable outcome that keeps an empty successful response distinct from failure. */ + public static final class Result { + private final Status status; + private final String stdout; + private final String stderr; + private final Integer exitCode; + private final long durationMs; + private final String failureReason; + + private Result(Status status, String stdout, String stderr, Integer exitCode, + long durationMs, String failureReason) { + this.status = status; + this.stdout = stdout == null ? "" : stdout; + this.stderr = stderr == null ? "" : stderr; + this.exitCode = exitCode; + this.durationMs = Math.max(0, durationMs); + this.failureReason = failureReason; + } + + public Status getStatus() { return status; } + public String getStdout() { return stdout; } + public String getStderr() { return stderr; } + public Integer getExitCode() { return exitCode; } + public long getDurationMs() { return durationMs; } + public String getFailureReason() { return failureReason; } + public boolean isSuccess() { return status == Status.SUCCESS; } + } + private ShellExecutor() {} + /** + * Compatibility helper. Prefer {@link #executeResult(String)} so callers do + * not confuse execution failure with a successful command that has no output. + */ + @Deprecated public static String execute(String command) { - return execute(command, DEFAULT_TIMEOUT_MS); + return executeResult(command).getStdout(); } + /** @see #execute(String) */ + @Deprecated public static String execute(String command, long timeoutMs) { + return executeResult(command, timeoutMs).getStdout(); + } + + public static Result executeResult(String command) { + return executeResult(command, DEFAULT_TIMEOUT_MS); + } + + public static Result executeResult(String command, long timeoutMs) { + long startNanos = System.nanoTime(); if (command == null || command.isBlank() || timeoutMs <= 0) { - return ""; + return result(Status.INVALID_COMMAND, "", "", null, startNanos, + "command_or_timeout_invalid"); } Process process = null; - Thread outputReader = null; + Thread stdoutReader = null; + Thread stderrReader = null; + StringBuffer stdout = new StringBuffer(); + StringBuffer stderr = new StringBuffer(); try { - process = new ProcessBuilder("/system/bin/sh", "-c", command) - .redirectErrorStream(true) - .start(); + process = new ProcessBuilder("/system/bin/sh", "-c", command).start(); process.getOutputStream().close(); - StringBuffer output = new StringBuffer(); - Process runningProcess = process; - outputReader = new Thread( - () -> drainOutput(runningProcess, output), - "risk-shell-output"); - outputReader.setDaemon(true); - outputReader.start(); + stdoutReader = startReader(process.getInputStream(), stdout, "risk-shell-stdout"); + stderrReader = startReader(process.getErrorStream(), stderr, "risk-shell-stderr"); if (!process.waitFor(timeoutMs, TimeUnit.MILLISECONDS)) { terminate(process); - awaitReader(outputReader); + awaitReaders(stdoutReader, stderrReader); CLog.w("Shell command timed out"); - return ""; + return result(Status.TIMEOUT, stdout.toString(), stderr.toString(), null, + startNanos, "timeout"); } - awaitReader(outputReader); - if (process.exitValue() != 0) { - CLog.w("Shell command failed with exit code " + process.exitValue()); - return ""; + awaitReaders(stdoutReader, stderrReader); + int exitCode = process.exitValue(); + if (exitCode != 0) { + CLog.w("Shell command failed with exit code " + exitCode); + return result(Status.NON_ZERO_EXIT, stdout.toString(), stderr.toString(), + exitCode, startNanos, "exit_code_" + exitCode); } - return output.toString(); + return result(Status.SUCCESS, stdout.toString(), stderr.toString(), + exitCode, startNanos, null); } catch (InterruptedException e) { Thread.currentThread().interrupt(); if (process != null) { terminate(process); } - return ""; + return result(Status.INTERRUPTED, stdout.toString(), stderr.toString(), null, + startNanos, "interrupted"); } catch (Exception e) { if (process != null) { terminate(process); } CLog.e("ShellExecutor failed", e); - return ""; + return result(Status.EXECUTION_ERROR, stdout.toString(), stderr.toString(), null, + startNanos, e.getClass().getSimpleName()); } } - private static void drainOutput(Process process, StringBuffer output) { + private static Result result(Status status, String stdout, String stderr, Integer exitCode, + long startNanos, String failureReason) { + long durationMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNanos); + return new Result(status, stdout, stderr, exitCode, durationMs, failureReason); + } + + private static Thread startReader(InputStream stream, StringBuffer output, String name) { + Thread thread = new Thread(() -> drainOutput(stream, output), name); + thread.setDaemon(true); + thread.start(); + return thread; + } + + private static void drainOutput(InputStream stream, StringBuffer output) { char[] buffer = new char[4_096]; try (BufferedReader reader = new BufferedReader(new InputStreamReader( - process.getInputStream(), StandardCharsets.UTF_8))) { + stream, StandardCharsets.UTF_8))) { int count; while ((count = reader.read(buffer)) != -1) { int remaining = MAX_OUTPUT_CHARS - output.length(); @@ -78,15 +148,16 @@ private static void drainOutput(Process process, StringBuffer output) { } } } catch (IOException ignored) { - // Destroying a timed-out process closes the stream asynchronously. + // Destroying a timed-out process closes its streams asynchronously. } } - private static void awaitReader(Thread reader) throws InterruptedException { - if (reader == null) { - return; + private static void awaitReaders(Thread... readers) throws InterruptedException { + for (Thread reader : readers) { + if (reader != null) { + reader.join(READER_JOIN_TIMEOUT_MS); + } } - reader.join(READER_JOIN_TIMEOUT_MS); } private static void terminate(Process process) { @@ -101,6 +172,7 @@ private static void terminate(Process process) { } try { process.getInputStream().close(); + process.getErrorStream().close(); } catch (IOException ignored) { // Best effort cleanup. } diff --git a/riskengine-sdk/src/test/java/com/wsttxm/riskenginesdk/DataAggregatorTest.java b/riskengine-sdk/src/test/java/com/wsttxm/riskenginesdk/DataAggregatorTest.java new file mode 100644 index 0000000..7cd6487 --- /dev/null +++ b/riskengine-sdk/src/test/java/com/wsttxm/riskenginesdk/DataAggregatorTest.java @@ -0,0 +1,36 @@ +package com.wsttxm.riskenginesdk; + +import com.wsttxm.riskenginesdk.core.DataAggregator; +import com.wsttxm.riskenginesdk.model.CollectorResult; +import com.wsttxm.riskenginesdk.model.DetectionResult; +import com.wsttxm.riskenginesdk.model.DetectionStatus; +import com.wsttxm.riskenginesdk.model.RiskLevel; +import com.wsttxm.riskenginesdk.model.RiskReport; + +import org.junit.Test; + +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class DataAggregatorTest { + @Test + public void synthesizedFieldsAreExposedButNotDoubleCountedInCoverage() { + CollectorResult collector = new CollectorResult("build_props"); + collector.addValue("model", "test"); + DetectionResult hook = new DetectionResult( + "hook_framework", RiskLevel.HIGH, DetectionStatus.DANGER, + 8, 10, false, List.of("maps:frida"), "maps:frida"); + + RiskReport report = new DataAggregator().aggregate( + List.of(collector), List.of(hook)); + + assertTrue(report.getFingerprint().getResults().containsKey("hook_memory_signals")); + assertTrue(report.getFingerprint().getResults() + .containsKey("runtime_integrity_score_inputs")); + assertEquals(2, report.getCheckCount()); + assertEquals(2, report.getCompletedCheckCount()); + assertEquals(100, report.getCoveragePercent()); + } +} diff --git a/riskengine-sdk/src/test/java/com/wsttxm/riskenginesdk/DetectorCoverageTest.java b/riskengine-sdk/src/test/java/com/wsttxm/riskenginesdk/DetectorCoverageTest.java new file mode 100644 index 0000000..a1c384a --- /dev/null +++ b/riskengine-sdk/src/test/java/com/wsttxm/riskenginesdk/DetectorCoverageTest.java @@ -0,0 +1,47 @@ +package com.wsttxm.riskenginesdk; + +import com.wsttxm.riskenginesdk.detector.BaseDetector; +import com.wsttxm.riskenginesdk.model.DetectionExecutionStatus; +import com.wsttxm.riskenginesdk.model.DetectionResult; +import com.wsttxm.riskenginesdk.model.RiskLevel; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + +public class DetectorCoverageTest { + @Test + public void allFailedSubchecksBecomeUnavailable() { + DetectionResult result = new CoverageDetector(false).call(); + assertEquals(DetectionExecutionStatus.UNAVAILABLE, result.getExecutionStatus()); + assertEquals(RiskLevel.UNKNOWN, result.getRiskLevel()); + } + + @Test + public void mixedSubchecksBecomePartial() { + DetectionResult result = new CoverageDetector(true).call(); + assertEquals(DetectionExecutionStatus.PARTIAL, result.getExecutionStatus()); + assertEquals(2, result.getChecksAttempted()); + assertEquals(1, result.getChecksSucceeded()); + assertEquals(1, result.getChecksFailed()); + } + + private static final class CoverageDetector extends BaseDetector { + private final boolean oneSucceeded; + + private CoverageDetector(boolean oneSucceeded) { + super(null); + this.oneSucceeded = oneSucceeded; + } + + @Override public String getName() { return "coverage"; } + + @Override protected DetectionResult detect() { + CheckCoverage coverage = new CheckCoverage(); + if (oneSucceeded) coverage.success(); + else coverage.failure("first_failed"); + coverage.failure("second_failed"); + return safe(coverage); + } + } +} diff --git a/riskengine-sdk/src/test/java/com/wsttxm/riskenginesdk/FailureSemanticsTest.java b/riskengine-sdk/src/test/java/com/wsttxm/riskenginesdk/FailureSemanticsTest.java index 7292c23..b1d0b53 100644 --- a/riskengine-sdk/src/test/java/com/wsttxm/riskenginesdk/FailureSemanticsTest.java +++ b/riskengine-sdk/src/test/java/com/wsttxm/riskenginesdk/FailureSemanticsTest.java @@ -7,6 +7,8 @@ import com.wsttxm.riskenginesdk.model.CollectorResult; import com.wsttxm.riskenginesdk.model.DetectionResult; import com.wsttxm.riskenginesdk.model.DetectionStatus; +import com.wsttxm.riskenginesdk.model.DetectionExecutionStatus; +import com.wsttxm.riskenginesdk.model.ReportStatus; import com.wsttxm.riskenginesdk.model.DeviceFingerprint; import com.wsttxm.riskenginesdk.model.RiskLevel; import com.wsttxm.riskenginesdk.model.RiskReport; @@ -14,6 +16,7 @@ import org.junit.Test; import java.util.List; +import java.util.Collections; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; @@ -29,6 +32,7 @@ public void detectorFailureIsUnknownRatherThanSafe() { DetectionResult result = detector.call(); assertEquals(DetectionStatus.UNKNOWN, result.getStatus()); assertEquals(RiskLevel.UNKNOWN, result.getRiskLevel()); + assertEquals(DetectionExecutionStatus.ERROR, result.getExecutionStatus()); } @Test @@ -39,6 +43,30 @@ public void unknownCoverageProducesUnknownOverallLevel() { assertEquals(1, report.getUnknownCount()); assertEquals(RiskLevel.UNKNOWN, report.getOverallRiskLevel()); + assertEquals(ReportStatus.UNAVAILABLE, report.getReportStatus()); + } + + @Test + public void timeoutAndUnavailableRemainDistinguishable() { + assertEquals(DetectionExecutionStatus.TIMEOUT, + DetectionResult.timeout("slow", "deadline").getExecutionStatus()); + assertEquals(DetectionExecutionStatus.UNAVAILABLE, + DetectionResult.unavailable("missing", "unsupported").getExecutionStatus()); + } + + @Test + public void partiallyExecutedSafeCheckCannotProduceSafeReport() { + DetectionResult partial = new DetectionResult( + "partial", RiskLevel.SAFE, DetectionStatus.NORMAL, + 0, 10, false, DetectionExecutionStatus.PARTIAL, + 2, 1, 1, List.of("second_check_failed"), + Collections.emptyList(), ""); + + RiskReport report = new RiskReport(new DeviceFingerprint(), List.of(partial)); + + assertEquals(RiskLevel.UNKNOWN, report.getOverallRiskLevel()); + assertEquals(ReportStatus.PARTIAL, report.getReportStatus()); + assertEquals(0, report.getCompletedCheckCount()); } @Test diff --git a/riskengine-sdk/src/test/java/com/wsttxm/riskenginesdk/ProcfsUtilsTest.java b/riskengine-sdk/src/test/java/com/wsttxm/riskenginesdk/ProcfsUtilsTest.java new file mode 100644 index 0000000..29bd721 --- /dev/null +++ b/riskengine-sdk/src/test/java/com/wsttxm/riskenginesdk/ProcfsUtilsTest.java @@ -0,0 +1,30 @@ +package com.wsttxm.riskenginesdk; + +import com.wsttxm.riskenginesdk.util.ProcfsUtils; + +import org.junit.Test; + +import java.util.Arrays; +import java.util.List; + +import static org.junit.Assert.assertEquals; + +public class ProcfsUtilsTest { + private final List processes = Arrays.asList( + new ProcfsUtils.ProcessInfo(101, "adbd", "/system/bin/adbd --root_seclabel"), + new ProcfsUtils.ProcessInfo(202, "app_process64", "org.lsposed.manager"), + new ProcfsUtils.ProcessInfo(303, "frida-server", "/data/local/tmp/frida-server") + ); + + @Test + public void exactProcessMatchUsesSharedSnapshot() { + assertEquals(Arrays.asList(101), + ProcfsUtils.findPidsByProcessNames(processes, "adbd")); + } + + @Test + public void fragmentMatchChecksCommAndCmdlineCaseInsensitively() { + assertEquals(Arrays.asList(202, 303), + ProcfsUtils.findPidsByNameFragments(processes, "LSPOSED", "frida")); + } +} diff --git a/riskengine-sdk/src/test/java/com/wsttxm/riskenginesdk/RiskEngineConfigTest.java b/riskengine-sdk/src/test/java/com/wsttxm/riskenginesdk/RiskEngineConfigTest.java index 3c330ba..ed9a770 100644 --- a/riskengine-sdk/src/test/java/com/wsttxm/riskenginesdk/RiskEngineConfigTest.java +++ b/riskengine-sdk/src/test/java/com/wsttxm/riskenginesdk/RiskEngineConfigTest.java @@ -15,6 +15,33 @@ public void defaultConfigValues() { assertTrue(config.isEnableRoot()); assertTrue(config.isEnableHookDetection()); assertEquals(10_000, config.getCollectTimeoutMs()); + assertEquals(PrivacyProfile.BALANCED, config.getPrivacyProfile()); + assertTrue(config.isCollectAndroidId()); + assertFalse(config.isCollectBootId()); + assertFalse(config.isCollectDrmId()); + } + + @Test + public void privacyProfilesAndOverridesAreResolvedAtBuildTime() { + RiskEngineConfig minimal = new RiskEngineConfig.Builder() + .privacyProfile(PrivacyProfile.MINIMAL) + .build(); + assertFalse(minimal.isCollectAndroidId()); + assertFalse(minimal.isCollectBootId()); + assertFalse(minimal.isCollectDrmId()); + + RiskEngineConfig diagnostic = new RiskEngineConfig.Builder() + .privacyProfile(PrivacyProfile.DIAGNOSTIC) + .collectDrmId(false) + .build(); + assertTrue(diagnostic.isCollectAndroidId()); + assertTrue(diagnostic.isCollectBootId()); + assertFalse(diagnostic.isCollectDrmId()); + } + + @Test(expected = IllegalArgumentException.class) + public void rejectsNullPrivacyProfile() { + new RiskEngineConfig.Builder().privacyProfile(null); } @Test diff --git a/riskengine-sdk/src/test/java/com/wsttxm/riskenginesdk/RiskReportJsonSerializerTest.java b/riskengine-sdk/src/test/java/com/wsttxm/riskenginesdk/RiskReportJsonSerializerTest.java new file mode 100644 index 0000000..83609ef --- /dev/null +++ b/riskengine-sdk/src/test/java/com/wsttxm/riskenginesdk/RiskReportJsonSerializerTest.java @@ -0,0 +1,29 @@ +package com.wsttxm.riskenginesdk; + +import com.wsttxm.riskenginesdk.model.DetectionResult; +import com.wsttxm.riskenginesdk.model.DeviceFingerprint; +import com.wsttxm.riskenginesdk.model.RiskReport; + +import org.json.JSONObject; +import org.junit.Test; + +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class RiskReportJsonSerializerTest { + @Test + public void existingReportCanBeSerializedWithoutCollectingAgain() throws Exception { + RiskReport report = new RiskReport(new DeviceFingerprint(), + List.of(DetectionResult.timeout("slow", "deadline"))); + + JSONObject json = new JSONObject(RiskEngine.reportToJson(report)); + + assertEquals("UNKNOWN", json.getString("overallRiskLevel")); + assertEquals("UNAVAILABLE", json.getString("reportStatus")); + assertEquals("TIMEOUT", json.getJSONArray("detections") + .getJSONObject(0).getString("executionStatus")); + assertTrue(json.has("coveragePercent")); + } +} diff --git a/riskengine-sdk/src/test/java/com/wsttxm/riskenginesdk/RiskScoringModelTest.java b/riskengine-sdk/src/test/java/com/wsttxm/riskenginesdk/RiskScoringModelTest.java index 3d9aabf..28515b5 100644 --- a/riskengine-sdk/src/test/java/com/wsttxm/riskenginesdk/RiskScoringModelTest.java +++ b/riskengine-sdk/src/test/java/com/wsttxm/riskenginesdk/RiskScoringModelTest.java @@ -14,6 +14,15 @@ public class RiskScoringModelTest { + @Test + public void scoreThresholdsHaveStableBoundaries() { + assertEquals(RiskLevel.SAFE, reportAtScore(0).getOverallRiskLevel()); + assertEquals(RiskLevel.LOW, reportAtScore(1).getOverallRiskLevel()); + assertEquals(RiskLevel.MEDIUM, reportAtScore(4).getOverallRiskLevel()); + assertEquals(RiskLevel.HIGH, reportAtScore(10).getOverallRiskLevel()); + assertEquals(RiskLevel.DEADLY, reportAtScore(18).getOverallRiskLevel()); + } + @Test public void aggregatesWeightedScoreInsteadOfOnlyTakingMaxLevel() { RiskReport report = new RiskReport( @@ -68,4 +77,15 @@ public void multipleWarnOnlySignalsRemainLowRisk() { assertEquals(0, report.getRiskScore()); assertEquals(RiskLevel.LOW, report.getOverallRiskLevel()); } + + private RiskReport reportAtScore(int score) { + if (score == 0) { + return new RiskReport(new DeviceFingerprint(), List.of( + new DetectionResult("test", RiskLevel.SAFE, DetectionStatus.NORMAL, + 0, 18, false, List.of(), ""))); + } + return new RiskReport(new DeviceFingerprint(), List.of( + new DetectionResult("test", RiskLevel.MEDIUM, DetectionStatus.WARNING, + score, Math.max(18, score), false, List.of("signal"), "signal"))); + } } diff --git a/riskengine-sdk/src/test/java/com/wsttxm/riskenginesdk/ShellExecutorTest.java b/riskengine-sdk/src/test/java/com/wsttxm/riskenginesdk/ShellExecutorTest.java new file mode 100644 index 0000000..7b9f8f1 --- /dev/null +++ b/riskengine-sdk/src/test/java/com/wsttxm/riskenginesdk/ShellExecutorTest.java @@ -0,0 +1,18 @@ +package com.wsttxm.riskenginesdk; + +import com.wsttxm.riskenginesdk.util.ShellExecutor; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; + +public class ShellExecutorTest { + @Test + public void invalidCommandHasStructuredFailure() { + ShellExecutor.Result result = ShellExecutor.executeResult("", 1000); + assertEquals(ShellExecutor.Status.INVALID_COMMAND, result.getStatus()); + assertFalse(result.isSuccess()); + assertEquals("", result.getStdout()); + } +} diff --git a/riskengine-sdk/src/test/java/com/wsttxm/riskenginesdk/SignalSnapshotTest.java b/riskengine-sdk/src/test/java/com/wsttxm/riskenginesdk/SignalSnapshotTest.java new file mode 100644 index 0000000..890de87 --- /dev/null +++ b/riskengine-sdk/src/test/java/com/wsttxm/riskenginesdk/SignalSnapshotTest.java @@ -0,0 +1,34 @@ +package com.wsttxm.riskenginesdk; + +import com.wsttxm.riskenginesdk.core.SignalResult; +import com.wsttxm.riskenginesdk.core.SignalSnapshot; +import com.wsttxm.riskenginesdk.util.AdbInspector; + +import org.junit.Test; + +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertSame; +import com.wsttxm.riskenginesdk.util.ShellExecutor; + +public class SignalSnapshotTest { + @Test + public void expensiveSignalOutcomeIsMemoizedUntilReset() { + SignalSnapshot snapshot = new SignalSnapshot(null); + + SignalResult first = snapshot.getAdbState(); + SignalResult second = snapshot.getAdbState(); + assertSame(first, second); + + snapshot.reset(); + assertNotSame(first, snapshot.getAdbState()); + } + + @Test + public void shellOutcomeIsMemoizedWithinOneReport() { + SignalSnapshot snapshot = new SignalSnapshot(null); + ShellExecutor.Result first = snapshot.getShellResult(""); + assertSame(first, snapshot.getShellResult("")); + snapshot.reset(); + assertNotSame(first, snapshot.getShellResult("")); + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index d3ca46e..12efdcd 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -23,3 +23,4 @@ dependencyResolutionManagement { rootProject.name = "RiskEngineSDK" include(":demo") include(":riskengine-sdk") +include(":integration-test")