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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 28 additions & 14 deletions scripts/network_watch.sh
Original file line number Diff line number Diff line change
Expand Up @@ -113,51 +113,65 @@ 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
addr = parts[9]
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"
Expand Down
17 changes: 15 additions & 2 deletions scripts/scanner_helper.jxa.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand All @@ -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
Expand Down
42 changes: 42 additions & 0 deletions tests/test_macos_network_watch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
52 changes: 52 additions & 0 deletions tests/test_service_contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down