diff --git a/scripts/network_watch.sh b/scripts/network_watch.sh index 0cbe5a6..9446c58 100755 --- a/scripts/network_watch.sh +++ b/scripts/network_watch.sh @@ -113,19 +113,27 @@ 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" 형태다. +# 파일 구분에 FNR==NR을 쓰지 않는다: 첫 표본 파일이 비어 있으면(첫 lsof가 +# 실패해 `|| true`가 삼킨 경우) FNR==NR이 두 번째 파일에 참이 되어, 구간 중 +# 생긴 새 연결 전부가 "기존 연결"로 등록되고 보고가 조용히 사라진다. 파일 +# 인자 사이의 변수 대입(POSIX)은 파일이 비어도 순서대로 적용된다. new_established() { /usr/bin/awk ' - FNR == NR { - if (FNR == 1) next + function command_name(value) { + gsub(/\\x20/, " ", value) + sub(/ +$/, "", value) + return value + } + FNR == 1 { next } + building == 1 { 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 + seen[command_name(parts[1]) "\t" substr(addr, arrow + 2)] = 1 next } - FNR == 1 { next } { n = split($0, parts, /[ \t]+/) if (n < 9) next @@ -133,31 +141,37 @@ new_established() { arrow = index(addr, "->") if (arrow == 0) next remote = substr(addr, arrow + 2) - matchkey = parts[1] "\t" remote + process = command_name(parts[1]) + matchkey = process "\t" remote if (matchkey in seen) next - printf "established\t%s\t%s\t%s\n", parts[1], parts[2], remote + printf "established\t%s\t%s\t%s\n", process, parts[2], remote } - ' "$1" "$2" + ' building=1 "$1" building=0 "$2" } new_listen() { /usr/bin/awk ' - FNR == NR { - if (FNR == 1) next + function command_name(value) { + gsub(/\\x20/, " ", value) + sub(/ +$/, "", value) + return value + } + FNR == 1 { next } + building == 1 { n = split($0, parts, /[ \t]+/) if (n < 9) next - seen[parts[1] "\t" parts[9]] = 1 + seen[command_name(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] + process = command_name(parts[1]) + matchkey = process "\t" parts[9] if (matchkey in seen) next - printf "listen\t%s\t%s\t%s\n", parts[1], parts[2], parts[9] + printf "listen\t%s\t%s\t%s\n", process, parts[2], parts[9] } - ' "$1" "$2" + ' building=1 "$1" building=0 "$2" } NEW_ESTABLISHED_FILE="$WORKSPACE/new_established.tsv" diff --git a/scripts/scanner_helper.jxa.js b/scripts/scanner_helper.jxa.js index ac73f11..0767aab 100644 --- a/scripts/scanner_helper.jxa.js +++ b/scripts/scanner_helper.jxa.js @@ -776,6 +776,18 @@ raw.sections.gpu = []; ); } +// lsof escapes a space inside a COMMAND name as literal "\x20" ("Codex " -> +// "Codex\x20") -- that escaping is what keeps whitespace field-splitting +// correct, but passed through verbatim it leaks into the UI and into rule +// matching against process names. Confirmed against real output that lsof +// drops a partial escape rather than truncating mid-sequence, so replacing +// the complete "\x20" form is sufficient. A trailing space left by the +// 9-character truncation ("Codex\x20" -> "Codex ") is invisible in the UI +// while still splitting dedup keys, so it is dropped too. +function lsofCommandName(value) { + return String(value || "").replace(/\\x20/g, " ").replace(/ +$/, ""); +} + const connections = []; tmp("net.txt").split(/\r?\n/).forEach(line => { if (!line.includes("->") || !line.includes("ESTABLISHED")) return; @@ -785,7 +797,7 @@ tmp("net.txt").split(/\r?\n/).forEach(line => { if (!m) return; const ip = m[1].replace(/^\[|\]$/g, ""); if (isLocalIp(ip)) return; - connections.push({ process: parts[0], pid_: Number(parts[1]), remoteAddress: ip, remotePort: Number(m[2]), path: "", vtIp: null }); + connections.push({ process: lsofCommandName(parts[0]), pid_: Number(parts[1]), remoteAddress: ip, remotePort: Number(m[2]), path: "", vtIp: null }); }); raw.sections.network = connections .filter((c, i, arr) => arr.findIndex(x => x.process === c.process && x.remoteAddress === c.remoteAddress && x.remotePort === c.remotePort) === i); @@ -795,7 +807,8 @@ raw.sections.listeningPorts = tmp("listen.txt").split(/\r?\n/).map(line => { const parts = line.trim().split(/\s+/); const m = line.match(/:(\d+)\s*\(LISTEN\)/); if (!m || parts.length < 2) return null; - return { port: Number(m[1]), name: parts[0], process: parts[0], pid_: Number(parts[1]), path: "" }; + const name = lsofCommandName(parts[0]); + return { port: Number(m[1]), name: name, process: name, pid_: Number(parts[1]), path: "" }; }).filter(Boolean).filter((p, i, arr) => arr.findIndex(x => x.port === p.port) === i).sort((a,b) => a.port - b.port); // Inventory only, from TCC.db's own access grants -- not live in-use diff --git a/tests/test_macos_network_watch.py b/tests/test_macos_network_watch.py index 97d647e..7cd2171 100644 --- a/tests/test_macos_network_watch.py +++ b/tests/test_macos_network_watch.py @@ -137,6 +137,48 @@ def test_no_changes_reports_zero_of_both(project_root, tmp_path): assert values["newListen"] == "0" +@pytest.mark.skipif(sys.platform != "darwin", reason="the network observer is macOS-only") +def test_lsof_escaped_spaces_in_process_names_are_unescaped(project_root, tmp_path): + # lsof escapes a space inside a COMMAND name as literal "\x20" + # ("Codex " -> "Codex\x20") -- real output from this machine, not + # hypothetical. Passed through verbatim it leaks into the app's UI; + # the trailing space itself is 9-character truncation residue and is + # trimmed rather than shown invisibly. + 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:51000->1.1.1.1:443 (ESTABLISHED)\n' + 'Codex\\x20 1142 ren 24u IPv4 0xbbb 0t0 TCP 192.168.0.156:52000->2.2.2.2:8080 (ESTABLISHED)\n' + ) + first_listen = "" + second_listen = 'Manus\\x20 2200 ren 11u IPv4 0xccc 0t0 TCP *:9999 (LISTEN)\n' + + result = run_watcher( + project_root, tmp_path, first, second, first_listen, second_listen, "--window", "5" + ) + + assert result.returncode == 0, result.stderr + assert parse_rows(result.stdout, "established") == [["Codex", "1142", "2.2.2.2:8080"]] + assert parse_rows(result.stdout, "listen") == [["Manus", "2200", "*:9999"]] + + +@pytest.mark.skipif(sys.platform != "darwin", reason="the network observer is macOS-only") +def test_a_failed_first_sample_does_not_suppress_new_reports(project_root, tmp_path): + # When the first lsof invocation fails, `|| true` swallows it and the + # first sample file is empty -- lsof also exits 1 with no output when + # nothing matches, so an empty sample is a real production shape. The + # original awk used the FNR==NR idiom, which misreads the second file + # as the first when the first is empty: every connection made during + # the window was registered as "already seen" and reporting went + # silent. With no baseline the row can't be distinguished from a + # genuinely new one, and over-reporting is the safe direction. + second = 'Codex 1142 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, "", second, "", "", "--window", "5") + + assert result.returncode == 0, result.stderr + assert parse_rows(result.stdout, "established") == [["Codex", "1142", "2.2.2.2:8080"]] + + def test_watcher_refuses_an_unbounded_window(project_root, tmp_path): result = run_watcher(project_root, tmp_path, "", "", "", "", "--window", "9000") diff --git a/tests/test_service_contracts.py b/tests/test_service_contracts.py index 0123cb7..c65f02a 100644 --- a/tests/test_service_contracts.py +++ b/tests/test_service_contracts.py @@ -1021,6 +1021,58 @@ def run_scenario(tcc_db_path, label): assert missing_privacy == "" +@pytest.mark.skipif(platform.system() != "Darwin", reason="osascript JXA requires macOS") +def test_macos_network_process_names_unescape_lsof_spaces(project_root, tmp_path): + """lsof escapes a space inside a COMMAND name as literal "\\x20" + ("Codex " -> "Codex\\x20") -- confirmed against this machine's real + output, where the GUI Codex and Manus apps both surface that way. The + network/listeningPorts parsers passed the token through verbatim, so + the escaped form leaked into scan_result.json, the security page's + connection list, and rule matching against process names.""" + facts = tmp_path / "facts" + facts.mkdir() + (facts / "net.txt").write_text( + "COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME\n" + "Codex\\x20 1142 ren 23u IPv4 0xaaa 0t0 TCP 192.168.0.156:51962->104.18.32.47:443 (ESTABLISHED)\n", + encoding="utf-8", + ) + (facts / "listen.txt").write_text( + "COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME\n" + "Manus\\x20 2200 ren 11u IPv4 0xbbb 0t0 TCP *:9999 (LISTEN)\n", + encoding="utf-8", + ) + for name in ("ps.txt", "security.txt", "load.txt", "plists.txt", + "storage_simulators.tsv", "collection_status.tsv"): + (facts / name).write_text("", encoding="utf-8") + + output = tmp_path / "scan.json" + env = os.environ.copy() + env.update({ + "TMP_DIR": str(facts), + "PCH_OUTPUT": str(output), + "PCH_RAW_PATH": str(tmp_path / "raw.json"), + "PCH_RULES_DIR": str(project_root / "rules"), + "PCH_CONFIG_PATH": str(tmp_path / "config.json"), + "PCH_WHITELIST_PATH": str(project_root / "data" / "whitelist.json"), + "PCH_SIMULATOR_KEEP_PATH": str(tmp_path / "simulator-keep.txt"), + "PCH_NO_VT": "true", + }) + result = subprocess.run( + ["/usr/bin/osascript", "-l", "JavaScript", str(project_root / "scripts" / "scanner_helper.jxa.js")], + capture_output=True, text=True, encoding="utf-8", env=env, timeout=30, + ) + assert result.returncode == 0, f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}" + scan = json.loads(output.read_text(encoding="utf-8")) + + # The trailing space itself is truncation residue (lsof cuts COMMAND at + # 9 characters), invisible in the UI while still splitting dedup keys, + # so the parsed name is both unescaped and right-trimmed. + network_processes = [row["process"] for row in scan["sections"]["network"]] + assert network_processes == ["Codex"], network_processes + listen_rows = [(row["process"], row["name"]) for row in scan["sections"]["listeningPorts"]] + assert listen_rows == [("Manus", "Manus")], listen_rows + + def test_macos_default_scan_never_prompts_for_sfltool_admin_access(project_root): autoruns = ( project_root / "scripts/modules/macos/autoruns.sh"