From 32db6d13f3cc78701c0db7ed1daa15d735e1fe4f Mon Sep 17 00:00:00 2001 From: Heznpc Date: Thu, 13 Aug 2026 19:45:09 +0900 Subject: [PATCH] Unescape lsof \x20 process names; fix empty-first-sample suppression Two bugs in lsof output handling, both confirmed against this machine's real output rather than hypothesized: 1. 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 leaked into the UI: the main scan's network/listeningPorts sections (present since the JXA port, "Codex\x20"/"Manus\x20" in this machine's real scan_result.json) and network_watch.sh's rows (#72). Both now unescape it, and drop the trailing space left by lsof's 9-character truncation -- invisible in the UI while still splitting dedup keys. 2. network_watch.sh's awk used the FNR==NR idiom to separate the two samples. With an empty first sample -- a real shape, since lsof exits 1 with no output both on failure and on zero matches, and `|| true` swallows it -- FNR==NR misreads the second file as the first, so every connection made during the window was registered as "already seen" and reporting went silent. Found because the new escaping test's fixture happened to use an empty first listen sample. Replaced with POSIX inter-file variable assignment (building=1 file1 building=0 file2), which holds even for empty files; with no baseline, over-reporting is the safe direction. All three new tests proven against the pre-fix code via stash/restore. --- scripts/network_watch.sh | 42 ++++++++++++++++--------- scripts/scanner_helper.jxa.js | 17 ++++++++-- tests/test_macos_network_watch.py | 42 +++++++++++++++++++++++++ tests/test_service_contracts.py | 52 +++++++++++++++++++++++++++++++ 4 files changed, 137 insertions(+), 16 deletions(-) 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"