diff --git a/.github/workflows/add-issue-to-project.yml b/.github/workflows/add-issue-to-project.yml index c4e6d84..09bf7f3 100644 --- a/.github/workflows/add-issue-to-project.yml +++ b/.github/workflows/add-issue-to-project.yml @@ -11,7 +11,7 @@ jobs: name: Add issue to project runs-on: ubuntu-latest steps: - - uses: actions/add-to-project@v0.5.0 + - uses: actions/add-to-project@5afcf98fcd03f1c2f92c3c83f58ae24323cc57fd # v2.0.0 with: # You can target a project in a different organization # to the issue diff --git a/.github/workflows/container.yaml b/.github/workflows/container.yaml index 828421e..c118ef8 100644 --- a/.github/workflows/container.yaml +++ b/.github/workflows/container.yaml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set image version id: version @@ -24,13 +24,13 @@ jobs: echo "version=${version#v}" >> "$GITHUB_OUTPUT" - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: Log in to GitHub Container Registry - uses: docker/login-action@v3 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -38,7 +38,7 @@ jobs: - name: Extract container metadata id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 with: images: ghcr.io/mvt-project/androidqf tags: | @@ -47,7 +47,7 @@ jobs: type=raw,value=latest,enable=${{ github.event.release.prerelease == false }} - name: Build and publish - uses: docker/build-push-action@v6 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: . platforms: linux/amd64,linux/arm64 diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index ad9157f..8014a35 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -14,14 +14,14 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version: "1.26.3" + go-version: "1.26.6" cache: true - name: Install cross-compilation tools @@ -30,10 +30,10 @@ jobs: sudo apt-get install -y gcc-mingw-w64-x86-64 wget unzip make - name: Run GoReleaser - uses: goreleaser/goreleaser-action@v6 + uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3 with: distribution: goreleaser - version: latest + version: v2.17.1 args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/staticcheck.yaml b/.github/workflows/staticcheck.yaml index 25d6ed4..7c02473 100644 --- a/.github/workflows/staticcheck.yaml +++ b/.github/workflows/staticcheck.yaml @@ -6,18 +6,32 @@ jobs: name: "Run CI" runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 1 - - uses: actions/setup-go@v5 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version: "1.26.3" + go-version: "1.26.6" cache: true - name: download assets run: make download - name: build collector run: make collector - - uses: dominikh/staticcheck-action@v1.3.1 - with: - version: "latest" - install-go: false + - name: Run tests + run: | + go test -race ./... + (cd android-collector && go test -race ./...) + - name: Run vet + run: | + go vet ./... + (cd android-collector && go vet ./...) + - name: Run staticcheck + run: | + go install honnef.co/go/tools/cmd/staticcheck@v0.7.0 + staticcheck ./... + (cd android-collector && staticcheck ./...) + - name: Run vulnerability checks + run: | + go install golang.org/x/vuln/cmd/govulncheck@v1.6.0 + govulncheck ./... + (cd android-collector && govulncheck ./...) diff --git a/.goreleaser.yaml b/.goreleaser.yaml index bf986e0..63695d3 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -7,19 +7,18 @@ before: - sh -c 'cd android-collector && CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=6 go build -ldflags="-s -w" -o ../assets/collector_arm . && upx --best ../assets/collector_arm' - sh -c 'cd android-collector && CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o ../assets/collector_arm64 . && upx --best ../assets/collector_arm64' # Download platform tools for different OSes - - mkdir -p /tmp/platform-tools-downloads # Windows platform tools - - bash -c 'if [ ! -f /tmp/platform-tools-downloads/platform-tools-latest-windows.zip ]; then wget https://dl.google.com/android/repository/platform-tools-latest-windows.zip -O /tmp/platform-tools-downloads/platform-tools-latest-windows.zip; fi' + - ./scripts/download_platform_tools.sh windows - bash -c 'cd /tmp && unzip -o /tmp/platform-tools-downloads/platform-tools-latest-windows.zip' - cp /tmp/platform-tools/AdbWinApi.dll assets/ - cp /tmp/platform-tools/AdbWinUsbApi.dll assets/ - cp /tmp/platform-tools/adb.exe assets/ # macOS platform tools - - bash -c 'if [ ! -f /tmp/platform-tools-downloads/platform-tools-latest-darwin.zip ]; then wget https://dl.google.com/android/repository/platform-tools-latest-darwin.zip -O /tmp/platform-tools-downloads/platform-tools-latest-darwin.zip; fi' + - ./scripts/download_platform_tools.sh darwin - bash -c 'cd /tmp && unzip -o /tmp/platform-tools-downloads/platform-tools-latest-darwin.zip' - cp /tmp/platform-tools/adb assets/adb_darwin # Linux platform tools - - bash -c 'if [ ! -f /tmp/platform-tools-downloads/platform-tools-latest-linux.zip ]; then wget https://dl.google.com/android/repository/platform-tools-latest-linux.zip -O /tmp/platform-tools-downloads/platform-tools-latest-linux.zip; fi' + - ./scripts/download_platform_tools.sh linux - bash -c 'cd /tmp && unzip -o /tmp/platform-tools-downloads/platform-tools-latest-linux.zip' - cp /tmp/platform-tools/adb assets/adb_linux diff --git a/Dockerfile b/Dockerfile index 207da0b..a58140a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM alpine:3.23 +FROM alpine:3.23.5@sha256:fd791d74b68913cbb027c6546007b3f0d3bc45125f797758156952bc2d6daf40 ARG TARGETARCH=amd64 ARG VERSION=1.8.3 @@ -10,6 +10,11 @@ RUN apk add --no-cache ca-certificates gcompat libgcc wget \ esac \ && wget -O /usr/local/bin/androidqf \ "https://github.com/mvt-project/androidqf/releases/download/v${VERSION}/androidqf_linux_${TARGETARCH}_${VERSION}" \ + && wget -O /tmp/checksums.txt \ + "https://github.com/mvt-project/androidqf/releases/download/v${VERSION}/checksums.txt" \ + && cd /usr/local/bin \ + && grep " androidqf_linux_${TARGETARCH}_${VERSION}$" /tmp/checksums.txt | sha256sum -c - \ + && rm /tmp/checksums.txt \ && chmod +x /usr/local/bin/androidqf WORKDIR /acquisition diff --git a/Makefile b/Makefile index 89a3ec6..1e221ef 100644 --- a/Makefile +++ b/Makefile @@ -16,21 +16,28 @@ LD_FLAGS = -s -w -X ${PACKAGE_PATH}/utils.Version=${VERSION} # Set if binaries should be compressed with UPX. Zero disables UPX UPX_COMPRESS ?= "0" -PLATFORMTOOLS_URL = https://dl.google.com/android/repository/ PLATFORMTOOLS_WINDOWS = platform-tools-latest-windows.zip PLATFORMTOOLS_DARWIN = platform-tools-latest-darwin.zip PLATFORMTOOLS_LINUX = platform-tools-latest-linux.zip PLATFORMTOOLS_FOLDER = /tmp/platform-tools +PLATFORMTOOLS_DOWNLOAD_FOLDER = /tmp/platform-tools-downloads check: @echo "[lint] Running go vet" go vet ./... - @echo "[lint] Running staticheck on codebase" + cd android-collector && go vet ./... + @echo "[lint] Running staticcheck on codebase" @staticcheck ./... + cd android-collector && staticcheck ./... vuln: @echo "Running go vuln check" @govulncheck ./... + cd android-collector && govulncheck ./... + +test: + go test -race ./... + cd android-collector && go test -race ./... fmt: gofumpt -l -w . @@ -52,13 +59,10 @@ collector: windows: @mkdir -p $(BUILD_FOLDER) - @if [ ! -f /tmp/$(PLATFORMTOOLS_WINDOWS) ]; then \ - echo "Downloading Windows Android Platform Tools..."; \ - wget $(PLATFORMTOOLS_URL)$(PLATFORMTOOLS_WINDOWS) -O /tmp/$(PLATFORMTOOLS_WINDOWS); \ - fi + @./scripts/download_platform_tools.sh windows @rm -rf $(PLATFORMTOOLS_FOLDER) - @cd /tmp && unzip -u $(PLATFORMTOOLS_WINDOWS) + @cd /tmp && unzip -u $(PLATFORMTOOLS_DOWNLOAD_FOLDER)/$(PLATFORMTOOLS_WINDOWS) @cp $(PLATFORMTOOLS_FOLDER)/AdbWinApi.dll $(ASSETS_FOLDER) @cp $(PLATFORMTOOLS_FOLDER)/AdbWinUsbApi.dll $(ASSETS_FOLDER) @cp $(PLATFORMTOOLS_FOLDER)/adb.exe $(ASSETS_FOLDER) @@ -72,14 +76,11 @@ windows: darwin: @mkdir -p $(BUILD_FOLDER) - @if [ ! -f /tmp/$(PLATFORMTOOLS_DARWIN) ]; then \ - echo "Downloading Darwin Android Platform Tools..."; \ - wget $(PLATFORMTOOLS_URL)$(PLATFORMTOOLS_DARWIN) -O /tmp/$(PLATFORMTOOLS_DARWIN); \ - fi + @./scripts/download_platform_tools.sh darwin @rm -rf $(PLATFORMTOOLS_FOLDER) - @cd /tmp && unzip -u $(PLATFORMTOOLS_DARWIN) - @cp $(PLATFORMTOOLS_FOLDER)/adb $(ASSETS_FOLDER) + @cd /tmp && unzip -u $(PLATFORMTOOLS_DOWNLOAD_FOLDER)/$(PLATFORMTOOLS_DARWIN) + @cp $(PLATFORMTOOLS_FOLDER)/adb $(ASSETS_FOLDER)/adb_darwin @echo "[builder] Building Darwin binary for amd64" @@ -91,14 +92,11 @@ darwin: linux: @mkdir -p $(BUILD_FOLDER) - @if [ ! -f /tmp/$(PLATFORMTOOLS_LINUX) ]; then \ - echo "Downloading Linux Android Platform Tools..."; \ - wget $(PLATFORMTOOLS_URL)$(PLATFORMTOOLS_LINUX) -O /tmp/$(PLATFORMTOOLS_LINUX); \ - fi + @./scripts/download_platform_tools.sh linux @rm -rf $(PLATFORMTOOLS_FOLDER) - @cd /tmp && unzip -u $(PLATFORMTOOLS_LINUX) - @cp $(PLATFORMTOOLS_FOLDER)/adb $(ASSETS_FOLDER) + @cd /tmp && unzip -u $(PLATFORMTOOLS_DOWNLOAD_FOLDER)/$(PLATFORMTOOLS_LINUX) + @cp $(PLATFORMTOOLS_FOLDER)/adb $(ASSETS_FOLDER)/adb_linux @echo "[builder] Building Linux binary for amd64" @@ -108,25 +106,25 @@ linux: @echo "[builder] Done!" download: - @if [ ! -f /tmp/$(PLATFORMTOOLS_WINDOWS) ]; then \ - echo "Downloading Windows Android Platform Tools..."; \ - wget $(PLATFORMTOOLS_URL)$(PLATFORMTOOLS_WINDOWS) -O /tmp/$(PLATFORMTOOLS_WINDOWS); \ - fi + @./scripts/download_platform_tools.sh windows @rm -rf $(PLATFORMTOOLS_FOLDER) - @cd /tmp && unzip -u $(PLATFORMTOOLS_WINDOWS) + @cd /tmp && unzip -u $(PLATFORMTOOLS_DOWNLOAD_FOLDER)/$(PLATFORMTOOLS_WINDOWS) @cp $(PLATFORMTOOLS_FOLDER)/AdbWinApi.dll $(ASSETS_FOLDER) @cp $(PLATFORMTOOLS_FOLDER)/AdbWinUsbApi.dll $(ASSETS_FOLDER) @cp $(PLATFORMTOOLS_FOLDER)/adb.exe $(ASSETS_FOLDER) - @if [ ! -f /tmp/$(PLATFORMTOOLS_DARWIN) ]; then \ - echo "Downloading Darwin Android Platform Tools..."; \ - wget $(PLATFORMTOOLS_URL)$(PLATFORMTOOLS_DARWIN) -O /tmp/$(PLATFORMTOOLS_DARWIN); \ - fi + @./scripts/download_platform_tools.sh darwin + + @rm -rf $(PLATFORMTOOLS_FOLDER) + @cd /tmp && unzip -u $(PLATFORMTOOLS_DOWNLOAD_FOLDER)/$(PLATFORMTOOLS_DARWIN) + @cp $(PLATFORMTOOLS_FOLDER)/adb $(ASSETS_FOLDER)/adb_darwin + + @./scripts/download_platform_tools.sh linux @rm -rf $(PLATFORMTOOLS_FOLDER) - @cd /tmp && unzip -u $(PLATFORMTOOLS_DARWIN) - @cp $(PLATFORMTOOLS_FOLDER)/adb $(ASSETS_FOLDER) + @cd /tmp && unzip -u $(PLATFORMTOOLS_DOWNLOAD_FOLDER)/$(PLATFORMTOOLS_LINUX) + @cp $(PLATFORMTOOLS_FOLDER)/adb $(ASSETS_FOLDER)/adb_linux all: collector windows darwin linux diff --git a/README.md b/README.md index 20d7684..dddbcda 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ This project uses [GoReleaser](https://goreleaser.com/) for automated builds and 1. Install GoReleaser: ```bash - go install github.com/goreleaser/goreleaser@latest + go install github.com/goreleaser/goreleaser@v2.17.1 ``` 2. Run a snapshot build (no publishing): @@ -34,7 +34,7 @@ This will create binaries for all platforms in the `dist/` directory, including ### Building with Make (Legacy) -You can still use the traditional Makefile approach. You will need Go 1.23+ installed, along with `make`, `git`, `unzip` and `wget`. AndroidQF includes a cross-compiled `collector` which runs on the target device to more reliably extract forensically relevant information. +You can still use the traditional Makefile approach. You will need Go 1.26.5+ installed, along with `make`, `git`, `unzip` and `curl`. AndroidQF includes a cross-compiled `collector` which runs on the target device to more reliably extract forensically relevant information. First build the `collector` module: @@ -111,7 +111,7 @@ The following data can be extracted: | A full backup or backup of SMS and MMS messages. | :white_check_mark: | `backup.ab` | | The output of the getprop shell command, providing build information and configuration parameters. | | `getprop.txt` | | All system settings | | `settings_*.txt` | -| The output of the ps shell command, providing a list of all running processes. | | `processes.txt` | +| The output of the ps shell command, providing a list of all running processes. | | `processes.json` when the collector is available, otherwise `processes.txt` | | The list of system's services. | | `services.txt` | | A copy of all the logs from the system. | | `logs/`, `logcat.txt` | | The output of the dumpsys shell command, providing diagnostic information about the device. | | `dumpsys.txt` | @@ -125,7 +125,11 @@ The following data can be extracted: Every acquisition also contains `acquisition.json`, `command.log` when log output was produced, and `hashes.csv`. The hash list records the SHA-256 digest of each preceding plaintext archive entry and does not include itself. Failed device -pulls are not committed as archive entries. See [Acquisition +transfers are not committed as archive entries. `acquisition.json` records the +status (`completed`, `partial`, or `failed`), timing, and error (if any) for +every module that ran. A finalized +partial acquisition exits unsuccessfully instead of printing the normal +completion message. See [Acquisition archives](docs/acquisition-archives.md) for details. ### About optional data collection diff --git a/acquisition/acquisition.go b/acquisition/acquisition.go index e3a43dd..b926cb4 100644 --- a/acquisition/acquisition.go +++ b/acquisition/acquisition.go @@ -40,9 +40,18 @@ type Acquisition struct { ZipWriter *StreamingZipWriter `json:"-"` StreamingMode bool `json:"streaming_mode"` StreamingPuller *StreamingPuller `json:"-"` + ModuleResults []ModuleResult `json:"module_results"` logBuffer *bytes.Buffer `json:"-"` } +type ModuleResult struct { + Name string `json:"name"` + Status string `json:"status"` + Error string `json:"error,omitempty"` + Started time.Time `json:"started"` + Completed time.Time `json:"completed"` +} + // New returns a new Acquisition instance. func New(path string) (*Acquisition, error) { acq := Acquisition{ @@ -60,6 +69,7 @@ func New(path string) (*Acquisition, error) { // Get system information first to get tmp folder err := acq.GetSystemInformation() if err != nil { + acq.cleanupRuntime() return nil, err } @@ -72,6 +82,7 @@ func New(path string) (*Acquisition, error) { zipWriter, err := NewStreamingZipWriter(acq.UUID, path) if err != nil { + acq.cleanupRuntime() return nil, err } acq.ZipWriter = zipWriter @@ -85,6 +96,9 @@ func New(path string) (*Acquisition, error) { closeLog, err := log.EnableWriterLog(log.DEBUG, acq.logBuffer) if err != nil { + _ = zipWriter.Close() + _ = os.Remove(zipWriter.GetOutputPath()) + acq.cleanupRuntime() return nil, fmt.Errorf("failed to enable writer logging: %v", err) } acq.closeLog = closeLog @@ -92,6 +106,16 @@ func New(path string) (*Acquisition, error) { return &acq, nil } +func (a *Acquisition) cleanupRuntime() { + if a.Collector != nil { + _ = a.Collector.Clean() + } + if adb.Client != nil { + _, _ = adb.Client.KillServer() + } + _ = assets.CleanAssets() +} + func (a *Acquisition) Complete() error { var completionErr error @@ -154,15 +178,7 @@ func (a *Acquisition) Complete() error { } } - if a.Collector != nil { - a.Collector.Clean() - } - - // Stop ADB server before trying to remove extracted assets - if adb.Client != nil { - adb.Client.KillServer() - } - assets.CleanAssets() + a.cleanupRuntime() return completionErr } @@ -175,8 +191,20 @@ func (a *Acquisition) PullToZipStaged(remotePath, zipPath string) error { return err } + return a.stageStreamToZip(zipPath, func(writer io.Writer) error { + return a.StreamingPuller.PullToWriter(remotePath, writer) + }) +} + +// stageStreamToZip completes and validates a producer before creating its ZIP +// entry. Encrypted acquisitions use authenticated encrypted temporary storage. +func (a *Acquisition) stageStreamToZip(zipPath string, produce func(io.Writer) error) error { + if produce == nil { + return fmt.Errorf("stream producer cannot be nil") + } + if a.ZipWriter.IsEncrypted() { - staged, err := a.StreamingPuller.PullToEncryptedTempFile(remotePath) + staged, err := createEncryptedTempFile(produce) if err != nil { return err } @@ -190,11 +218,21 @@ func (a *Acquisition) PullToZipStaged(remotePath, zipPath string) error { return a.ZipWriter.CreateFileFromReader(zipPath, reader) } - tempPath, err := a.StreamingPuller.PullToTempFile(remotePath) + tempFile, err := os.CreateTemp("", "androidqf-stream-*") if err != nil { - return err + return fmt.Errorf("failed to create temporary file: %w", err) } + tempPath := tempFile.Name() defer os.Remove(tempPath) + + if err := produce(tempFile); err != nil { + _ = tempFile.Close() + return err + } + if err := tempFile.Close(); err != nil { + return fmt.Errorf("failed to close temporary file: %w", err) + } + return a.ZipWriter.CreateFileFromPath(zipPath, tempPath) } @@ -290,14 +328,9 @@ func (a *Acquisition) StreamBackupToZip(arg, zipPath string) error { return fmt.Errorf("zip path cannot be empty") } - // Create zip entry writer - writer, err := a.ZipWriter.CreateFile(zipPath) - if err != nil { - return fmt.Errorf("failed to create zip entry for backup: %v", err) - } - - // Stream backup directly to zip - err = a.StreamingPuller.BackupToWriter(arg, writer) + err := a.stageStreamToZip(zipPath, func(writer io.Writer) error { + return a.StreamingPuller.BackupToWriter(arg, writer) + }) if err != nil { return fmt.Errorf("failed to stream backup %q to zip: %v", arg, err) } @@ -315,14 +348,7 @@ func (a *Acquisition) StreamBugreportToZip(zipPath string) error { return fmt.Errorf("zip path cannot be empty") } - // Create zip entry writer - writer, err := a.ZipWriter.CreateFile(zipPath) - if err != nil { - return fmt.Errorf("failed to create zip entry for bugreport: %v", err) - } - - // Stream bugreport directly to zip - err = a.StreamingPuller.BugreportToWriter(writer) + err := a.stageStreamToZip(zipPath, a.StreamingPuller.BugreportToWriter) if err != nil { return fmt.Errorf("failed to stream bugreport to zip: %v", err) } diff --git a/acquisition/acquisition_test.go b/acquisition/acquisition_test.go index d0eb5ea..fa7de29 100644 --- a/acquisition/acquisition_test.go +++ b/acquisition/acquisition_test.go @@ -5,6 +5,7 @@ import ( "bytes" "encoding/json" "errors" + "fmt" "io" "os" "path/filepath" @@ -30,7 +31,14 @@ func TestCompleteWritesMetadataToStreamingZip(t *testing.T) { Started: started, ZipWriter: zipWriter, StreamingMode: true, - logBuffer: bytes.NewBufferString("logged command\n"), + ModuleResults: []ModuleResult{{ + Name: "files", + Status: "failed", + Error: "partial collection", + Started: started, + Completed: started.Add(time.Second), + }}, + logBuffer: bytes.NewBufferString("logged command\n"), } if err := acq.Complete(); err != nil { @@ -59,6 +67,9 @@ func TestCompleteWritesMetadataToStreamingZip(t *testing.T) { if stored.Completed.IsZero() { t.Fatal("acquisition.json contains a zero completed timestamp") } + if len(stored.ModuleResults) != 1 || stored.ModuleResults[0].Status != "failed" || stored.ModuleResults[0].Error != "partial collection" { + t.Fatalf("module results = %+v", stored.ModuleResults) + } if stored.ADBHostPublicKey != acq.ADBHostPublicKey { t.Fatalf("acquisition.json ADB host public key = %q, want %q", stored.ADBHostPublicKey, acq.ADBHostPublicKey) } @@ -192,3 +203,41 @@ func TestNewStreamingZipWriterWithoutKeyCreatesPlainZip(t *testing.T) { t.Fatalf("Stat(output) error = %v", err) } } + +func TestStageStreamToZipDoesNotCreateEntryForFailedProducer(t *testing.T) { + for _, encrypted := range []bool{false, true} { + t.Run(fmt.Sprintf("encrypted=%v", encrypted), func(t *testing.T) { + outputDir := t.TempDir() + zipWriter, err := NewStreamingZipWriter("failed-stream", outputDir) + if err != nil { + t.Fatalf("NewStreamingZipWriter() error = %v", err) + } + zipWriter.encrypted = encrypted + + acq := &Acquisition{ZipWriter: zipWriter} + err = acq.stageStreamToZip("backup.ab", func(writer io.Writer) error { + if _, err := io.WriteString(writer, "partial evidence"); err != nil { + return err + } + return errors.New("producer failed") + }) + if err == nil { + t.Fatal("stageStreamToZip() error = nil") + } + if err := zipWriter.Close(); err != nil { + t.Fatalf("Close() error = %v", err) + } + + reader, err := zip.OpenReader(zipWriter.GetOutputPath()) + if err != nil { + t.Fatalf("zip.OpenReader() error = %v", err) + } + defer reader.Close() + for _, file := range reader.File { + if file.Name == "backup.ab" { + t.Fatal("failed producer left backup.ab in archive") + } + } + }) + } +} diff --git a/acquisition/streaming_buffer.go b/acquisition/streaming_buffer.go index 3dace76..72d9743 100644 --- a/acquisition/streaming_buffer.go +++ b/acquisition/streaming_buffer.go @@ -7,6 +7,7 @@ package acquisition import ( "bytes" + "context" "crypto/rand" "errors" "fmt" @@ -14,8 +15,10 @@ import ( "os" "os/exec" "strings" + "sync" "github.com/minio/sio" + "github.com/mvt-project/androidqf/adb" ) var ErrStreamingBufferMemoryLimit = errors.New("streaming buffer memory limit exceeded") @@ -76,6 +79,8 @@ type StreamingPuller struct { adbPath string serial string maxMem int64 + ctxMu sync.RWMutex + ctx context.Context } // EncryptedTempFile is a DARE-encrypted temporary file used to validate a @@ -137,9 +142,30 @@ func NewStreamingPuller(adbPath, serial string, maxMemoryMB int) *StreamingPulle adbPath: adbPath, serial: serial, maxMem: int64(maxMemoryMB) * 1024 * 1024, + ctx: context.Background(), } } +// SetContext changes the context used by subsequent streaming ADB commands. +func (sp *StreamingPuller) SetContext(ctx context.Context) { + if ctx == nil { + ctx = context.Background() + } + sp.ctxMu.Lock() + sp.ctx = ctx + sp.ctxMu.Unlock() +} + +func (sp *StreamingPuller) command(args ...string) *exec.Cmd { + sp.ctxMu.RLock() + ctx := sp.ctx + sp.ctxMu.RUnlock() + if ctx == nil { + ctx = context.Background() + } + return exec.CommandContext(ctx, sp.adbPath, args...) +} + // PullToBuffer pulls a file from device directly into memory buffer func (sp *StreamingPuller) PullToBuffer(remotePath string) (*StreamingBuffer, error) { if remotePath == "" { @@ -148,12 +174,12 @@ func (sp *StreamingPuller) PullToBuffer(remotePath string) (*StreamingBuffer, er buffer := NewStreamingBuffer(int(sp.maxMem / (1024 * 1024))) - args := []string{"exec-out", "cat", remotePath} + args := []string{"exec-out", "cat", adb.QuoteRemoteShellArg(remotePath)} if sp.serial != "" { args = append([]string{"-s", sp.serial}, args...) } - cmd := exec.Command(sp.adbPath, args...) + cmd := sp.command(args...) cmd.Stdout = buffer err := cmd.Run() @@ -173,12 +199,12 @@ func (sp *StreamingPuller) PullToWriter(remotePath string, writer io.Writer) err return fmt.Errorf("writer cannot be nil") } - args := []string{"exec-out", "cat", remotePath} + args := []string{"exec-out", "cat", adb.QuoteRemoteShellArg(remotePath)} if sp.serial != "" { args = append([]string{"-s", sp.serial}, args...) } - cmd := exec.Command(sp.adbPath, args...) + cmd := sp.command(args...) cmd.Stdout = writer err := cmd.Run() @@ -292,7 +318,7 @@ func (sp *StreamingPuller) BackupToBuffer(arg string) (*StreamingBuffer, error) args = append([]string{"-s", sp.serial}, args...) } - cmd := exec.Command(sp.adbPath, args...) + cmd := sp.command(args...) cmd.Stdout = buffer err := cmd.Run() @@ -317,7 +343,7 @@ func (sp *StreamingPuller) BackupToWriter(arg string, writer io.Writer) error { args = append([]string{"-s", sp.serial}, args...) } - cmd := exec.Command(sp.adbPath, args...) + cmd := sp.command(args...) cmd.Stdout = writer err := cmd.Run() @@ -341,12 +367,12 @@ func (sp *StreamingPuller) BugreportToBuffer() (*StreamingBuffer, error) { // Stream the bugreport file to buffer buffer := NewStreamingBuffer(int(sp.maxMem / (1024 * 1024))) - streamArgs := []string{"exec-out", "cat", filename} + streamArgs := []string{"exec-out", "cat", adb.QuoteRemoteShellArg(filename)} if sp.serial != "" { streamArgs = append([]string{"-s", sp.serial}, streamArgs...) } - streamCmd := exec.Command(sp.adbPath, streamArgs...) + streamCmd := sp.command(streamArgs...) streamCmd.Stdout = buffer err = streamCmd.Run() @@ -372,12 +398,12 @@ func (sp *StreamingPuller) BugreportToWriter(writer io.Writer) error { defer sp.cleanupDeviceFile(filename) // Stream the bugreport file to writer - streamArgs := []string{"exec-out", "cat", filename} + streamArgs := []string{"exec-out", "cat", adb.QuoteRemoteShellArg(filename)} if sp.serial != "" { streamArgs = append([]string{"-s", sp.serial}, streamArgs...) } - streamCmd := exec.Command(sp.adbPath, streamArgs...) + streamCmd := sp.command(streamArgs...) streamCmd.Stdout = writer err = streamCmd.Run() @@ -395,7 +421,7 @@ func (sp *StreamingPuller) generateBugreport() (string, error) { args = append([]string{"-s", sp.serial}, args...) } - cmd := exec.Command(sp.adbPath, args...) + cmd := sp.command(args...) output, err := cmd.Output() if err != nil { return "", fmt.Errorf("failed to generate bugreport with bugreportz: %v", err) @@ -418,7 +444,7 @@ func (sp *StreamingPuller) cleanupDeviceFile(filename string) { return } - cleanupArgs := []string{"shell", "rm", filename} + cleanupArgs := []string{"shell", "rm", adb.QuoteRemoteShellArg(filename)} if sp.serial != "" { cleanupArgs = append([]string{"-s", sp.serial}, cleanupArgs...) } diff --git a/acquisition/streaming_buffer_test.go b/acquisition/streaming_buffer_test.go index 8bf6840..85acdca 100644 --- a/acquisition/streaming_buffer_test.go +++ b/acquisition/streaming_buffer_test.go @@ -3,6 +3,7 @@ package acquisition import ( "archive/zip" "bytes" + "context" "errors" "io" "os" @@ -10,6 +11,7 @@ import ( "runtime" "strings" "testing" + "time" ) func TestStreamingBufferMemoryLimitError(t *testing.T) { @@ -48,6 +50,53 @@ func TestPullToBufferPreservesMemoryLimitError(t *testing.T) { } } +func TestPullToWriterCancelsActiveADBCommand(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("uses a POSIX shell script as a fake adb executable") + } + + fakeADB := filepath.Join(t.TempDir(), "adb") + marker := filepath.Join(t.TempDir(), "started") + t.Setenv("ANDROIDQF_FAKE_ADB_MARKER", marker) + if err := os.WriteFile(fakeADB, []byte("#!/bin/sh\n: > \"$ANDROIDQF_FAKE_ADB_MARKER\"\nexec sleep 3600\n"), 0o700); err != nil { + t.Fatalf("WriteFile(fake adb) error = %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + puller := NewStreamingPuller(fakeADB, "", 1) + puller.SetContext(ctx) + writer := new(bytes.Buffer) + done := make(chan error, 1) + go func() { + done <- puller.PullToWriter("/data/local/tmp/file", writer) + }() + + deadline := time.Now().Add(2 * time.Second) + for { + if _, err := os.Stat(marker); err == nil { + break + } + select { + case err := <-done: + t.Fatalf("fake adb stopped before cancellation: %v", err) + default: + } + if time.Now().After(deadline) { + t.Fatal("fake adb did not start") + } + time.Sleep(time.Millisecond) + } + cancel() + select { + case err := <-done: + if err == nil { + t.Fatal("PullToWriter() error = nil after cancellation") + } + case <-time.After(2 * time.Second): + t.Fatal("PullToWriter() did not stop after cancellation") + } +} + func TestEncryptedTempFileDoesNotStagePlaintextAndSupportsSeeking(t *testing.T) { content := bytes.Repeat([]byte("sensitive APK content 0123456789\n"), 4096) staged, err := createEncryptedTempFile(func(writer io.Writer) error { diff --git a/adb/adb.go b/adb/adb.go index 3bdcf5f..f9f9459 100644 --- a/adb/adb.go +++ b/adb/adb.go @@ -6,18 +6,23 @@ package adb import ( + "context" "errors" "fmt" "os/exec" "strings" + "sync" saveSlice "github.com/botherder/go-savetime/slice" + "github.com/mvt-project/androidqf/assets" "github.com/mvt-project/androidqf/log" ) type ADB struct { ExePath string Serial string + ctxMu sync.RWMutex + ctx context.Context } type DeviceInfo struct { @@ -32,7 +37,14 @@ var Client *ADB // New returns a new ADB instance. func New() (*ADB, error) { + return NewWithContext(context.Background()) +} + +// NewWithContext returns a new ADB instance whose commands are canceled when +// ctx is canceled. +func NewWithContext(ctx context.Context) (*ADB, error) { adb := ADB{} + adb.SetContext(ctx) err := adb.findExe() if err != nil { return nil, fmt.Errorf("failed to find a usable adb executable: %v", @@ -43,11 +55,33 @@ func New() (*ADB, error) { // Confirm that we can call "adb devices" without errors _, err = adb.Devices() if err != nil { + _, _ = adb.KillServer() + _ = assets.CleanAssets() return nil, err } return &adb, nil } +// SetContext changes the context used by subsequent ADB commands. +func (a *ADB) SetContext(ctx context.Context) { + if ctx == nil { + ctx = context.Background() + } + a.ctxMu.Lock() + a.ctx = ctx + a.ctxMu.Unlock() +} + +func (a *ADB) command(args ...string) *exec.Cmd { + a.ctxMu.RLock() + ctx := a.ctx + a.ctxMu.RUnlock() + if ctx == nil { + ctx = context.Background() + } + return exec.CommandContext(ctx, a.ExePath, args...) +} + func (a *ADB) SetSerial(serial string) (string, error) { devices, err := a.Devices() if err != nil { @@ -79,7 +113,7 @@ func (a *ADB) SetSerial(serial string) (string, error) { // List existing devices func (a *ADB) Devices() ([]string, error) { var devices []string - out, err := exec.Command(a.ExePath, "devices").Output() + out, err := a.command("devices").Output() if err != nil { return devices, fmt.Errorf("failed to use the adb executable: %v", err) @@ -99,7 +133,7 @@ func (a *ADB) Devices() ([]string, error) { func (a *ADB) DeviceInfos() ([]DeviceInfo, error) { var devices []DeviceInfo - out, err := exec.Command(a.ExePath, "devices", "-l").Output() + out, err := a.command("devices", "-l").Output() if err != nil { return devices, fmt.Errorf("failed to use the adb executable: %v", err) @@ -151,12 +185,12 @@ func parseDeviceInfoLine(line string) (DeviceInfo, bool) { // Returns string and/or error func (a *ADB) Exec(args ...string) ([]byte, error) { if a.Serial == "" { - return exec.Command(a.ExePath, args...).Output() + return a.command(args...).Output() } else { var params []string params = append(params, "-s", a.Serial) params = append(params, args...) - return exec.Command(a.ExePath, params...).Output() + return a.command(params...).Output() } } @@ -217,7 +251,7 @@ func (a *ADB) Backup(outPath, arg string) error { if a.Serial != "" { args = append([]string{"-s", a.Serial}, args...) } - cmd := exec.Command(a.ExePath, args...) + cmd := a.command(args...) output, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("%w: %s", err, string(output)) @@ -231,7 +265,7 @@ func (a *ADB) Bugreport(outPath string) error { if a.Serial != "" { args = append([]string{"-s", a.Serial}, args...) } - cmd := exec.Command(a.ExePath, args...) + cmd := a.command(args...) err := cmd.Run() return err } @@ -253,7 +287,7 @@ func (a *ADB) IL() error { // check if file exists func (a *ADB) FileExists(path string) (bool, error) { - out, err := a.Shell("[", "-f", path, "] || echo 1") + out, err := a.Shell("[", "-f", QuoteRemoteShellArg(path), "] || echo 1") if err != nil { return false, err } @@ -267,11 +301,11 @@ func (a *ADB) FileExists(path string) (bool, error) { func (a *ADB) ListFiles(remotePath string, recursive bool) ([]string, error) { var remoteFiles []string - // Quote remotePath so files with spaces on their name work - qPath := fmt.Sprintf("'%s'", remotePath) + // Quote remotePath so shell metacharacters remain part of the path. + qPath := QuoteRemoteShellArg(remotePath) if recursive { - out, _ := a.Shell("find", qPath, "2>", "/dev/null") + out, err := a.Shell("find", qPath, "-type", "f", "2>", "/dev/null") if out != "" { tmpFiles := strings.Split(out, "\n") for _, file := range tmpFiles { @@ -281,8 +315,11 @@ func (a *ADB) ListFiles(remotePath string, recursive bool) ([]string, error) { } } } + if err != nil { + return remoteFiles, err + } } else { - out, err := a.Shell("ls", remotePath) + out, err := a.Shell("ls", qPath) if err != nil { return remoteFiles, err } diff --git a/adb/adb_darwin.go b/adb/adb_darwin.go index 7954420..a905f02 100644 --- a/adb/adb_darwin.go +++ b/adb/adb_darwin.go @@ -14,16 +14,17 @@ import ( ) func (a *ADB) findExe() error { - assetDir, err := assets.DeployAssets() - if err != nil { - return err - } - adbPath, err := exec.LookPath("adb") if err == nil { a.ExePath = adbPath return nil - } else if assetDir != "" { + } + + assetDir, err := assets.DeployAssets() + if err != nil { + return err + } + if assetDir != "" { a.ExePath = filepath.Join(assetDir, "adb") } else { a.ExePath = filepath.Join(saveRuntime.GetExecutableDirectory(), "adb") diff --git a/adb/adb_linux.go b/adb/adb_linux.go index 819c1c7..a905f02 100644 --- a/adb/adb_linux.go +++ b/adb/adb_linux.go @@ -14,15 +14,17 @@ import ( ) func (a *ADB) findExe() error { + adbPath, err := exec.LookPath("adb") + if err == nil { + a.ExePath = adbPath + return nil + } + assetDir, err := assets.DeployAssets() if err != nil { return err } - - adbPath, err := exec.LookPath("adb") - if err == nil { - a.ExePath = adbPath - } else if assetDir != "" { + if assetDir != "" { a.ExePath = filepath.Join(assetDir, "adb") } else { a.ExePath = filepath.Join(saveRuntime.GetExecutableDirectory(), "adb") diff --git a/adb/adb_test.go b/adb/adb_test.go index b37bdef..f772db7 100644 --- a/adb/adb_test.go +++ b/adb/adb_test.go @@ -1,10 +1,13 @@ package adb import ( + "context" "fmt" "os" + "path/filepath" "strings" "testing" + "time" ) func TestMain(m *testing.M) { @@ -35,11 +38,71 @@ func fakeADB() { } case "pubkey": fmt.Println(os.Getenv("ANDROIDQF_FAKE_ADB_PUBLIC_KEY")) + case "wait": + _ = os.WriteFile(os.Getenv("ANDROIDQF_FAKE_ADB_MARKER"), []byte("started"), 0o600) + select {} + case "shell": + if os.Getenv("ANDROIDQF_FAKE_ADB_REQUIRE_TYPE_FILE") == "1" && !strings.Contains(strings.Join(os.Args[2:], " "), "-type f") { + os.Exit(2) + } + fmt.Print(os.Getenv("ANDROIDQF_FAKE_ADB_SHELL_OUTPUT")) + if os.Getenv("ANDROIDQF_FAKE_ADB_SHELL_FAIL") == "1" { + os.Exit(1) + } default: os.Exit(2) } } +func TestExecCancelsActiveADBCommand(t *testing.T) { + client := newFakeADB(t, "") + marker := filepath.Join(t.TempDir(), "started") + t.Setenv("ANDROIDQF_FAKE_ADB_MARKER", marker) + ctx, cancel := context.WithCancel(context.Background()) + client.SetContext(ctx) + + done := make(chan error, 1) + go func() { + _, err := client.Exec("wait") + done <- err + }() + + deadline := time.Now().Add(2 * time.Second) + for { + if _, err := os.Stat(marker); err == nil { + break + } + if time.Now().After(deadline) { + t.Fatal("fake adb did not start") + } + time.Sleep(time.Millisecond) + } + cancel() + select { + case err := <-done: + if err == nil { + t.Fatal("Exec() error = nil after cancellation") + } + case <-time.After(2 * time.Second): + t.Fatal("Exec() did not stop after cancellation") + } +} + +func TestListFilesReturnsPartialOutputAndError(t *testing.T) { + client := newFakeADB(t, "") + t.Setenv("ANDROIDQF_FAKE_ADB_REQUIRE_TYPE_FILE", "1") + t.Setenv("ANDROIDQF_FAKE_ADB_SHELL_OUTPUT", "/sdcard/one\n/sdcard/two\n") + t.Setenv("ANDROIDQF_FAKE_ADB_SHELL_FAIL", "1") + + files, err := client.ListFiles("/sdcard", true) + if err == nil { + t.Fatal("ListFiles() error = nil") + } + if len(files) != 2 || files[0] != "/sdcard/one" || files[1] != "/sdcard/two" { + t.Fatalf("ListFiles() files = %v", files) + } +} + func TestDeviceInfosParsesLongDeviceList(t *testing.T) { client := newFakeADB(t, "device-1,device-2") devices, err := client.DeviceInfos() diff --git a/adb/adb_windows.go b/adb/adb_windows.go index 9c1a139..c575a2f 100644 --- a/adb/adb_windows.go +++ b/adb/adb_windows.go @@ -16,16 +16,17 @@ import ( ) func (a *ADB) findExe() error { - // TODO: only deploy assets when needed + adbPath, err := exec.LookPath("adb.exe") + if err == nil { + a.ExePath = adbPath + return nil + } + assetDir, err := assets.DeployAssets() if err != nil { return err } - - adbPath, err := exec.LookPath("adb.exe") - if err == nil { - a.ExePath = adbPath - } else if assetDir != "" { + if assetDir != "" { a.ExePath = filepath.Join(assetDir, "adb.exe") } else { // Get path of the current directory diff --git a/adb/collector.go b/adb/collector.go index d8f6b0f..83c0b4c 100644 --- a/adb/collector.go +++ b/adb/collector.go @@ -18,6 +18,8 @@ import ( "github.com/mvt-project/androidqf/assets" ) +var createCollectorTemp = os.CreateTemp + type Collector struct { ExePath string Installed bool @@ -45,16 +47,16 @@ type FileInfo struct { } type ProcessInfo struct { - Pid uint32 `json:"pid"` - Uid uint32 `json:"uid"` - Ppid uint32 `json:"ppid"` - Pgroup uint32 `json:"pgroup"` - Psid uint32 `json:"psid"` + Pid int `json:"pid"` + Uid int `json:"uid"` + Ppid int `json:"ppid"` + Pgroup int `json:"pgroup"` + Psid int `json:"psid"` Filename string `json:"filename"` - Priority uint32 `json:"priority"` + Priority int `json:"priority"` State string `json:"state"` - UserTime uint32 `json:"user_time"` - KernelTime uint32 `json:"kernel_time"` + UserTime int64 `json:"user_time"` + KernelTime int64 `json:"kernel_time"` Path string `json:"path"` Context string `json:"context"` PreviousContext string `json:"previous_context"` @@ -86,29 +88,22 @@ func (c *Collector) isInstalled() bool { // Clean the phone. func (c *Collector) Clean() error { - _, err := c.Adb.Shell("rm", c.ExePath) + _, err := c.Adb.Shell("rm", QuoteRemoteShellArg(c.ExePath)) return err } // Install the collector. func (c *Collector) Install() error { if c.isInstalled() { - _, err := c.Adb.Shell("rm", c.ExePath) + _, err := c.Adb.Shell("rm", QuoteRemoteShellArg(c.ExePath)) if err != nil { return err } } - collectorName := "" - switch { - case strings.HasPrefix(c.Architecture, "armeabi-v"): - collectorName = "collector_arm" - case strings.HasPrefix(c.Architecture, "armeabi-v7"): - collectorName = "collector_arm" - case strings.HasPrefix(c.Architecture, "arm64-v8"): - collectorName = "collector_arm64" - default: - return fmt.Errorf("unsupported architecture for collector: %s", c.Architecture) + collectorName, err := collectorNameForArchitecture(c.Architecture) + if err != nil { + return err } log.Debugf("Deploying collector binary '%s' for architecture '%s'.", collectorName, c.Architecture) @@ -118,7 +113,7 @@ func (c *Collector) Install() error { return errors.New("couldn't find the collector binary") } - collectorTemp, _ := os.CreateTemp("", "collector_") + collectorTemp, err := createCollectorTemp("", "collector_") if err != nil { return err } @@ -129,12 +124,15 @@ func (c *Collector) Install() error { collectorTemp.Close() return err } + if err := collectorTemp.Close(); err != nil { + return err + } _, err = c.Adb.Push(collectorTemp.Name(), c.ExePath) if err != nil { return err } - _, err = c.Adb.Shell("chmod", "+x", c.ExePath) + _, err = c.Adb.Shell("chmod", "+x", QuoteRemoteShellArg(c.ExePath)) if err != nil { return err } @@ -142,10 +140,22 @@ func (c *Collector) Install() error { return nil } +func collectorNameForArchitecture(architecture string) (string, error) { + switch { + case strings.HasPrefix(architecture, "armeabi-v"): + return "collector_arm", nil + case strings.HasPrefix(architecture, "arm64-v8"): + return "collector_arm64", nil + case strings.HasPrefix(architecture, "x86_64"): + return "collector_amd64", nil + default: + return "", fmt.Errorf("unsupported architecture for collector: %s", architecture) + } +} + // List files on the phone at the given path (no hash). func (c *Collector) Find(path string) ([]FileInfo, error) { var results []FileInfo - var file FileInfo if !c.isInstalled() { err := c.Install() if err != nil { @@ -154,15 +164,19 @@ func (c *Collector) Find(path string) ([]FileInfo, error) { } } - out, err := c.Adb.Shell(c.ExePath, "find", path) + out, err := c.Adb.Shell(QuoteRemoteShellArg(c.ExePath), "find", QuoteRemoteShellArg(path)) if err != nil { return results, err } for _, line := range strings.Split(out, "\n") { - err = json.Unmarshal([]byte(line), &file) - if err == nil { - results = append(results, file) + if strings.TrimSpace(line) == "" { + continue } + var file FileInfo + if err := json.Unmarshal([]byte(line), &file); err != nil { + return results, fmt.Errorf("failed to parse collector file record: %w", err) + } + results = append(results, file) } return results, nil @@ -171,7 +185,6 @@ func (c *Collector) Find(path string) ([]FileInfo, error) { // List files with their hash on the phone at the given path. func (c *Collector) FindHash(path string) ([]FileInfo, error) { var results []FileInfo - var file FileInfo if !c.isInstalled() { err := c.Install() if err != nil { @@ -180,15 +193,19 @@ func (c *Collector) FindHash(path string) ([]FileInfo, error) { } } - out, err := c.Adb.Shell(c.ExePath, "find", "-H", path) + out, err := c.Adb.Shell(QuoteRemoteShellArg(c.ExePath), "find", "-H", QuoteRemoteShellArg(path)) if err != nil { return results, err } for _, line := range strings.Split(out, "\n") { - err = json.Unmarshal([]byte(line), &file) - if err == nil { - results = append(results, file) + if strings.TrimSpace(line) == "" { + continue + } + var file FileInfo + if err := json.Unmarshal([]byte(line), &file); err != nil { + return results, fmt.Errorf("failed to parse collector file record: %w", err) } + results = append(results, file) } return results, nil @@ -197,7 +214,7 @@ func (c *Collector) FindHash(path string) ([]FileInfo, error) { func (c *Collector) Processes() ([]ProcessInfo, error) { var results []ProcessInfo - if c.isInstalled() { + if !c.isInstalled() { err := c.Install() if err != nil { log.Debugf("Impossible to install collector: %v", err) @@ -205,7 +222,7 @@ func (c *Collector) Processes() ([]ProcessInfo, error) { } } - out, err := c.Adb.Shell(c.ExePath, "ps") + out, err := c.Adb.Shell(QuoteRemoteShellArg(c.ExePath), "ps") if err != nil { return results, err } diff --git a/adb/collector_test.go b/adb/collector_test.go new file mode 100644 index 0000000..1a36cd8 --- /dev/null +++ b/adb/collector_test.go @@ -0,0 +1,31 @@ +package adb + +import "testing" + +func TestCollectorNameForArchitecture(t *testing.T) { + tests := []struct { + architecture string + want string + }{ + {architecture: "armeabi-v7a", want: "collector_arm"}, + {architecture: "arm64-v8a", want: "collector_arm64"}, + {architecture: "x86_64", want: "collector_amd64"}, + } + for _, tt := range tests { + t.Run(tt.architecture, func(t *testing.T) { + got, err := collectorNameForArchitecture(tt.architecture) + if err != nil { + t.Fatalf("collectorNameForArchitecture() error = %v", err) + } + if got != tt.want { + t.Fatalf("collectorNameForArchitecture() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestCollectorNameForArchitectureRejectsUnsupportedABI(t *testing.T) { + if _, err := collectorNameForArchitecture("x86"); err == nil { + t.Fatal("collectorNameForArchitecture(x86) error = nil") + } +} diff --git a/adb/files.go b/adb/files.go index a38dc22..30b7b1c 100644 --- a/adb/files.go +++ b/adb/files.go @@ -12,50 +12,68 @@ import ( ) func (a *ADB) FindFullCommand(path string) ([]FileInfo, error) { - var results []FileInfo - out, err := a.Shell("find", fmt.Sprintf("'%s'", path), "-type", "f", "-printf", "'%T@ %m %s %u %g %p\n'", "2>", "/dev/null") - + out, err := a.Shell( + "find", + QuoteRemoteShellArg(path), + "-type", "f", + "-printf", `'%T@\t%m\t%s\t%u\t%g\t%p\0'`, + "2>", "/dev/null", + ) if err != nil { - return results, err + return nil, err } + return parseFullFindOutput(out) +} - for _, line := range strings.Split(out, "\n") { - var new_file FileInfo - s := strings.Fields(line) - if len(s) == 0 { +func parseFullFindOutput(out string) ([]FileInfo, error) { + var results []FileInfo + for _, record := range strings.Split(out, "\x00") { + if record == "" { continue } - time, err := strconv.ParseFloat(s[0], 64) - if err == nil { - new_file.ModifiedTime = int64(time) - } - new_file.Mode = s[1] - size, err := strconv.ParseInt(s[2], 10, 64) - if err == nil { - new_file.Size = size + fields := strings.SplitN(record, "\t", 6) + if len(fields) != 6 { + return nil, fmt.Errorf("malformed find output record %q", record) } - new_file.UserName = s[3] - new_file.GroupName = s[4] - new_file.Path = strings.Join(s[5:], "/") - results = append(results, new_file) + modified, err := strconv.ParseFloat(fields[0], 64) + if err != nil { + return nil, fmt.Errorf("invalid modification time in find output %q: %w", record, err) + } + size, err := strconv.ParseInt(fields[2], 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid size in find output %q: %w", record, err) + } + results = append(results, FileInfo{ + ModifiedTime: int64(modified), + Mode: fields[1], + Size: size, + UserName: fields[3], + GroupName: fields[4], + Path: fields[5], + }) } - return results, nil } func (a *ADB) FindLimitedCommand(path string) ([]FileInfo, error) { var results []FileInfo - out, err := a.Shell("find", fmt.Sprintf("'%s'", path), "-type", "f", "2>", "/dev/null") + out, err := a.Shell("find", QuoteRemoteShellArg(path), "-type", "f", "2>", "/dev/null") if err != nil { return results, err } - for _, line := range strings.Split(out, "\n") { - var new_file FileInfo - new_file.Path = line - results = append(results, new_file) + for _, filePath := range strings.Split(out, "\n") { + if filePath != "" { + results = append(results, FileInfo{Path: filePath}) + } } return results, nil } + +// QuoteRemoteShellArg quotes a value for use as one argument in an adb shell +// command. +func QuoteRemoteShellArg(value string) string { + return "'" + strings.ReplaceAll(value, "'", `'"'"'`) + "'" +} diff --git a/adb/files_test.go b/adb/files_test.go new file mode 100644 index 0000000..21aa956 --- /dev/null +++ b/adb/files_test.go @@ -0,0 +1,40 @@ +package adb + +import "testing" + +func TestParseFullFindOutputPreservesPathCharacters(t *testing.T) { + out := "1700000000.25\t640\t42\tshell\tshell\t/sdcard/My File\nwith newline.txt\x00" + files, err := parseFullFindOutput(out) + if err != nil { + t.Fatalf("parseFullFindOutput() error = %v", err) + } + if len(files) != 1 { + t.Fatalf("file count = %d, want 1", len(files)) + } + if files[0].Path != "/sdcard/My File\nwith newline.txt" { + t.Fatalf("path = %q", files[0].Path) + } + if files[0].Size != 42 || files[0].Mode != "640" || files[0].ModifiedTime != 1700000000 { + t.Fatalf("metadata = %+v", files[0]) + } +} + +func TestParseFullFindOutputRejectsMalformedRecords(t *testing.T) { + for _, out := range []string{ + "short record\x00", + "invalid\t640\t42\tshell\tshell\t/path\x00", + "1700000000\t640\tinvalid\tshell\tshell\t/path\x00", + } { + if _, err := parseFullFindOutput(out); err == nil { + t.Fatalf("parseFullFindOutput(%q) error = nil", out) + } + } +} + +func TestQuoteRemoteShellArgEscapesApostrophes(t *testing.T) { + got := QuoteRemoteShellArg("/sdcard/user's files") + want := `'/sdcard/user'"'"'s files'` + if got != want { + t.Fatalf("QuoteRemoteShellArg() = %q, want %q", got, want) + } +} diff --git a/adb/host_key.go b/adb/host_key.go index cd5a5af..48fba94 100644 --- a/adb/host_key.go +++ b/adb/host_key.go @@ -8,7 +8,6 @@ package adb import ( "fmt" "os" - "os/exec" "path/filepath" "strings" ) @@ -36,7 +35,7 @@ func (a *ADB) hostPublicKey(privateKeyPath string) (string, error) { // ADB normally creates adbkey.pub alongside adbkey. Derive it from the // private key if the public file is missing, without exposing private data. - publicKey, err = exec.Command(a.ExePath, "pubkey", privateKeyPath).Output() + publicKey, err = a.command("pubkey", privateKeyPath).Output() if err != nil { return "", fmt.Errorf("failed to derive ADB host public key: %w", err) } diff --git a/adb/packages.go b/adb/packages.go index 6d471fa..e94dbb0 100644 --- a/adb/packages.go +++ b/adb/packages.go @@ -39,8 +39,19 @@ type Package struct { ThirdParty bool `json:"third_party"` } +type packageListAttempt struct { + args []string + withInstaller bool +} + +type packageListEntry struct { + name string + installer string + uid int +} + func (a *ADB) getPackageFiles(packageName string, fast bool) []PackageFile { - out, err := a.Shell("pm", "path", packageName) + out, err := a.Shell("pm", "path", QuoteRemoteShellArg(packageName)) if err != nil { log.Errorf("Failed to get file paths for package %s: %v: %s", packageName, err, out) return []PackageFile{} @@ -60,19 +71,20 @@ func (a *ADB) getPackageFiles(packageName string, fast bool) []PackageFile { if !fast { // Not sure if this is useful or not considering packages may // be downloaded later on - md5Out, err := a.Shell("md5sum", packagePath) + quotedPackagePath := QuoteRemoteShellArg(packagePath) + md5Out, err := a.Shell("md5sum", quotedPackagePath) if err == nil { packageFile.MD5 = strings.SplitN(md5Out, " ", 2)[0] } - sha1Out, err := a.Shell("sha1sum", packagePath) + sha1Out, err := a.Shell("sha1sum", quotedPackagePath) if err == nil { packageFile.SHA1 = strings.SplitN(sha1Out, " ", 2)[0] } - sha256Out, err := a.Shell("sha256sum", packagePath) + sha256Out, err := a.Shell("sha256sum", quotedPackagePath) if err == nil { packageFile.SHA256 = strings.SplitN(sha256Out, " ", 2)[0] } - sha512Out, err := a.Shell("sha512sum", packagePath) + sha512Out, err := a.Shell("sha512sum", quotedPackagePath) if err == nil { packageFile.SHA512 = strings.SplitN(sha512Out, " ", 2)[0] } @@ -86,48 +98,43 @@ func (a *ADB) getPackageFiles(packageName string, fast bool) []PackageFile { // GetPackages returns the list of installed package names. func (a *ADB) GetPackages(fast bool) ([]Package, error) { - withInstaller := true - out, err := a.Shell("pm", "list", "packages", "-U", "-u", "-i") - if err != nil { - // Some phones do not support -i option - out, err = a.Shell("pm", "list", "packages", "-U", "-u") - if err != nil { - // old Samsung throw errors when trying to access installed packages of other users - out, err = a.Shell("pm", "list", "packages", "-U", "-u", "-i", "--user 0") - if err != nil { - return []Package{}, fmt.Errorf("failed to launch `pm list packages` command: %v", - err) - } - } - withInstaller = false + attempts := []packageListAttempt{ + {args: []string{"pm", "list", "packages", "-U", "-u", "-i"}, withInstaller: true}, + {args: []string{"pm", "list", "packages", "-U", "-u"}, withInstaller: false}, + // Some older Samsung builds reject cross-user package access. + {args: []string{"pm", "list", "packages", "-U", "-u", "-i", "--user", "0"}, withInstaller: true}, } - packages := []Package{} - var installer string - var uid int - for _, line := range strings.Split(out, "\n") { - fields := strings.Fields(line) - packageName := strings.TrimPrefix(strings.TrimSpace(fields[0]), "package:") - if withInstaller { - installer = strings.TrimPrefix(strings.TrimSpace(fields[1]), "installer=") - uid, _ = strconv.Atoi(strings.TrimPrefix(strings.TrimSpace(fields[2]), "uid:")) - } else { - uid, _ = strconv.Atoi(strings.TrimPrefix(strings.TrimSpace(fields[1]), "uid:")) - installer = "" - } - - if packageName == "" { - continue + var ( + out string + attempt packageListAttempt + err error + ) + for _, candidate := range attempts { + out, err = a.Shell(candidate.args...) + if err == nil { + attempt = candidate + break } + } + if err != nil { + return []Package{}, fmt.Errorf("failed to launch `pm list packages` command: %v", err) + } + entries, err := parsePackageList(out, attempt.withInstaller) + if err != nil { + return []Package{}, err + } + packages := []Package{} + for _, entry := range entries { newPackage := Package{ - Name: packageName, - Installer: installer, - UID: uid, + Name: entry.name, + Installer: entry.installer, + UID: entry.uid, Disabled: false, System: false, ThirdParty: false, - Files: a.getPackageFiles(packageName, fast), + Files: a.getPackageFiles(entry.name, fast), } packages = append(packages, newPackage) @@ -171,10 +178,60 @@ func (a *ADB) GetPackages(fast bool) ([]Package, error) { return packages, nil } +func parsePackageList(out string, withInstaller bool) ([]packageListEntry, error) { + var entries []packageListEntry + for _, line := range strings.Split(out, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + fields := strings.Fields(line) + if len(fields) == 0 || !strings.HasPrefix(fields[0], "package:") { + log.Debugf("Ignoring unexpected package-list output: %s", line) + continue + } + + expectedFields := 2 + uidIndex := 1 + if withInstaller { + expectedFields = 3 + uidIndex = 2 + } + if len(fields) < expectedFields { + return nil, fmt.Errorf("malformed package-list output %q", line) + } + + entry := packageListEntry{name: strings.TrimPrefix(fields[0], "package:")} + if entry.name == "" { + return nil, fmt.Errorf("malformed package-list output %q", line) + } + if withInstaller { + if !strings.HasPrefix(fields[1], "installer=") { + return nil, fmt.Errorf("malformed installer field in %q", line) + } + entry.installer = strings.TrimPrefix(fields[1], "installer=") + } + if !strings.HasPrefix(fields[uidIndex], "uid:") { + return nil, fmt.Errorf("malformed UID field in %q", line) + } + uid, err := strconv.Atoi(strings.TrimPrefix(fields[uidIndex], "uid:")) + if err != nil { + return nil, fmt.Errorf("malformed UID field in %q: %w", line, err) + } + entry.uid = uid + entries = append(entries, entry) + } + if len(entries) == 0 { + return nil, fmt.Errorf("package-list output contained no package records") + } + return entries, nil +} + // GetPackagePaths returns a list of file paths associated with the provided // package name. func (a *ADB) GetPackagePaths(packageName string) ([]string, error) { - out, err := a.Shell("pm", "path", packageName) + out, err := a.Shell("pm", "path", QuoteRemoteShellArg(packageName)) if err != nil { return []string{}, fmt.Errorf("failed to launch `pm path` command: %v", err) diff --git a/adb/packages_test.go b/adb/packages_test.go new file mode 100644 index 0000000..8bd16c0 --- /dev/null +++ b/adb/packages_test.go @@ -0,0 +1,45 @@ +package adb + +import "testing" + +func TestParsePackageListWithInstaller(t *testing.T) { + out := "package:org.example installer=com.android.vending uid:10123\n" + entries, err := parsePackageList(out, true) + if err != nil { + t.Fatalf("parsePackageList() error = %v", err) + } + if len(entries) != 1 { + t.Fatalf("entry count = %d, want 1", len(entries)) + } + if entries[0].name != "org.example" || entries[0].installer != "com.android.vending" || entries[0].uid != 10123 { + t.Fatalf("entry = %+v", entries[0]) + } +} + +func TestParsePackageListWithoutInstaller(t *testing.T) { + entries, err := parsePackageList("package:org.example uid:10123", false) + if err != nil { + t.Fatalf("parsePackageList() error = %v", err) + } + if len(entries) != 1 || entries[0].installer != "" || entries[0].uid != 10123 { + t.Fatalf("entries = %+v", entries) + } +} + +func TestParsePackageListRejectsEmptyOutput(t *testing.T) { + if _, err := parsePackageList("", true); err == nil { + t.Fatal("parsePackageList() error = nil") + } +} + +func TestParsePackageListRejectsMalformedPackageRecords(t *testing.T) { + for _, line := range []string{ + "package:org.example", + "package:org.example unexpected uid:10123", + "package:org.example installer=com.android.vending uid:not-a-number", + } { + if _, err := parsePackageList(line, true); err == nil { + t.Fatalf("parsePackageList(%q) error = nil", line) + } + } +} diff --git a/android-collector/cmd/find.go b/android-collector/cmd/find.go index 4c46b50..2b9c964 100644 --- a/android-collector/cmd/find.go +++ b/android-collector/cmd/find.go @@ -7,8 +7,10 @@ import ( "crypto/sha512" "encoding/hex" "encoding/json" + "errors" "fmt" "hash" + "io" "log" "math" "net/http" @@ -50,6 +52,7 @@ type Job struct { } var hashOption bool +var outputMu sync.Mutex func getMimeType(buf []byte) (string, error) { kind, err := filetype.Match(buf) @@ -97,27 +100,33 @@ func processFile(filePath string, fileInfo os.FileInfo, getHash bool) FileInfo { } if getHash { - // no hash for /proc/ - if strings.HasPrefix(filePath, "/proc/") || strings.HasPrefix(filePath, "/sys/") || strings.HasPrefix(filePath, "/system/") { + // Avoid pseudo-files whose reads can block or have side effects. + if isPathWithin(filePath, "/proc") || isPathWithin(filePath, "/sys") { return f } file, err := os.Open(filePath) if err != nil { + f.Error = err.Error() return f } defer file.Close() - buf := make([]byte, f.Size) - _, err = file.Read(buf) - if err != nil { + header := make([]byte, 512) + headerSize, err := io.ReadFull(file, header) + if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) { + f.Error = err.Error() return f } - mimeType, err := getMimeType(buf) + mimeType, err := getMimeType(header[:headerSize]) if err == nil { f.MimeType = mimeType } + if _, err := file.Seek(0, io.SeekStart); err != nil { + f.Error = err.Error() + return f + } hashes := []hash.Hash{ md5.New(), @@ -126,8 +135,17 @@ func processFile(filePath string, fileInfo os.FileInfo, getHash bool) FileInfo { sha512.New(), } - for _, h := range hashes { - h.Write(buf) + writers := make([]io.Writer, len(hashes)) + for i, h := range hashes { + writers[i] = h + } + bytesRead, err := io.Copy(io.MultiWriter(writers...), file) + if err != nil { + f.Error = err.Error() + return f + } + if bytesRead != f.Size { + f.Error = fmt.Sprintf("file size changed during hashing: expected %d bytes, read %d", f.Size, bytesRead) } f.MD5 = hex.EncodeToString(hashes[0].Sum(nil)) @@ -140,6 +158,12 @@ func processFile(filePath string, fileInfo os.FileInfo, getHash bool) FileInfo { return f } +func isPathWithin(filePath, root string) bool { + cleanPath := filepath.Clean(filePath) + cleanRoot := filepath.Clean(root) + return cleanPath == cleanRoot || strings.HasPrefix(cleanPath, cleanRoot+string(filepath.Separator)) +} + func worker(jobChan chan Job, wg *sync.WaitGroup) { defer wg.Done() @@ -149,7 +173,9 @@ func worker(jobChan chan Job, wg *sync.WaitGroup) { if err != nil { continue } + outputMu.Lock() fmt.Println(string(jsonData)) + outputMu.Unlock() } } diff --git a/android-collector/cmd/find_test.go b/android-collector/cmd/find_test.go new file mode 100644 index 0000000..e1c800e --- /dev/null +++ b/android-collector/cmd/find_test.go @@ -0,0 +1,56 @@ +package cmd + +import ( + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + "testing" +) + +func TestProcessFileStreamsCompleteHashes(t *testing.T) { + content := []byte("forensic content") + filePath := filepath.Join(t.TempDir(), "evidence.bin") + if err := os.WriteFile(filePath, content, 0o600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + info, err := os.Stat(filePath) + if err != nil { + t.Fatalf("Stat() error = %v", err) + } + + got := processFile(filePath, info, true) + want := sha256.Sum256(content) + if got.SHA256 != hex.EncodeToString(want[:]) { + t.Fatalf("SHA256 = %q, want %q", got.SHA256, hex.EncodeToString(want[:])) + } + if got.Error != "" { + t.Fatalf("Error = %q", got.Error) + } +} + +func TestProcessFileHashesEmptyFiles(t *testing.T) { + filePath := filepath.Join(t.TempDir(), "empty") + if err := os.WriteFile(filePath, nil, 0o600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + info, err := os.Stat(filePath) + if err != nil { + t.Fatalf("Stat() error = %v", err) + } + + got := processFile(filePath, info, true) + want := sha256.Sum256(nil) + if got.SHA256 != hex.EncodeToString(want[:]) { + t.Fatalf("SHA256 = %q, want %q", got.SHA256, hex.EncodeToString(want[:])) + } +} + +func TestIsPathWithin(t *testing.T) { + if !isPathWithin("/proc/1/stat", "/proc") { + t.Fatal("/proc/1/stat should be within /proc") + } + if isPathWithin("/proc-backup/file", "/proc") { + t.Fatal("/proc-backup/file should not be within /proc") + } +} diff --git a/android-collector/cmd/ps.go b/android-collector/cmd/ps.go index b443e0a..3074a07 100644 --- a/android-collector/cmd/ps.go +++ b/android-collector/cmd/ps.go @@ -9,6 +9,7 @@ import ( "path/filepath" "strconv" "strings" + "syscall" "github.com/spf13/cobra" "github.com/tklauser/go-sysconf" @@ -23,8 +24,8 @@ type ProcessInfo struct { Filename string `json:"filename"` Priority int `json:"priority"` State string `json:"state"` - UserTime int `json:"user_time"` - KernelTime int `json:"kernel_time"` + UserTime int64 `json:"user_time"` + KernelTime int64 `json:"kernel_time"` Path string `json:"path"` Context string `json:"context"` PreviousContext string `json:"previous_context"` @@ -49,40 +50,94 @@ func conv(in []byte) string { } func (p *ProcessInfo) readStat() error { - stat, err := os.Open(filepath.Join("/proc/", fmt.Sprint(p.Pid), "stat")) + data, err := os.ReadFile(filepath.Join("/proc/", fmt.Sprint(p.Pid), "stat")) if err != nil { return err } - _, err = fmt.Fscanf(stat, - "%d %s %c %d %d %d %d %d %d %d %d %d %d %d %d %d %d %d", - new(int), - &p.Filename, - &p.State, - &p.Ppid, - &p.Pgroup, - &p.Psid, - new(int), - new(int), - new(int), - new(int), - new(int), - new(int), - new(int), - new(int), - &p.UserTime, - new(int), - &p.KernelTime, - new(int), - new(int), - &p.Priority, - ) + return p.parseStat(string(data)) +} + +func (p *ProcessInfo) parseStat(stat string) error { + open := strings.IndexByte(stat, '(') + close := strings.LastIndex(stat, ")") + if open < 0 || close <= open { + return fmt.Errorf("malformed process stat") + } + + pid, err := strconv.Atoi(strings.TrimSpace(stat[:open])) + if err != nil { + return fmt.Errorf("invalid process id: %w", err) + } + fields := strings.Fields(stat[close+1:]) + if len(fields) < 16 { + return fmt.Errorf("malformed process stat: got %d fields after command", len(fields)) + } + + parseInt := func(index int, name string) (int, error) { + value, err := strconv.Atoi(fields[index]) + if err != nil { + return 0, fmt.Errorf("invalid %s: %w", name, err) + } + return value, nil + } + parseInt64 := func(index int, name string) (int64, error) { + value, err := strconv.ParseInt(fields[index], 10, 64) + if err != nil { + return 0, fmt.Errorf("invalid %s: %w", name, err) + } + return value, nil + } + + ppid, err := parseInt(1, "parent process id") if err != nil { return err } + pgroup, err := parseInt(2, "process group") + if err != nil { + return err + } + psid, err := parseInt(3, "session id") + if err != nil { + return err + } + userTime, err := parseInt64(11, "user time") + if err != nil { + return err + } + kernelTime, err := parseInt64(12, "kernel time") + if err != nil { + return err + } + priority, err := parseInt(15, "priority") + if err != nil { + return err + } + + p.Pid = pid + p.Filename = stat[open+1 : close] + p.State = fields[0] + p.Ppid = ppid + p.Pgroup = pgroup + p.Psid = psid + p.UserTime = userTime + p.KernelTime = kernelTime + p.Priority = priority return nil } +func (p *ProcessInfo) readIdentityAndPath() { + procPath := filepath.Join("/proc/", fmt.Sprint(p.Pid)) + if info, err := os.Stat(procPath); err == nil { + if stat, ok := info.Sys().(*syscall.Stat_t); ok { + p.Uid = int(stat.Uid) + } + } + if executable, err := os.Readlink(filepath.Join(procPath, "exe")); err == nil { + p.Path = executable + } +} + func (p *ProcessInfo) readCmdline() error { cmdlinePath := filepath.Join("/proc/", fmt.Sprint(p.Pid), "cmdline") cmdline, err := os.ReadFile(cmdlinePath) @@ -161,7 +216,7 @@ func ps(cmd *cobra.Command, args []string) { } var processes []ProcessInfo - clktck, _ := sysconf.Sysconf(sysconf.SC_CLK_TCK) + clktck, clockErr := sysconf.Sysconf(sysconf.SC_CLK_TCK) for _, file := range files { if !file.IsDir() { @@ -177,10 +232,11 @@ func ps(cmd *cobra.Command, args []string) { new_process.Pid = pid err = new_process.readStat() - if err != nil { - new_process.UserTime = new_process.UserTime / int(clktck) - new_process.KernelTime = new_process.KernelTime / int(clktck) + if err == nil && clockErr == nil && clktck > 0 { + new_process.UserTime /= clktck + new_process.KernelTime /= clktck } + new_process.readIdentityAndPath() new_process.readCmdline() new_process.readContext() new_process.readEnv() diff --git a/android-collector/cmd/ps_test.go b/android-collector/cmd/ps_test.go new file mode 100644 index 0000000..3dce671 --- /dev/null +++ b/android-collector/cmd/ps_test.go @@ -0,0 +1,43 @@ +package cmd + +import "testing" + +func TestParseStatHandlesProcessNamesAndFields(t *testing.T) { + process := ProcessInfo{} + stat := "42 (worker name) S 1 2 3 4 5 6 7 8 9 10 1200 340 14 15 -5 0" + + if err := process.parseStat(stat); err != nil { + t.Fatalf("parseStat() error = %v", err) + } + + if process.Pid != 42 || process.Filename != "worker name" || process.State != "S" { + t.Fatalf("identity fields = %+v", process) + } + if process.Ppid != 1 || process.Pgroup != 2 || process.Psid != 3 { + t.Fatalf("relationship fields = %+v", process) + } + if process.UserTime != 1200 || process.KernelTime != 340 || process.Priority != -5 { + t.Fatalf("accounting fields = %+v", process) + } +} + +func TestParseStatUsesLastClosingParenthesis(t *testing.T) { + process := ProcessInfo{} + stat := "7 (worker) name) R 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17" + + if err := process.parseStat(stat); err != nil { + t.Fatalf("parseStat() error = %v", err) + } + if process.Filename != "worker) name" { + t.Fatalf("filename = %q, want %q", process.Filename, "worker) name") + } +} + +func TestParseStatRejectsMalformedInput(t *testing.T) { + for _, stat := range []string{"", "1 worker", "1 (worker) S 1"} { + process := ProcessInfo{} + if err := process.parseStat(stat); err == nil { + t.Fatalf("parseStat(%q) error = nil", stat) + } + } +} diff --git a/android-collector/go.mod b/android-collector/go.mod index 7560573..e8da668 100644 --- a/android-collector/go.mod +++ b/android-collector/go.mod @@ -1,17 +1,19 @@ module github.com/mvt-project/androidqf/android-collector -go 1.20 +go 1.26.5 require ( github.com/h2non/filetype v1.1.3 - github.com/opencontainers/selinux v1.10.0 - github.com/spf13/cobra v1.6.1 - github.com/tklauser/go-sysconf v0.3.11 + github.com/opencontainers/selinux v1.15.1 + github.com/spf13/cobra v1.10.2 + github.com/tklauser/go-sysconf v0.4.0 ) require ( - github.com/inconshreveable/mousetrap v1.0.1 // indirect - github.com/spf13/pflag v1.0.5 // indirect - github.com/tklauser/numcpus v0.6.0 // indirect - golang.org/x/sys v0.2.0 // indirect + cyphar.com/go-pathrs v0.2.5 // indirect + github.com/cyphar/filepath-securejoin v0.7.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/tklauser/numcpus v0.12.0 // indirect + golang.org/x/sys v0.47.0 // indirect ) diff --git a/android-collector/go.sum b/android-collector/go.sum index 27ae545..2fe4e98 100644 --- a/android-collector/go.sum +++ b/android-collector/go.sum @@ -1,21 +1,33 @@ -github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +cyphar.com/go-pathrs v0.2.5 h1:SnX9FBvnoyn3lUs1dkMgZ52bAETpirNu3FTRh5HlRik= +cyphar.com/go-pathrs v0.2.5/go.mod h1:y8f1EMG7r+hCuFf/rXsKqMJrJAUoADZGNh5/vZPKcGc= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/cyphar/filepath-securejoin v0.7.0 h1:s0Y3ITPy6sQn5xt54DuYvTF8hu134ooYLUb58DX/HjE= +github.com/cyphar/filepath-securejoin v0.7.0/go.mod h1:ymLGms/u3BYaviIiuKFnUx8EkQEZeK6cInNoAPJA3o4= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/h2non/filetype v1.1.3 h1:FKkx9QbD7HR/zjK1Ia5XiBsq9zdLi5Kf3zGyFTAFkGg= github.com/h2non/filetype v1.1.3/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY= -github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc= -github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/opencontainers/selinux v1.10.0 h1:rAiKF8hTcgLI3w0DHm6i0ylVVcOrlgR1kK99DRLDhyU= -github.com/opencontainers/selinux v1.10.0/go.mod h1:2i0OySw99QjzBBQByd1Gr9gSjvuho1lHsJxIJ3gGbJI= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/opencontainers/selinux v1.15.1 h1:ERxeh5caJvCzNAKdI8WQbJmB1LDTn4BuaAg8wihLBpA= +github.com/opencontainers/selinux v1.15.1/go.mod h1:LenyElirjUHszfxrjuFqC85HIeXZKumHcKMQtnaDlQQ= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/spf13/cobra v1.6.1 h1:o94oiPyS4KD1mPy2fmcYYHHfCxLqYjJOhGsCHFZtEzA= -github.com/spf13/cobra v1.6.1/go.mod h1:IOw/AERYS7UzyrGinqmz6HLUo219MORXGxhbaJUqzrY= -github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/tklauser/go-sysconf v0.3.11 h1:89WgdJhk5SNwJfu+GKyYveZ4IaJ7xAkecBo+KdJV0CM= -github.com/tklauser/go-sysconf v0.3.11/go.mod h1:GqXfhXY3kiPa0nAXPDIQIWzJbMCB7AmcWpGR8lSZfqI= -github.com/tklauser/numcpus v0.6.0 h1:kebhY2Qt+3U6RNK7UqpYNA+tJ23IBEGKkB7JQBfDYms= -github.com/tklauser/numcpus v0.6.0/go.mod h1:FEZLMke0lhOUG6w2JadTzp0a+Nl8PF/GFkQ5UVIcaL4= -golang.org/x/sys v0.0.0-20191115151921-52ab43148777/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.2.0 h1:ljd4t30dBnAvMZaQCevtY0xLLD0A+bRZXbgLMLU1F/A= -golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU= +github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI= +github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4= +github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/assets/adb_darwin b/assets/adb_darwin index 0471191..8e505d4 100755 Binary files a/assets/adb_darwin and b/assets/adb_darwin differ diff --git a/assets/adb_linux b/assets/adb_linux index e1383de..333a14f 100755 Binary files a/assets/adb_linux and b/assets/adb_linux differ diff --git a/assets/assets_bundled.go b/assets/assets_bundled.go index 4d54f2f..fa5508d 100644 --- a/assets/assets_bundled.go +++ b/assets/assets_bundled.go @@ -9,7 +9,6 @@ package assets import ( "embed" - "errors" "os" "path/filepath" "sync" @@ -49,34 +48,8 @@ func DeployAssets() (string, error) { for _, asset := range getAssets() { assetPath := filepath.Join(dir, asset.Name) - - // If the file already exists, skip it. This avoids failing when adb - // is already deployed or in use by another process. - if _, err := os.Stat(assetPath); err == nil { - continue - } else if !os.IsNotExist(err) { - // Can't determine file existence (e.g., permission error); skip deploying this asset. - continue - } - - // Try to create the asset file. If creation fails (for example because - // the file was created between the Stat and OpenFile calls, or because - // the file is locked by another process), skip the asset instead of failing. - assetFile, err := os.OpenFile(assetPath, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0o755) - if err != nil { - // If the file exists now, just continue; otherwise skip this asset. - if errors.Is(err, os.ErrExist) { - continue - } - // Could be locked or another transient error — do not fail the whole deployment. - continue - } - - // Write and close immediately (avoid defer in a loop). - _, err = assetFile.Write(asset.Data) - assetFile.Close() - if err != nil { - os.RemoveAll(dir) + if err := os.WriteFile(assetPath, asset.Data, 0o755); err != nil { + _ = os.RemoveAll(dir) return "", err } } diff --git a/assets/assets_bundled_test.go b/assets/assets_bundled_test.go new file mode 100644 index 0000000..a455268 --- /dev/null +++ b/assets/assets_bundled_test.go @@ -0,0 +1,43 @@ +//go:build !unbundle + +package assets + +import ( + "bytes" + "os" + "path/filepath" + "testing" +) + +func TestDeployAssetsWritesEveryEmbeddedAsset(t *testing.T) { + if err := CleanAssets(); err != nil { + t.Fatalf("CleanAssets() error = %v", err) + } + t.Cleanup(func() { + if err := CleanAssets(); err != nil { + t.Errorf("CleanAssets() error = %v", err) + } + }) + + dir, err := DeployAssets() + if err != nil { + t.Fatalf("DeployAssets() error = %v", err) + } + for _, asset := range getAssets() { + path := filepath.Join(dir, asset.Name) + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile(%q) error = %v", path, err) + } + if !bytes.Equal(data, asset.Data) { + t.Fatalf("deployed asset %q does not match embedded data", asset.Name) + } + info, err := os.Stat(path) + if err != nil { + t.Fatalf("Stat(%q) error = %v", path, err) + } + if info.Mode().Perm()&0o111 == 0 { + t.Fatalf("deployed asset %q is not executable: %v", asset.Name, info.Mode()) + } + } +} diff --git a/build_locally.sh b/build_locally.sh index 6b7aa1f..11ad0dd 100755 --- a/build_locally.sh +++ b/build_locally.sh @@ -12,7 +12,7 @@ echo "" # Check if goreleaser is installed if ! command -v goreleaser &> /dev/null; then echo "GoReleaser is not installed." - echo "Install it with: go install github.com/goreleaser/goreleaser@latest" + echo "Install it with: go install github.com/goreleaser/goreleaser@v2.17.1" echo "Or see: https://goreleaser.com/install/" exit 1 fi diff --git a/device_selection_test.go b/device_selection_test.go index 048bb1b..c011f5c 100644 --- a/device_selection_test.go +++ b/device_selection_test.go @@ -2,6 +2,7 @@ package main import ( "errors" + "os" "strings" "testing" "time" @@ -9,6 +10,21 @@ import ( "github.com/mvt-project/androidqf/adb" ) +func TestWaitForConnectionRetryReturnsSignal(t *testing.T) { + signals := make(chan os.Signal, 1) + signals <- os.Interrupt + + if got := waitForConnectionRetry(signals, time.Hour); got != os.Interrupt { + t.Fatalf("waitForConnectionRetry() = %v, want %v", got, os.Interrupt) + } +} + +func TestWaitForConnectionRetryReturnsAfterDelay(t *testing.T) { + if got := waitForConnectionRetry(nil, time.Millisecond); got != nil { + t.Fatalf("waitForConnectionRetry() = %v, want nil", got) + } +} + func TestResolveADBSerialNoDevicesDoesNotPrompt(t *testing.T) { called := false serial, prompted, err := resolveADBSerial("", nil, func([]deviceMenuItem) (string, error) { diff --git a/docs/acquisition-archives.md b/docs/acquisition-archives.md index fd6fd4b..8bdaac8 100644 --- a/docs/acquisition-archives.md +++ b/docs/acquisition-archives.md @@ -25,7 +25,7 @@ The archive contains the collected module outputs documented in the main | Entry | Purpose | |---|---| -| `acquisition.json` | Acquisition UUID, timestamps, androidqf version and device information. | +| `acquisition.json` | Acquisition UUID, timestamps, androidqf version, device information, and per-module outcomes. | | `adb_host_key.pub` | Public half of the ADB host key available to androidqf during the acquisition. | | `command.log` | Debug-level command and acquisition log, when log output was produced. | | `hashes.csv` | SHA-256 integrity records for preceding plaintext archive entries. | @@ -42,14 +42,11 @@ The file does not contain a hash record for itself. ## Streaming and failed pulls -Module output is written to the archive as it is collected. Data that can be -produced reliably as one stream, such as a backup or bug report, is streamed -directly into its ZIP entry. - -Files that may disappear or fail partway through an ADB pull are staged and -validated before androidqf creates their ZIP entries. Consequently, a failed -pull does not leave an empty or partial entry that appears valid in -`hashes.csv`. +Module output is written to the archive as it is collected. Streams produced +by external ADB commands, including backups, bug reports, and device-file +transfers, are staged and validated before androidqf creates their ZIP entries. +Consequently, a command that fails after producing some output does not leave +an empty or partial entry that appears valid in `hashes.csv`. Archive completion writes `acquisition.json`, `command.log` and `hashes.csv`, then closes the ZIP and, when enabled, the age encryption stream. A failure in @@ -57,6 +54,13 @@ any of these operations is reported as a failed acquisition; androidqf does not print the acquisition-success message for an archive that could not be finalized. +`acquisition.json` includes a `module_results` list with the status, start and +completion timestamps, and any error for each selected module. Status is +`completed`, `partial` when some requested evidence could not be collected, or +`failed`. If a module is partial or fails, androidqf still finalizes the archive +so successfully collected evidence remains available, but exits unsuccessfully +and clearly reports that the acquisition contains incomplete modules. + ## Encrypted acquisitions When a valid `key.txt` is present, the ZIP stream is encrypted to every @@ -125,8 +129,8 @@ not added to the archive. ## Operational considerations -- Keep sufficient free space for the output archive and, when staging is - required, one additional copy of the largest staged file. +- Keep sufficient free space for the output archive and one additional staged + copy of the largest device file, backup, or bug report. - Treat a finalization error as a failed acquisition. The output may be incomplete or unreadable and should not be treated as finalized evidence. - Preserve the age identity separately from the acquisition host when the diff --git a/go.mod b/go.mod index 7e3256c..0a79149 100644 --- a/go.mod +++ b/go.mod @@ -1,10 +1,10 @@ module github.com/mvt-project/androidqf -go 1.26.3 +go 1.26.5 require ( - filippo.io/age v1.2.1 - github.com/avast/apkverifier v0.0.0-20250626104651-727e33396aec + filippo.io/age v1.3.1 + github.com/avast/apkverifier v0.0.0-20260710162049-d0e1a791cd5a github.com/botherder/go-savetime v1.5.0 github.com/google/uuid v1.6.0 github.com/i582/cfmt v1.4.0 @@ -13,11 +13,14 @@ require ( ) require ( - github.com/avast/apkparser v0.0.0-20250626104540-d53391f4d69d // indirect + c2sp.org/CCTV/age v0.0.0-20260605191406-1e3d2860d46e // indirect + filippo.io/hpke v0.4.0 // indirect + github.com/avast/apkparser v0.0.0-20260423123151-7fcaee440f68 // indirect github.com/chzyer/readline v1.5.1 // indirect - github.com/gookit/color v1.5.4 // indirect - github.com/klauspost/compress v1.18.0 // indirect + github.com/gookit/color v1.6.1 // indirect + github.com/klauspost/compress v1.19.1 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/crypto v0.43.0 // indirect - golang.org/x/sys v0.38.0 // indirect + golang.org/x/crypto v0.54.0 // indirect + golang.org/x/exp v0.0.0-20260727155853-b88d891fe743 // indirect + golang.org/x/sys v0.47.0 // indirect ) diff --git a/go.sum b/go.sum index e2fe499..df43610 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,13 @@ -c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805 h1:u2qwJeEvnypw+OCPUHmoZE3IqwfuN5kgDfo5MLzpNM0= -c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805/go.mod h1:FomMrUJ2Lxt5jCLmZkG3FHa72zUprnhd3v/Z18Snm4w= -filippo.io/age v1.2.1 h1:X0TZjehAZylOIj4DubWYU1vWQxv9bJpo+Uu2/LGhi1o= -filippo.io/age v1.2.1/go.mod h1:JL9ew2lTN+Pyft4RiNGguFfOpewKwSHm5ayKD/A4004= -github.com/avast/apkparser v0.0.0-20250626104540-d53391f4d69d h1:PGSn2pnK/u5ZBompy83R6Wo4BqLYp3dX43QWDoPv7TA= -github.com/avast/apkparser v0.0.0-20250626104540-d53391f4d69d/go.mod h1:3F9A8btIerUcuy7Fmno+g/nIk4ELKJ6NCs2/KK1bvLs= -github.com/avast/apkverifier v0.0.0-20250626104651-727e33396aec h1:Y/3gd5e29uUSK5BqY5zwRpBE+UWv0dObUyCGyCLhCIw= -github.com/avast/apkverifier v0.0.0-20250626104651-727e33396aec/go.mod h1:a8y1CMmslGuTI5b+4Fz0XekhsAfQZZJ5GuMJq5smTLQ= +c2sp.org/CCTV/age v0.0.0-20260605191406-1e3d2860d46e h1:N9DpijOhcuCLIlgcX/6qhBQeMQIlBxsVQITjbpJaBso= +c2sp.org/CCTV/age v0.0.0-20260605191406-1e3d2860d46e/go.mod h1:SrHC2C7r5GkDk8R+NFVzYy/sdj0Ypg9htaPXQq5Cqeo= +filippo.io/age v1.3.1 h1:hbzdQOJkuaMEpRCLSN1/C5DX74RPcNCk6oqhKMXmZi0= +filippo.io/age v1.3.1/go.mod h1:EZorDTYUxt836i3zdori5IJX/v2Lj6kWFU0cfh6C0D4= +filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A= +filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY= +github.com/avast/apkparser v0.0.0-20260423123151-7fcaee440f68 h1:+7UZ0vN+mEIzkjcgC5ZUzTV+ikKqCrfK06OyVCQyzAo= +github.com/avast/apkparser v0.0.0-20260423123151-7fcaee440f68/go.mod h1:3F9A8btIerUcuy7Fmno+g/nIk4ELKJ6NCs2/KK1bvLs= +github.com/avast/apkverifier v0.0.0-20260710162049-d0e1a791cd5a h1:8chZxVfT6jnpiDC7tGMiWbkmiPWSZrRwBi7+fUkrJQ0= +github.com/avast/apkverifier v0.0.0-20260710162049-d0e1a791cd5a/go.mod h1:LRDgSMX7AD3MdV5MlmZ9Sly1tcf1Dqa6olfKg3X9DB8= github.com/botherder/go-savetime v1.5.0 h1:i4vt4d4IcXgFXnIK5FBuSCUUZSV8E+s4S8TLm+9tYdM= github.com/botherder/go-savetime v1.5.0/go.mod h1:w8rKlqwexRgSmekdFAZVfenmaZKhXBIew2tDvuox2sI= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= @@ -18,36 +20,31 @@ github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMn github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04= github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gookit/assert v0.1.1 h1:lh3GcawXe/p+cU7ESTZ5Ui3Sm/x8JWpIis4/1aF0mY0= +github.com/gookit/assert v0.1.1/go.mod h1:jS5bmIVQZTIwk42uXl4lyj4iaaxx32tqH16CFj0VX2E= github.com/gookit/color v1.3.2/go.mod h1:R3ogXq2B9rTbXoSHJ1HyUVAZ3poOJHpd9nQmyGZsfvQ= -github.com/gookit/color v1.5.4 h1:FZmqs7XOyGgCAxmWyPslpiok1k05wmY3SJTytgvYFs0= -github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w= +github.com/gookit/color v1.6.1 h1:KoTnDxJPRgrL0SoX0f8rCFg2zI0t4E3GZZBMo2nN8LU= +github.com/gookit/color v1.6.1/go.mod h1:9ACFc7/1IpHGBW8RwuDm/0YEnhg3dwwXpoMsmtyHfjs= github.com/i582/cfmt v1.4.0 h1:DNugs+dvy3xjJSUk9Oita0udy1YVQh2vDP6cWYhDCIQ= github.com/i582/cfmt v1.4.0/go.mod h1:tpHWAxhE4Y7yy7sliaNe0pnnEs1SZe67KLljyOlEYI8= -github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= -github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= +github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA= github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= github.com/minio/sio v0.5.1 h1:sqtImrnCSHbDqO/lVy3tfbsctHzfelDv3NbXWEVcWT8= github.com/minio/sio v0.5.1/go.mod h1:4ANoe4CCXqnt1FCiLM0+vlBUhhWZzVOhYCz0069KtFc= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= -golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= -golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/exp v0.0.0-20260727155853-b88d891fe743 h1:ex206bKw+v3K0dm3andkrIF+ijyQKJG1pLgwQ2PYdQM= +golang.org/x/exp v0.0.0-20260727155853-b88d891fe743/go.mod h1:EdfpwwqSu+0Li0mzskwHU6FWDV3t9Q+RZDo3QMUtL3Q= golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/main.go b/main.go index c9bc600..1d19bdb 100644 --- a/main.go +++ b/main.go @@ -6,16 +6,21 @@ package main import ( + "context" + "errors" "flag" "fmt" "os" + "os/signal" "strings" + "syscall" "time" "github.com/i582/cfmt/cmd/cfmt" "github.com/manifoldco/promptui" "github.com/mvt-project/androidqf/acquisition" "github.com/mvt-project/androidqf/adb" + "github.com/mvt-project/androidqf/assets" "github.com/mvt-project/androidqf/log" "github.com/mvt-project/androidqf/modules" "github.com/mvt-project/androidqf/utils" @@ -122,6 +127,123 @@ func errorOnDeviceSelection([]deviceMenuItem) (string, error) { return "", fmt.Errorf("multiple devices detected, use -serial to select one") } +func waitForConnectionRetry(signals <-chan os.Signal, delay time.Duration) os.Signal { + timer := time.NewTimer(delay) + defer timer.Stop() + select { + case received := <-signals: + return received + case <-timer.C: + return nil + } +} + +func abortBeforeAcquisition(received os.Signal) { + cleanupBeforeAcquisition() + message := "Interrupted before acquisition started." + if received != nil { + message = fmt.Sprintf("Interrupted before acquisition started: %s", received) + } + log.Fatal(message) +} + +func cleanupBeforeAcquisition() { + if adb.Client != nil { + _, _ = adb.Client.KillServer() + } + _ = assets.CleanAssets() +} + +func fatalBeforeAcquisition(v ...any) { + cleanupBeforeAcquisition() + log.Fatal(v...) +} + +func pendingSignal(signals <-chan os.Signal) os.Signal { + select { + case received := <-signals: + return received + default: + return nil + } +} + +func runModule(mod modules.Module, acq *acquisition.Acquisition, opts *modules.Options, signals <-chan os.Signal) (error, bool) { + if mod == nil { + return fmt.Errorf("module cannot be nil"), false + } + if opts == nil { + return fmt.Errorf("module options cannot be nil"), false + } + + moduleCtx, cancel := context.WithCancel(context.Background()) + moduleSignals := make(chan os.Signal, 1) + opts.Context = moduleCtx + opts.Signals = moduleSignals + if adb.Client != nil { + adb.Client.SetContext(moduleCtx) + } + if acq != nil && acq.StreamingPuller != nil { + acq.StreamingPuller.SetContext(moduleCtx) + } + defer func() { + cancel() + opts.Context = nil + opts.Signals = nil + if adb.Client != nil { + adb.Client.SetContext(context.Background()) + } + if acq != nil && acq.StreamingPuller != nil { + acq.StreamingPuller.SetContext(context.Background()) + } + }() + + done := make(chan error, 1) + go func() { + done <- mod.Run(acq, opts) + }() + + intrusionWaitSkipped := false + for { + select { + case err := <-done: + return err, false + case received, ok := <-signals: + if !ok { + signals = nil + continue + } + if received == nil { + continue + } + if received == os.Interrupt && mod.Name() == modules.NewIL().Name() && !intrusionWaitSkipped { + moduleSignals <- received + intrusionWaitSkipped = true + continue + } + + log.Warningf("Received %s; canceling module %s before finalizing the partial acquisition.", received, mod.Name()) + cancel() + moduleErr := <-done + interruptErr := fmt.Errorf("%w: received %s", modules.ErrAcquisitionInterrupted, received) + return errors.Join(moduleErr, interruptErr), true + } + } +} + +func moduleResultStatus(err error) string { + if err == nil { + return "completed" + } + if errors.Is(err, modules.ErrAcquisitionInterrupted) { + return "failed" + } + if errors.Is(err, modules.ErrPartialCollection) { + return "partial" + } + return "failed" +} + func buildOptions(fast, nonInteractive bool, backup, download, removeTrusted, intrusionLogs, hashFiles, moduleFilter string) (*modules.Options, error) { opts := &modules.Options{Fast: fast, NonInteractive: nonInteractive} var err error @@ -226,9 +348,18 @@ func main() { log.Fatal(err) } + signals := make(chan os.Signal, 2) + signal.Notify(signals, os.Interrupt, syscall.SIGTERM) + defer signal.Stop(signals) + setupCtx, stopSetupSignals := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stopSetupSignals() + log.Debug("Starting androidqf") - adb.Client, err = adb.New() + adb.Client, err = adb.NewWithContext(setupCtx) if err != nil { + if setupCtx.Err() != nil { + abortBeforeAcquisition(pendingSignal(signals)) + } log.Fatal("Impossible to initialize ADB: ", err) } @@ -257,15 +388,20 @@ func main() { if serial == "" { devices, err := adb.Client.DeviceInfos() if err != nil { + if nonInteractive { + fatalBeforeAcquisition("Error listing ADB devices: ", err) + } log.Error(fmt.Sprintf("Error listing ADB devices: %s", err)) } else { serial, _, err = resolveADBSerial(serial, devices, selectDevice, activeRunningExtractionsBySerial()) if err != nil { if nonInteractive { - log.Fatal("Error selecting ADB device: ", err) + fatalBeforeAcquisition("Error selecting ADB device: ", err) } log.Error(fmt.Sprintf("Error selecting ADB device: %s", err)) - time.Sleep(5 * time.Second) + if received := waitForConnectionRetry(signals, 5*time.Second); received != nil { + abortBeforeAcquisition(received) + } continue } } @@ -273,6 +409,9 @@ func main() { serial, err = adb.Client.SetSerial(serial) if err != nil { + if nonInteractive { + fatalBeforeAcquisition("Error trying to connect over ADB: ", err) + } log.Error(fmt.Sprintf("Error trying to connect over ADB: %s", err)) if !specificDeviceRequested { serial = "" @@ -283,15 +422,35 @@ func main() { break } log.Debug(err) + if nonInteractive { + fatalBeforeAcquisition("Unable to get device state: ", err) + } log.Error("Unable to get device state. Please make sure it is connected and authorized. Trying again in 5 seconds...") if !specificDeviceRequested { serial = "" } } - time.Sleep(5 * time.Second) + if received := waitForConnectionRetry(signals, 5*time.Second); received != nil { + abortBeforeAcquisition(received) + } } - releaseRunning, err := registerRunningExtraction(adb.Client.Serial, "") + if setupCtx.Err() != nil { + abortBeforeAcquisition(pendingSignal(signals)) + } + acq, err := acquisition.New(output_folder) + if err != nil { + if setupCtx.Err() != nil { + abortBeforeAcquisition(pendingSignal(signals)) + } + log.Debug(err) + log.FatalExc("Impossible to initialise the acquisition", err) + } + stopSetupSignals() + adb.Client.SetContext(context.Background()) + acq.StreamingPuller.SetContext(context.Background()) + + releaseRunning, err := registerRunningExtraction(adb.Client.Serial, acq.StoragePath) if err != nil { log.Warningf("Unable to record running extraction state: %v", err) releaseRunning = func() {} @@ -303,24 +462,53 @@ func main() { } }() - acq, err := acquisition.New(output_folder) - if err != nil { - log.Debug(err) - log.FatalExc("Impossible to initialise the acquisition", err) - } - // Start acquisitions log.Info(fmt.Sprintf("Started new acquisition archive in %s", acq.StoragePath)) mods := modules.List() + incompleteModules := 0 + interrupted := false + select { + case received := <-signals: + log.Warningf("Received %s; finalizing without running acquisition modules.", received) + interrupted = true + default: + } for _, mod := range mods { + if interrupted { + break + } if !modules.ModuleEnabled(mod.Name(), module) { continue } + select { + case received := <-signals: + log.Warningf("Received %s; finalizing before module %s.", received, mod.Name()) + interrupted = true + continue + default: + } - err = mod.Run(acq, opts) + moduleStarted := time.Now().UTC() + err, interrupted = runModule(mod, acq, opts, signals) + result := acquisition.ModuleResult{ + Name: mod.Name(), + Status: moduleResultStatus(err), + Started: moduleStarted, + Completed: time.Now().UTC(), + } if err != nil { - log.Infof("ERROR: failed to run module %s: %v", mod.Name(), err) + result.Error = err.Error() + incompleteModules++ + log.Infof("ERROR: module %s completed with status %s: %v", mod.Name(), result.Status, err) + } + acq.ModuleResults = append(acq.ModuleResults, result) + + if errors.Is(err, modules.ErrAcquisitionInterrupted) { + interrupted = true + } + if interrupted { + break } } @@ -332,9 +520,16 @@ func main() { } releaseRunning() runningReleased = true + if interrupted { + log.Fatal("Acquisition was interrupted and finalized as partial.") + } + if incompleteModules > 0 { + log.Fatalf("Acquisition finalized with %d incomplete module(s). Review acquisition.json and command.log for details.", incompleteModules) + } log.Info("Acquisition completed.") if !nonInteractive { + signal.Stop(signals) systemPause() } } diff --git a/main_options_test.go b/main_options_test.go index 3f4deaa..9d2ad63 100644 --- a/main_options_test.go +++ b/main_options_test.go @@ -39,10 +39,12 @@ func TestBuildOptionsInvalidValueFails(t *testing.T) { } } -func TestBuildOptionsNonInteractiveUnknownModuleFails(t *testing.T) { - _, err := buildOptions(false, true, "", "", "", "", "", "typo") - if err == nil || !strings.Contains(err.Error(), "unknown -module value") { - t.Fatalf("err = %v, want unknown -module error", err) +func TestBuildOptionsUnknownModuleFails(t *testing.T) { + for _, nonInteractive := range []bool{false, true} { + _, err := buildOptions(false, nonInteractive, "", "", "", "", "", "typo") + if err == nil || !strings.Contains(err.Error(), "unknown -module value") { + t.Fatalf("nonInteractive=%v: err = %v, want unknown -module error", nonInteractive, err) + } } } diff --git a/module_execution_test.go b/module_execution_test.go new file mode 100644 index 0000000..ba7c2f9 --- /dev/null +++ b/module_execution_test.go @@ -0,0 +1,113 @@ +package main + +import ( + "errors" + "fmt" + "os" + "syscall" + "testing" + + "github.com/mvt-project/androidqf/acquisition" + "github.com/mvt-project/androidqf/adb" + "github.com/mvt-project/androidqf/modules" +) + +type contextBlockingModule struct { + name string + started chan struct{} +} + +func (m *contextBlockingModule) Name() string { return m.name } + +func (m *contextBlockingModule) Run(_ *acquisition.Acquisition, opts *modules.Options) error { + close(m.started) + <-opts.ContextOrBackground().Done() + return opts.ContextOrBackground().Err() +} + +type interruptWaitingModule struct { + started chan struct{} +} + +func (m *interruptWaitingModule) Name() string { return modules.NewIL().Name() } + +func (m *interruptWaitingModule) Run(_ *acquisition.Acquisition, opts *modules.Options) error { + close(m.started) + received := <-opts.Signals + if received != os.Interrupt { + return fmt.Errorf("received %v, want %v", received, os.Interrupt) + } + return nil +} + +func TestRunModuleCancelsActiveModuleOnTermination(t *testing.T) { + oldClient := adb.Client + adb.Client = nil + t.Cleanup(func() { adb.Client = oldClient }) + + started := make(chan struct{}) + signals := make(chan os.Signal, 1) + go func() { + <-started + signals <- syscall.SIGTERM + }() + + opts := &modules.Options{} + err, interrupted := runModule( + &contextBlockingModule{name: "blocking", started: started}, + &acquisition.Acquisition{}, + opts, + signals, + ) + if !interrupted { + t.Fatal("runModule() interrupted = false") + } + if !errors.Is(err, modules.ErrAcquisitionInterrupted) { + t.Fatalf("runModule() error = %v, want ErrAcquisitionInterrupted", err) + } + if opts.Context != nil || opts.Signals != nil { + t.Fatalf("module options retained runtime state: %+v", opts) + } +} + +func TestRunModuleForwardsFirstIntrusionLogInterrupt(t *testing.T) { + oldClient := adb.Client + adb.Client = nil + t.Cleanup(func() { adb.Client = oldClient }) + + started := make(chan struct{}) + signals := make(chan os.Signal, 1) + go func() { + <-started + signals <- os.Interrupt + }() + + err, interrupted := runModule( + &interruptWaitingModule{started: started}, + &acquisition.Acquisition{}, + &modules.Options{}, + signals, + ) + if err != nil { + t.Fatalf("runModule() error = %v", err) + } + if interrupted { + t.Fatal("runModule() interrupted = true") + } +} + +func TestModuleResultStatusDistinguishesPartialCollection(t *testing.T) { + if got := moduleResultStatus(nil); got != "completed" { + t.Fatalf("nil status = %q, want completed", got) + } + if got := moduleResultStatus(fmt.Errorf("%w: missing file", modules.ErrPartialCollection)); got != "partial" { + t.Fatalf("partial status = %q, want partial", got) + } + interruptedPartial := errors.Join( + fmt.Errorf("%w: missing file", modules.ErrPartialCollection), + modules.ErrAcquisitionInterrupted, + ) + if got := moduleResultStatus(interruptedPartial); got != "failed" { + t.Fatalf("interrupted partial status = %q, want failed", got) + } +} diff --git a/modules/files.go b/modules/files.go index 7f82cf3..4fddeb1 100644 --- a/modules/files.go +++ b/modules/files.go @@ -5,10 +5,10 @@ package modules import ( + "errors" "fmt" "strings" - "github.com/botherder/go-savetime/slice" "github.com/manifoldco/promptui" "github.com/mvt-project/androidqf/acquisition" "github.com/mvt-project/androidqf/adb" @@ -67,8 +67,9 @@ func (f *Files) Run(acq *acquisition.Acquisition, opts *Options) error { } log.Info("Collecting list of files... This might take a while...") - var fileFounds []string + fileFound := make(map[string]struct{}) var fileDetails []adb.FileInfo + var collectionErr error method := "collector" if acq.Collector == nil { @@ -111,15 +112,21 @@ func (f *Files) Run(acq *acquisition.Acquisition, opts *Options) error { out, err = adb.Client.FindLimitedCommand(folder) } - if err == nil { - for _, s := range out { - if !slice.Contains(fileFounds, s.Path) { - fileFounds = append(fileFounds, s.Path) - fileDetails = append(fileDetails, s) - } + if err != nil { + log.Warningf("Failed to collect files under %s: %v", folder, err) + collectionErr = errors.Join(collectionErr, fmt.Errorf("%s: %w", folder, err)) + } + for _, s := range out { + if _, exists := fileFound[s.Path]; !exists { + fileFound[s.Path] = struct{}{} + fileDetails = append(fileDetails, s) } } } - return saveDataToAcquisition(acq, "files.json", &fileDetails) + saveErr := saveDataToAcquisition(acq, "files.json", &fileDetails) + if saveErr != nil { + return errors.Join(collectionErr, saveErr) + } + return partialCollectionError(collectionErr) } diff --git a/modules/intrusion_logs.go b/modules/intrusion_logs.go index 67054fe..039acdd 100644 --- a/modules/intrusion_logs.go +++ b/modules/intrusion_logs.go @@ -7,9 +7,9 @@ package modules import ( "context" + "errors" "fmt" "os" - "os/signal" "path" "strings" "time" @@ -50,12 +50,13 @@ func ParseIntrusionLogsOption(value string) (string, error) { } func (m *IL) Run(acq *acquisition.Acquisition, opts *Options) error { + var collectionErr error + // Check whether the device supports AAPM. compatible, err := m.isAAPMCompatibleDevice() if err != nil { - // Don't break acquisition if the check fails, just log and skip. log.Debugf("Failed to check AAPM compatibility: %v", err) - return nil + return partialCollectionError(fmt.Errorf("failed to check AAPM compatibility: %w", err)) } // TODO: Investigate whether IL data could exist on a non-compatible device @@ -93,6 +94,7 @@ func (m *IL) Run(acq *acquisition.Acquisition, opts *Options) error { aapmEnabled, err := m.isAAPMEnabled() if err != nil { log.Debugf("Failed to check AAPM enabled state: %v", err) + collectionErr = errors.Join(collectionErr, fmt.Errorf("failed to check AAPM enabled state: %w", err)) aapmEnabled = false } @@ -101,13 +103,14 @@ func (m *IL) Run(acq *acquisition.Acquisition, opts *Options) error { before, err := m.listDirSet(m.DirOnDevice) if err != nil { - log.Errorf("IL: failed to list %s: %v", m.DirOnDevice, err) - return nil + log.Errorf("IL: failed to completely list %s before requesting new logs: %v", m.DirOnDevice, err) + collectionErr = errors.Join(collectionErr, fmt.Errorf("failed to list %s before requesting new logs: %w", m.DirOnDevice, err)) } // Start the Activity to prompt the user to download a new Intrusion Log if err := adb.Client.IL(); err != nil { log.Errorf("Failed to launch intrusion detection activity: %v\n", err) + collectionErr = errors.Join(collectionErr, err) // Still allow pulling existing files if user wants; continue anyway. } @@ -115,15 +118,14 @@ func (m *IL) Run(acq *acquisition.Acquisition, opts *Options) error { log.Info("On the device: scroll down, tap 'Access Logs', then press 'Download and Decrypt' for each listed device.\n") log.Info("Waiting for intrusion logs to be written to device. (Ctrl+C to skip waiting and continue acquisition)...") - // Watch directory (Ctrl+C cancels watch but continues acquisition) - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) - defer stop() - // Pulls every 2 seconds. Stops on Ctrl+C or after 15 minutes. - watchErr := m.waitForNewFiles(ctx, m.DirOnDevice, before, 2*time.Second, 15*time.Minute) + watchErr := m.waitForNewFiles(opts.ContextOrBackground(), opts.Signals, m.DirOnDevice, before, 2*time.Second, 15*time.Minute) + if errors.Is(watchErr, ErrAcquisitionInterrupted) { + return watchErr + } if watchErr != nil { - // If user Ctrl+C, context is canceled and acquisition continues log.Info("Stopped waiting, continuing with acquisition...") + collectionErr = errors.Join(collectionErr, watchErr) } } else { log.Debug("AAPM is disabled, skipping activity launch and new file watcher (pulling existing files only).") @@ -132,22 +134,20 @@ func (m *IL) Run(acq *acquisition.Acquisition, opts *Options) error { // Pull all files (old + new) files, err := adb.Client.ListFiles(m.DirOnDevice, true) if err != nil { - log.Errorf("IL: failed to list files for pull in %s: %v", m.DirOnDevice, err) - return nil + collectionErr = errors.Join(collectionErr, fmt.Errorf("failed to list files for pull in %s: %w", m.DirOnDevice, err)) } if len(files) == 0 { log.Info("No files found in " + m.DirOnDevice) - return nil + return partialCollectionError(collectionErr) } if err := m.pullAll(acq, files); err != nil { log.Errorf("IL: failed pulling IL files: %v", err) - // continue acquisition - return nil + collectionErr = errors.Join(collectionErr, err) } log.Infof("Downloaded %d Instrusion Logging files from the phone.", len(files)) log.Info("Intrusion Logging acquisition is completed; continuing with acquisition ...") - return nil + return partialCollectionError(collectionErr) } func (m *IL) isAAPMCompatibleDevice() (bool, error) { @@ -187,20 +187,18 @@ func (m *IL) isAAPMEnabled() (bool, error) { func (m *IL) listDirSet(dir string) (map[string]struct{}, error) { files, err := adb.Client.ListFiles(dir, true) - if err != nil { - return nil, err - } log.Debugf("IL: Polling found %d intrusion logging files on device at '%s'", len(files), dir) set := make(map[string]struct{}, len(files)) for _, f := range files { set[f] = struct{}{} } - return set, nil + return set, err } // Watch for new files until Ctrl+C or timeout. func (m *IL) waitForNewFiles( ctx context.Context, + signals <-chan os.Signal, dir string, before map[string]struct{}, pollEvery time.Duration, @@ -215,9 +213,14 @@ func (m *IL) waitForNewFiles( for { select { case <-ctx.Done(): - // Ctrl+C => continue acquisition (non-fatal) - log.Info("Ctrl+C detected. Continuing acquisition...") - return nil + return fmt.Errorf("%w: %v", ErrAcquisitionInterrupted, ctx.Err()) + + case received := <-signals: + if received == os.Interrupt { + log.Info("Ctrl+C detected. Continuing acquisition...") + return nil + } + return fmt.Errorf("%w: received %s", ErrAcquisitionInterrupted, received) case <-timeout.C: log.Info("Finished waiting for intrusion logs (15 minute timeout reached).") @@ -244,6 +247,7 @@ func (m *IL) waitForNewFiles( } func (m *IL) pullAll(acq *acquisition.Acquisition, deviceFiles []string) error { + var collectionErr error for _, file := range deviceFiles { if file == m.DirOnDevice { continue @@ -252,6 +256,7 @@ func (m *IL) pullAll(acq *acquisition.Acquisition, deviceFiles []string) error { rel, err := relativeDeviceChild(m.DirOnDevice, file) if err != nil { log.Errorf("Skipping IL file with unsafe path %s: %v\n", file, err) + collectionErr = errors.Join(collectionErr, fmt.Errorf("%s: %w", file, err)) continue } @@ -259,11 +264,12 @@ func (m *IL) pullAll(acq *acquisition.Acquisition, deviceFiles []string) error { if err := acq.PullToZipStaged(file, zipPath); err != nil { log.Errorf("Failed to stage IL file %s for archive: %v\n", file, err) + collectionErr = errors.Join(collectionErr, fmt.Errorf("%s: %w", file, err)) continue } log.Debugf("Staged IL file %s and added it to archive as %s", file, zipPath) } - return nil + return collectionErr } diff --git a/modules/intrusion_logs_test.go b/modules/intrusion_logs_test.go new file mode 100644 index 0000000..d6fbb3b --- /dev/null +++ b/modules/intrusion_logs_test.go @@ -0,0 +1,40 @@ +package modules + +import ( + "context" + "errors" + "os" + "syscall" + "testing" + "time" +) + +func TestWaitForNewFilesConsumesInterruptAsSkip(t *testing.T) { + signals := make(chan os.Signal, 1) + signals <- os.Interrupt + + err := NewIL().waitForNewFiles(context.Background(), signals, "/unused", nil, time.Hour, time.Hour) + if err != nil { + t.Fatalf("waitForNewFiles() error = %v", err) + } +} + +func TestWaitForNewFilesPropagatesTermination(t *testing.T) { + signals := make(chan os.Signal, 1) + signals <- syscall.SIGTERM + + err := NewIL().waitForNewFiles(context.Background(), signals, "/unused", nil, time.Hour, time.Hour) + if !errors.Is(err, ErrAcquisitionInterrupted) { + t.Fatalf("waitForNewFiles() error = %v, want ErrAcquisitionInterrupted", err) + } +} + +func TestWaitForNewFilesPropagatesContextCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + err := NewIL().waitForNewFiles(ctx, nil, "/unused", nil, time.Hour, time.Hour) + if !errors.Is(err, ErrAcquisitionInterrupted) { + t.Fatalf("waitForNewFiles() error = %v, want ErrAcquisitionInterrupted", err) + } +} diff --git a/modules/logs.go b/modules/logs.go index 3b9eeab..75eb964 100644 --- a/modules/logs.go +++ b/modules/logs.go @@ -38,7 +38,6 @@ func (l *Logs) Run(acq *acquisition.Acquisition, opts *Options) error { files, err := adb.Client.ListFiles(logFolder, true) if err != nil { log.Debugf("Impossible to get files from %s", logFolder) - continue } if len(files) == 0 { continue diff --git a/modules/options.go b/modules/options.go index bddc7f4..49ed51d 100644 --- a/modules/options.go +++ b/modules/options.go @@ -6,10 +6,16 @@ package modules import ( + "context" + "errors" "fmt" + "os" "strings" ) +var ErrAcquisitionInterrupted = errors.New("acquisition interrupted") +var ErrPartialCollection = errors.New("partial collection") + // Options carries per-run module configuration. An empty string field means // no answer was provided on the command line, so the module prompts // interactively unless NonInteractive is set. @@ -21,6 +27,22 @@ type Options struct { RemoveTrusted string IntrusionLogs string HashFiles string + Signals <-chan os.Signal + Context context.Context +} + +func (o *Options) ContextOrBackground() context.Context { + if o == nil || o.Context == nil { + return context.Background() + } + return o.Context +} + +func partialCollectionError(err error) error { + if err == nil { + return nil + } + return fmt.Errorf("%w: %v", ErrPartialCollection, err) } func ModuleEnabled(name, filter string) bool { @@ -37,13 +59,12 @@ func moduleExists(name string) bool { } func ValidateNonInteractive(opts *Options, moduleFilter string) error { - if opts == nil || !opts.NonInteractive { - return nil - } - if moduleFilter != "" && !moduleExists(moduleFilter) { return fmt.Errorf("unknown -module value %q, use -list to see available modules", moduleFilter) } + if opts == nil || !opts.NonInteractive { + return nil + } var missing []string if ModuleEnabled(NewBackup().Name(), moduleFilter) && opts.Backup == "" { @@ -76,5 +97,22 @@ func resolveOption(opts *Options, value, flagUsage string, prompt func() (string if opts.NonInteractive { return "", fmt.Errorf("-non-interactive is set but %s was not provided", flagUsage) } - return prompt() + + type promptResult struct { + value string + err error + } + result := make(chan promptResult, 1) + go func() { + value, err := prompt() + result <- promptResult{value: value, err: err} + }() + + ctx := opts.ContextOrBackground() + select { + case <-ctx.Done(): + return "", fmt.Errorf("%w: %v", ErrAcquisitionInterrupted, ctx.Err()) + case resolved := <-result: + return resolved.value, resolved.err + } } diff --git a/modules/options_test.go b/modules/options_test.go index 403044f..8acbc1e 100644 --- a/modules/options_test.go +++ b/modules/options_test.go @@ -1,10 +1,23 @@ package modules import ( + "context" + "errors" "strings" "testing" ) +func TestPartialCollectionError(t *testing.T) { + want := errors.New("missing evidence") + err := partialCollectionError(want) + if !errors.Is(err, ErrPartialCollection) { + t.Fatalf("partialCollectionError() = %v, want ErrPartialCollection", err) + } + if err == nil || !strings.Contains(err.Error(), want.Error()) { + t.Fatalf("partialCollectionError() = %v, want original detail", err) + } +} + func TestParseOptions(t *testing.T) { tests := []struct { name string @@ -94,6 +107,26 @@ func TestResolveOptionInteractivePrompts(t *testing.T) { } } +func TestResolveOptionStopsWaitingWhenContextIsCanceled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + started := make(chan struct{}) + release := make(chan struct{}) + go func() { + <-started + cancel() + }() + + _, err := resolveOption(&Options{Context: ctx}, "", "-backup", func() (string, error) { + close(started) + <-release + return backupOnlySMS, nil + }) + close(release) + if !errors.Is(err, ErrAcquisitionInterrupted) { + t.Fatalf("resolveOption() error = %v, want ErrAcquisitionInterrupted", err) + } +} + func TestModuleEnabled(t *testing.T) { if !ModuleEnabled("backup", "") { t.Fatal("empty filter should enable every module") @@ -115,7 +148,13 @@ func TestValidateNonInteractive(t *testing.T) { wantMissing []string }{ {"interactive", &Options{}, "", nil, nil}, - {"interactive ignores unknown module", &Options{}, "typo", nil, nil}, + { + "interactive rejects unknown module", + &Options{}, + "typo", + []string{"unknown -module value"}, + nil, + }, { "unknown module filter", &Options{NonInteractive: true}, diff --git a/modules/packages.go b/modules/packages.go index 71a6485..3549e62 100644 --- a/modules/packages.go +++ b/modules/packages.go @@ -112,6 +112,7 @@ func ParseRemoveTrustedOption(value string) (string, error) { func (p *Packages) Run(acq *acquisition.Acquisition, opts *Options) error { log.Info("Collecting information on installed apps. This might take a while...") + var collectionErr error packages, err := adb.Client.GetPackages(opts.Fast) if err != nil { @@ -171,13 +172,18 @@ func (p *Packages) Run(acq *acquisition.Acquisition, opts *Options) error { if err := p.processAPKStreaming(packages[ip].Name, packageFile, keepOption, acq, usedZipPaths); err != nil { log.Debugf("ERROR: failed to process APK %s: %v", packageFile.Path, err) + collectionErr = errors.Join(collectionErr, fmt.Errorf("%s: %w", packageFile.Path, err)) continue } } } } - return saveDataToAcquisition(acq, "packages.json", &packages) + saveErr := saveDataToAcquisition(acq, "packages.json", &packages) + if saveErr != nil { + return errors.Join(collectionErr, saveErr) + } + return partialCollectionError(collectionErr) } func (p *Packages) processAPKStreaming(packageName string, packageFile *adb.PackageFile, keepOption string, acq *acquisition.Acquisition, usedZipPaths map[string]struct{}) error { @@ -185,7 +191,7 @@ func (p *Packages) processAPKStreaming(packageName string, packageFile *adb.Pack if err != nil { log.Errorf("Skipping APK with unsafe path %q: %v", packageFile.Path, err) packageFile.Error = err.Error() - return nil + return err } buffer, err := acq.StreamingPuller.PullToBuffer(packageFile.Path) diff --git a/modules/settings.go b/modules/settings.go index 4b818b4..a9b9d6d 100644 --- a/modules/settings.go +++ b/modules/settings.go @@ -5,6 +5,7 @@ package modules import ( + "errors" "fmt" "github.com/mvt-project/androidqf/acquisition" @@ -24,6 +25,7 @@ func (s *Settings) Name() string { func (s *Settings) Run(acq *acquisition.Acquisition, opts *Options) error { log.Info("Collecting device settings...") + var collectionErr error for _, namespace := range []string{"system", "secure", "global"} { out, err := adb.Client.Shell(fmt.Sprintf("cmd settings list %s", namespace)) @@ -34,8 +36,9 @@ func (s *Settings) Run(acq *acquisition.Acquisition, opts *Options) error { err = saveStringToAcquisition(acq, fmt.Sprintf("settings_%s.txt", namespace), out) if err != nil { log.Errorf("Impossible to save settings: %v", err) + collectionErr = errors.Join(collectionErr, fmt.Errorf("%s: %w", namespace, err)) } } - return nil + return partialCollectionError(collectionErr) } diff --git a/modules/temp.go b/modules/temp.go index 68c2f89..dc44c79 100644 --- a/modules/temp.go +++ b/modules/temp.go @@ -5,6 +5,7 @@ package modules import ( + "errors" "fmt" "path" @@ -25,11 +26,12 @@ func (t *Temp) Name() string { func (t *Temp) Run(acq *acquisition.Acquisition, opts *Options) error { log.Info("Collecting files in tmp folder...") + var collectionErr error // TODO: Also check default tmp folders tmpFiles, err := adb.Client.ListFiles(acq.TmpDir, true) if err != nil { - return fmt.Errorf("failed to list files in tmp: %v", err) + collectionErr = errors.Join(collectionErr, fmt.Errorf("failed to list files in tmp: %w", err)) } for _, file := range tmpFiles { @@ -40,6 +42,7 @@ func (t *Temp) Run(acq *acquisition.Acquisition, opts *Options) error { rel, err := relativeDeviceChild(acq.TmpDir, file) if err != nil { log.Errorf("Skipping temp file with unsafe path %s: %v\n", file, err) + collectionErr = errors.Join(collectionErr, fmt.Errorf("%s: %w", file, err)) continue } @@ -47,10 +50,11 @@ func (t *Temp) Run(acq *acquisition.Acquisition, opts *Options) error { if err := acq.PullToZipStaged(file, zipPath); err != nil { log.Errorf("Failed to stage temp file %s for archive: %v\n", file, err) + collectionErr = errors.Join(collectionErr, fmt.Errorf("%s: %w", file, err)) continue } log.Debugf("Staged temp file %s and added it to archive as %s", file, zipPath) } - return nil + return partialCollectionError(collectionErr) } diff --git a/run_state.go b/run_state.go index bd424c6..ea0800c 100644 --- a/run_state.go +++ b/run_state.go @@ -19,15 +19,17 @@ import ( ) type runningExtraction struct { - Serial string `json:"serial"` - PID int `json:"pid"` - Started time.Time `json:"started"` - StoragePath string `json:"storage_path,omitempty"` + Serial string `json:"serial"` + PID int `json:"pid"` + ProcessToken string `json:"process_token"` + Started time.Time `json:"started"` + StoragePath string `json:"storage_path,omitempty"` } var ( runningStateDir = defaultRunningStateDir processExists = defaultProcessExists + processToken = defaultProcessToken ) func defaultRunningStateDir() string { @@ -49,23 +51,45 @@ func registerRunningExtraction(serial, storagePath string) (func(), error) { } stateDir := runningStateDir() - if err := os.MkdirAll(stateDir, 0o755); err != nil { + if err := os.MkdirAll(stateDir, 0o700); err != nil { + return nil, err + } + if err := os.Chmod(stateDir, 0o700); err != nil { return nil, err } state := runningExtraction{ - Serial: serial, - PID: os.Getpid(), - Started: time.Now().UTC(), - StoragePath: storagePath, + Serial: serial, + PID: os.Getpid(), + ProcessToken: processToken(os.Getpid()), + Started: time.Now().UTC(), + StoragePath: storagePath, } - statePath := filepath.Join(stateDir, runningExtractionFileName(state.PID, state.Serial)) data, err := json.MarshalIndent(state, "", " ") if err != nil { return nil, err } - if err := os.WriteFile(statePath, data, 0o644); err != nil { + tempFile, err := os.CreateTemp(stateDir, runningExtractionFileName(state.PID, state.Serial)+"-*.tmp") + if err != nil { + return nil, err + } + tempPath := tempFile.Name() + cleanupTemp := func() { + _ = tempFile.Close() + _ = os.Remove(tempPath) + } + if _, err := tempFile.Write(data); err != nil { + cleanupTemp() + return nil, err + } + if err := tempFile.Close(); err != nil { + _ = os.Remove(tempPath) + return nil, err + } + statePath := strings.TrimSuffix(tempPath, ".tmp") + ".json" + if err := os.Rename(tempPath, statePath); err != nil { + _ = os.Remove(tempPath) return nil, err } @@ -100,7 +124,7 @@ func activeRunningExtractionsBySerial() map[string]runningExtraction { continue } - if !processExists(state.PID) { + if !processExists(state.PID) || state.ProcessToken == "" || processToken(state.PID) != state.ProcessToken { _ = os.Remove(statePath) continue } @@ -111,6 +135,42 @@ func activeRunningExtractionsBySerial() map[string]runningExtraction { return result } +func defaultProcessToken(pid int) string { + if pid <= 0 { + return "" + } + if runtime.GOOS == "windows" { + command := fmt.Sprintf("(Get-Process -Id %d).StartTime.ToUniversalTime().Ticks", pid) + out, err := exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-Command", command).Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) + } + if runtime.GOOS == "linux" { + data, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "stat")) + if err != nil { + return "" + } + stat := string(data) + close := strings.LastIndex(stat, ")") + if close < 0 { + return "" + } + fields := strings.Fields(stat[close+1:]) + if len(fields) <= 19 { + return "" + } + return fields[19] + } + + out, err := exec.Command("ps", "-p", strconv.Itoa(pid), "-o", "lstart=").Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + func defaultProcessExists(pid int) bool { if pid <= 0 { return false diff --git a/run_state_test.go b/run_state_test.go index a27b09b..d907493 100644 --- a/run_state_test.go +++ b/run_state_test.go @@ -9,17 +9,38 @@ func TestRegisterRunningExtractionAppearsActiveAndReleases(t *testing.T) { stateDir := t.TempDir() oldStateDir := runningStateDir oldProcessExists := processExists + oldProcessToken := processToken runningStateDir = func() string { return stateDir } processExists = func(pid int) bool { return pid == os.Getpid() } + processToken = func(pid int) string { return "start-token" } t.Cleanup(func() { runningStateDir = oldStateDir processExists = oldProcessExists + processToken = oldProcessToken }) release, err := registerRunningExtraction("device-1", "out") if err != nil { t.Fatalf("registerRunningExtraction returned error: %v", err) } + dirInfo, err := os.Stat(stateDir) + if err != nil { + t.Fatalf("Stat(stateDir) error = %v", err) + } + if got := dirInfo.Mode().Perm(); got != 0o700 { + t.Fatalf("state directory mode = %o, want 700", got) + } + entries, err := os.ReadDir(stateDir) + if err != nil || len(entries) != 1 { + t.Fatalf("state entries = %v, error = %v", entries, err) + } + fileInfo, err := entries[0].Info() + if err != nil { + t.Fatalf("state file Info() error = %v", err) + } + if got := fileInfo.Mode().Perm(); got != 0o600 { + t.Fatalf("state file mode = %o, want 600", got) + } active := activeRunningExtractionsBySerial() state, ok := active["device-1"] @@ -41,11 +62,14 @@ func TestActiveRunningExtractionsRemovesStaleState(t *testing.T) { stateDir := t.TempDir() oldStateDir := runningStateDir oldProcessExists := processExists + oldProcessToken := processToken runningStateDir = func() string { return stateDir } processExists = func(int) bool { return false } + processToken = func(int) string { return "start-token" } t.Cleanup(func() { runningStateDir = oldStateDir processExists = oldProcessExists + processToken = oldProcessToken }) release, err := registerRunningExtraction("device-1", "out") @@ -67,3 +91,29 @@ func TestActiveRunningExtractionsRemovesStaleState(t *testing.T) { t.Fatalf("state files remaining = %d, want 0", len(entries)) } } + +func TestActiveRunningExtractionsRemovesReusedPIDState(t *testing.T) { + stateDir := t.TempDir() + oldStateDir := runningStateDir + oldProcessExists := processExists + oldProcessToken := processToken + runningStateDir = func() string { return stateDir } + processExists = func(int) bool { return true } + processToken = func(int) string { return "original-token" } + t.Cleanup(func() { + runningStateDir = oldStateDir + processExists = oldProcessExists + processToken = oldProcessToken + }) + + release, err := registerRunningExtraction("device-1", "out") + if err != nil { + t.Fatalf("registerRunningExtraction returned error: %v", err) + } + defer release() + processToken = func(int) string { return "reused-pid-token" } + + if active := activeRunningExtractionsBySerial(); len(active) != 0 { + t.Fatalf("active state = %+v, want none", active) + } +} diff --git a/scripts/download_platform_tools.sh b/scripts/download_platform_tools.sh new file mode 100755 index 0000000..b72bcbc --- /dev/null +++ b/scripts/download_platform_tools.sh @@ -0,0 +1,54 @@ +#!/bin/sh + +set -eu + +download_dir=${ANDROIDQF_PLATFORM_TOOLS_DOWNLOAD_DIR:-/tmp/platform-tools-downloads} +platform=${1:-all} +base_url=https://dl.google.com/android/repository +platform_tools_version=37.0.1 + +download() { + platform_name=$1 + archive_platform_name=$2 + expected_hash=$3 + archive="$download_dir/platform-tools-latest-$platform_name.zip" + + umask 077 + mkdir -p "$download_dir" + + if [ -f "$archive" ] && printf '%s %s\n' "$expected_hash" "$archive" | sha256sum -c - >/dev/null 2>&1; then + printf '%s\n' "$archive" + return + fi + + temporary=$(mktemp "$archive.XXXXXX") + trap 'rm -f "$temporary"' EXIT HUP INT TERM + curl --fail --location --silent --show-error \ + "$base_url/platform-tools_r$platform_tools_version-$archive_platform_name.zip" \ + --output "$temporary" + printf '%s %s\n' "$expected_hash" "$temporary" | sha256sum -c - + mv "$temporary" "$archive" + trap - EXIT HUP INT TERM + printf '%s\n' "$archive" +} + +case "$platform" in + windows) + download windows win 45f4d63113e895ebde0c90f194099a4676b6ac653bd28d54314a9e022bbc1a99 + ;; + darwin) + download darwin darwin ee39ad5967e95c2a07f04dbcbde96b1a0c916ba376096db5d2f498b7727a5d1d + ;; + linux) + download linux linux d230f13842f60f782a8645f9c813f8f845bf36089ea7289f28c48f17979313f1 + ;; + all) + "$0" windows + "$0" darwin + "$0" linux + ;; + *) + echo "usage: $0 [windows|darwin|linux|all]" >&2 + exit 2 + ;; +esac