From 73de0621161584c2987330eccdcd8dbbc11749f9 Mon Sep 17 00:00:00 2001 From: Heznpc Date: Thu, 13 Aug 2026 18:43:55 +0900 Subject: [PATCH 1/2] Add on-demand CPU/network observation window (Phase 5-1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New "CPU·네트워크 관찰" section on the Activity page: the owner picks a window (30s-5min) and triggers idle_cpu.sh and network_watch.sh concurrently over the same period, showing real CPU deltas and any newly-appeared connections/listening ports. Scoped this as synchronous and user-triggered rather than a scheduled background watch after investigating the existing options: schedule.sh is entirely hardcoded to one job (storage-watch's own label/plist), not a generic scheduler, so extending it would mean building new scheduling infrastructure in the most security-sensitive part of the codebase. CPU/network are also fast, bursty signals where a fixed hourly sample would almost always just catch them idle -- unlike free disk space, which changes slowly enough for that cadence to make sense. Windows' own scripts/monitor.ps1 is the existing cross-platform precedent for this shape: synchronous, foreground, user-triggered, never scheduled. New scripts/network_watch.sh mirrors idle_cpu.sh's two-sample delta pattern: identity for established connections is (process, remote host:port), ignoring the local ephemeral port, so an ordinary reconnect to an already-seen server isn't reported as "new" -- proven with a fixture and a revert/restore of the fix (it also caught the "no changes" case, not just the one it targeted). idle_cpu.sh needed no changes at all; it's invoked with a longer window than the scan's own 3s default, same script either way. Verified end-to-end in the actual signed app (built via build_macos_swift_app.sh, after #71 fixed the runtime bundle): the window picker, in-flight state, and results all render correctly, and triggering it for real over a 30s window surfaced genuine CPU usage and network connections happening on this machine during the run. --- .../Modore/Models/ObservationModels.swift | 36 ++++ .../Modore/Services/ObservationService.swift | 143 +++++++++++++++ .../Sources/Modore/Services/ScanModel.swift | 3 + .../Sources/Modore/Views/ActivityView.swift | 109 +++++++++++ .../ModoreTests/ObservationServiceTests.swift | 85 +++++++++ scripts/build_macos_swift_app.sh | 1 + scripts/network_watch.sh | 171 ++++++++++++++++++ scripts/release_smoke.py | 1 + tests/test_macos_network_watch.py | 139 ++++++++++++++ 9 files changed, 688 insertions(+) create mode 100644 macos/Modore/Sources/Modore/Models/ObservationModels.swift create mode 100644 macos/Modore/Sources/Modore/Services/ObservationService.swift create mode 100644 macos/Modore/Tests/ModoreTests/ObservationServiceTests.swift create mode 100755 scripts/network_watch.sh create mode 100644 tests/test_macos_network_watch.py diff --git a/macos/Modore/Sources/Modore/Models/ObservationModels.swift b/macos/Modore/Sources/Modore/Models/ObservationModels.swift new file mode 100644 index 0000000..f0240d4 --- /dev/null +++ b/macos/Modore/Sources/Modore/Models/ObservationModels.swift @@ -0,0 +1,36 @@ +import Foundation + +/// One process's actual CPU usage during the observation window, from +/// idle_cpu.sh's two-sample delta (not ps's lifetime-decayed average). +struct ObservedProcessRow: Identifiable { + let id = UUID() + let percent: Double + let pid: Int + let name: String + let ownerPid: Int + let ownerName: String + let startedFromShell: Bool + + /// Same meaning as ScanModels' BackgroundCpuRow: work not attributable to + /// the process's own owner, so quitting the named app wouldn't stop it. + var isDetachedFromAnApp: Bool { startedFromShell && ownerPid != pid } +} + +/// One connection or listening port that appeared during the observation +/// window and wasn't present at its start, from network_watch.sh. +struct ObservedConnectionRow: Identifiable { + let id = UUID() + let kind: String + let process: String + let pid: Int + let address: String + + var isListening: Bool { kind == "listen" } +} + +struct ObservationResult { + let windowSeconds: Int + let processRows: [ObservedProcessRow] + let newConnectionRows: [ObservedConnectionRow] + let networkUnavailable: Bool +} diff --git a/macos/Modore/Sources/Modore/Services/ObservationService.swift b/macos/Modore/Sources/Modore/Services/ObservationService.swift new file mode 100644 index 0000000..0401f71 --- /dev/null +++ b/macos/Modore/Sources/Modore/Services/ObservationService.swift @@ -0,0 +1,143 @@ +import Foundation + +/// Runs idle_cpu.sh and network_watch.sh concurrently over the same window, +/// on demand -- see the two scripts' own header comments for why this is a +/// bounded, user-triggered observation rather than a scheduled background +/// watch like storage_watch.sh. storage_watch.sh's launchd job is entirely +/// hardcoded to one script/label/plist (schedule.sh has no generic multi-job +/// concept), and CPU/network are fast, bursty signals where a fixed hourly +/// sample would almost always just catch them idle -- unlike free disk +/// space, which genuinely changes slowly enough for that cadence to make +/// sense. Windows' own scripts/monitor.ps1 is the cross-platform precedent +/// for this shape: synchronous, foreground, user-triggered, never scheduled. +enum ObservationOutcome { + case ready(ObservationResult) + case failure(String) +} + +enum ObservationService { + /// Pure and independently testable: idle_cpu.sh's own TSV protocol, + /// `process\t{percent}\t{pid}\t{name}\t{ownerPid}\t{ownerName}\t{startedFromShell}`. + static func parseProcessRows(_ output: String) -> [ObservedProcessRow] { + output.split(separator: "\n").compactMap { line -> ObservedProcessRow? in + let fields = line.components(separatedBy: "\t") + guard fields.count == 7, fields[0] == "process", + let percent = Double(fields[1]), let pid = Int(fields[2]), + let ownerPid = Int(fields[4]) else { return nil } + return ObservedProcessRow( + percent: percent, + pid: pid, + name: fields[3], + ownerPid: ownerPid, + ownerName: fields[5], + startedFromShell: fields[6] == "true" + ) + } + } + + /// Pure and independently testable: network_watch.sh's own TSV protocol, + /// `established|listen\t{process}\t{pid}\t{address}`. + static func parseConnectionRows(_ output: String) -> [ObservedConnectionRow] { + output.split(separator: "\n").compactMap { line -> ObservedConnectionRow? in + let fields = line.components(separatedBy: "\t") + guard fields.count == 4, fields[0] == "established" || fields[0] == "listen", + let pid = Int(fields[2]) else { return nil } + return ObservedConnectionRow(kind: fields[0], process: fields[1], pid: pid, address: fields[3]) + } + } + + static func observe(projectRoot: URL, windowSeconds: Int) async -> ObservationOutcome { + guard let execution = await Task.detached(priority: .userInitiated, operation: { + RuntimeWorkspace.prepareExecution(projectRoot: projectRoot) + }).value else { + return .failure("서명된 실행 런타임을 확인하지 못해 실행하지 않았습니다.") + } + guard let cpuInvocation = execution.pinnedInvocation(relativePath: "scripts/idle_cpu.sh", name: "idle_cpu"), + let networkInvocation = execution.pinnedInvocation( + relativePath: "scripts/network_watch.sh", + name: "network_watch" + ) else { + return .failure("봉인한 관찰 스크립트를 확인하지 못해 실행하지 않았습니다.") + } + + let timeout = TimeInterval(windowSeconds + 20) + async let cpuOutcome = run( + argument: cpuInvocation.argument, + files: cpuInvocation.files, + arguments: ["--window", String(windowSeconds)], + execution: execution, + timeout: timeout + ) + async let networkOutcome = run( + argument: networkInvocation.argument, + files: networkInvocation.files, + arguments: ["--window", String(windowSeconds)], + execution: execution, + timeout: timeout + ) + let (cpuResult, networkResult) = await (cpuOutcome, networkOutcome) + + guard case .success(let cpuOutput) = cpuResult else { + if case .failure(let message) = cpuResult { return .failure(message) } + return .failure("CPU 관찰을 실행하지 못했습니다.") + } + guard case .success(let networkOutput) = networkResult else { + if case .failure(let message) = networkResult { return .failure(message) } + return .failure("네트워크 관찰을 실행하지 못했습니다.") + } + + let networkValues = StorageWatchService.protocolValues(networkOutput) + return .ready(ObservationResult( + windowSeconds: windowSeconds, + processRows: parseProcessRows(cpuOutput), + newConnectionRows: parseConnectionRows(networkOutput), + networkUnavailable: networkValues["error"] != nil + )) + } + + private enum RawOutcome { + case success(String) + case failure(String) + } + + private static func run( + argument: String, + files: [String: Data], + arguments: [String], + execution: RuntimeExecutionContext, + timeout: TimeInterval + ) async -> RawOutcome { + let result = await LocalProcessRunner.capture( + executable: "/bin/bash", + arguments: [argument] + arguments, + currentDirectory: execution.runtimeRoot, + expectedCurrentDirectoryIdentity: execution.runtimeRootIdentity, + expectedSignedBundleURL: execution.signedBundleURL, + pinnedFiles: files, + environment: [:], + timeout: timeout + ) + guard result.status == 0, result.endState == .exited else { + return .failure("관찰 스크립트 실행이 실패했습니다 (status \(result.status)).") + } + return .success(result.output) + } +} + +extension ScanModel { + func observeNow(windowSeconds: Int) { + guard !observationInFlight else { return } + observationInFlight = true + observationErrorMessage = nil + let root = projectRoot + Task { + defer { observationInFlight = false } + switch await ObservationService.observe(projectRoot: root, windowSeconds: windowSeconds) { + case .ready(let result): + observationResult = result + case .failure(let message): + observationErrorMessage = message + } + } + } +} diff --git a/macos/Modore/Sources/Modore/Services/ScanModel.swift b/macos/Modore/Sources/Modore/Services/ScanModel.swift index 291fbf9..4549e7b 100644 --- a/macos/Modore/Sources/Modore/Services/ScanModel.swift +++ b/macos/Modore/Sources/Modore/Services/ScanModel.swift @@ -38,6 +38,9 @@ final class ScanModel: ObservableObject { @Published var pendingLoginItemRemoval: PendingLoginItemRemoval? @Published var loginItemActionInFlight: String? @Published var archiveError: String? + @Published var observationResult: ObservationResult? + @Published var observationInFlight = false + @Published var observationErrorMessage: String? let logStore = ScanLogStore() let projectRoot: URL diff --git a/macos/Modore/Sources/Modore/Views/ActivityView.swift b/macos/Modore/Sources/Modore/Views/ActivityView.swift index b2ee9a3..f177453 100644 --- a/macos/Modore/Sources/Modore/Views/ActivityView.swift +++ b/macos/Modore/Sources/Modore/Views/ActivityView.swift @@ -6,6 +6,7 @@ struct ActivityPage: View { var body: some View { Form { StorageWatchActivitySection() + ContinuousObservationSection() if let latestEvent = model.storageWatchPathEvents.last { StorageWatchPathEvidenceSection(event: latestEvent) @@ -75,6 +76,114 @@ private struct StorageWatchActivitySection: View { } } +private struct ContinuousObservationSection: View { + @EnvironmentObject private var model: ScanModel + @State private var windowSeconds = 60 + + var body: some View { + Section { + HStack { + Picker("관찰 시간", selection: $windowSeconds) { + Text("30초").tag(30) + Text("1분").tag(60) + Text("2분").tag(120) + Text("5분").tag(300) + } + .labelsHidden() + .frame(maxWidth: 140) + Spacer() + Button(model.observationInFlight ? "관찰 중…" : "지금 관찰하기") { + model.observeNow(windowSeconds: windowSeconds) + } + .buttonStyle(.bordered) + .disabled(model.observationInFlight) + } + + if model.observationInFlight { + HStack(spacing: 8) { + ProgressView() + .controlSize(.small) + Text("\(windowSeconds)초 동안 CPU와 네트워크를 관찰하는 중입니다…") + .font(.callout) + .foregroundStyle(.secondary) + } + } else if let message = model.observationErrorMessage { + Text(message) + .font(.callout) + .foregroundStyle(.secondary) + } else if let result = model.observationResult { + ObservationResultRows(result: result) + } else { + Text("기본 검사는 순간 스냅샷만 봅니다. 관찰을 시작하면 지정한 시간 동안 실제 CPU 사용과 새로 나타난 네트워크 연결만 골라 보여줍니다.") + .font(.callout) + .foregroundStyle(.secondary) + } + } header: { + NativeSectionHeader( + title: "CPU·네트워크 관찰", + subtitle: "지정한 시간 동안 두 시점을 비교해 실제 점유와 새 연결만 보고합니다. 예약 실행이 아니라 누를 때만 동작합니다.", + value: model.observationResult.map { "\($0.windowSeconds)초 관찰됨" } ?? "미실행" + ) + } + } +} + +private struct ObservationResultRows: View { + let result: ObservationResult + + var body: some View { + if result.processRows.isEmpty { + Text("관찰 구간 동안 뚜렷한 CPU 사용이 없었습니다.") + .font(.callout) + .foregroundStyle(.secondary) + } else { + ForEach(result.processRows) { row in + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(row.name) + .font(.body.weight(.medium)) + Text(row.isDetachedFromAnApp ? "\(row.ownerName)에서 시작된 셸 작업" : row.ownerName) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Text(String(format: "%.1f%%", row.percent)) + .font(.callout.weight(.medium)) + .monospacedDigit() + } + } + } + + if result.networkUnavailable { + Text("lsof를 사용할 수 없어 네트워크는 관찰하지 못했습니다.") + .font(.callout) + .foregroundStyle(.secondary) + } else if result.newConnectionRows.isEmpty { + Text("관찰 구간 동안 새로 나타난 연결이나 포트가 없었습니다.") + .font(.callout) + .foregroundStyle(.secondary) + } else { + ForEach(result.newConnectionRows) { row in + HStack { + Image(systemName: row.isListening ? "antenna.radiowaves.left.and.right" : "network") + .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 2) { + Text(row.process) + .font(.body.weight(.medium)) + Text(row.isListening ? "새 수신 포트" : "새 연결") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Text(row.address) + .font(.callout.monospaced()) + .foregroundStyle(.secondary) + } + } + } + } +} + private struct StorageWatchPathEvidenceSection: View { let event: StorageWatchPathEvent diff --git a/macos/Modore/Tests/ModoreTests/ObservationServiceTests.swift b/macos/Modore/Tests/ModoreTests/ObservationServiceTests.swift new file mode 100644 index 0000000..51360d8 --- /dev/null +++ b/macos/Modore/Tests/ModoreTests/ObservationServiceTests.swift @@ -0,0 +1,85 @@ +import XCTest +@testable import Modore + +final class ObservationServiceTests: XCTestCase { + // MARK: parseProcessRows + + func testParsesProcessRowsFromIdleCpuProtocol() { + let output = """ + version\t1 + operation\tidle-cpu + windowSeconds\t60 + minPercent\t1 + process\t42.5\t501\tnode\t500\tzsh\ttrue + process\t10.0\t900\tElectron\t900\tElectron\tfalse + observed\t2 + reported\t2 + """ + + let rows = ObservationService.parseProcessRows(output) + + XCTAssertEqual(rows.map(\.name), ["node", "Electron"]) + XCTAssertEqual(rows[0].percent, 42.5, accuracy: 0.001) + XCTAssertEqual(rows[0].pid, 501) + XCTAssertEqual(rows[0].ownerPid, 500) + XCTAssertEqual(rows[0].ownerName, "zsh") + XCTAssertTrue(rows[0].startedFromShell) + // A process that owns itself, not started from a shell. + XCTAssertFalse(rows[1].startedFromShell) + XCTAssertFalse(rows[1].isDetachedFromAnApp) + } + + // Same reasoning as BackgroundCpuRow: work not attributable to the + // application it appears to belong to, so quitting that app wouldn't + // stop it. + func testFlagsWorkDetachedFromAnyOwningApp() { + let output = "process\t99.0\t501\tnode\t500\tzsh\ttrue" + + let rows = ObservationService.parseProcessRows(output) + + XCTAssertEqual(rows.count, 1) + XCTAssertTrue(rows[0].isDetachedFromAnApp) + } + + func testIgnoresNonProcessLinesAndMalformedRows() { + let output = """ + version\t1 + windowSeconds\t60 + process\tnot-a-number\t501\tnode\t500\tzsh\ttrue + process\t10.0\t501\tnode\t500 + observed\t0 + """ + + XCTAssertTrue(ObservationService.parseProcessRows(output).isEmpty) + } + + // MARK: parseConnectionRows + + func testParsesNewConnectionAndListenRows() { + let output = """ + version\t1 + operation\tnetwork-watch + windowSeconds\t60 + established\tCodex\t2000\t2.2.2.2:8080 + listen\tnewsvc\t3000\t*:9999 + newEstablished\t1 + newListen\t1 + """ + + let rows = ObservationService.parseConnectionRows(output) + + XCTAssertEqual(rows.count, 2) + XCTAssertEqual(rows[0].kind, "established") + XCTAssertEqual(rows[0].process, "Codex") + XCTAssertEqual(rows[0].pid, 2000) + XCTAssertEqual(rows[0].address, "2.2.2.2:8080") + XCTAssertFalse(rows[0].isListening) + XCTAssertTrue(rows[1].isListening) + } + + func testIgnoresMetadataLinesWhenParsingConnections() { + let output = "version\t1\nwindowSeconds\t60\nnewEstablished\t0\nnewListen\t0" + + XCTAssertTrue(ObservationService.parseConnectionRows(output).isEmpty) + } +} diff --git a/scripts/build_macos_swift_app.sh b/scripts/build_macos_swift_app.sh index b0313a8..826898d 100755 --- a/scripts/build_macos_swift_app.sh +++ b/scripts/build_macos_swift_app.sh @@ -369,6 +369,7 @@ RUNTIME_FILES=( "scripts/report.jxa.js" "scripts/scanner_helper.jxa.js" "scripts/idle_cpu.sh" + "scripts/network_watch.sh" "scripts/login_items.sh" "scripts/modules/support_dir.sh" "scripts/modules/approval_token.sh" diff --git a/scripts/network_watch.sh b/scripts/network_watch.sh new file mode 100755 index 0000000..0cbe5a6 --- /dev/null +++ b/scripts/network_watch.sh @@ -0,0 +1,171 @@ +#!/bin/bash -p +# Modore - 네트워크 관측기 (macOS). idle_cpu.sh와 같은 2표본 델타 패턴. +# +# 기본 스캔의 network 모듈은 시점 하나의 스냅샷만 본다. "관찰 구간 동안 어떤 +# 새 목적지에 연결했는가"에 답하려면 구간 시작과 끝, 두 시점을 비교해야 한다. +# +# 연결의 동일성은 (프로세스 이름, 원격 주소:포트)로 판단하고 로컬 임시 포트는 +# 무시한다 -- 이미 알던 서버로 재연결할 때마다 새 임시 포트가 배정되는 것은 +# 정상 동작이라, 그것까지 "새 연결"로 세면 신호가 아니라 잡음이 된다. LISTEN +# 포트는 반대로 바인딩된 주소:포트 자체가 신호이므로 그대로 키로 쓴다. +# +# 읽기 전용이다. 아무것도 종료하거나 차단하지 않는다. + +set -u +set -o pipefail +umask 077 +export PATH="/usr/bin:/bin:/usr/sbin:/sbin" +unset BASH_ENV ENV CDPATH GLOBIGNORE + +PROTOCOL_VERSION="1" +WINDOW_SECONDS="${PCH_NETWORK_WATCH_WINDOW:-60}" + +usage() { + /usr/bin/printf '%s\n' \ + 'Usage:' \ + ' network_watch.sh [--window ]' \ + '' \ + '관찰 구간 시작과 끝, 두 시점의 연결/포트 목록을 비교해 새로 나타난 것만 보고한다.' +} + +while [[ "$#" -gt 0 ]]; do + case "$1" in + --window) WINDOW_SECONDS="${2:-}"; shift ;; + -h|--help) usage; exit 0 ;; + *) /usr/bin/printf 'ERROR: unknown option: %s\n' "$1" >&2; usage >&2; exit 64 ;; + esac + shift +done + +# 관측 구간을 제한한다. idle_cpu.sh와 동일한 상한(300초). +[[ "$WINDOW_SECONDS" =~ ^[1-9][0-9]{0,2}$ && "$WINDOW_SECONDS" -le 300 ]] \ + || { /usr/bin/printf 'ERROR: --window must be 1-300 seconds.\n' >&2; exit 64; } + +if [[ "$(/usr/bin/uname -s)" != "Darwin" ]]; then + /usr/bin/printf 'ERROR: 이 관측기는 macOS 전용입니다.\n' >&2 + exit 1 +fi + +LSOF_BIN="/usr/sbin/lsof" +if [[ "${PCH_TEST_MODE:-0}" == "1" ]]; then + LSOF_BIN="${PCH_TEST_LSOF_BIN:-$LSOF_BIN}" +fi + +emit() { + local key="$1" + local value="${2:-}" + case "$key$value" in + *$'\t'*|*$'\n'*|*$'\r'*) value="출력할 수 없는 제어 문자가 포함되었습니다." ;; + esac + /usr/bin/printf '%s\t%s\n' "$key" "$value" +} + +if [[ ! -x "$LSOF_BIN" ]]; then + emit "version" "$PROTOCOL_VERSION" + emit "operation" "network-watch" + emit "windowSeconds" "$WINDOW_SECONDS" + emit "error" "lsof를 사용할 수 없습니다." + exit 0 +fi + +WORKSPACE="$(/usr/bin/mktemp -d "${TMPDIR:-/tmp}/modore-network-watch.XXXXXX")" || exit 1 +cleanup() { /bin/rm -rf "$WORKSPACE"; } +trap cleanup EXIT + +sample_established() { + "$LSOF_BIN" -nP -iTCP -sTCP:ESTABLISHED 2>/dev/null || true +} + +sample_listen() { + "$LSOF_BIN" -nP -iTCP -sTCP:LISTEN 2>/dev/null || true +} + +# 테스트는 네 표본 파일을 직접 주입한다. 실제 lsof 표는 재현할 수 없으므로, +# 델타 계산을 고정된 입력으로 검증한다. +inject_or_empty() { + local var_name="$1" + local destination="$2" + local source="${!var_name:-}" + if [[ -n "$source" && -f "$source" && ! -L "$source" ]]; then + /bin/cat "$source" > "$destination" + else + : > "$destination" + fi +} + +if [[ "${PCH_TEST_MODE:-0}" == "1" ]]; then + inject_or_empty PCH_NETWORK_WATCH_FIRST_ESTABLISHED "$WORKSPACE/first_established" + inject_or_empty PCH_NETWORK_WATCH_FIRST_LISTEN "$WORKSPACE/first_listen" + inject_or_empty PCH_NETWORK_WATCH_SECOND_ESTABLISHED "$WORKSPACE/second_established" + inject_or_empty PCH_NETWORK_WATCH_SECOND_LISTEN "$WORKSPACE/second_listen" +else + sample_established > "$WORKSPACE/first_established" + sample_listen > "$WORKSPACE/first_listen" + /bin/sleep "$WINDOW_SECONDS" + sample_established > "$WORKSPACE/second_established" + sample_listen > "$WORKSPACE/second_listen" +fi + +emit "version" "$PROTOCOL_VERSION" +emit "operation" "network-watch" +emit "windowSeconds" "$WINDOW_SECONDS" + +# lsof 열: COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME (ESTABLISHED 행은 +# NAME 뒤에 "(ESTABLISHED)"가 하나 더 붙어 총 10 필드). NAME(9번째 필드)이 +# established는 "LOCAL->REMOTE", listen은 "ADDR:PORT" 형태다. +new_established() { + /usr/bin/awk ' + FNR == NR { + if (FNR == 1) next + n = split($0, parts, /[ \t]+/) + if (n < 9) next + addr = parts[9] + arrow = index(addr, "->") + if (arrow == 0) next + seen[parts[1] "\t" substr(addr, arrow + 2)] = 1 + next + } + FNR == 1 { next } + { + n = split($0, parts, /[ \t]+/) + if (n < 9) next + addr = parts[9] + arrow = index(addr, "->") + if (arrow == 0) next + remote = substr(addr, arrow + 2) + matchkey = parts[1] "\t" remote + if (matchkey in seen) next + printf "established\t%s\t%s\t%s\n", parts[1], parts[2], remote + } + ' "$1" "$2" +} + +new_listen() { + /usr/bin/awk ' + FNR == NR { + if (FNR == 1) next + n = split($0, parts, /[ \t]+/) + if (n < 9) next + seen[parts[1] "\t" parts[9]] = 1 + next + } + FNR == 1 { next } + { + n = split($0, parts, /[ \t]+/) + if (n < 9) next + matchkey = parts[1] "\t" parts[9] + if (matchkey in seen) next + printf "listen\t%s\t%s\t%s\n", parts[1], parts[2], parts[9] + } + ' "$1" "$2" +} + +NEW_ESTABLISHED_FILE="$WORKSPACE/new_established.tsv" +NEW_LISTEN_FILE="$WORKSPACE/new_listen.tsv" +new_established "$WORKSPACE/first_established" "$WORKSPACE/second_established" > "$NEW_ESTABLISHED_FILE" +new_listen "$WORKSPACE/first_listen" "$WORKSPACE/second_listen" > "$NEW_LISTEN_FILE" + +/bin/cat "$NEW_ESTABLISHED_FILE" "$NEW_LISTEN_FILE" + +emit "newEstablished" "$(/usr/bin/wc -l < "$NEW_ESTABLISHED_FILE" | /usr/bin/tr -d ' ')" +emit "newListen" "$(/usr/bin/wc -l < "$NEW_LISTEN_FILE" | /usr/bin/tr -d ' ')" diff --git a/scripts/release_smoke.py b/scripts/release_smoke.py index a16fccf..5603e7b 100644 --- a/scripts/release_smoke.py +++ b/scripts/release_smoke.py @@ -355,6 +355,7 @@ def verify_tag_with_signer( "scripts/schedule.sh", "scripts/scree.py", "scripts/idle_cpu.sh", + "scripts/network_watch.sh", "scripts/login_items.sh", "scripts/report.jxa.js", "scripts/scanner_helper.jxa.js", diff --git a/tests/test_macos_network_watch.py b/tests/test_macos_network_watch.py new file mode 100644 index 0000000..9ad37a6 --- /dev/null +++ b/tests/test_macos_network_watch.py @@ -0,0 +1,139 @@ +"""Contract tests for the read-only network observation window. + +network_watch.sh takes two lsof samples separated by a wait, like +idle_cpu.sh's two-sample CPU delta. These tests pin the property that makes +it worth having: identity by (process, remote host:port) for established +connections -- ignoring the local ephemeral port -- so an ordinary reconnect +to an already-seen server isn't reported as a "new" connection, while a +genuinely new destination or listening port is. +""" + +import os +import subprocess +from pathlib import Path + +import pytest + +LSOF_HEADER = "COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME" + + +def run_watcher( + project_root: Path, + tmp_path: Path, + first_established: str, + second_established: str, + first_listen: str = "", + second_listen: str = "", + *args: str, +): + files = { + "first_established.txt": first_established, + "second_established.txt": second_established, + "first_listen.txt": first_listen, + "second_listen.txt": second_listen, + } + paths = {} + for name, content in files.items(): + path = tmp_path / name + body = content if content.startswith(LSOF_HEADER) or not content else f"{LSOF_HEADER}\n{content}" + path.write_text(body, encoding="utf-8") + paths[name] = path + + env = os.environ.copy() + env.update( + { + "PCH_TEST_MODE": "1", + "PCH_NETWORK_WATCH_FIRST_ESTABLISHED": str(paths["first_established.txt"]), + "PCH_NETWORK_WATCH_SECOND_ESTABLISHED": str(paths["second_established.txt"]), + "PCH_NETWORK_WATCH_FIRST_LISTEN": str(paths["first_listen.txt"]), + "PCH_NETWORK_WATCH_SECOND_LISTEN": str(paths["second_listen.txt"]), + } + ) + return subprocess.run( + [str(project_root / "scripts" / "network_watch.sh"), *args], + capture_output=True, + text=True, + encoding="utf-8", + env=env, + ) + + +def parse_values(stdout: str) -> dict[str, str]: + values: dict[str, str] = {} + for line in stdout.splitlines(): + if "\t" not in line: + continue + key, value = line.split("\t", 1) + values[key] = value + return values + + +def parse_rows(stdout: str, kind: str) -> list[list[str]]: + return [ + line.split("\t")[1:] + for line in stdout.splitlines() + if line.startswith(f"{kind}\t") + ] + + +def test_reconnect_to_the_same_host_on_a_new_local_port_is_not_reported(project_root, tmp_path): + first = 'Chrome 1000 ren 23u IPv4 0xaaa 0t0 TCP 192.168.0.156:51000->1.1.1.1:443 (ESTABLISHED)\n' + second = 'Chrome 1000 ren 23u IPv4 0xaaa 0t0 TCP 192.168.0.156:51999->1.1.1.1:443 (ESTABLISHED)\n' + + result = run_watcher(project_root, tmp_path, first, second, "--window", "5") + + assert result.returncode == 0, result.stderr + assert parse_rows(result.stdout, "established") == [] + values = parse_values(result.stdout) + assert values["newEstablished"] == "0" + + +def test_a_genuinely_new_remote_destination_is_reported(project_root, tmp_path): + first = 'Chrome 1000 ren 23u IPv4 0xaaa 0t0 TCP 192.168.0.156:51000->1.1.1.1:443 (ESTABLISHED)\n' + second = ( + 'Chrome 1000 ren 23u IPv4 0xaaa 0t0 TCP 192.168.0.156:51999->1.1.1.1:443 (ESTABLISHED)\n' + 'Codex 2000 ren 24u IPv4 0xbbb 0t0 TCP 192.168.0.156:52000->2.2.2.2:8080 (ESTABLISHED)\n' + ) + + result = run_watcher(project_root, tmp_path, first, second, "--window", "5") + + assert result.returncode == 0, result.stderr + rows = parse_rows(result.stdout, "established") + assert rows == [["Codex", "2000", "2.2.2.2:8080"]] + values = parse_values(result.stdout) + assert values["newEstablished"] == "1" + + +def test_a_new_listening_port_is_reported(project_root, tmp_path): + first_listen = 'rapportd 658 ren 11u IPv4 0x475 0t0 TCP *:49152 (LISTEN)\n' + second_listen = ( + 'rapportd 658 ren 11u IPv4 0x475 0t0 TCP *:49152 (LISTEN)\n' + 'newsvc 3000 ren 9u IPv4 0xccc 0t0 TCP *:9999 (LISTEN)\n' + ) + + result = run_watcher(project_root, tmp_path, "", "", first_listen, second_listen, "--window", "5") + + assert result.returncode == 0, result.stderr + rows = parse_rows(result.stdout, "listen") + assert rows == [["newsvc", "3000", "*:9999"]] + values = parse_values(result.stdout) + assert values["newListen"] == "1" + + +def test_no_changes_reports_zero_of_both(project_root, tmp_path): + established = 'Chrome 1000 ren 23u IPv4 0xaaa 0t0 TCP 192.168.0.156:51000->1.1.1.1:443 (ESTABLISHED)\n' + listen = 'rapportd 658 ren 11u IPv4 0x475 0t0 TCP *:49152 (LISTEN)\n' + + result = run_watcher(project_root, tmp_path, established, established, listen, listen, "--window", "5") + + assert result.returncode == 0, result.stderr + values = parse_values(result.stdout) + assert values["newEstablished"] == "0" + assert values["newListen"] == "0" + + +def test_watcher_refuses_an_unbounded_window(project_root, tmp_path): + result = run_watcher(project_root, tmp_path, "", "", "", "", "--window", "9000") + + assert result.returncode == 64 + assert "window" in result.stderr From f4853fc8d691a7f30943b38f9e639625b9973e51 Mon Sep 17 00:00:00 2001 From: Heznpc Date: Thu, 13 Aug 2026 18:56:54 +0900 Subject: [PATCH 2/2] Skip network_watch.sh's injection-mode tests on non-macOS CI network_watch.sh checks uname -s before its PCH_TEST_MODE branch, same as idle_cpu.sh -- the injection path is unreachable on Linux, so these tests need the same skipif guard test_macos_idle_cpu.py already uses for its own analogous tests. Missed this the first time; the argument- validation test doesn't need it since that check runs before the platform gate. --- tests/test_macos_network_watch.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/test_macos_network_watch.py b/tests/test_macos_network_watch.py index 9ad37a6..97d647e 100644 --- a/tests/test_macos_network_watch.py +++ b/tests/test_macos_network_watch.py @@ -10,6 +10,7 @@ import os import subprocess +import sys from pathlib import Path import pytest @@ -76,6 +77,7 @@ def parse_rows(stdout: str, kind: str) -> list[list[str]]: ] +@pytest.mark.skipif(sys.platform != "darwin", reason="the network observer is macOS-only") def test_reconnect_to_the_same_host_on_a_new_local_port_is_not_reported(project_root, tmp_path): first = 'Chrome 1000 ren 23u IPv4 0xaaa 0t0 TCP 192.168.0.156:51000->1.1.1.1:443 (ESTABLISHED)\n' second = 'Chrome 1000 ren 23u IPv4 0xaaa 0t0 TCP 192.168.0.156:51999->1.1.1.1:443 (ESTABLISHED)\n' @@ -88,6 +90,7 @@ def test_reconnect_to_the_same_host_on_a_new_local_port_is_not_reported(project_ assert values["newEstablished"] == "0" +@pytest.mark.skipif(sys.platform != "darwin", reason="the network observer is macOS-only") def test_a_genuinely_new_remote_destination_is_reported(project_root, tmp_path): first = 'Chrome 1000 ren 23u IPv4 0xaaa 0t0 TCP 192.168.0.156:51000->1.1.1.1:443 (ESTABLISHED)\n' second = ( @@ -104,6 +107,7 @@ def test_a_genuinely_new_remote_destination_is_reported(project_root, tmp_path): assert values["newEstablished"] == "1" +@pytest.mark.skipif(sys.platform != "darwin", reason="the network observer is macOS-only") def test_a_new_listening_port_is_reported(project_root, tmp_path): first_listen = 'rapportd 658 ren 11u IPv4 0x475 0t0 TCP *:49152 (LISTEN)\n' second_listen = ( @@ -120,6 +124,7 @@ def test_a_new_listening_port_is_reported(project_root, tmp_path): assert values["newListen"] == "1" +@pytest.mark.skipif(sys.platform != "darwin", reason="the network observer is macOS-only") def test_no_changes_reports_zero_of_both(project_root, tmp_path): established = 'Chrome 1000 ren 23u IPv4 0xaaa 0t0 TCP 192.168.0.156:51000->1.1.1.1:443 (ESTABLISHED)\n' listen = 'rapportd 658 ren 11u IPv4 0x475 0t0 TCP *:49152 (LISTEN)\n'