From 6642cf9cd7f03555ee4f7d1b396de866c926005e Mon Sep 17 00:00:00 2001 From: Caio Lins Date: Sun, 25 Oct 2026 04:02:13 +0900 Subject: [PATCH 1/2] scripts: make LINE extension capture work on macOS, Linux and Windows - Add line-capture-preflight.sh (OS, Chrome/Chromium, Python 3.8+, websocket-client) and have the launcher use its CHROME/PYTHON. - Find the normal Chrome profile per OS; skip unreadable extension folders with a warning instead of aborting. - Start on about:blank; open LINE from the toolbar icon. - Recorder: resume targets held by the debugger, save request bodies (binary included) via Network.getRequestPostData and the on-the-wire headers from requestWillBeSentExtraInfo. - Redact MIDs, picturePath and raw body bytes; parser reads the new fields. - Agent docs: preflight step, no DevTools on the extension during capture. Co-Authored-By: Claude Sonnet 5.5 --- .agents/commands/line-capture.md | 29 ++++-- .agents/commands/line-implement.md | 5 ++ .agents/skills/line-implement/SKILL.md | 16 ++++ scripts/line-capture-preflight.sh | 101 +++++++++++++++++++++ scripts/line-cdp-capture.py | 66 ++++++++++++-- scripts/line-chrome-capture.sh | 117 +++++++++++++++++-------- scripts/parse-line-traffic.py | 23 ++++- scripts/redact-line-capture.py | 13 ++- 8 files changed, 318 insertions(+), 52 deletions(-) create mode 100755 scripts/line-capture-preflight.sh diff --git a/.agents/commands/line-capture.md b/.agents/commands/line-capture.md index b0ecd69c..f05951af 100644 --- a/.agents/commands/line-capture.md +++ b/.agents/commands/line-capture.md @@ -2,9 +2,21 @@ Launch Chrome with network capture enabled for LINE Chrome Extension analysis. ## What to do -1. **Close any running Chrome instances first** (Chrome only supports one instance per profile with remote debugging). Warn the user if Chrome is running. +1. **Check dependencies.** Run `bash scripts/line-capture-preflight.sh` (on + Windows, from Git Bash). It prints the detected OS and an `OK` or `MISSING` + line per dependency. It does not say how to install anything: choose the + install method that fits the detected OS and the package managers that are + actually available (for example Homebrew on macOS; apt, dnf or pacman on + Linux; winget, Chocolatey or Scoop on Windows; ` -m pip install + --user` for Python packages, using the Python the preflight reported). + Show the user the missing items with the exact commands you plan to run, + ask them to confirm, then install and re-run the preflight until it exits 0. + Never install without that confirmation. The launcher refuses to start while + dependencies are missing. -2. Run the capture script: +2. **Close any running Chrome instances first** (Chrome only supports one instance per profile with remote debugging). Warn the user if Chrome is running. + +3. Run the capture script: ``` bash scripts/line-chrome-capture.sh /tmp/line-chrome-extension-capture ``` @@ -28,11 +40,16 @@ Launch Chrome with network capture enabled for LINE Chrome Extension analysis. for `ophjlpahpchlmihnnnihgmmeilfjmjjc`, verify LINE / LY Corporation, click Add to Chrome, and accept the standard "Add extension" confirmation -3. Tell the user: - - Open the LINE Chrome Extension (browser toolbar icon) +4. Tell the user: + - Chrome opens on `about:blank`. Open LINE from the extension's toolbar icon + (puzzle-piece menu if it is not pinned), not by opening the extension's + `index.html` as a tab: that behaves differently from the real popup. + `LINE_CAPTURE_START_URL` overrides the start URL. - Log in manually if the capture profile is not already logged in - Perform whatever flow they want to analyze - - When done, quit Chrome completely (Cmd+Q) + - Do not open DevTools on the extension: LINE has `debugger;` traps that + pause it, and the recorder does not need DevTools + - When done, quit Chrome completely (all windows, not only the tab) -4. Once Chrome is closed, the net-log file is ready. Continue with the +5. Once Chrome is closed, the net-log file is ready. Continue with the line-analyze workflow. diff --git a/.agents/commands/line-implement.md b/.agents/commands/line-implement.md index 09bf18b1..760af405 100644 --- a/.agents/commands/line-implement.md +++ b/.agents/commands/line-implement.md @@ -38,11 +38,16 @@ Support Matrix group avatar changes by matching LINE Chrome Extension traffic. - Use a fresh `/tmp/line-chrome-extension-capture/-` directory with `umask 077`. - Do not commit raw capture files, Chrome profiles, screenshots, QR codes, or parsed JSON. - Do not request, store, or automate the LINE password. + - Do not open DevTools on the LINE extension during a capture (LINE has `debugger;` traps); the recorder does not need it. The launcher opens `about:blank`; the user opens LINE from the extension's toolbar icon. - The user performs LINE login manually, including email/password, QR, PIN, 2FA, and mobile approval. - Do not run the bridge and LINE Chrome Extension simultaneously unless the user explicitly accepts session invalidation. - Prefer the persistent capture Chrome profile from `scripts/line-chrome-capture.sh`. Use the user's normal Chrome profile only after explicit approval. 5. **Run the capture.** + - Run `bash scripts/line-capture-preflight.sh` (Git Bash on Windows). If + anything is `MISSING`, pick install commands that fit the reported OS and + the package managers present, show them to the user, install only after + they confirm, and re-run until it passes. See `line-capture.md` step 1. - Check for running Chrome and `matrix-line` processes; warn before closing or stopping anything. - Start capture: ``` diff --git a/.agents/skills/line-implement/SKILL.md b/.agents/skills/line-implement/SKILL.md index d4d0a8a2..98f2d03d 100644 --- a/.agents/skills/line-implement/SKILL.md +++ b/.agents/skills/line-implement/SKILL.md @@ -19,6 +19,10 @@ Chrome Extension behavior. `pkg/line/client.go` `ExtensionVersion` and hardcoded LINE application header versions against the installed extension `manifest.json`. 5. First inspect code and `docs/protocol`; capture only unknown behavior. + Before capturing, run `scripts/line-capture-preflight.sh` (macOS, Linux, or + Git Bash on Windows). For anything `MISSING`, choose install commands for + the detected OS and available package managers, and install only after the + user confirms. 6. Treat all raw captures as secrets and work from redacted outputs whenever possible. ## Required Loop @@ -67,6 +71,18 @@ ID `ophjlpahpchlmihnnnihgmmeilfjmjjc` (LINE, offered by LY Corporation), click Add to Chrome, and accept the standard "Add extension" confirmation. Do not accept prompts for any other extension or publisher without a new user request. +During a capture, never open DevTools on the LINE extension (popup, service +worker or `popup.html` windows): LINE has `debugger;` traps that pause it and +show "Debugger paused in another tab". The CDP recorder attaches by itself and +does not need DevTools. The launcher starts Chrome on `about:blank`; the user +opens LINE from the extension's toolbar icon, because opening `index.html` as +a tab behaves differently from the real popup. + +The recorder stores request bodies (binary uploads included, as size + sha256 +plus base64 in the raw capture only) and the real on-the-wire request headers. +Redact before quoting: `scripts/redact-line-capture.py` removes tokens, MIDs, +`picturePath` and the raw body bytes. + Do not request, store, or automate LINE credentials. If the extension is not logged in, pause and ask the user to complete login manually in the capture browser, including email/password, QR, PIN, 2FA, and mobile approval. diff --git a/scripts/line-capture-preflight.sh b/scripts/line-capture-preflight.sh new file mode 100755 index 00000000..ac1a047a --- /dev/null +++ b/scripts/line-capture-preflight.sh @@ -0,0 +1,101 @@ +#!/bin/bash +# Check that everything the LINE Chrome Extension capture needs is installed. +# Works on macOS, Linux and Windows (Git Bash / MSYS2). +# +# Usage: +# ./scripts/line-capture-preflight.sh # human/agent-readable report +# eval "$(./scripts/line-capture-preflight.sh --print-env)" # sets CHROME and PYTHON +# +# The report prints the detected OS, one OK line per dependency found and one +# MISSING line per dependency that is not. It deliberately does not say how to +# install anything: the agent running it picks the install method that fits +# the OS and the package managers available, and asks the user first. +# Exits 1 when anything required is missing. +# +# Override detection with CHROME=/path/to/chrome or PYTHON=python-command. + +set -uo pipefail + +case "$(uname -s)" in + Darwin) OS=macos ;; + Linux) + if grep -qi microsoft /proc/version 2>/dev/null; then OS=wsl; else OS=linux; fi ;; + MINGW* | MSYS* | CYGWIN*) OS=windows ;; + *) OS=unknown ;; +esac + +find_chrome() { + if [ -n "${CHROME:-}" ]; then + [ -x "$CHROME" ] && echo "$CHROME" + return + fi + local candidates=() + case "$OS" in + macos) + candidates=("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" + "$HOME/Applications/Google Chrome.app/Contents/MacOS/Google Chrome") ;; + windows) + candidates=("${PROGRAMFILES:-/c/Program Files}/Google/Chrome/Application/chrome.exe" + "${PROGRAMFILES_X86:-/c/Program Files (x86)}/Google/Chrome/Application/chrome.exe" + "${LOCALAPPDATA:-$HOME/AppData/Local}/Google/Chrome/Application/chrome.exe") ;; + *) + local name + for name in google-chrome google-chrome-stable chromium chromium-browser; do + command -v "$name" >/dev/null 2>&1 && { command -v "$name"; return; } + done ;; + esac + local path + for path in "${candidates[@]}"; do + [ -x "$path" ] && { echo "$path"; return; } + done +} + +find_python() { + if [ -n "${PYTHON:-}" ]; then + $PYTHON -c "import sys; sys.exit(sys.version_info < (3, 8))" 2>/dev/null && echo "$PYTHON" + return + fi + # "python3" on Windows can be the Microsoft Store stub, so the version check + # also filters out commands that exist but don't run. + local cmd + for cmd in python3 python "py -3"; do + $cmd -c "import sys; sys.exit(sys.version_info < (3, 8))" 2>/dev/null && { echo "$cmd"; return; } + done +} + +CHROME_FOUND="$(find_chrome)" +PYTHON_FOUND="$(find_python)" + +if [ "${1:-}" = "--print-env" ]; then + printf 'CHROME=%q\nPYTHON=%q\n' "$CHROME_FOUND" "$PYTHON_FOUND" + exit 0 +fi + +missing=0 +ok() { echo "OK $1"; } +need() { echo "MISSING $1"; missing=1; } + +echo "OS $OS ($(uname -sm))" +if [ "$OS" = wsl ]; then + echo "NOTE WSL detected: Chrome must be reachable from WSL (a Linux Chrome in WSL, or set CHROME to the Windows chrome.exe)." +fi + +if [ -n "$CHROME_FOUND" ]; then + ok "Google Chrome: $CHROME_FOUND" +else + need "Google Chrome (or Chromium on Linux); set CHROME=/path/to/chrome if it is installed somewhere else" +fi + +if [ -n "$PYTHON_FOUND" ]; then + ok "Python 3.8+: $PYTHON_FOUND ($($PYTHON_FOUND --version 2>&1))" + # line-cdp-capture.py records request bodies and SSE events through CDP. + if $PYTHON_FOUND -c "import websocket" >/dev/null 2>&1; then + ok "Python package websocket-client" + else + need "Python package websocket-client (pip name: websocket-client), for $PYTHON_FOUND" + fi +else + need "Python 3.8+" +fi + +exit $missing diff --git a/scripts/line-cdp-capture.py b/scripts/line-cdp-capture.py index 142befe9..dff38f33 100755 --- a/scripts/line-cdp-capture.py +++ b/scripts/line-cdp-capture.py @@ -4,7 +4,10 @@ Connects to Chrome's remote debugging port and captures: - SSE EventSource messages (the critical piece net-log misses) - Thrift RPC request/response bodies -- All LINE-domain request headers +- All LINE-domain request headers, including the ones Chrome adds itself + (Network.requestWillBeSentExtraInfo) +- Request bodies of every LINE request, binary ones (image uploads) included + (stored as size + sha256, and the bytes in base64 in this raw capture) Usage: # First, launch Chrome with --remote-debugging-port=9222 @@ -21,6 +24,7 @@ import signal import time import base64 +import hashlib import threading from collections import defaultdict from datetime import datetime, timezone @@ -69,6 +73,8 @@ def __init__(self, output_path: str): self.thrift_responses = {} # requestId -> response body self.request_bodies = {} # requestId -> request body self.pending_body_requests = {} # msg_id -> (requestId, session_id) + self.pending_post_requests = {} # msg_id -> requestId + self.extra_headers = {} # requestId -> headers from requestWillBeSentExtraInfo self.sessions = {} # sessionId -> targetInfo self.running = True @@ -162,6 +168,12 @@ def handle_event(self, msg: dict): "maxTotalBufferSize": 100 * 1024 * 1024, "maxResourceBufferSize": 10 * 1024 * 1024, }) + # Windows opened by the extension (e.g. the per-group popup.html) + # can arrive held at "waiting for debugger" even with + # waitForDebuggerOnStart off. Left alone they stay blank and grey + # out the extension with "Debugger paused in another tab". + if params.get("waitingForDebugger"): + self.send_to_session(sid, "Runtime.runIfWaitingForDebugger") # Track session -> target mapping self.sessions[sid] = target_info return @@ -174,6 +186,8 @@ def handle_event(self, msg: dict): if method == "Network.requestWillBeSent": self._on_request(params, session_id) + elif method == "Network.requestWillBeSentExtraInfo": + self._on_request_extra_info(params) elif method == "Network.responseReceived": self._on_response(params, session_id) elif method == "Network.loadingFinished": @@ -186,6 +200,27 @@ def handle_event(self, msg: dict): def handle_response(self, msg: dict): mid = msg.get("id") + if mid in self.pending_post_requests: + request_id = self.pending_post_requests.pop(mid) + result = msg.get("result", {}) + post_data = result.get("postData") + if post_data is not None: + # Newer Chrome flags binary bodies with base64Encoded; older + # ones return text only, which is lossy for binary data. + if result.get("base64Encoded"): + raw = base64.b64decode(post_data) + else: + raw = post_data.encode("utf-8", errors="surrogateescape") + self.request_bodies[request_id] = { + "size": len(raw), + "sha256": hashlib.sha256(raw).hexdigest(), + "base64": base64.b64encode(raw).decode("ascii"), + "text": raw.decode("utf-8") if self._is_text(raw) else None, + } + else: + error = msg.get("error", {}).get("message", "unavailable") + self.request_bodies[request_id] = {"error": error} + return if mid in self.pending_body_requests: request_id, session_id = self.pending_body_requests.pop(mid) result = msg.get("result", {}) @@ -216,16 +251,33 @@ def _on_request(self, params: dict, session_id: str = ""): "session_id": session_id, } - # Capture POST body for Thrift calls - if request.get("hasPostData") and "/thrift/" in url: - post_data = request.get("postData", "") - if post_data: - self.request_bodies[request_id] = post_data + # Request bodies: the inline postData is missing for large or binary + # bodies (e.g. obs/uploadProfile), so always ask for it explicitly. + if request.get("hasPostData"): + target = session_id + if target: + mid = self.send_to_session(target, "Network.getRequestPostData", {"requestId": request_id}) + else: + mid = self.send("Network.getRequestPostData", {"requestId": request_id}) + self.pending_post_requests[mid] = request_id count = len(self.requests) if count % 10 == 1: print(f" [{count} LINE requests captured]", file=sys.stderr) + @staticmethod + def _is_text(raw: bytes) -> bool: + try: + raw.decode("utf-8") + except UnicodeDecodeError: + return False + return b"\x00" not in raw + + def _on_request_extra_info(self, params: dict): + """Headers as sent on the wire (Cookie, Origin, Sec-*, X-Line-* ...).""" + request_id = params.get("requestId") + self.extra_headers.setdefault(request_id, {}).update(params.get("headers", {})) + def _on_response(self, params: dict, session_id: str = ""): request_id = params.get("requestId") if request_id not in self.requests: @@ -295,6 +347,8 @@ def save(self): entry.pop("session_id", None) # internal, don't export if rid in self.thrift_responses: entry["response_body"] = self.thrift_responses[rid] + if rid in self.extra_headers: + entry["request_extra_headers"] = self.extra_headers[rid] if rid in self.request_bodies: entry["request_body"] = self.request_bodies[rid] requests_list.append(entry) diff --git a/scripts/line-chrome-capture.sh b/scripts/line-chrome-capture.sh index 101967ed..b75f3755 100755 --- a/scripts/line-chrome-capture.sh +++ b/scripts/line-chrome-capture.sh @@ -29,13 +29,55 @@ # This is useful when installing LINE from the Chrome Web Store into the # persistent capture profile. # -# When done, close Chrome (Cmd+Q). The capture files will be at: +# The browser opens on about:blank; open LINE from the extension's toolbar icon +# (LINE_CAPTURE_START_URL overrides the start URL). When done, quit Chrome +# completely. The capture files will be at: # /line-cdp-capture.json — CDP capture (SSE + request bodies) # /line-net-log.json — Chrome net-log (low-level backup) set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Without its dependencies the CDP listener dies silently and only the net-log +# is saved, which lacks request bodies and SSE events. +if ! "$SCRIPT_DIR/line-capture-preflight.sh" >/dev/null; then + "$SCRIPT_DIR/line-capture-preflight.sh" + echo "" + echo "==> Install the missing dependencies above, then run this again." + exit 1 +fi +# Sets CHROME and PYTHON. PYTHON can be several words ("py -3"), so it is +# expanded unquoted below. +eval "$("$SCRIPT_DIR/line-capture-preflight.sh" --print-env)" + +warn() { echo " WARNING: $*" >&2; } + +# Chrome on Windows needs native paths; Git Bash paths only convert implicitly +# for some arguments. +native_path() { + if command -v cygpath >/dev/null 2>&1; then + cygpath -w "$1" + else + echo "$1" + fi +} + +# Directories that can hold the user's normal Chrome profiles, per OS. +normal_chrome_dirs() { + case "$(uname -s)" in + Darwin) + echo "$HOME/Library/Application Support/Google/Chrome" ;; + MINGW* | MSYS* | CYGWIN*) + local local_appdata="${LOCALAPPDATA:-$HOME/AppData/Local}" + command -v cygpath >/dev/null 2>&1 && local_appdata="$(cygpath -u "$local_appdata")" + echo "$local_appdata/Google/Chrome/User Data" ;; + *) + echo "$HOME/.config/google-chrome" + echo "$HOME/.config/chromium" ;; + esac +} + OUTPUT_DIR="${1:-/tmp/line-chrome-extension-capture}" mkdir -p "$OUTPUT_DIR" NETLOG_FILE="$OUTPUT_DIR/line-net-log.json" @@ -56,7 +98,7 @@ seed_chrome_capture_profile() { # below keep the choices explicit and readable for future maintainers. touch "$profile_dir/First Run" - python3 - "$profile_dir" <<'PY' + $PYTHON - "$profile_dir" <<'PY' import json import sys from pathlib import Path @@ -139,10 +181,16 @@ PY find_latest_extension_dir() { local extension_id="$1" - local base="$HOME/Library/Application Support/Google/Chrome/Default/Extensions/$extension_id" - if [ -d "$base" ]; then - find "$base" -mindepth 1 -maxdepth 1 -type d | sort -V | tail -n 1 - fi + local chrome_dir base + while IFS= read -r chrome_dir; do + base="$chrome_dir/Default/Extensions/$extension_id" + [ -d "$base" ] || continue + # Chrome's folders can be unreadable (macOS privacy protection answers + # "Operation not permitted"); skip those instead of aborting. + if ! find "$base" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | sort -V | tail -n 1 | grep .; then + warn "cannot read $base, skipping" + fi + done < <(normal_chrome_dirs) } find_latest_profile_extension_dir() { @@ -150,14 +198,14 @@ find_latest_profile_extension_dir() { local extension_id="$2" local base="$profile_dir/Default/Extensions/$extension_id" if [ -d "$base" ]; then - find "$base" -mindepth 1 -maxdepth 1 -type d | sort -V | tail -n 1 + find "$base" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | sort -V | tail -n 1 || true fi } read_manifest_version() { local extension_dir="$1" if [ -n "${extension_dir:-}" ] && [ -f "$extension_dir/manifest.json" ]; then - python3 - "$extension_dir/manifest.json" <<'PY' + $PYTHON - "$extension_dir/manifest.json" <<'PY' import json import sys from pathlib import Path @@ -173,7 +221,7 @@ PY find_repo_extension_version() { local client_file="$SCRIPT_DIR/../pkg/line/client.go" if [ -f "$client_file" ]; then - python3 - "$client_file" <<'PY' + $PYTHON - "$client_file" <<'PY' import re import sys from pathlib import Path @@ -193,7 +241,7 @@ stage_extension_dir() { fi local version - version="$(python3 - "$source_dir/manifest.json" <<'PY' + version="$($PYTHON - "$source_dir/manifest.json" <<'PY' import json import sys from pathlib import Path @@ -211,13 +259,21 @@ PY # Chrome Web Store extension directories contain _metadata/, which Chrome # rejects when loading as unpacked. Stage a clean copy for capture runs. + # An unreadable source must not abort the capture; skip with a warning. if command -v rsync >/dev/null 2>&1; then - rsync -a --delete --exclude '_metadata' "$source_dir/" "$dest/" + rsync -a --delete --exclude '_metadata' "$source_dir/" "$dest/" 2>/dev/null || { + warn "could not copy $source_dir, skipping this extension" + return 0 + } else local tmp="$dest.tmp" rm -rf "$tmp" mkdir -p "$tmp" - (cd "$source_dir" && tar --exclude './_metadata' -cf - .) | (cd "$tmp" && tar -xf -) + if ! { (cd "$source_dir" && tar --exclude './_metadata' -cf - .) | (cd "$tmp" && tar -xf -); } 2>/dev/null; then + rm -rf "$tmp" + warn "could not copy $source_dir, skipping this extension" + return 0 + fi rm -rf "$dest" mv "$tmp" "$dest" fi @@ -250,13 +306,6 @@ if [ -n "${CODEX_EXTENSION_DIR:-}" ] && [ -f "$CODEX_EXTENSION_DIR/manifest.json EXTENSION_DIRS+=("$CODEX_EXTENSION_DIR") fi -CHROME="${CHROME:-/Applications/Google Chrome.app/Contents/MacOS/Google Chrome}" -if [ ! -x "$CHROME" ]; then - echo "Chrome not found at $CHROME" - echo "Set CHROME env var to your Chrome binary path." - exit 1 -fi - FIRST_RUN=false if [ ! -d "$DEBUG_PROFILE" ]; then FIRST_RUN=true @@ -298,8 +347,9 @@ if [ "$FIRST_RUN" = true ]; then echo " The profile persists across capture dirs — you only need to do this once." echo "" fi -echo " 1. Open the LINE Chrome Extension and do your flow" -echo " 2. When done, close Chrome (Cmd+Q)" +echo " 1. Click the LINE extension icon in the toolbar (puzzle-piece menu if not pinned) and do your flow" +echo " Do not open DevTools on the extension: LINE has debugger traps and the recorder does not need it." +echo " 2. When done, quit Chrome completely (all windows, not just the tab)" echo "" # Chrome 146+ requires --user-data-dir for remote debugging. @@ -310,14 +360,18 @@ CHROME_ARGS=( --disable-features=SignInPromo,SigninInterception,ChromeSigninIntercept,DiceWebSigninInterception --remote-debugging-port=9222 --remote-allow-origins="*" - --user-data-dir="$DEBUG_PROFILE" - --log-net-log="$NETLOG_FILE" + --user-data-dir="$(native_path "$DEBUG_PROFILE")" + --log-net-log="$(native_path "$NETLOG_FILE")" --net-log-capture-mode=IncludeSensitive ) if [ ${#EXTENSION_DIRS[@]} -gt 0 ]; then + NATIVE_EXTENSION_DIRS=() + for ext_dir in "${EXTENSION_DIRS[@]}"; do + NATIVE_EXTENSION_DIRS+=("$(native_path "$ext_dir")") + done IFS=, - EXTENSION_ARG="${EXTENSION_DIRS[*]}" + EXTENSION_ARG="${NATIVE_EXTENSION_DIRS[*]}" unset IFS if [ -z "${LINE_PROFILE_EXTENSION_DIR:-}" ]; then CHROME_ARGS+=(--disable-extensions-except="$EXTENSION_ARG") @@ -325,22 +379,15 @@ if [ ${#EXTENSION_DIRS[@]} -gt 0 ]; then CHROME_ARGS+=(--load-extension="$EXTENSION_ARG") fi -START_URL="${LINE_CAPTURE_START_URL:-}" -if [ -z "$START_URL" ] && [ -n "${LINE_PROFILE_EXTENSION_DIR:-}" ] && [ -f "$LINE_PROFILE_EXTENSION_DIR/manifest.json" ]; then - START_URL="chrome-extension://ophjlpahpchlmihnnnihgmmeilfjmjjc/index.html" -fi -if [ -z "$START_URL" ] && [ -n "${LINE_EXTENSION_DIR:-}" ] && [ -f "$LINE_EXTENSION_DIR/manifest.json" ]; then - START_URL="chrome-extension://ophjlpahpchlmihnnnihgmmeilfjmjjc/index.html" -fi -if [ -z "$START_URL" ]; then - START_URL="about:blank" -fi +# about:blank on purpose: opening the extension's index.html as a tab behaves +# differently from the real popup, so the user opens LINE from the toolbar icon. +START_URL="${LINE_CAPTURE_START_URL:-about:blank}" "$CHROME" "${CHROME_ARGS[@]}" "$START_URL" 2>/dev/null & CHROME_PID=$! # Start the CDP capture (it waits for Chrome to be ready) -python3 "$SCRIPT_DIR/line-cdp-capture.py" "$CDP_FILE" & +$PYTHON "$SCRIPT_DIR/line-cdp-capture.py" "$CDP_FILE" & CDP_PID=$! # Wait for Chrome to exit diff --git a/scripts/parse-line-traffic.py b/scripts/parse-line-traffic.py index dd21a688..7b605c1b 100755 --- a/scripts/parse-line-traffic.py +++ b/scripts/parse-line-traffic.py @@ -231,14 +231,17 @@ def parse_cdp_capture(data: dict) -> dict: entry["status"] = req["status"] # Extract interesting request headers - headers = req.get("headers", {}) + # requestWillBeSentExtraInfo carries the headers Chrome really sent + # (Cookie, Origin, Sec-*, X-Line-*), which requestWillBeSent omits. + headers = {**req.get("headers", {}), **req.get("request_extra_headers", {})} interesting_req = {} for k, v in headers.items(): if k.lower() in ( "content-type", "x-line-access", "x-line-application", "x-lhm", "x-lpv", "x-lsr", "x-obs-params", - "x-line-channeltoken", "accept", - ): + "x-line-channeltoken", "accept", "origin", "user-agent", + "x-line-chrome-version", "x-line-mid", + ) or k.lower().startswith("x-line-") or k.lower().startswith("x-ls"): interesting_req[k] = v if interesting_req: entry["request_headers"] = interesting_req @@ -270,7 +273,19 @@ def parse_cdp_capture(data: dict) -> dict: # Include request body if available if "request_body" in req: - entry["request_body_text"] = req["request_body"] + body = req["request_body"] + if isinstance(body, dict): + # Raw captures keep the bytes in base64; the parsed output only + # keeps the text form and a size + sha256 for binary bodies. + if body.get("error"): + entry["request_body_error"] = body["error"] + else: + entry["request_body_size"] = body.get("size") + entry["request_body_sha256"] = body.get("sha256") + if body.get("text") is not None: + entry["request_body_text"] = body["text"] + else: + entry["request_body_text"] = body entry["timestamp"] = req.get("timestamp", "") line_requests.append(entry) diff --git a/scripts/redact-line-capture.py b/scripts/redact-line-capture.py index 8c20f481..049ec2d4 100755 --- a/scripts/redact-line-capture.py +++ b/scripts/redact-line-capture.py @@ -39,6 +39,16 @@ "refresh_token", "session", "certificate", + # Identifiers that point at a person, group or uploaded object. + "mid", + "chatmid", + "contactmid", + "picturepath", + "picturestatus", + "oid", + "x-obs-oid", + # Raw request-body bytes from the CDP recorder (uploads, Thrift payloads). + "base64", } SENSITIVE_KEY_FRAGMENTS = ( @@ -53,7 +63,8 @@ EMAIL_RE = re.compile(r"(?i)[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}") JWT_RE = re.compile(r"eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}") -MID_RE = re.compile(r"\b([ucr][0-9a-f]{20,})\b", re.IGNORECASE) +# Not \b: "_" counts as a word character there, which hid MIDs after "_". +MID_RE = re.compile(r"(? Date: Sun, 25 Oct 2026 04:34:36 +0900 Subject: [PATCH 2/2] scripts: address greptile review feedback (greploop iteration 1) - Keep PYTHON as an array so a path with spaces survives. - Take only the first usable extension dir when Chrome and Chromium both have it. - Reject Windows chrome.exe under WSL instead of passing it WSL paths. Co-Authored-By: Claude Sonnet 5.5 --- scripts/line-capture-preflight.sh | 37 ++++++++++++++++++++----------- scripts/line-chrome-capture.sh | 29 +++++++++++++++--------- 2 files changed, 43 insertions(+), 23 deletions(-) diff --git a/scripts/line-capture-preflight.sh b/scripts/line-capture-preflight.sh index ac1a047a..0711ff12 100755 --- a/scripts/line-capture-preflight.sh +++ b/scripts/line-capture-preflight.sh @@ -4,7 +4,7 @@ # # Usage: # ./scripts/line-capture-preflight.sh # human/agent-readable report -# eval "$(./scripts/line-capture-preflight.sh --print-env)" # sets CHROME and PYTHON +# eval "$(./scripts/line-capture-preflight.sh --print-env)" # sets CHROME and the PYTHON array # # The report prints the detected OS, one OK line per dependency found and one # MISSING line per dependency that is not. It deliberately does not say how to @@ -12,7 +12,8 @@ # the OS and the package managers available, and asks the user first. # Exits 1 when anything required is missing. # -# Override detection with CHROME=/path/to/chrome or PYTHON=python-command. +# Override detection with CHROME=/path/to/chrome or PYTHON=/path/to/python +# (one executable; spaces in the path are fine). set -uo pipefail @@ -50,24 +51,34 @@ find_chrome() { done } +# Sets the PYTHON_FOUND array: the executable first, then optional arguments +# ("py -3"). An array keeps a PYTHON override with spaces in its path in one piece. find_python() { + PYTHON_FOUND=() + local candidate if [ -n "${PYTHON:-}" ]; then - $PYTHON -c "import sys; sys.exit(sys.version_info < (3, 8))" 2>/dev/null && echo "$PYTHON" + "$PYTHON" -c "import sys; sys.exit(sys.version_info < (3, 8))" 2>/dev/null && PYTHON_FOUND=("$PYTHON") return fi # "python3" on Windows can be the Microsoft Store stub, so the version check # also filters out commands that exist but don't run. - local cmd - for cmd in python3 python "py -3"; do - $cmd -c "import sys; sys.exit(sys.version_info < (3, 8))" 2>/dev/null && { echo "$cmd"; return; } + for candidate in "python3" "python" "py -3"; do + # shellcheck disable=SC2206 # intentional split of "py -3" + local cmd=($candidate) + if "${cmd[@]}" -c "import sys; sys.exit(sys.version_info < (3, 8))" 2>/dev/null; then + PYTHON_FOUND=("${cmd[@]}") + return + fi done } CHROME_FOUND="$(find_chrome)" -PYTHON_FOUND="$(find_python)" +find_python if [ "${1:-}" = "--print-env" ]; then - printf 'CHROME=%q\nPYTHON=%q\n' "$CHROME_FOUND" "$PYTHON_FOUND" + printf 'CHROME=%q\nPYTHON=(' "$CHROME_FOUND" + [ ${#PYTHON_FOUND[@]} -gt 0 ] && printf '%q ' "${PYTHON_FOUND[@]}" + printf ')\n' exit 0 fi @@ -77,7 +88,7 @@ need() { echo "MISSING $1"; missing=1; } echo "OS $OS ($(uname -sm))" if [ "$OS" = wsl ]; then - echo "NOTE WSL detected: Chrome must be reachable from WSL (a Linux Chrome in WSL, or set CHROME to the Windows chrome.exe)." + echo "NOTE WSL detected: use a Linux Chrome or Chromium inside WSL. Windows chrome.exe cannot use WSL paths or reach the recorder, so run from Git Bash on Windows instead." fi if [ -n "$CHROME_FOUND" ]; then @@ -86,13 +97,13 @@ else need "Google Chrome (or Chromium on Linux); set CHROME=/path/to/chrome if it is installed somewhere else" fi -if [ -n "$PYTHON_FOUND" ]; then - ok "Python 3.8+: $PYTHON_FOUND ($($PYTHON_FOUND --version 2>&1))" +if [ ${#PYTHON_FOUND[@]} -gt 0 ]; then + ok "Python 3.8+: ${PYTHON_FOUND[*]} ($("${PYTHON_FOUND[@]}" --version 2>&1))" # line-cdp-capture.py records request bodies and SSE events through CDP. - if $PYTHON_FOUND -c "import websocket" >/dev/null 2>&1; then + if "${PYTHON_FOUND[@]}" -c "import websocket" >/dev/null 2>&1; then ok "Python package websocket-client" else - need "Python package websocket-client (pip name: websocket-client), for $PYTHON_FOUND" + need "Python package websocket-client (pip name: websocket-client), for ${PYTHON_FOUND[*]}" fi else need "Python 3.8+" diff --git a/scripts/line-chrome-capture.sh b/scripts/line-chrome-capture.sh index b75f3755..17c23cf4 100755 --- a/scripts/line-chrome-capture.sh +++ b/scripts/line-chrome-capture.sh @@ -47,10 +47,15 @@ if ! "$SCRIPT_DIR/line-capture-preflight.sh" >/dev/null; then echo "==> Install the missing dependencies above, then run this again." exit 1 fi -# Sets CHROME and PYTHON. PYTHON can be several words ("py -3"), so it is -# expanded unquoted below. +# Sets CHROME and the PYTHON array (it can be several words, like "py -3"). eval "$("$SCRIPT_DIR/line-capture-preflight.sh" --print-env)" +# Windows Chrome cannot use WSL paths or reach the recorder inside WSL. +if grep -qi microsoft /proc/version 2>/dev/null && [[ "$CHROME" == *.exe ]]; then + echo "Windows chrome.exe does not work from WSL. Use a Linux Chrome or Chromium in WSL, or run this from Git Bash on Windows." >&2 + exit 1 +fi + warn() { echo " WARNING: $*" >&2; } # Chrome on Windows needs native paths; Git Bash paths only convert implicitly @@ -98,7 +103,7 @@ seed_chrome_capture_profile() { # below keep the choices explicit and readable for future maintainers. touch "$profile_dir/First Run" - $PYTHON - "$profile_dir" <<'PY' + "${PYTHON[@]}" - "$profile_dir" <<'PY' import json import sys from pathlib import Path @@ -181,15 +186,19 @@ PY find_latest_extension_dir() { local extension_id="$1" - local chrome_dir base + local chrome_dir base found while IFS= read -r chrome_dir; do base="$chrome_dir/Default/Extensions/$extension_id" [ -d "$base" ] || continue # Chrome's folders can be unreadable (macOS privacy protection answers # "Operation not permitted"); skip those instead of aborting. - if ! find "$base" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | sort -V | tail -n 1 | grep .; then - warn "cannot read $base, skipping" + found="$(find "$base" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | sort -V | tail -n 1)" + if [ -n "$found" ]; then + # First usable match only: Chrome and Chromium can both have it. + echo "$found" + return 0 fi + warn "cannot read $base, skipping" done < <(normal_chrome_dirs) } @@ -205,7 +214,7 @@ find_latest_profile_extension_dir() { read_manifest_version() { local extension_dir="$1" if [ -n "${extension_dir:-}" ] && [ -f "$extension_dir/manifest.json" ]; then - $PYTHON - "$extension_dir/manifest.json" <<'PY' + "${PYTHON[@]}" - "$extension_dir/manifest.json" <<'PY' import json import sys from pathlib import Path @@ -221,7 +230,7 @@ PY find_repo_extension_version() { local client_file="$SCRIPT_DIR/../pkg/line/client.go" if [ -f "$client_file" ]; then - $PYTHON - "$client_file" <<'PY' + "${PYTHON[@]}" - "$client_file" <<'PY' import re import sys from pathlib import Path @@ -241,7 +250,7 @@ stage_extension_dir() { fi local version - version="$($PYTHON - "$source_dir/manifest.json" <<'PY' + version="$("${PYTHON[@]}" - "$source_dir/manifest.json" <<'PY' import json import sys from pathlib import Path @@ -387,7 +396,7 @@ START_URL="${LINE_CAPTURE_START_URL:-about:blank}" CHROME_PID=$! # Start the CDP capture (it waits for Chrome to be ready) -$PYTHON "$SCRIPT_DIR/line-cdp-capture.py" "$CDP_FILE" & +"${PYTHON[@]}" "$SCRIPT_DIR/line-cdp-capture.py" "$CDP_FILE" & CDP_PID=$! # Wait for Chrome to exit