Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Allowlist: only what the Gradle build needs enters the build context, so
# local scan reports (noir-out/) and tool caches can never end up in a layer.
*
!build.gradle.kts
!settings.gradle.kts
!gradle.properties
!gradlew
!gradle/
!core/
!cli/
!LICENSE

**/build/
**/.gradle/
**/.kotlin/
6 changes: 6 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,9 @@ updates:
schedule:
interval: weekly
open-pull-requests-limit: 5

- package-ecosystem: docker
directory: /
schedule:
interval: weekly
open-pull-requests-limit: 5
52 changes: 52 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,55 @@ jobs:
if: always()
shell: bash
run: ./gradlew --stop || true

docker:
name: docker (${{ matrix.arch }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
arch: amd64
- os: ubuntu-24.04-arm
arch: arm64

steps:
- uses: actions/checkout@v7

# .dockerignore is an allowlist: a local scan report must not reach a layer.
- name: Plant a report the build context must ignore
shell: bash
run: |
mkdir -p noir-out
echo "private scan data" > noir-out/report.json

- name: Build image
shell: bash
run: docker build -t noir:ci .

- name: Smoke test
shell: bash
run: |
set -euo pipefail
docker run --rm noir:ci --version
docker run --rm --entrypoint adb noir:ci version
# No phone here, so doctor exits 1. It must still find the packaged
# adb, notice the container and explain USB passthrough.
docker run --rm -e NOIR_LANG=en noir:ci doctor | tee doctor.txt || true
grep -q "container docker" doctor.txt
grep -q "adb /usr/bin/adb" doctor.txt
grep -q "no device detected in the container" doctor.txt

- name: Build context holds the allowlist only
shell: bash
run: |
set -euo pipefail
docker build --target build -t noir:ci-build .
allowed='build\.gradle\.kts|settings\.gradle\.kts|gradle\.properties|gradlew|gradle|core|cli|LICENSE|build|\.gradle|\.kotlin'
extra="$(docker run --rm --entrypoint ls noir:ci-build -A /src | grep -vxE "$allowed" || true)"
if [ -n "$extra" ]; then
echo "unexpected files in the build context:"
echo "$extra"
exit 1
fi
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,23 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]

### Added

- **Docker detection** — `noir doctor` now reports whether Docker is installed
and whether its daemon is running, and prints the fix when the daemon is
stopped or refuses access. Any other failure, such as a rejected TLS
certificate, is shown as unknown with Docker's own message. Docker stays
optional: the row never changes the exit code, and the check only runs the
local `docker --version` and `docker version`.
- **container awareness** — NOIR recognises when it runs inside Docker, Podman,
Kubernetes or LXC. `doctor` shows a `container` row, and the "no device"
guidance explains USB passthrough instead of blaming the cable.
- **Docker image** — a `Dockerfile` builds an image with a JRE, adb and NOIR,
so a Linux host needs nothing but Docker. The README documents the USB
passthrough flags and the volume that keeps the adb authorisation. Docker
Desktop cannot pass USB through, so macOS and Windows stay with the
portable build.

### Fixed

- **dumpsys compatibility** — package parsing now tolerates OEM indentation
Expand Down
42 changes: 42 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# NOIR in a container: a JRE, adb and NOIR itself, so a Linux host needs
# nothing but Docker.
#
# docker build -t noir .
# docker run --rm -it \
# --device-cgroup-rule='c 189:* rmw' -v /dev/bus/usb:/dev/bus/usb \
# -v noir-home:/root -v "$PWD:/work" noir
#
# README ("方式三:Docker") explains every flag.

# Build and runtime share one Java release: Gradle targets the JDK it runs on,
# so a newer build JDK would produce classes the runtime cannot load.
# Both base images are pinned by version and digest; Dependabot moves the pins.
FROM eclipse-temurin:17.0.21_9-jdk-noble@sha256:1bedb04434bd275ef072355e9caec2a22be5240fbe82ad51a59323f2bbcab4a6 AS build
WORKDIR /src
COPY . .
RUN ./gradlew :cli:installDist --no-daemon --console=plain

FROM eclipse-temurin:17.0.21_9-jre-noble@sha256:042c05a77eb915684fc8c92cfbbef4fd6f8c24ca7d934d1a7db3543453aaa077
LABEL org.opencontainers.image.title="NOIR" \
org.opencontainers.image.description="Android leak audit" \
org.opencontainers.image.licenses="MIT"

# adb comes from the distribution: Google's platform-tools zip is x86_64 only,
# the package also exists for arm64. jadx is not baked in; `noir setup`
# downloads it into the /root volume when deep/deepscan are wanted.
RUN apt-get update \
&& apt-get install -y --no-install-recommends adb \
&& rm -rf /var/lib/apt/lists/*

COPY --from=build /src/cli/build/install/noir /opt/noir
COPY LICENSE /opt/noir/LICENSE
ENV PATH="/opt/noir/bin:${PATH}"

# The container stays root on purpose: adb has to open the USB device nodes
# passed through from the host, and those belong to root. With the documented
# flags that means every USB device on the host, not only the phone, so run
# only an image you built yourself.
#
# Reports land in ./noir-out relative to the working directory; mount it.
WORKDIR /work
ENTRYPOINT ["noir"]
56 changes: 56 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ noir doctor
NOIR doctor
platform Mac OS X aarch64
java 17.0.19
docker /usr/local/bin/docker
客户端 29.9.2 · 守护进程 29.9.2 运行中
adb /opt/homebrew/.../platform-tools/adb
Version 37.0.1-15733141
device 178b0d2f device 2509FPN0BC
Expand All @@ -89,6 +91,24 @@ noir setup # 缺什么下什么
noir setup --yes # 不询问,适合脚本
```

### Docker 安装 / 运行检测

`docker` 一行回答两个问题:Docker 装了没有,守护进程在不在跑。Docker 是可选项
(只有想在容器里运行 NOIR 才需要),所以这一行只报告,不影响 `doctor` 的退出码:

| 情况 | `doctor` 显示 |
|---|---|
| 没装 | `未安装(可选:…)` |
| 装了,守护进程在跑 | `客户端 29.9.2 · 守护进程 29.9.2 运行中` |
| 装了,没启动 | `守护进程未运行`,下一行是启动方法 |
| 装了,当前用户没权限 | `守护进程拒绝访问`,下一行是解决方法 |
| 其他错误(例如 TLS 证书校验失败),或 10 秒内没有应答 | `守护进程状态未知`,下一行是原因:Docker 的原始报错,或超时提示 |

检测只调用本机的 `docker --version` 和 `docker version`,不拉镜像,也不启动容器。

NOIR 自己跑在容器里时(Docker / Podman / Kubernetes / LXC),`doctor` 会多一行
`container docker`。这时找不到设备,提示会换成 USB 透传的做法,而不是让你换数据线。

## 出现问题时

所有错误都会带「怎么办」,而不是抛异常栈:
Expand Down Expand Up @@ -180,6 +200,42 @@ cd NOIR
./cli/build/install/noir/bin/noir --about
```

**方式三:Docker(Linux 宿主机)**

镜像里带 JRE、adb 和 NOIR 本身,宿主机只需要 Docker。在仓库根目录构建
(首次会下载 Gradle 和依赖,需要几分钟):

```bash
docker build -t noir .
```

```bash
docker run --rm -it \
--device-cgroup-rule='c 189:* rmw' -v /dev/bus/usb:/dev/bus/usb \
-v noir-home:/root -v "$PWD:/work" \
noir
```

| 参数 | 作用 |
|---|---|
| `--device-cgroup-rule='c 189:* rmw'`<br>`-v /dev/bus/usb:/dev/bus/usb` | 把 USB 设备透传进容器,重新插拔也有效 |
| `-v noir-home:/root` | 保存 adb 授权密钥和扫描缓存;不加的话,每次运行手机都要重新点「允许」 |
| `-v "$PWD:/work"` | 报告写到当前目录的 `noir-out/` |

- 宿主机上如果也装了 adb,先执行 `adb kill-server`,否则它会占住手机。
- 命令末尾可以接任何子命令,例如 `… noir doctor`、`… noir ui`。
- `deep` / `deepscan` 需要的 jadx 不在镜像里:执行一次 `… noir setup --yes`,它会下载到 `noir-home`。
- 在容器里找不到设备时,NOIR 会直接提示上面的透传参数。

> **权限说明**:容器内的 NOIR 和 adb 以 root 运行,这样才能打开透传进来的 USB 设备节点。
> 上面两个 USB 参数给出的是宿主机上**全部** USB 设备的访问权,不只是手机,所以只运行你自己构建的镜像。
> 副作用是 `noir-out/` 里的文件属主也是 root。

macOS / Windows 的 Docker Desktop 不能把 USB 设备透传进容器,这两个系统请用方式一的便携包。

不要改成让容器去连宿主机的 adb server:它默认只监听 `127.0.0.1:5037`,容器通常连不上;
而用 `adb -a` 让它监听所有网卡,会把一个没有认证的端口暴露给整个局域网,任何人都能借它控制手机。

## 构建

```bash
Expand Down
111 changes: 111 additions & 0 deletions cli/src/main/kotlin/app/noir/cli/Guidance.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ package app.noir.cli

import app.noir.core.Lang
import app.noir.core.Tui
import app.noir.core.platform.ContainerRuntime
import app.noir.core.platform.DockerDaemon
import app.noir.core.platform.DockerStatus
import app.noir.core.platform.Platform

/** Human-friendly text blocks: every failure should end with an action. */
object Guidance {
Expand Down Expand Up @@ -171,6 +175,113 @@ object Guidance {
""".trimMargin()
})

/**
* Doctor rows for Docker. Docker is optional, so "not installed" is a plain
* fact; a daemon that is installed but unusable still ends with an action.
*/
fun docker(
status: DockerStatus,
lang: Lang = LANG,
windows: Boolean = Platform.isWindows,
mac: Boolean = Platform.isMac
): String {
fun l(zh: String, en: String) = if (lang == Lang.EN) en else zh

if (!status.installed) {
return l(
" docker 未安装(可选:只有想在容器里运行 NOIR 才需要)",
" docker not installed (optional: only needed to run NOIR in a container)"
)
}

val client = status.clientVersion?.let { l("客户端 $it", "client $it") }
val daemon = when (status.daemon) {
DockerDaemon.RUNNING ->
status.serverVersion?.let { l("守护进程 $it 运行中", "daemon $it running") }
?: l("守护进程运行中", "daemon running")
DockerDaemon.STOPPED -> l("守护进程未运行", "daemon not running")
DockerDaemon.DENIED -> l("守护进程拒绝访问", "daemon refused access")
DockerDaemon.UNKNOWN -> l("守护进程状态未知", "daemon state unknown")
}
val next = when (status.daemon) {
DockerDaemon.RUNNING -> null
DockerDaemon.STOPPED ->
if (windows || mac) {
l("启动方法:打开 Docker Desktop", "start it: open Docker Desktop")
} else {
l("启动方法:sudo systemctl start docker", "start it: sudo systemctl start docker")
}
DockerDaemon.DENIED -> when {
windows -> l(
"解决方法:把当前账号加入 docker-users 用户组,然后重新登录",
"fix: add your account to the docker-users group, then sign in again"
)
mac -> l("解决方法:重启 Docker Desktop", "fix: restart Docker Desktop")
else -> l(
"解决方法:sudo usermod -aG docker \$USER,然后重新登录",
"fix: sudo usermod -aG docker \$USER, then log in again"
)
}
DockerDaemon.UNKNOWN -> status.detail
}

val indent = " ".repeat(12)
val sb = StringBuilder(" docker ").append(status.cli)
sb.append('\n').append(indent).append(asciiText(listOfNotNull(client, daemon).joinToString(" · ")))
if (next != null) sb.append('\n').append(indent).append(asciiText(next))
return sb.toString()
}

/** Doctor row shown only when NOIR itself runs inside a container. */
fun container(runtime: ContainerRuntime, lang: Lang = LANG): String {
val name = if (runtime == ContainerRuntime.OTHER) {
if (lang == Lang.EN) "yes" else "是"
} else {
runtime.label
}
return " container $name"
}

/**
* "No device" inside a container: the cable is rarely the problem there,
* the missing USB passthrough is.
*/
fun containerDeviceGuide(runtime: ContainerRuntime, lang: Lang = LANG): String {
val known = runtime != ContainerRuntime.OTHER
val zh = listOf(
"",
"NOIR 正在容器里运行" + (if (known) "(${runtime.label})" else "") +
",容器默认看不到宿主机的 USB 设备。",
"",
"1. Linux 宿主机:docker run 时加上这两个参数,把 USB 透传进容器",
" --device-cgroup-rule='c 189:* rmw' -v /dev/bus/usb:/dev/bus/usb",
" 宿主机上的 adb 会占住手机,先执行 adb kill-server",
"",
"2. macOS / Windows:Docker Desktop 不能透传 USB",
" 请改用便携包,直接在宿主机上运行 NOIR",
"",
"3. 手机端仍需开启 USB 调试,并在弹窗里点「允许」"
)
val en = listOf(
"",
"NOIR is running inside a container" + (if (known) " (${runtime.label})" else "") +
". Containers cannot see the host's USB devices by default.",
"",
"1. Linux host: add these two flags to docker run to pass USB through",
" --device-cgroup-rule='c 189:* rmw' -v /dev/bus/usb:/dev/bus/usb",
" A host adb server holds the phone; stop it first: adb kill-server",
"",
"2. macOS / Windows: Docker Desktop cannot pass USB through",
" Use the portable build and run NOIR on the host instead",
"",
"3. The phone still needs USB debugging on, and its prompt allowed."
)
return guideBox(
if (lang == Lang.EN) "no device detected in the container" else "容器里没有检测到设备",
if (lang == Lang.EN) en else zh
)
}

fun error(title: String, hint: String? = null): String {
val total = (Tui.terminalColumns() - 2).coerceIn(40, 100)
val inner = total - 4
Expand Down
Loading
Loading