
Happy App Audit
- 126 installs
- 303 repo stars
- Updated April 20, 2026
- iamzhihuix/happy-claude-skills
Run pre-release app audits covering auth, data handling, dependencies, config leaks, and common OWASP-style issues before shipping.
About
Happy-app-audit provides a structured security and quality review for applications before launch: authentication boundaries, sensitive data paths, misconfigurations, third-party risks, and compliance-oriented gaps. Use when hardening MVPs, preparing investor or customer diligence, or gating production releases.
- Auth and session review prompts
- Secrets and config exposure checks
- Dependency vulnerability awareness
- Privacy and data-flow scrutiny
- Release-blocking issue prioritization
Happy App Audit by the numbers
- 126 all-time installs (skills.sh)
- +2 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #935 of 2,203 Security skills by installs in the Skillselion catalog
- Data as of Aug 2, 2026 (Skillselion catalog sync)
npx skills add https://github.com/iamzhihuix/happy-claude-skills --skill happy-app-auditAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 126 |
|---|---|
| repo stars | ★ 303 |
| Last updated | April 20, 2026 |
| Repository | iamzhihuix/happy-claude-skills ↗ |
What it does
Run pre-release app audits covering auth, data handling, dependencies, config leaks, and common OWASP-style issues before shipping.
Files
Happy App Audit
Static-only macOS app telemetry auditor. Produces a markdown report describing what an installed .app bundle reports, to whom, how often (inferred), and what it leaves on disk.
When to invoke
Invoke when the user says any of: "审计 / 调查 / 看看 / 拆 / 逆向 / 上报 / 埋点 / 隐私 / 抓 SDK" combined with a .app path or app name. Also invoke when given paths under /Applications, ~/Applications, /Library/Input Methods, or /Library/PrivilegedHelperTools.
Do NOT invoke for: source-code repos, web sites, mobile (iOS/Android) packages — this skill is macOS-bundle specific.
Hard rules (non-negotiable)
- Read only. No
curl/wget/nc/digagainst discovered endpoints. Nolldb attach,dtrace,fs_usage,tcpdump,mitmproxy,frida. No Keychain reads. No DRM bypass. No memory dump. - Allowed commands only. See
references/safe_commands.md. If a step seems to need something outside the whitelist, stop and tell the user instead of improvising. - Privacy by default. In every output file, scrub
device_id,uid,session_id,email, IDFV, IDFA, JWT, and any 16+ hex blob to<redacted:N>(keep length, drop content). - Scope cap. Refuse a single invocation that targets more than 5 apps. Refuse paths under
/System/,/usr/libexec/,/private/var/db/com.apple.*. Those are OS components, not third-party telemetry targets.
Runtime
{baseDir} = directory of this SKILL.md.
All scripts are bun + TypeScript. Resolve runtime as: prefer bun in PATH, otherwise npx -y bun. If neither exists, abort with a one-line install hint.
# Smoke check
bun --version || npx --version || echo "Need bun (recommended) or npx"Workflow — 6 phases, in order
Each phase has: Goal → Inputs → Commands → Output → Stop conditions. Do not skip ahead. Do not interleave.
Phase 0 — Scope confirm
Goal. Lock the target list to ≤5 valid .app paths.
Inputs. Whatever the user said — could be a path, a name, or "the input methods I have installed."
Commands.
- If user gave a path → verify it exists and ends with
.app - If user gave a name → search a fixed list:
/Applications (depth 2)
~/Applications (depth 2)
/Library/Input Methods (depth 1)
/Library/PrivilegedHelperTools (depth 1)- Reject anything under
/System/,/usr/libexec/,/private/var/db/com.apple.*
Output. A list target_apps[] with absolute paths.
Stop. If the list is empty, ask the user once. If >5, ask which to keep.
Phase 1 — Metadata snapshot
Goal. Per app, capture the immutable surface: bundle id, version, signing, entitlements, network policy, embedded frameworks.
Inputs. target_apps[] from Phase 0.
Commands. Run scripts/snapshot_app.ts:
bun {baseDir}/scripts/snapshot_app.ts <app-path> --out <workdir>/meta.jsonThe script collects:
plutil -p <app>/Contents/Info.plistcodesign -dv --entitlements - <app>(stderr)find <app>/Contents/Frameworks -maxdepth 3 -name '*.dylib' -o -name '*.framework'otool -L <main-binary>file <main-binary>for arch- Sizes via
du -sh
Output. <workdir>/meta.json with: bundle_id, version, sandboxed, arbitrary_loads, ats_exceptions[], entitlements_summary[], frameworks[] (each: name, path, size_bytes, archs).
Stop. If bundle_id cannot be read → abort, app is malformed.
Phase 2 — Strings preprocessing
Goal. Turn raw strings of every embedded binary into bucketed markdown that fits in context.
Inputs. meta.json::frameworks[].
Commands.
bun {baseDir}/scripts/classify_strings.ts <workdir>/meta.json --out <workdir>/strings/For each binary, the script runs strings -a -n 6 and sorts each line into one of:
urls— anything matchinghttps?://domains— bare hostnamespaths—/Library/...,~/Library/..., container-relative pathssql—CREATE TABLE,INSERT INTO,SELECT ... FROMevents— looks like an event name (/^[a-z][a-z0-9_]{8,80}$/with at least one underscore)keys— base64 / hex blobs ≥ 24 chars (kept count + first 12 chars only, never full)noise— discarded
Output. <workdir>/strings/<binary-name>.{urls,domains,paths,sql,events}.md (the keys bucket holds only counts + redacted previews).
Stop. If a binary is >200 MB → skip it and emit a warning line, do not OOM.
Phase 3 — SDK fingerprint matching
Goal. Identify which third-party SDKs are present and how confident.
Inputs. <workdir>/strings/, plus references/sdk_fingerprints.md.
Commands.
bun {baseDir}/scripts/match_fingerprints.ts <workdir>/strings/ \
--fingerprints {baseDir}/references/sdk_fingerprints.md \
--out <workdir>/matched.mdThe script applies each fingerprint's tell-tale strings regex set to the bucketed strings. A fingerprint counts as confirmed when its min_hits threshold is met (defined per fingerprint).
Output. <workdir>/matched.md with one row per SDK: name, vendor, hits, evidence file lines, status (confirmed / partial / absent).
Stop. If zero fingerprints confirmed AND the app embeds no third-party .framework → write a one-line "no telemetry detected" report and skip Phase 4-5.
Phase 4 — Endpoint mapping
Goal. Build the table that answers "where does it talk to, with what protocol, for what purpose, how often?"
Inputs. <workdir>/strings/*.urls.md + <workdir>/strings/*.domains.md + <workdir>/matched.md + references/known_endpoints.md.
Commands. This phase is mostly Claude reading the files. The only mechanical step:
bun {baseDir}/scripts/match_fingerprints.ts <workdir>/strings/ \
--fingerprints {baseDir}/references/known_endpoints.md \
--out <workdir>/endpoints.mdThen Claude writes <workdir>/endpoint_table.md:
| Endpoint | SDK | Protocol | Inferred purpose | Frequency source |
|---|
Frequency source MUST cite either: a literal interval found in <workdir>/strings/, or a config file found in Phase 5, or "unknown — not stated in static evidence." Never guess.
Stop. If endpoints.md is empty but Phase 3 confirmed an SDK → flag in the report (likely runtime-resolved hosts).
Phase 5 — Local data dive
Goal. Inventory the on-disk surface that the app writes to.
Inputs. meta.json::bundle_id, plus references/data_locations.md.
Commands.
bun {baseDir}/scripts/inventory_data.ts <bundle_id> --out <workdir>/local_data.mdThe script finds:
~/Library/Application Support/<bundle>/~/Library/Containers/<bundle>/Data/~/Library/Group Containers/group.<bundle-prefix>.*/~/Library/Caches/<bundle>/~/Library/Preferences/<bundle>.plist~/Library/Logs/<bundle>/
For each .sqlite* file: sqlite3 <file> '.schema' and .tables only — never SELECT. For each .mmkv / .json config: list path + size, do not open.
Output. <workdir>/local_data.md with: tree of relevant paths, sizes, and SQLite schemas.
Stop. If user is not the file owner → skip with a note (do not prompt sudo).
Phase 6 — Report rendering (+ optional 4:5 card)
Goal. Assemble the user-facing report. Optionally render a 4:5 infographic card for sharing.
Inputs. All prior phase outputs.
Commands.
bun {baseDir}/scripts/render_report.ts <workdir> \
--template {baseDir}/templates/report.md.tmpl \
--out ~/Documents/app-telemetry-audit/<YYYY-MM-DD>_<bundle-id>/report.mdWhen --card is passed to scripts/run.ts, the orchestrator additionally:
1. Calls lib/card.ts::renderCardPrompt(), which extracts top-6 SDKs (by size, with privacy-hot ones flagged red), top-6 endpoints (preferring endpoints.md confirmed matches with Chinese purpose labels and synthesized proto for quic/-ws. hosts), and top-5 local-data buckets (collapsed by parent dir + note, with DoubaoIme/doubaoime casing variants merged). 2. Writes the filled prompt to <workdir>/card_prompt.md. 3. Auto-discovers baoyu-imagine at ~/.claude/skills/baoyu-imagine/scripts/main.ts (or via BAOYU_IMAGINE_SCRIPT env), invokes it with --ar 4:5 --quality 2k defaults, and writes <workdir>/card.png.
Pass --no-image to write only the prompt and skip the image call. Pass any --image-* flag (--image-provider, --image-model, --image-imageSize, etc.) to override the defaults — e.g. --image-provider google --image-model gemini-3-pro-image-preview --image-imageSize 4K for native nano-banana-pro 4K output.
If baoyu-imagine is not installed, Phase 6 still writes card_prompt.md and prints an install hint, but skips the PNG. The skill remains fully functional without it.
Output. Final markdown report. Print its absolute path. If --card, also card_prompt.md and card.png.
Output layout (per app)
~/Documents/app-telemetry-audit/<YYYY-MM-DD>_<bundle-id>/
├── meta.json
├── matched.md
├── endpoints.md
├── endpoint_table.md
├── local_data.md
├── strings/
│ └── <binary>.{urls,domains,paths,sql,events}.md
├── card_prompt.md # only if --card requested
└── report.md # the deliverableWorking files (strings/, intermediate *.md) are kept by default — they are the audit trail. Pass --clean to delete them after report.md is written.
Quick start
# Single app, full audit (markdown report only)
bun {baseDir}/scripts/run.ts /Library/Input\ Methods/DoubaoIme.app
# Add a 4:5 share card (prompt + PNG via baoyu-imagine)
bun {baseDir}/scripts/run.ts /Library/Input\ Methods/DoubaoIme.app --card
# Card prompt only — skip image generation
bun {baseDir}/scripts/run.ts /Library/Input\ Methods/DoubaoIme.app --card --no-image
# Card with Google nano-banana-pro at 4K
bun {baseDir}/scripts/run.ts /Library/Input\ Methods/DoubaoIme.app --card \
--image-provider google \
--image-model gemini-3-pro-image-preview \
--image-imageSize 4K
# Multiple apps in one go (capped at 5)
bun {baseDir}/scripts/run.ts /Library/Input\ Methods/DoubaoIme.app /Applications/Foo.appscripts/run.ts is a thin orchestrator that calls Phases 1→6 in sequence. Use it for the common case. Use individual phase scripts only when iterating.
--card flag surface
| Flag | Purpose | Default |
|---|---|---|
--card | Render card_prompt.md AND card.png | off |
--no-image | With --card: write prompt, skip PNG | off |
--image-provider | baoyu-imagine provider (google, openai, replicate, dashscope, …) | provider auto-selected |
--image-model | Model id within the provider | provider default |
--image-ar | Aspect ratio | 4:5 |
--image-size | Explicit WxH | from --image-quality / provider |
--image-quality | normal or 2k | 2k |
--image-imageSize | Google/OpenRouter 1K/2K/4K | from --image-quality |
--out | Override output root | ~/Documents/app-telemetry-audit/ |
Any --image-* flag implicitly enables --card.
Relevant references
references/safe_commands.md— command whitelist + rationalereferences/sdk_fingerprints.md— SDK detection rules (12+ SDKs MVP)references/known_endpoints.md— domain → product reverse lookupreferences/data_locations.md— typical on-disk layout per vendorreferences/methodology_examples.md— two worked examples (WeType, DoubaoIme)templates/report.md.tmpl— final report skeletontemplates/card_prompt.md.tmpl— 4:5 visual card prompt skeleton
Failure modes Claude should NOT do
- Do not paraphrase strings into "looks like X" without quoting the literal evidence line + file path
- Do not infer frequencies from SDK names — only from literal numbers in strings or config files
- Do not run any binary inside the target app
- Do not open
.sqlitecontent — only schemas - Do not write a report when Phase 3 found nothing — write the short "no telemetry detected" note instead
- Do not invent endpoints from training memory; if the URL is not in
<workdir>/strings/, it does not go in the table
Verification (when developing this skill)
Smoke test on /Library/Input Methods/DoubaoIme.app and confirm the report covers:
- Frameworks:
applogrs,Parfait,bytenn,onnxruntime,sscronet,TTNet,ime_net_sdk,sami - Endpoints: at least 3 of
ime.doubao.com/obric/ime/cloud/convert,log-klink.zijieapi.com,ime-gw.oceancloudapi.com,frontier-audio - Local data:
~/Library/Application Support/DoubaoIme/Parfait/ready/685343/0/
Regression: run on the WeType IME bundle and confirm wetype.weixin.qq.com + CACHE_LOG_TBL schema appear.
Negative: run on a small app with no third-party telemetry — must produce the short "no telemetry detected" output without inventing SDKs.
// Build the 4:5 infographic prompt from the already-generated Phase 1..5
// artifacts. This is the bridge between the audit pipeline and
// baoyu-imagine — the filled prompt is what the image model sees.
import { readFile, readdir } from "node:fs/promises";
import { basename, join } from "node:path";
interface Framework {
name: string;
path: string;
size_bytes: number;
archs?: string[];
}
interface Meta {
bundle_id: string;
version?: string;
short_version?: string;
sandboxed?: boolean | null;
arbitrary_loads?: boolean | null;
main_executable?: string | null;
frameworks?: Framework[];
}
const PRIVACY_HOT = /applog|sscronet|ttnet|ime_net_sdk|parfait|sentry|bugly|umeng|firebase|sensorsdata|growingio|adjust|appsflyer|datadog|bytenn|sami|mars|mmkv/i;
const SDK_NOTES: Array<[RegExp, string]> = [
[/onnxruntime/i, "设备端 ML 推理"],
[/sscronet/i, "TTNet / Cronet 网络栈"],
[/ime[_-]?net[_-]?sdk/i, "QUIC 云候选长链"],
[/OimeEngine/i, "输入法引擎"],
[/bytenn/i, "字节自研 NN 推理"],
[/applogrs/i, "AppLog / TEA 埋点"],
[/Parfait/i, "Parfait APM 采样"],
[/ttnetdownloader/i, "TTNet 下载器"],
[/sqlcipher/i, "加密 SQLite"],
[/SwifterSwift/i, "Swift 工具库"],
[/OimeCommon/i, "输入法公共库"],
[/sentry/i, "Sentry 崩溃上报"],
[/bugly/i, "Bugly 崩溃 / 埋点"],
[/firebase/i, "Firebase 分析"],
[/crashlytics/i, "Crashlytics"],
[/mars/i, "mars 长链"],
[/mmkv/i, "MMKV 存储"],
];
function sdkNote(name: string): string {
for (const [re, note] of SDK_NOTES) if (re.test(name)) return note;
return "—";
}
function formatSize(n: number): string {
if (n < 1024) return `${n}B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)}K`;
if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)}M`;
return `${(n / 1024 / 1024 / 1024).toFixed(2)}G`;
}
function padLeft(s: string, w: number): string {
if (s.length >= w) return s;
return " ".repeat(w - s.length) + s;
}
function padRight(s: string, w: number): string {
if (s.length >= w) return s;
return s + " ".repeat(w - s.length);
}
function redactLiteral(s: string): string {
// 16+ consecutive digits → device id placeholder
return s.replace(/\d{12,}/g, "〈redacted〉");
}
function buildSdkRows(meta: Meta): string {
const top = (meta.frameworks || [])
.slice()
.sort((a, b) => b.size_bytes - a.size_bytes)
.slice(0, 6);
const sizeCol = Math.max(5, ...top.map((f) => formatSize(f.size_bytes).length));
const nameCol = Math.max(12, ...top.map((f) => f.name.length));
return top
.map((f) => {
const flag = PRIVACY_HOT.test(f.name) ? " ← RED" : "";
return `${padLeft(formatSize(f.size_bytes), sizeCol)} ${padRight(
f.name,
nameCol,
)} ${sdkNote(f.name)}${flag}`;
})
.join("\n");
}
// Endpoints are ranked by reverse-lookup match (endpoints.md) first, then by
// raw URL / domain frequency in strings/*. Domains that are clearly static /
// CDN / documentation / code-table noise are suppressed.
const ENDPOINT_NOISE = /\.(png|svg|jpg|jpeg|gif|css|js|html|ico|json|pdf)(?:$|[/?#])/i;
const DOCS_HOSTS = /(^|\.)(github\.com|apple\.com|w3\.org|ietf\.org|example\.com|mozilla\.org|openssl\.org|rfc-editor\.org|yaml\.org|www\.google\.com)(\/|$)/i;
// Hosts with a doubled TLD (e.g. `foo.com...bar.com`) are glued string-table
// artifacts, not real endpoints. Also reject any host with an uppercase letter
// between lowercase (classic camelCase leak from a symbol table).
const GLUED_HOST = /(?:\.com|\.org|\.net|\.io|\.cn)[^.]*\.(?:com|org|net|io|cn)(?:$|\/)/i;
const ENDPOINT_CN_PURPOSE: Record<string, string> = {
"endpoint.bytedance.ime": "每次按键云端候选",
"endpoint.bytedance.sami": "语音实时推流",
"endpoint.bytedance.applog": "埋点 / 行为日志",
"endpoint.bytedance.ttnet": "TTNet 长链配置",
"endpoint.bytedance.parfait": "APM 采样上报",
"endpoint.bytedance.frontier": "长链接 frontier",
"endpoint.bytedance.tnc": "TTNet 节点调度",
"endpoint.bytedance.crash": "崩溃上报",
"endpoint.tencent.mars": "mars 长链",
"endpoint.sentry": "崩溃上报",
"endpoint.firebase": "Firebase 分析",
"endpoint.bugly": "Bugly 崩溃上报",
"endpoint.umeng": "友盟统计",
"endpoint.sensorsdata": "神策埋点",
"endpoint.growingio": "GrowingIO 埋点",
};
function isKeystrokeLike(endpointLine: string): boolean {
return /ime|keystroke|convert|input-method/i.test(endpointLine);
}
function synthProto(host: string, explicit?: string): string {
if (explicit) return explicit.toUpperCase();
if (/quic/i.test(host)) return "QUIC";
if (/(^|[-.])ws([-.]|$)/i.test(host)) return "WSS";
return "HTTPS";
}
function isValidHost(host: string): boolean {
if (!host || !host.includes(".")) return false;
if (host.length > 80) return false;
if (!/^[a-z0-9.-]+$/.test(host)) return false;
if (host.startsWith(".") || host.endsWith(".")) return false;
if (host.includes("..")) return false;
// Reject obvious glued-string artifacts
if (GLUED_HOST.test(host)) return false;
if (DOCS_HOSTS.test(host)) return false;
return true;
}
function normalizeUrl(raw: string): { proto: string; host: string; path: string } | null {
const m = raw.match(/^(https?|wss?|quic):\/\/([^/\s]+)(\/[^\s"'<>]*)?/i);
if (!m) return null;
const [, proto, host, path] = m;
if (ENDPOINT_NOISE.test(raw)) return null;
const lower = host.toLowerCase();
if (!isValidHost(lower)) return null;
return {
proto: synthProto(lower, proto),
host: lower,
path: (path || "").replace(/[)>\]}]+$/, ""),
};
}
// Parse a bare host (no scheme) — used for endpoint evidence from domains.md.
function normalizeHost(raw: string): { proto: string; host: string; path: string } | null {
const cleaned = raw.trim().replace(/^`+|`+$/g, "");
const lower = cleaned.toLowerCase();
if (!isValidHost(lower)) return null;
return { proto: synthProto(lower), host: lower, path: "" };
}
async function collectUrls(stringsDir: string): Promise<string[]> {
const files = await readdir(stringsDir).catch(() => []);
const urls: string[] = [];
for (const f of files) {
if (!f.endsWith(".urls.md")) continue;
const text = await readFile(join(stringsDir, f), "utf8").catch(() => "");
for (const line of text.split("\n")) {
const m = line.match(/^- `(https?:\/\/[^`]+)`$/);
if (m) urls.push(m[1]);
}
}
return urls;
}
async function collectDomains(stringsDir: string): Promise<string[]> {
const files = await readdir(stringsDir).catch(() => []);
const hosts: string[] = [];
for (const f of files) {
if (!f.endsWith(".domains.md")) continue;
const text = await readFile(join(stringsDir, f), "utf8").catch(() => "");
for (const line of text.split("\n")) {
const m = line.match(/^- `([^`]+)`$/);
if (m) hosts.push(m[1]);
}
}
return hosts;
}
async function parseConfirmedEndpointMatches(
endpointsMdPath: string,
): Promise<Array<{ id: string; display: string }>> {
const text = await readFile(endpointsMdPath, "utf8").catch(() => "");
// rows look like: | endpoint.bytedance.ime | ime cloud convert | ... | **confirmed** |
const rows: Array<{ id: string; display: string }> = [];
for (const line of text.split("\n")) {
const m = line.match(
/^\|\s*(endpoint\.[\w.-]+)\s*\|\s*([^|]+?)\s*\|[^|]*\|[^|]*\|[^|]*\|\s*\*\*confirmed\*\*\s*\|/,
);
if (m) rows.push({ id: m[1].trim(), display: m[2].trim() });
}
return rows;
}
async function endpointEvidenceAll(
endpointsMdPath: string,
id: string,
): Promise<string[]> {
const text = await readFile(endpointsMdPath, "utf8").catch(() => "");
const sectionStart = text.indexOf(`### ${id}`);
if (sectionStart < 0) return [];
const after = text.slice(sectionStart);
const end = after.indexOf("\n### ");
const section = end < 0 ? after : after.slice(0, end);
const out: string[] = [];
for (const line of section.split("\n")) {
const m = line.match(/^- `[^`]+`: `([^`]+)`$/);
if (m) out.push(m[1]);
}
return out;
}
function purposeFor(
id: string,
englishDisplay: string,
): string {
return ENDPOINT_CN_PURPOSE[id] || englishDisplay || "—";
}
export async function buildEndpointRows(
endpointsMdPath: string,
stringsDir: string,
max = 6,
): Promise<string> {
const confirmed = await parseConfirmedEndpointMatches(endpointsMdPath);
const rows: Array<{
star: boolean;
proto: string;
url: string;
purpose: string;
}> = [];
const seenHosts = new Set<string>();
for (const m of confirmed) {
const evidence = await endpointEvidenceAll(endpointsMdPath, m.id);
const purpose = purposeFor(m.id, m.display);
for (const ev of evidence) {
if (rows.length >= max) break;
const norm = normalizeUrl(ev) || normalizeHost(ev);
if (!norm) continue;
const dedupKey = `${norm.host}${norm.path}`;
if (seenHosts.has(dedupKey)) continue;
seenHosts.add(dedupKey);
const keystroke =
isKeystrokeLike(m.id) || /\/convert\b|\/cloud\/|obric/i.test(norm.path);
rows.push({
star: keystroke,
proto: norm.proto,
url: norm.host + norm.path,
purpose,
});
}
if (rows.length >= max) break;
}
if (rows.length < max) {
const urls = await collectUrls(stringsDir);
for (const u of urls) {
if (rows.length >= max) break;
const norm = normalizeUrl(u);
if (!norm) continue;
if (seenHosts.has(norm.host)) continue;
seenHosts.add(norm.host);
const purpose = inferPurpose(norm.host);
if (purpose === "—") continue;
rows.push({
star: false,
proto: norm.proto,
url: norm.host + norm.path.slice(0, 40),
purpose,
});
}
}
if (rows.length < max) {
const hosts = await collectDomains(stringsDir);
for (const h of hosts) {
if (rows.length >= max) break;
const norm = normalizeHost(h);
if (!norm) continue;
if (seenHosts.has(norm.host)) continue;
seenHosts.add(norm.host);
const purpose = inferPurpose(norm.host);
if (purpose === "—") continue;
rows.push({ star: false, proto: norm.proto, url: norm.host, purpose });
}
}
if (rows.length === 0) return "(no endpoints observed in static strings)";
const protoCol = Math.max(5, ...rows.map((r) => r.proto.length));
const urlCol = Math.max(24, ...rows.map((r) => r.url.length));
return rows
.map((r) => {
const prefix = r.star ? "★ " : " ";
return `${prefix}${padRight(r.proto, protoCol)} ${padRight(
r.url,
urlCol,
)} → ${r.purpose}`;
})
.join("\n");
}
function inferPurpose(host: string): string {
if (/log|applog|snssdk|zijieapi|klink/i.test(host)) return "埋点 / 日志上报";
if (/crash|bugly|sentry|crashlytics/i.test(host)) return "崩溃上报";
if (/frontier|sami|speech|audio/i.test(host)) return "语音 / 音频";
if (/ime|convert|input/i.test(host)) return "输入法云端";
if (/firebase|analytics|umeng|sensorsdata|growingio/i.test(host)) return "用户分析";
if (/push|jpush|getui/i.test(host)) return "推送";
if (/cdn|static/i.test(host)) return "静态资源";
return "—";
}
// Local-data rows are picked by pattern priority: Parfait, alog, MMKV, sqlite,
// ttnet/server.json, other. Device ids in paths are redacted.
const LOCAL_PRIORITY: Array<[RegExp, string]> = [
[/Parfait\/.*pftconfig/i, "APM 配置"],
[/Parfait\/ready\//i, "APM 待上传队列"],
[/alog\/(cache|log)\//i, "ALog 压缩日志"],
[/ttnet\/server\.json/i, "TTNet 长轮询配置"],
[/ttnet\/.*\.config|ttnet\/version/i, "TTNet 配置"],
[/MMKV\//i, "MMKV 存储"],
[/Recorder\/.*asrHistory/i, "ASR 录音历史"],
[/Cache\.db(-wal|-shm)?$/i, "HTTP 缓存"],
[/Crash\//i, "崩溃日志"],
[/sqlcipher|\.db$/i, "SQLite"],
[/\.plist$/i, "偏好"],
[/Containers\/.*\/Data/i, "沙盒容器"],
];
interface LocalRow {
size: string;
kind: string;
path: string;
note: string;
priority: number;
}
function classifyLocal(filePath: string): { kind: string; note: string; priority: number } {
for (let i = 0; i < LOCAL_PRIORITY.length; i++) {
const [re, note] = LOCAL_PRIORITY[i];
if (re.test(filePath)) {
if (/pftconfig/i.test(filePath)) return { kind: "cfg", note, priority: i };
if (/alog/i.test(filePath)) return { kind: "log", note, priority: i };
if (/\.json$/i.test(filePath)) return { kind: "json", note, priority: i };
if (/\.db$/i.test(filePath)) return { kind: "sqlite", note, priority: i };
if (/mmkv/i.test(filePath)) return { kind: "mmkv", note, priority: i };
return { kind: "other", note, priority: i };
}
}
return { kind: "other", note: "—", priority: 99 };
}
export async function buildLocalRows(
localMdPath: string,
homeDir: string,
max = 5,
): Promise<string> {
const text = await readFile(localMdPath, "utf8").catch(() => "");
// Parse lines like: | 1.0K | cfg | `foo.pftconfig` |
const rows: LocalRow[] = [];
let currentDir = "";
for (const line of text.split("\n")) {
// Directory headings can contain spaces (e.g. "Application Support"), so
// match to end-of-line, not \S.
const dirMatch = line.match(/^##\s+(~\/.+|\/.+)\s*$/);
if (dirMatch) {
currentDir = dirMatch[1].trim();
continue;
}
const fileMatch = line.match(/^\|\s*([\d.]+[BKMG])\s*\|\s*(\w+)\s*\|\s*`([^`]+)`\s*\|/);
if (!fileMatch || !currentDir) continue;
const [, size, , name] = fileMatch;
const fullPath = `${currentDir}/${name}`;
const { kind, note, priority } = classifyLocal(fullPath);
const displayPath = shortenPath(fullPath);
rows.push({ size, kind, path: redactLiteral(displayPath), note, priority });
}
// Collapse group = priority bucket + parent dir. Files in the *same* bucket
// sharing a parent directory collapse into one row with `(×N)`. Keep the
// larger size as the representative.
const grouped = new Map<
string,
LocalRow & { count: number; totalBytes: number }
>();
for (const r of rows) {
// Group by (note, top-2 notable segments) so that both casings of the app
// dir (DoubaoIme vs doubaoime) and related subdirs (alog/cache + alog/log,
// ttnet/server.json + ttnet/version) collapse into one representative row.
const key = `${r.note}:${groupKey(r.path)}`;
const bytes = parseSizeDesc(r.size);
const prev = grouped.get(key);
if (prev) {
prev.count++;
prev.totalBytes += bytes;
if (bytes > parseSizeDesc(prev.size)) {
prev.size = r.size;
prev.path = r.path;
prev.kind = r.kind;
}
} else {
grouped.set(key, { ...r, count: 1, totalBytes: bytes });
}
}
const collapsed = [...grouped.values()]
.sort((a, b) => a.priority - b.priority || b.totalBytes - a.totalBytes)
.slice(0, max)
.map((r) => ({
...r,
path: r.count > 1 ? `${pathTrunk(r.path)}/* (×${r.count})` : r.path,
}));
if (collapsed.length === 0) return "(no on-disk surface observed)";
const sizeCol = Math.max(4, ...collapsed.map((r) => r.size.length));
const kindCol = Math.max(5, ...collapsed.map((r) => r.kind.length));
const pathCol = Math.max(30, ...collapsed.map((r) => r.path.length));
return collapsed
.map(
(r) =>
`${padLeft(r.size, sizeCol)} ${padRight(r.kind, kindCol)} ${padRight(
r.path,
pathCol,
)} ${r.note}`,
)
.join("\n");
}
function shortenPath(p: string): string {
// Shorten `/Users/xxx/Library/Application Support/App/Parfait/...` to
// `~/…/App/Parfait/...` so one line fits in a card row.
const m = p.match(/Application Support\/([^/]+)\/(.+)$/);
if (m) return `~/…/${m[1]}/${m[2]}`;
const m2 = p.match(/~\/Library\/(?:Application Support|Caches|Logs|Preferences)\/(.+)$/);
if (m2) return `~/…/${m2[1]}`;
return p;
}
function pathTrunk(p: string): string {
// Strip the filename; keep dir. Used for collapsing repeated filenames.
const i = p.lastIndexOf("/");
return i < 0 ? p : p.slice(0, i);
}
function groupKey(p: string): string {
// Canonical bucket: lowercase, strip `~/…/`, drop the filename, then keep
// only the first 2 directory segments (so we collapse sibling files in the
// same dir and related subdirs). Examples:
// `~/…/DoubaoIme/Log/alog/cache/foo.alog.cache` → `log/alog`
// `~/…/DoubaoIme/Log/alog/log/bar.alog.hot` → `log/alog`
// `~/…/DoubaoIme/ttnet/tt_net_config.config` → `ttnet`
// `~/…/DoubaoIme/ttnet/version` → `ttnet`
const stripped = p.replace(/^~\/…\//, "").toLowerCase();
const parts = stripped.split("/");
if (parts.length <= 1) return stripped;
const dirs = parts.slice(0, -1); // drop filename
const afterApp = dirs.slice(1); // drop app-name segment
return afterApp.slice(0, 2).join("/");
}
function parseSizeDesc(s: string): number {
const m = s.match(/^([\d.]+)([BKMG])$/);
if (!m) return 0;
const n = parseFloat(m[1]);
const unit = { B: 1, K: 1024, M: 1024 * 1024, G: 1024 * 1024 * 1024 }[m[2]] || 1;
return n * unit;
}
export interface CardInputs {
meta: Meta;
endpointsMd: string;
localMd: string;
stringsDir: string;
templatePath: string;
homeDir: string;
}
export async function renderCardPrompt(i: CardInputs): Promise<string> {
const tpl = await readFile(i.templatePath, "utf8");
const sdkRows = buildSdkRows(i.meta);
const endpointRows = await buildEndpointRows(i.endpointsMd, i.stringsDir);
const localRows = await buildLocalRows(i.localMd, i.homeDir);
const version = [i.meta.short_version, i.meta.version]
.filter((x) => x && String(x).trim() !== "")
.join(" ")
.trim();
const appDisplay = guessAppName(i.meta);
return tpl
.replaceAll("{{bundle_id}}", i.meta.bundle_id || "unknown")
.replaceAll("{{version}}", version || "unknown")
.replaceAll("{{sandboxed}}", String(i.meta.sandboxed ?? "unknown"))
.replaceAll(
"{{ats}}",
i.meta.arbitrary_loads === true ? "arbitrary" : i.meta.arbitrary_loads === false ? "strict" : "unknown",
)
.replaceAll("{{app_display_name}}", appDisplay)
.replaceAll("{{sdk_rows}}", sdkRows)
.replaceAll("{{endpoint_rows}}", endpointRows)
.replaceAll("{{local_rows}}", localRows);
}
function guessAppName(meta: Meta): string {
const exe = meta.main_executable ? basename(meta.main_executable) : "";
if (/doubao/i.test(exe)) return "豆包输入法";
if (/wetype/i.test(exe)) return "微信输入法";
if (/wechat|weixin/i.test(exe)) return "微信";
if (/dingding|dingtalk/i.test(exe)) return "钉钉";
return exe || meta.bundle_id || "未知 App";
}
// Whitelisted command runner. Anything not in ALLOWED is rejected.
// Rationale: skill is read-only by contract; this is the enforcement layer.
import { spawn } from "node:child_process";
const ALLOWED = new Set([
"strings",
"nm",
"otool",
"codesign",
"plutil",
"file",
"find",
"du",
"sqlite3",
"xxd",
"stat",
"head",
"wc",
]);
export interface RunResult {
ok: boolean;
code: number;
stdout: string;
stderr: string;
cmd: string;
}
export interface RunOpts {
// Maximum bytes of stdout to capture. Anything beyond is truncated.
maxBytes?: number;
// Wall-clock timeout in ms.
timeoutMs?: number;
// If true, do not throw on non-zero exit.
allowNonZero?: boolean;
}
export async function run(
cmd: string,
args: string[],
opts: RunOpts = {},
): Promise<RunResult> {
if (!ALLOWED.has(cmd)) {
throw new Error(
`[shell] command not in whitelist: ${cmd}. ` +
`Allowed: ${[...ALLOWED].join(", ")}. ` +
`If you genuinely need this, edit lib/shell.ts deliberately.`,
);
}
const maxBytes = opts.maxBytes ?? 50 * 1024 * 1024;
const timeoutMs = opts.timeoutMs ?? 60_000;
return new Promise((resolve, reject) => {
const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
let outLen = 0;
let errLen = 0;
const outChunks: Buffer[] = [];
const errChunks: Buffer[] = [];
let truncated = false;
const killTimer = setTimeout(() => {
child.kill("SIGKILL");
}, timeoutMs);
child.stdout.on("data", (b: Buffer) => {
if (outLen + b.length > maxBytes) {
const room = Math.max(0, maxBytes - outLen);
if (room > 0) outChunks.push(b.subarray(0, room));
outLen = maxBytes;
truncated = true;
// do not kill yet — let it drain naturally; just stop accumulating.
} else {
outChunks.push(b);
outLen += b.length;
}
});
child.stderr.on("data", (b: Buffer) => {
if (errLen + b.length <= 2 * 1024 * 1024) {
errChunks.push(b);
errLen += b.length;
}
});
child.on("error", (e) => {
clearTimeout(killTimer);
reject(e);
});
child.on("close", (code) => {
clearTimeout(killTimer);
const stdout =
Buffer.concat(outChunks).toString("utf8") +
(truncated ? `\n[truncated at ${maxBytes} bytes]` : "");
const stderr = Buffer.concat(errChunks).toString("utf8");
const result: RunResult = {
ok: code === 0,
code: code ?? -1,
stdout,
stderr,
cmd: `${cmd} ${args.join(" ")}`,
};
if (!result.ok && !opts.allowNonZero) {
reject(
new Error(
`[shell] ${result.cmd} exited ${code}\nstderr: ${stderr.slice(0, 4000)}`,
),
);
} else {
resolve(result);
}
});
});
}
// Convenience: stream `strings -a -n 6 <file>` line by line into a callback.
// Used by classify_strings.ts to avoid loading 50MB into memory at once.
export async function streamStrings(
filePath: string,
onLine: (line: string) => void,
opts: { minLen?: number; timeoutMs?: number } = {},
): Promise<void> {
const minLen = opts.minLen ?? 6;
return new Promise((resolve, reject) => {
const child = spawn("strings", ["-a", "-n", String(minLen), filePath], {
stdio: ["ignore", "pipe", "pipe"],
});
const killTimer = setTimeout(
() => child.kill("SIGKILL"),
opts.timeoutMs ?? 120_000,
);
let leftover = "";
child.stdout.on("data", (b: Buffer) => {
const text = leftover + b.toString("utf8");
const lines = text.split("\n");
leftover = lines.pop() ?? "";
for (const line of lines) onLine(line);
});
child.on("error", (e) => {
clearTimeout(killTimer);
reject(e);
});
child.on("close", () => {
clearTimeout(killTimer);
if (leftover) onLine(leftover);
resolve();
});
});
}
// Helpers for path safety used by Phase 0.
export function rejectSystemPath(p: string): string | null {
const denied = [
"/System/",
"/usr/libexec/",
"/private/var/db/com.apple.",
"/usr/bin/",
"/usr/sbin/",
];
for (const prefix of denied) {
if (p.startsWith(prefix) || p === prefix.slice(0, -1)) {
return `path under ${prefix} is OS-owned, not a third-party telemetry target`;
}
}
return null;
}
Local data locations — where macOS apps actually write to
Phase 5 (inventory_data.ts) walks these roots automatically. This document explains what to look for once the file list is in front of you.
Standard macOS roots
| Root | Owned by | What to expect |
|---|---|---|
~/Library/Application Support/<bundle>/ | the app | Persistent state: SQLite DBs, ONNX models, KV stores, embedded user dictionaries |
~/Library/Containers/<bundle>/Data/ | sandboxed apps | Same as above, but constrained — sandboxed apps put everything here |
~/Library/Group Containers/group.<vendor>.<...>/ | shared between bundles from the same vendor | Cross-app shared state. High-signal for vendor-wide trackers (Google, Tencent, ByteDance). |
~/Library/Caches/<bundle>/ | the app | Outbound queues, downloaded configs, image caches |
~/Library/Logs/<bundle>/ | the app | Log files, sometimes the very telemetry payloads pre-upload |
~/Library/Preferences/<bundle>.plist | macOS prefs system | Settings + sometimes anonymous IDs |
Per-vendor patterns to recognize
ByteDance stack
Parfait/settings/<aid>/<channel>/<region>/<...>/pftconfig— sampling intervals as JSONParfait/ready/<aid>/0/— pre-upload batch directory (each file is a payload waiting to ship)ttnet/server.json— TTNet host poolapplog/<aid>/...— AppLog cached events<aid>is the ByteDance app id; you can pin which product by looking it up in the matched fingerprints
Tencent stack
MMKV/directory full of*.mmkv+*.crcpairsmars/directory with binary log files<bundle>.tdbSQLite databases (Tencent message store)cgi-bin/cached config files
Sentry / Firebase
Sentry/orio.sentry.<project>/envelope files (one file = one crash event waiting to upload)com.google.firebase.installations/— the FID and refresh tokencom.crashlytics.data.<bundle>/— Crashlytics scratch
Generic analytics
- Look for any SQLite called
events.db,analytics.db,tracker.db,cache_log.db,task_log.db,report.db— they almost universally hold the upload queue - Look for any plain
.jsonfile under Caches with names likepending_*.json,queue_*.json
What to do once you see a candidate
- SQLite: open with
sqlite3 <file> .schema(already done by the script). Read the table names. The schema usually telegraphs the data model. Names likeLOG_CONTENT,BODY,PAYLOAD,EVENT_NAMEare the actual queue. - MMKV: do not open. Note the size and path only. MMKV files often contain user identifiers.
- JSON config: if it's clearly a config (small, top-level keys are config-like), you may quote a redacted excerpt. If it's a data payload, treat like MMKV — list size only.
- Logs: do not paste log contents into the report. Note the directory path and date range only. They frequently contain the exact strings being uploaded.
Red flags worth calling out in the final report
- A
device_idthat looks like the same shape as ByteDance / WeChat unified ids (16-19 digits all-numeric) → likely cross-app correlation - Any file under a Group Container shared with a different bundle → interpret as "this app is part of a larger vendor ecosystem and shares state"
- A
pending/ready/queuedirectory that has files older than 24 hours → upload may be failing or rate-limited; user might want to know
Known endpoints — domain → product reverse lookup
Same fingerprint format as sdk_fingerprints.md. Used by match_fingerprints.ts during Phase 4 to label which discovered URLs belong to known products.
The goal is labeling, not detection — so min_hits is usually 1 and the regex is tight.
---
endpoint.bytedance.applog — log-klink (AppLog ingest)
- vendor: ByteDance
- tell-tale:
- "log-klink\\.zijieapi\\.com"
- "log\\.snssdk\\.com"
- "applog\\.byteoversea\\.com"
- min_hits: 1
- notes: TEA / AppLog batched event ingest.
endpoint.bytedance.ime — ime cloud convert
- vendor: ByteDance
- tell-tale:
- "ime\\.doubao\\.com"
- "obric/ime/cloud/convert"
- "ime-gw\\.oceancloudapi\\.com"
- min_hits: 1
- notes: Per-keystroke cloud conversion / cloud candidates endpoint.
endpoint.bytedance.sami — speech / sami
- vendor: ByteDance
- tell-tale:
- "speech\\.bytedance\\.com"
- "frontier-audio"
- "sami-api"
- min_hits: 1
- notes: Speech recognition / synthesis.
endpoint.tencent.wetype — WeType (微信输入法)
- vendor: Tencent
- tell-tale:
- "wetype\\.weixin\\.qq\\.com"
- "wetype\\.qq\\.com"
- min_hits: 1
- notes: WeChat IME report + cloud candidates.
endpoint.tencent.bugly — Bugly
- vendor: Tencent
- tell-tale:
- "bugly\\.qq\\.com"
- "android\\.bugly\\.qq\\.com"
- min_hits: 1
- notes: Crash + perf reporting.
endpoint.tencent.beacon — Beacon
- vendor: Tencent
- tell-tale:
- "beacon\\.qq\\.com"
- "beacon\\.tencent\\.com"
- min_hits: 1
- notes: Tencent unified analytics.
endpoint.sentry — Sentry ingest
- vendor: Sentry
- tell-tale:
- "ingest\\.sentry\\.io"
- "o\\d+\\.ingest\\.sentry\\.io"
- min_hits: 1
- notes: Project-scoped DSN host.
endpoint.firebase — Firebase / Crashlytics
- vendor: Google
- tell-tale:
- "firebaseio\\.com"
- "firebaseinstallations\\.googleapis\\.com"
- "firebaselogging\\."
- "crashlyticsreports-pa\\.googleapis\\.com"
- min_hits: 1
- notes: Multiple subdomains depending on product.
endpoint.google.analytics — Google Analytics / GA4
- vendor: Google
- tell-tale:
- "google-analytics\\.com"
- "analytics\\.google\\.com"
- "app-measurement\\.com"
- min_hits: 1
- notes: GA / Firebase Analytics ingest.
endpoint.umeng — Umeng
- vendor: 友盟
- tell-tale:
- "umeng\\.com"
- "ucs\\.umeng\\.com"
- "alog\\.umeng\\.com"
- min_hits: 1
- notes: Umeng analytics ingest.
endpoint.sensors — Sensors Analytics
- vendor: SensorsData
- tell-tale:
- "sensorsdata\\.cn"
- "datasink"
- min_hits: 1
- notes: Tenant-scoped subdomain typically.
endpoint.growingio — GrowingIO
- vendor: GrowingIO
- tell-tale:
- "growingio\\.com"
- "gio-api"
- min_hits: 1
- notes:
endpoint.adjust — Adjust
- vendor: Adjust
- tell-tale:
- "app\\.adjust\\.com"
- "adjust\\.world"
- min_hits: 1
- notes:
endpoint.appsflyer — AppsFlyer
- vendor: AppsFlyer
- tell-tale:
- "appsflyer\\.com"
- "events\\.appsflyer\\.com"
- min_hits: 1
- notes:
endpoint.datadog — Datadog
- vendor: Datadog
- tell-tale:
- "datadoghq\\.com"
- "datadoghq\\.eu"
- "browser-intake-datadoghq"
- min_hits: 1
- notes:
endpoint.jpush — JPush
- vendor: 极光
- tell-tale:
- "jpush\\.cn"
- "jiguang\\.cn"
- min_hits: 1
- notes:
endpoint.bytedance.maliva — Maliva (overseas)
- vendor: ByteDance
- tell-tale:
- "tnc[0-9]?-pacific\\.snssdk\\.com"
- "maliva-mssdk"
- "rtcmaliva"
- min_hits: 1
- notes: ByteDance overseas-region ingest hosts.
Methodology examples — two worked audits
These are the manual reverse-engineering passes that motivated this skill. Read them as ground truth: when you run the skill on these same apps, the generated report should cover the same material. If it doesn't, the fingerprint library or the workflow is wrong, not the case study.
Both examples use only static analysis: strings, nm, otool, codesign, plutil, and reading files under ~/Library/Application Support/. No packet capture, no debugger attach.
---
Example 1 — WeType (微信输入法 macOS)
What was found
Encrypted strategy file
- Bundle path holds a file at
business/ccc/2/aaa(1848 bytes), encrypted with AES-256-ECB. - The decryption key is the MD5 of the plaintext, embedded inside the ciphertext itself: 4 × 8 bytes scattered, located by an 8-byte footer that holds 4 big-endian uint16 offsets.
- After decryption: a
BusLogConfigRspprotobuf with two key fields: instantReports(field 1) — a list of ~58 event names that bypass batching and report immediately. Includeswt_stat_user_info,wt_stat_userid_record,wt_stat_user_devicetoken_info,wt_full_access_stat,wt_mac_usage_monitor,wt_device_ui_info,wt_stat_keyboard_ai_chat_action_report,wt_voice_input_content_error,wt_stat_clipboard_action_report.report_interval(field 2) —180000msfor everything else.- Symbol that loads it:
ReportApi::LoadStrategyFromFileinbusiness/report/report_api.cpp.
Pending upload queue
- Found at
business/ccc/2/bbb— a SQLite DB with two tables:CACHE_LOG_TBL(ID, LOG_CONTENT, LOG_COUNT)andTASK_LOG_TBL(... RETRY_COUNT). - Each
LOG_CONTENTrow is<event_name>,<base64(protobuf)>. - A typical pending row decoded:
event = wt_stat_candidate_user_click_reportapp_version = 2.0.0(570)session_id = EC8C1E72-AB74-409E-BD1C-BDB2D28848CB_xxx_27user_uin = 1282- `host_app = "com.cmuxterm.app"` ← the headline. The IME records which app you were typing into.
keyboard = "128&128&0&128&0&0&128&0"(key-press distribution)experiments = [{layer:11291919, group:0, expt:0, hit:2}, ...]candidates = [{idx, user_dict_source, cloud_source, typo}, ...]selected = [{user_dict_source, cloud_source}]
Upload path
https://wetype.weixin.qq.comover QUIC (Cronet) with mars long-link fallback.
What the audit report should contain
- Frameworks list including
mars,MMKV,Cronet,WCDB(or similar SQLite wrapper) - Endpoints table including
wetype.weixin.qq.com - Local data table including the
business/ccc/2/...directory +CACHE_LOG_TBLschema - A privacy-section bullet calling out
host_appcross-process tracking report_interval=180000msquoted from the strategy file as the only frequency claim (because that is the only literal source for it)
---
Example 2 — DoubaoIme (豆包输入法 macOS)
What was found
Per-keystroke pipeline (the headline) 1. Local pinyin segmentation + dictionary lookup via OimeEngine.framework (~13.5 MB). 2. ONNX MLP rerank of local candidates: mlp_no_gpt2.onnx via onnxruntime 1.16.2. 3. Build a cloud_convert protobuf with fields input, context_segment, TouchPos, Features, KeyCorInfos. Including the cursor's surrounding text + touch coordinates + client ML features. 4. POST to https://ime.doubao.com/obric/ime/cloud/convert over QUIC. 5. Cloud response → second ONNX rerank: mlp_for_cloud.onnx. 6. Insert a tea_event into the local AppLog queue (libapplogrs.dylib, Rust SDK).
Background pipelines (always running, even when you're not typing)
- Parfait APM — samples CPU/memory/disk-info/disk-IO every 5s, batches every 30s. Source:
Parfait/settings/685343/.../pftconfig. - AppLog (TEA) — event queue flushes every 60s to
log-klink.zijieapi.com(gzip + tt-data encryption). - ime-net-sdk — Rust QUIC long link, keepalive against
ime-gw.oceancloudapi.com:443. - SAMI WebSocket — voice input only:
speech.bytedance.com+frontier-audioQUIC.
Local data
- Pre-upload batches at
~/Library/Application Support/DoubaoIme/Parfait/ready/685343/0/. device_id = 3393558419100996— same shape as the IDs used by Douyin / CapCut, suggesting cross-product correlation.- App is not sandboxed (
app-sandbox = false),NSAllowsArbitraryLoads = true,audio-input = true. - Loaded frameworks:
applogrs · ime_net_sdk · bytenn · onnxruntime · sscronet · TTNet · Parfait · sqlcipher.
What the audit report should contain
- Frameworks list including the 8 above
- Endpoints table covering at least 3 of:
ime.doubao.com/obric/ime/cloud/convert,log-klink.zijieapi.com,ime-gw.oceancloudapi.com,speech.bytedance.com - Frequency citations: 5s/30s for Parfait (sourced from
pftconfig), 60s for AppLog batch (sourced from strings/config — DO NOT invent a number if it isn't there) - Local data table including the
Parfait/ready/685343/0/queue - A privacy-section bullet covering: per-keystroke cloud upload + cursor context + cross-product
device_id - App attributes:
sandboxed=false,arbitrary_loads=trueflagged
---
How to use these as a self-check
After running scripts/run.ts /Library/Input\ Methods/DoubaoIme.app, manually check that:
matched.mdlistsbytedance.applog,bytedance.parfait,bytedance.ttnet,bytedance.imenet,bytedance.sami,onnxruntime.engineas confirmedendpoints.mdlists at least 3 of the endpoint fingerprints from this caselocal_data.mdincludes a row under~/Library/Application Support/DoubaoIme/Parfait/ready/- The final
report.mddoes not invent any field that is not traceable to anevidenceline inmatched.mdor a path inlocal_data.md
If any of these are missing, the gap is in the fingerprint library or in the classification regexes — fix those, not the report.
Safe commands — whitelist + rationale
This file is the contract that makes the skill safe to give to Claude. The runner (lib/shell.ts) enforces the whitelist programmatically. This document explains each entry and the reasoning, so a future maintainer can decide whether to expand it.
Whitelist
| Command | Used for | Why it's safe |
|---|---|---|
strings | Phase 2 string extraction | Read-only. Pure data on the binary's raw bytes. |
nm | Symbol enumeration (future) | Read-only. Walks Mach-O symbol tables. |
otool | Linked libs, Mach-O headers | Read-only. Apple's tool, no side effects. |
codesign -d | Signing identity, entitlements | Verification mode (-d). Does not modify signatures. |
plutil -p | Pretty-print Info.plist | Read-only. The -c modes (convert) are blocked by argument inspection in lib/shell.ts callers. |
file | Architecture / file type | Read-only. |
find | Walk the bundle and the user's Library | Read-only when used without -delete / -exec. Callers always pass -type filters and -maxdepth. |
du | Sizes (rare; usually stat() instead) | Read-only. |
sqlite3 <file> '.schema' and '.tables' | Phase 5 schema dump | Read-only meta-commands. Never SELECT / INSERT / ATTACH. |
xxd -l <N> | Hex peek of small file headers | Capped at 4 KB. Read-only. |
stat | File metadata | Read-only. |
head / wc | Trim large outputs | Read-only. |
Forbidden — and the reason
| Forbidden | Reason |
|---|---|
curl, wget, nc | Skill is not allowed to make network requests against discovered endpoints. That would change the audit from "static" to "active." |
dig, host, nslookup | Same as above — DNS lookups are network requests that reveal you to the target's infra. |
lldb, gdb | Debugger attach is dynamic instrumentation. Not in scope. |
dtrace, fs_usage, tcpdump | System-wide tracing. Out of scope and requires elevated privileges. |
mitmproxy, frida, gum | Active interception. Out of scope. |
sudo, security find-generic-password | Keychain access. Out of scope. |
chmod, chown, mv, rm, cp | This skill never modifies files belonging to the target. |
Any .app/Contents/MacOS/<binary> invocation | We never run the target. Static analysis only. |
What to do if you hit a wall
If you genuinely think a step requires a non-whitelisted command:
1. Stop. Do not improvise. 2. Tell the user what you wanted to do and why the whitelist blocks it. 3. Suggest what they can do manually (e.g., "run tcpdump outside of this skill if you want to see live traffic; this skill is intentionally static").
The whitelist is enforced in lib/shell.ts::ALLOWED. Editing it should be a deliberate, reviewed change.
Privacy redaction
Independent of the command whitelist, every output file written by this skill must scrub the following before disk write:
- 16+ char hex blobs →
<redacted:hexN> - Email addresses →
<redacted:email> - JWTs (
eyJ...three dots) →<redacted:jwt> device_id,uid,user_id,session_idvalues →<redacted:idN>(keep length)
The current scripts perform best-effort redaction inside classify_strings.ts (by never storing key-shaped strings verbatim). When Claude writes the endpoint table or final report by hand, Claude must apply the same rule.
SDK fingerprints
Each section below is one fingerprint. match_fingerprints.ts parses this file and applies the regexes against the bucketed strings produced by Phase 2.
Format (strict, do not deviate):
<id> — <Display Name>
- vendor: <vendor>
- dylib: <regex over binary file name, optional>
- tell-tale:
- "<regex>"
- "<regex>"
- min_hits: <int> # how many distinct tell-tale regexes must match
- notes: <one-line free text>
Notes:
min_hitsshould be small but not 1 unless the regex is highly specific.
Use 2 for SDKs whose name appears as a single string (e.g. Sentry).
- Regexes are case-insensitive. Escape
.and/if you want literal. - Only put regexes that are unlikely to false-positive in unrelated apps.
---
bytedance.applog — AppLog / TEA
- vendor: ByteDance
- dylib: applogrs|applog
- tell-tale:
- "libapplogrs"
- "tea_event"
- "log[_-]?snssdk"
- "log-klink"
- "zijieapi"
- "applog/v3"
- "tobtech\\.com"
- min_hits: 1
- notes: ByteDance internal analytics. Per-event queue, gzip + tt-data encryption, batch every 60s typical. Rust .rodata often glues tokens, so substring matches are intentional.
bytedance.parfait — Parfait APM
- vendor: ByteDance
- dylib: ^Parfait$
- tell-tale:
- "Parfait"
- "pftconfig"
- "parfait_settings"
- "Parfait/ready/"
- "pft_event"
- min_hits: 1
- notes: APM. Samples CPU/memory/IO at ~5s, batches at ~30s. Settings file dictates intervals. Embedded as
Parfait.framework, so dylib match alone is strong evidence.
bytedance.ttnet — TTNet / Cronet
- vendor: ByteDance
- dylib: sscronet|ttnet
- tell-tale:
- "libsscronet"
- "ttnet"
- "bytenetwork"
- "ttnet_dispatcher"
- "ttnetdownloader"
- "TTNetDownloader"
- min_hits: 1
- notes: Networking layer (Cronet fork). Long-poll config from
server.json. Multi-binary footprint, dylib match alone is strong.
bytedance.bytenn — bytenn / on-device inference
- vendor: ByteDance
- tell-tale:
- "bytenn"
- "BytennEngine"
- "libbytenn"
- min_hits: 1
- notes: ML inference runtime. Pairs with onnxruntime in IME / camera apps.
bytedance.alog — ALog
- vendor: ByteDance
- tell-tale:
- "alog2"
- "ALogManager"
- "libalog"
- min_hits: 1
- notes: Internal log SDK, compressed local logs uploaded with AppLog.
bytedance.imenet — ime-net-sdk
- vendor: ByteDance
- tell-tale:
- "ime[_-]net[_-]sdk"
- "ime-gw\\.oceancloudapi\\.com"
- "obric/ime/"
- min_hits: 1
- notes: Rust QUIC long-link used by Doubao input method; carries cloud convert / candidates.
bytedance.sami — SAMI / Speech
- vendor: ByteDance
- tell-tale:
- "speech\\.bytedance\\.com"
- "frontier-audio"
- "samiapi"
- "sami_ws"
- min_hits: 1
- notes: Speech / voice SDK. WebSocket + QUIC, on by voice input.
tencent.mars — mars long-link
- vendor: Tencent
- tell-tale:
- "mars::stn"
- "marsservice"
- "longlink"
- "MarsServiceProxy"
- min_hits: 2
- notes: Tencent's long-poll/long-link library. Ubiquitous across WeChat-family apps.
tencent.mmkv — MMKV
- vendor: Tencent
- tell-tale:
- "MMKV"
- "mmkv_storage"
- "MMKVNamespace"
- min_hits: 1
- notes: Mmap key-value store. Inspect on disk via Phase 5 file enumeration only.
tencent.bugly — Bugly
- vendor: Tencent
- tell-tale:
- "Bugly"
- "bugly\\.qq\\.com"
- "BuglyCrash"
- min_hits: 1
- notes: Tencent crash + telemetry SDK. Endpoint usually bugly.qq.com.
sentry.native — Sentry native
- vendor: Sentry
- tell-tale:
- "sentry-native"
- "sentry\\.envelope"
- "ingest\\.sentry\\.io"
- "SENTRY_DSN"
- min_hits: 2
- notes: Crash + envelope reporting. DSN URL holds the project id.
firebase.crashlytics — Firebase / Crashlytics
- vendor: Google
- tell-tale:
- "FIRApp"
- "crashlytics"
- "firebaseio\\.com"
- "firebaseinstallations"
- min_hits: 2
- notes: Google's mobile / desktop telemetry. Crashlytics + Analytics share SDK.
umeng.analytics — Umeng (友盟)
- vendor: 友盟
- tell-tale:
- "UMConfigure"
- "umeng\\.com"
- "ucs\\.umeng\\.com"
- "cnzz"
- min_hits: 1
- notes: Mostly Chinese-market analytics SDK.
sensors.analytics — Sensors Analytics (神策)
- vendor: SensorsData
- tell-tale:
- "SensorsAnalytics"
- "SensorsData"
- "sensorsdata\\.cn"
- min_hits: 1
- notes: Customer behavior analytics; common in Chinese SaaS.
growingio.sdk — GrowingIO
- vendor: GrowingIO
- tell-tale:
- "GrowingIO"
- "growingio\\.com"
- "GrowingTracker"
- min_hits: 1
- notes: User-action analytics / experimentation.
jpush.sdk — JPush (极光)
- vendor: 极光
- tell-tale:
- "JPushService"
- "jpush\\.cn"
- "JCore"
- min_hits: 1
- notes: Push + accompanying analytics. Often bundled with JAnalytics.
adjust.sdk — Adjust
- vendor: Adjust
- dylib: ^Adjust$|AdjustSdk
- tell-tale:
- "AdjustSdk"
- "AdjustEvent"
- "AdjustConfig"
- "app\\.adjust\\.com"
- "Adjust\\.framework"
- min_hits: 1
- notes: Mobile attribution. Bare token "Adjust" is too generic (matches
loudness_adjust,adjust_rtt); require SDK-shaped identifiers.
appsflyer.sdk — AppsFlyer
- vendor: AppsFlyer
- tell-tale:
- "AppsFlyer"
- "appsflyer\\.com"
- "AFSDKConst"
- min_hits: 2
- notes: Attribution SDK.
datadog.rum — Datadog RUM
- vendor: Datadog
- tell-tale:
- "DatadogCore"
- "datadoghq\\."
- "ddtrace"
- "rum-applications"
- min_hits: 2
- notes: Real user monitoring. Usually US/EU SaaS apps.
onnxruntime.engine — ONNX Runtime
- vendor: Microsoft (LF AI)
- tell-tale:
- "onnxruntime"
- "Ort::Session"
- "OrtAllocator"
- min_hits: 1
- notes: Not telemetry on its own, but a strong indicator the app runs on-device ML inference. Useful context for IME / camera / voice apps.
#!/usr/bin/env bun
// Phase 2: For each binary in meta.json, run `strings`, sort lines into buckets.
// Buckets: urls, domains, paths, sql, events, keys (counts only).
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join, basename } from "node:path";
import { streamStrings } from "../lib/shell.ts";
interface FrameworkEntry {
name: string;
path: string;
size_bytes: number;
}
interface Meta {
bundle_id: string;
frameworks: FrameworkEntry[];
main_executable: string | null;
}
interface Buckets {
urls: Set<string>;
domains: Set<string>;
paths: Set<string>;
sql: Set<string>;
events: Set<string>;
keysCount: number;
keysPreview: Set<string>;
}
function emptyBuckets(): Buckets {
return {
urls: new Set(),
domains: new Set(),
paths: new Set(),
sql: new Set(),
events: new Set(),
keysCount: 0,
keysPreview: new Set(),
};
}
const RE_URL_G = /https?:\/\/[^\s"'<>\x00-\x1f]{4,200}/gi;
// Real TLDs only — multi-part domains like log-klink.zijieapi.com are caught
// by the {1,4} subdomain repeat plus the actual TLD at the end.
const RE_DOMAIN_G = /\b([a-z0-9-]{2,}\.){1,5}(com|cn|net|org|io|app|dev|me|info|tv|co|us|jp|kr|ai|gg|xyz|cloud|qq|gov|edu|world|run|top|wang|site|live|tech)\b(:\d+)?/gi;
const RE_DOMAIN_STRICT = /^([a-z0-9-]+\.){1,5}[a-z]{2,}(:\d+)?$/i;
const RE_PATH = /(^|\s)(\/(Library|System|Applications|var|tmp|Users)\/|~\/)/;
const RE_SQL = /\b(CREATE\s+TABLE|INSERT\s+INTO|SELECT\s+.+FROM|UPDATE\s+\w+\s+SET|DROP\s+TABLE)\b/i;
const RE_EVENT_TOKEN = /\b([a-z][a-z0-9_]{8,80})\b/g;
const RE_KEY = /^[A-Za-z0-9+/=]{24,}$|^[A-Fa-f0-9]{24,}$/;
const RE_NOISE_PREFIX = /^(__|@_|_OBJC_|\.LFB|\.LBE|@OBJC_|libobjc|0x[0-9a-f]+|\([0-9])/;
const TLD_OK = /\.(com|cn|net|org|io|app|dev|me|info|tv|co|us|jp|kr|ai|gg|xyz|cloud|qq|gov|edu|world|run|top|wang|site|live|tech)(:\d+)?$/i;
function classify(line: string, b: Buckets) {
const t = line.replace(/\x00/g, "").trim();
if (t.length < 6) return;
if (RE_NOISE_PREFIX.test(t)) return;
// For ANY length line: multi-scan for URLs and domain-shaped tokens.
// The key bug we are working around: Rust binaries cram many literals into
// one continuous .rodata blob, so `strings` can return one 50KB "line" that
// contains every endpoint, every event name, every error message.
let foundSomething = false;
// URLs (multi-match)
const urlMatches = t.match(RE_URL_G);
if (urlMatches) {
for (const u of urlMatches) {
b.urls.add(u);
const hostMatch = u.match(/https?:\/\/([^/:]+)/i);
if (hostMatch) b.domains.add(hostMatch[1].toLowerCase());
}
foundSomething = true;
}
// Domain-shaped tokens (multi-match) — only against a curated TLD set so
// common version strings don't pollute.
const domMatches = t.match(RE_DOMAIN_G);
if (domMatches) {
for (const d of domMatches) {
const dn = d.toLowerCase();
// Skip obvious file extensions
if (/\.(swift|m|mm|cpp|h|hpp|js|ts|json|plist|xib|nib|po)$/.test(dn))
continue;
b.domains.add(dn);
}
foundSomething = true;
}
// Short-line specific buckets — only attempt full-line classification when
// the line is short enough to be a single semantic token.
if (t.length <= 300) {
if (RE_SQL.test(t)) {
b.sql.add(t.slice(0, 300));
foundSomething = true;
}
if (RE_PATH.test(t)) {
b.paths.add(t.slice(0, 300));
foundSomething = true;
}
if (
t.length < 100 &&
RE_DOMAIN_STRICT.test(t) &&
TLD_OK.test(t) &&
!b.domains.has(t.toLowerCase())
) {
b.domains.add(t.toLowerCase());
foundSomething = true;
}
if (RE_KEY.test(t)) {
b.keysCount++;
if (b.keysPreview.size < 8) {
b.keysPreview.add(`${t.slice(0, 12)}…(${t.length}ch)`);
}
foundSomething = true;
}
}
// Event-name-shaped tokens (multi-match across the line). Only for lines
// that aren't dominated by URLs (avoid grabbing fragments of paths).
// Cap the harvest per line to avoid pathological blobs.
let eventCount = 0;
for (const m of t.matchAll(RE_EVENT_TOKEN)) {
const tok = m[1];
if (!tok.includes("_")) continue;
if (tok.length < 9) continue;
// Skip obvious noise tokens
if (/^(get_|set_|src_|dst_|tmp_|str_|out_|err_|new_|old_|ret_|res_|val_)/.test(tok))
continue;
if (b.events.size > 5000) break; // hard cap per binary
b.events.add(tok);
if (++eventCount > 500) break;
}
void foundSomething;
}
function bucketToMd(title: string, items: Iterable<string>): string {
const arr = [...items].sort();
if (arr.length === 0) return `# ${title}\n\n_(empty)_\n`;
const head = `# ${title} (${arr.length})\n\n`;
const body = arr.map((s) => `- \`${s.replace(/`/g, "ʹ")}\``).join("\n");
return head + body + "\n";
}
function keysBucketMd(b: Buckets): string {
const lines = [
`# keys (count=${b.keysCount})`,
"",
"Key-shaped strings are NOT stored verbatim. Below are first-12-char previews.",
"",
];
for (const p of b.keysPreview) lines.push(`- \`${p}\``);
return lines.join("\n") + "\n";
}
async function processOne(
binaryPath: string,
outDir: string,
maxBytes: number,
): Promise<{ name: string; counts: Record<string, number> } | null> {
// Skip giant or missing binaries
const name = basename(binaryPath);
const buckets = emptyBuckets();
let lineCount = 0;
try {
await streamStrings(
binaryPath,
(line) => {
lineCount++;
if (lineCount > 2_000_000) return; // hard cap
classify(line, buckets);
},
{ minLen: 6, timeoutMs: 180_000 },
);
} catch (e) {
console.error(`skip ${name}: ${(e as Error).message}`);
return null;
}
const safe = name.replace(/[^A-Za-z0-9._-]+/g, "_");
await writeFile(join(outDir, `${safe}.urls.md`), bucketToMd("urls", buckets.urls));
await writeFile(
join(outDir, `${safe}.domains.md`),
bucketToMd("domains", buckets.domains),
);
await writeFile(join(outDir, `${safe}.paths.md`), bucketToMd("paths", buckets.paths));
await writeFile(join(outDir, `${safe}.sql.md`), bucketToMd("sql", buckets.sql));
await writeFile(
join(outDir, `${safe}.events.md`),
bucketToMd("events", buckets.events),
);
await writeFile(join(outDir, `${safe}.keys.md`), keysBucketMd(buckets));
return {
name,
counts: {
urls: buckets.urls.size,
domains: buckets.domains.size,
paths: buckets.paths.size,
sql: buckets.sql.size,
events: buckets.events.size,
keys: buckets.keysCount,
},
};
}
async function main() {
const args = process.argv.slice(2);
const metaPath = args.find((a) => !a.startsWith("--"));
const outIdx = args.indexOf("--out");
const out = outIdx >= 0 ? args[outIdx + 1] : "";
if (!metaPath || !out) {
console.error("usage: classify_strings.ts <meta.json> --out <strings-dir>");
process.exit(2);
}
const meta: Meta = JSON.parse(await readFile(metaPath, "utf8"));
await mkdir(out, { recursive: true });
const targets: string[] = [];
if (meta.main_executable) targets.push(meta.main_executable);
for (const fw of meta.frameworks) {
if (fw.size_bytes > 200 * 1024 * 1024) continue;
targets.push(fw.path);
}
const summary: Array<{ name: string; counts: Record<string, number> }> = [];
for (const t of targets) {
const r = await processOne(t, out, 200 * 1024 * 1024);
if (r) summary.push(r);
}
const idx = [
"# strings index",
"",
"| binary | urls | domains | paths | sql | events | keys |",
"|---|---:|---:|---:|---:|---:|---:|",
...summary.map(
(s) =>
`| ${s.name} | ${s.counts.urls} | ${s.counts.domains} | ${s.counts.paths} | ${s.counts.sql} | ${s.counts.events} | ${s.counts.keys} |`,
),
"",
].join("\n");
await writeFile(join(out, "INDEX.md"), idx);
console.log(`classified ${summary.length} binaries -> ${out}`);
}
main().catch((e) => {
console.error(e instanceof Error ? e.message : String(e));
process.exit(1);
});
#!/usr/bin/env bun
// Phase 5: Inventory the on-disk surface of an app by bundle id.
// Lists files only. SQLite gets `.schema` (no row data). MMKV/JSON gets size only.
import { mkdir, readdir, stat, writeFile } from "node:fs/promises";
import { homedir } from "node:os";
import { join, dirname } from "node:path";
import { run } from "../lib/shell.ts";
interface FoundFile {
path: string;
size: number;
kind: "sqlite" | "mmkv" | "json" | "plist" | "log" | "binary" | "other";
schema?: string;
}
const HOME = homedir();
function bundlePrefix(bundleId: string): string {
// group containers usually look like group.<vendor>.<...>
const parts = bundleId.split(".");
if (parts.length >= 2) return parts.slice(0, 2).join(".");
return bundleId;
}
const ROOTS = (bundleId: string) => [
join(HOME, "Library", "Application Support", bundleId),
join(HOME, "Library", "Caches", bundleId),
join(HOME, "Library", "Logs", bundleId),
join(HOME, "Library", "Containers", bundleId, "Data"),
join(HOME, "Library", "Preferences", `${bundleId}.plist`),
];
async function listGroupContainers(prefix: string): Promise<string[]> {
const root = join(HOME, "Library", "Group Containers");
try {
const entries = await readdir(root);
return entries
.filter((e) => e.startsWith(`group.${prefix}`) || e.includes(prefix))
.map((e) => join(root, e));
} catch {
return [];
}
}
function classify(p: string): FoundFile["kind"] {
if (/\.(sqlite|sqlite3|db)$/i.test(p)) return "sqlite";
if (/\.(mmkv|crc)$/i.test(p)) return "mmkv";
if (/\.json$/i.test(p)) return "json";
if (/\.plist$/i.test(p)) return "plist";
if (/\.(log|txt)$/i.test(p) || /\/Logs\//.test(p)) return "log";
if (/\.(dylib|so|framework)$/i.test(p)) return "binary";
return "other";
}
async function walk(
root: string,
out: FoundFile[],
maxDepth = 6,
depth = 0,
): Promise<void> {
if (depth > maxDepth) return;
let entries: string[];
try {
entries = await readdir(root);
} catch {
return;
}
for (const e of entries) {
if (e.startsWith(".")) continue;
const p = join(root, e);
let s;
try {
s = await stat(p);
} catch {
continue;
}
if (s.isDirectory()) {
await walk(p, out, maxDepth, depth + 1);
} else if (s.isFile()) {
out.push({ path: p, size: s.size, kind: classify(p) });
}
}
}
async function dumpSchema(sqlitePath: string): Promise<string> {
try {
const r = await run("sqlite3", [sqlitePath, ".schema"], {
timeoutMs: 15_000,
allowNonZero: true,
});
return r.stdout.trim() || "(no tables)";
} catch (e) {
return `(failed: ${(e as Error).message.slice(0, 120)})`;
}
}
function fmtSize(n: number): string {
if (n < 1024) return `${n}B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)}K`;
if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)}M`;
return `${(n / 1024 / 1024 / 1024).toFixed(2)}G`;
}
async function main() {
const args = process.argv.slice(2);
const positional = args.filter((a) => !a.startsWith("--"));
const bundle = positional[0];
const aliases = positional.slice(1).filter(Boolean);
const outIdx = args.indexOf("--out");
const out = outIdx >= 0 ? args[outIdx + 1] : "";
if (!bundle || !out) {
console.error(
"usage: inventory_data.ts <bundle.id> [<alias-name> ...] --out <local_data.md>",
);
process.exit(2);
}
// Apps don't always store data under bundle id — Input Methods commonly use
// CFBundleExecutable as the directory name (e.g. ~/Library/Application
// Support/DoubaoIme/ instead of .../com.bytedance.inputmethod.doubaoime/).
// Accept aliases (typically the executable name + the last bundle segment)
// to cover both layouts.
const lastSeg = bundle.split(".").pop() || "";
const allKeys = [bundle, ...aliases, lastSeg].filter(Boolean);
const seen = new Set<string>();
const rootsRaw: string[] = [];
for (const k of allKeys) {
for (const r of ROOTS(k)) rootsRaw.push(r);
}
const groupRoots = await listGroupContainers(bundlePrefix(bundle));
for (const r of [...rootsRaw, ...groupRoots]) {
if (!seen.has(r)) {
seen.add(r);
}
}
const roots = [...seen];
const files: FoundFile[] = [];
for (const r of roots) {
let isFile = false;
try {
const s = await stat(r);
if (s.isFile()) {
files.push({ path: r, size: s.size, kind: classify(r) });
isFile = true;
}
} catch {
continue;
}
if (!isFile) await walk(r, files);
}
// Schema for SQLite — capped at 12 to avoid runaway
let schemaCount = 0;
for (const f of files) {
if (f.kind === "sqlite" && schemaCount < 12) {
f.schema = await dumpSchema(f.path);
schemaCount++;
}
}
// Group by parent dir
const byDir = new Map<string, FoundFile[]>();
for (const f of files) {
const d = dirname(f.path);
if (!byDir.has(d)) byDir.set(d, []);
byDir.get(d)!.push(f);
}
const dirsSorted = [...byDir.keys()].sort();
const lines: string[] = [
`# local data — ${bundle}`,
"",
`Found ${files.length} files across ${dirsSorted.length} directories.`,
"",
];
if (files.length === 0) {
lines.push("_(no on-disk surface found for this bundle id)_\n");
} else {
for (const d of dirsSorted) {
lines.push(`## ${d.replace(HOME, "~")}`);
lines.push("");
lines.push("| size | kind | name |");
lines.push("|---:|---|---|");
for (const f of byDir.get(d)!.sort((a, b) => b.size - a.size)) {
const name = f.path.slice(d.length + 1);
lines.push(`| ${fmtSize(f.size)} | ${f.kind} | \`${name}\` |`);
}
lines.push("");
}
const sqlites = files.filter((f) => f.schema);
if (sqlites.length) {
lines.push("## SQLite schemas");
lines.push("");
for (const f of sqlites) {
lines.push(`### \`${f.path.replace(HOME, "~")}\``);
lines.push("");
lines.push("```sql");
lines.push(f.schema!.split("\n").slice(0, 80).join("\n"));
lines.push("```");
lines.push("");
}
}
}
await mkdir(dirname(out), { recursive: true });
await writeFile(out, lines.join("\n"));
console.log(`inventory: ${files.length} files -> ${out}`);
}
main().catch((e) => {
console.error(e instanceof Error ? e.message : String(e));
process.exit(1);
});
#!/usr/bin/env bun
// Phase 3 / Phase 4: Apply SDK fingerprints (or known-endpoint table)
// against the bucketed strings produced by classify_strings.ts.
//
// Fingerprint file format (markdown, see references/sdk_fingerprints.md):
//
// ## <id> — <Display Name>
// - vendor: <vendor>
// - dylib: <regex> # optional, matches binary name
// - tell-tale:
// - "<regex>"
// - "<regex>"
// - min_hits: <int>
// - notes: <free text>
import { readFile, readdir, writeFile } from "node:fs/promises";
import { join, basename } from "node:path";
interface Fingerprint {
id: string;
display: string;
vendor: string;
dylib?: RegExp;
tellTale: RegExp[];
minHits: number;
notes: string;
}
interface Hit {
fp: Fingerprint;
hits: number;
evidence: Array<{ file: string; line: string }>;
}
function parseFingerprints(md: string): Fingerprint[] {
const out: Fingerprint[] = [];
const sections = md.split(/^## /m).slice(1);
for (const sec of sections) {
const lines = sec.split("\n");
const header = lines[0].trim();
const m = header.match(/^([\w.-]+)\s*[—-]\s*(.+)$/);
if (!m) continue;
const id = m[1];
const display = m[2].trim();
let vendor = "";
let dylib: RegExp | undefined;
let minHits = 1;
let notes = "";
const tellTale: RegExp[] = [];
let inTellTale = false;
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
const t = line.trim();
if (t.startsWith("- vendor:")) {
vendor = t.slice("- vendor:".length).trim();
inTellTale = false;
} else if (t.startsWith("- dylib:")) {
const v = t.slice("- dylib:".length).trim();
try {
dylib = new RegExp(v, "i");
} catch {
/* ignore bad regex */
}
inTellTale = false;
} else if (t.startsWith("- min_hits:")) {
minHits = parseInt(t.slice("- min_hits:".length).trim(), 10) || 1;
inTellTale = false;
} else if (t.startsWith("- notes:")) {
notes = t.slice("- notes:".length).trim();
inTellTale = false;
} else if (t === "- tell-tale:") {
inTellTale = true;
} else if (inTellTale && t.startsWith("- ")) {
const raw = t.slice(2).trim().replace(/^"|"$/g, "");
try {
tellTale.push(new RegExp(raw, "i"));
} catch {
/* ignore */
}
} else if (t === "" || t.startsWith("# ")) {
// section break
if (t.startsWith("# ")) break;
}
}
if (tellTale.length === 0) continue;
out.push({ id, display, vendor, dylib, tellTale, minHits, notes });
}
return out;
}
async function loadStringsBuckets(
dir: string,
): Promise<Array<{ file: string; lines: string[] }>> {
const files = await readdir(dir);
const out: Array<{ file: string; lines: string[] }> = [];
for (const f of files) {
if (!f.endsWith(".md")) continue;
if (f === "INDEX.md") continue;
const text = await readFile(join(dir, f), "utf8");
// bucket lines look like: - `the literal string`
const lines: string[] = [];
for (const line of text.split("\n")) {
const m = line.match(/^- `(.+?)`$/);
if (m) lines.push(m[1]);
}
out.push({ file: f, lines });
}
return out;
}
function applyFingerprint(
fp: Fingerprint,
buckets: Array<{ file: string; lines: string[] }>,
): Hit {
const hit: Hit = { fp, hits: 0, evidence: [] };
const matchedRegexes = new Set<number>();
let dylibSeen = false;
for (const b of buckets) {
// dylib: filter is informational — when the binary name matches, the
// fingerprint gets a free hit (binary name itself is strong evidence).
if (fp.dylib && fp.dylib.test(b.file) && !dylibSeen) {
dylibSeen = true;
hit.hits++;
hit.evidence.push({
file: b.file,
line: `(binary name matches /${fp.dylib.source}/)`,
});
}
for (const line of b.lines) {
for (let i = 0; i < fp.tellTale.length; i++) {
if (fp.tellTale[i].test(line)) {
if (!matchedRegexes.has(i)) {
matchedRegexes.add(i);
hit.hits++;
}
if (hit.evidence.length < 6) {
hit.evidence.push({ file: b.file, line: line.slice(0, 200) });
}
}
}
}
}
return hit;
}
function statusOf(hit: Hit): "confirmed" | "partial" | "absent" {
if (hit.hits >= hit.fp.minHits) return "confirmed";
if (hit.hits > 0) return "partial";
return "absent";
}
function render(hits: Hit[]): string {
const ordered = hits
.filter((h) => h.hits > 0)
.sort((a, b) => b.hits - a.hits);
const lines: string[] = [
"# fingerprint matches",
"",
"| id | display | vendor | hits | min | status |",
"|---|---|---|---:|---:|---|",
];
for (const h of ordered) {
lines.push(
`| ${h.fp.id} | ${h.fp.display} | ${h.fp.vendor} | ${h.hits} | ${h.fp.minHits} | **${statusOf(h)}** |`,
);
}
lines.push("", "## evidence", "");
for (const h of ordered) {
lines.push(`### ${h.fp.id} — ${h.fp.display}`);
if (h.fp.notes) lines.push(`> ${h.fp.notes}`);
lines.push("");
for (const e of h.evidence) {
const safe = e.line.replace(/`/g, "ʹ");
lines.push(`- \`${e.file}\`: \`${safe}\``);
}
lines.push("");
}
return lines.join("\n");
}
async function main() {
const args = process.argv.slice(2);
const dir = args.find((a) => !a.startsWith("--"));
const fpIdx = args.indexOf("--fingerprints");
const outIdx = args.indexOf("--out");
const fpPath = fpIdx >= 0 ? args[fpIdx + 1] : "";
const out = outIdx >= 0 ? args[outIdx + 1] : "";
if (!dir || !fpPath || !out) {
console.error(
"usage: match_fingerprints.ts <strings-dir> --fingerprints <md> --out <out.md>",
);
process.exit(2);
}
const fps = parseFingerprints(await readFile(fpPath, "utf8"));
if (fps.length === 0) {
console.error("no fingerprints parsed from " + fpPath);
process.exit(3);
}
const buckets = await loadStringsBuckets(dir);
const hits = fps.map((f) => applyFingerprint(f, buckets));
await writeFile(out, render(hits));
const confirmed = hits.filter((h) => statusOf(h) === "confirmed").length;
console.log(
`match: ${confirmed} confirmed / ${hits.filter((h) => h.hits > 0).length} partial -> ${out}`,
);
}
main().catch((e) => {
console.error(e instanceof Error ? e.message : String(e));
process.exit(1);
});
#!/usr/bin/env bun
// Phase 6: Stitch the per-phase outputs into the user-facing report.
// Pure templating — no judgment, no inference. The hard work was already done
// by Claude before this script ran.
import { mkdir, readFile, writeFile, stat } from "node:fs/promises";
import { dirname, join } from "node:path";
interface FrameworkEntry {
name: string;
size_bytes: number;
}
interface Meta {
app_path: string;
bundle_id: string;
short_version: string;
version: string;
sandboxed: boolean | null;
arbitrary_loads: boolean | null;
ats_exception_domains: string[];
entitlements_summary: string[];
frameworks: FrameworkEntry[];
}
function fmtSize(n: number): string {
if (n < 1024) return `${n}B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)}K`;
if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)}M`;
return `${(n / 1024 / 1024 / 1024).toFixed(2)}G`;
}
async function readIfExists(p: string): Promise<string> {
try {
await stat(p);
return await readFile(p, "utf8");
} catch {
return "";
}
}
async function main() {
const args = process.argv.slice(2);
const workdir = args.find((a) => !a.startsWith("--"));
const tplIdx = args.indexOf("--template");
const outIdx = args.indexOf("--out");
const tpl = tplIdx >= 0 ? args[tplIdx + 1] : "";
const out = outIdx >= 0 ? args[outIdx + 1] : "";
if (!workdir || !tpl || !out) {
console.error(
"usage: render_report.ts <workdir> --template <tmpl> --out <report.md>",
);
process.exit(2);
}
const meta: Meta = JSON.parse(
await readFile(join(workdir, "meta.json"), "utf8"),
);
const matched = await readIfExists(join(workdir, "matched.md"));
const endpoints = await readIfExists(join(workdir, "endpoints.md"));
const endpointTable = await readIfExists(join(workdir, "endpoint_table.md"));
const localData = await readIfExists(join(workdir, "local_data.md"));
const fwTable = [
"| name | size |",
"|---|---:|",
...meta.frameworks
.sort((a, b) => b.size_bytes - a.size_bytes)
.slice(0, 30)
.map((f) => `| \`${f.name}\` | ${fmtSize(f.size_bytes)} |`),
].join("\n");
const ents = meta.entitlements_summary
.filter((e) => e.includes(".") || e.startsWith("com."))
.slice(0, 20)
.map((e) => `\`${e}\``)
.join(", ");
const tplText = await readFile(tpl, "utf8");
const filled = tplText
.replaceAll("{{bundle_id}}", meta.bundle_id || "(unknown)")
.replaceAll(
"{{version}}",
`${meta.short_version || ""} (${meta.version || ""})`.trim(),
)
.replaceAll("{{app_path}}", meta.app_path)
.replaceAll(
"{{sandboxed}}",
meta.sandboxed === null ? "unknown" : String(meta.sandboxed),
)
.replaceAll(
"{{arbitrary_loads}}",
meta.arbitrary_loads === null ? "unknown" : String(meta.arbitrary_loads),
)
.replaceAll(
"{{ats_exception_domains}}",
meta.ats_exception_domains.length
? meta.ats_exception_domains.map((d) => `\`${d}\``).join(", ")
: "_(none)_",
)
.replaceAll("{{entitlements}}", ents || "_(none captured)_")
.replaceAll("{{frameworks_table}}", fwTable)
.replaceAll("{{frameworks_count}}", String(meta.frameworks.length))
.replaceAll("{{matched_md}}", matched || "_(no SDKs detected)_")
.replaceAll(
"{{endpoint_table}}",
endpointTable ||
"_(empty — Claude must fill this in by reading `strings/*.urls.md` and `endpoints.md`)_",
)
.replaceAll(
"{{endpoints_md}}",
endpoints || "_(no known endpoints matched)_",
)
.replaceAll("{{local_data}}", localData || "_(nothing on disk)_")
.replaceAll("{{generated_at}}", new Date().toISOString());
await mkdir(dirname(out), { recursive: true });
await writeFile(out, filled);
console.log(`report -> ${out}`);
}
main().catch((e) => {
console.error(e instanceof Error ? e.message : String(e));
process.exit(1);
});
#!/usr/bin/env bun
// Thin orchestrator that runs Phase 1 → 2 → 3 → 5 → 6 in sequence.
// Phase 0 (scope confirm) and Phase 4 (endpoint mapping) require Claude
// judgment and are NOT in here — Claude must do them in the conversation.
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
import { homedir } from "node:os";
import { basename, dirname, join, resolve } from "node:path";
import { spawn } from "node:child_process";
import { rejectSystemPath } from "../lib/shell.ts";
import { renderCardPrompt } from "../lib/card.ts";
const HERE = dirname(new URL(import.meta.url).pathname);
const SKILL_ROOT = resolve(HERE, "..");
const FP = join(SKILL_ROOT, "references", "sdk_fingerprints.md");
const FP_ENDPOINTS = join(SKILL_ROOT, "references", "known_endpoints.md");
const TPL = join(SKILL_ROOT, "templates", "report.md.tmpl");
const CARD_TPL = join(SKILL_ROOT, "templates", "card_prompt.md.tmpl");
function todayStamp(): string {
const d = new Date();
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
function safeBundle(s: string): string {
return s.replace(/[^A-Za-z0-9._-]+/g, "_") || "unknown";
}
async function bunRun(script: string, args: string[]): Promise<void> {
const cmd = process.execPath.endsWith("/bun") ? process.execPath : "bun";
return new Promise((res, rej) => {
const child = spawn(cmd, [script, ...args], { stdio: "inherit" });
child.on("error", rej);
child.on("close", (code) =>
code === 0
? res()
: rej(new Error(`${basename(script)} exited ${code}`)),
);
});
}
interface CardOpts {
provider?: string;
model?: string;
ar?: string;
size?: string;
quality?: string;
imageSize?: string;
}
async function findBaoyuImagine(): Promise<string | null> {
const candidates = [
join(homedir(), ".claude", "skills", "baoyu-imagine", "scripts", "main.ts"),
join(homedir(), ".claude", "plugins", "marketplaces", "baoyu-skills", "skills", "baoyu-imagine", "scripts", "main.ts"),
process.env.BAOYU_IMAGINE_SCRIPT || "",
].filter(Boolean);
for (const c of candidates) {
try {
const s = await stat(c);
if (s.isFile()) return c;
} catch {
/* not here */
}
}
return null;
}
async function generateCard(
workdir: string,
cardOpts: CardOpts,
): Promise<boolean> {
const promptPath = join(workdir, "card_prompt.md");
const imagePath = join(workdir, "card.png");
const script = await findBaoyuImagine();
if (!script) {
console.error(
"card: baoyu-imagine not found — prompt written but image skipped.",
);
console.error(
" install it or set BAOYU_IMAGINE_SCRIPT to .../scripts/main.ts",
);
return false;
}
const args = [
script,
"--promptfiles",
promptPath,
"--image",
imagePath,
"--ar",
cardOpts.ar || "4:5",
];
if (cardOpts.provider) args.push("--provider", cardOpts.provider);
if (cardOpts.model) args.push("--model", cardOpts.model);
if (cardOpts.size) args.push("--size", cardOpts.size);
if (cardOpts.quality) args.push("--quality", cardOpts.quality);
if (cardOpts.imageSize) args.push("--imageSize", cardOpts.imageSize);
if (!cardOpts.quality && !cardOpts.size && !cardOpts.imageSize) {
args.push("--quality", "2k");
}
const cmd = process.execPath.endsWith("/bun") ? process.execPath : "bun";
return new Promise((res) => {
const child = spawn(cmd, args, { stdio: "inherit" });
child.on("error", (e) => {
console.error(`card: baoyu-imagine spawn failed: ${e.message}`);
res(false);
});
child.on("close", (code) => {
if (code === 0) {
console.log(`card → ${imagePath}`);
res(true);
} else {
console.error(`card: baoyu-imagine exited ${code}`);
res(false);
}
});
});
}
async function auditOne(
appPath: string,
opts: {
card: boolean;
generateImage: boolean;
clean: boolean;
outRoot: string;
cardOpts: CardOpts;
},
): Promise<void> {
const denied = rejectSystemPath(appPath);
if (denied) {
console.error(`refused: ${appPath} — ${denied}`);
return;
}
// Phase 1
const tmpMeta = `/tmp/happy-app-audit-meta-${Date.now()}.json`;
await bunRun(join(SKILL_ROOT, "scripts", "snapshot_app.ts"), [
appPath,
"--out",
tmpMeta,
]);
const meta = JSON.parse(await readFile(tmpMeta, "utf8"));
const bundle = meta.bundle_id || basename(appPath);
const workdir = join(opts.outRoot, `${todayStamp()}_${safeBundle(bundle)}`);
await mkdir(workdir, { recursive: true });
await writeFile(
join(workdir, "meta.json"),
JSON.stringify(meta, null, 2),
);
// Phase 2
const stringsDir = join(workdir, "strings");
await bunRun(join(SKILL_ROOT, "scripts", "classify_strings.ts"), [
join(workdir, "meta.json"),
"--out",
stringsDir,
]);
// Phase 3
await bunRun(join(SKILL_ROOT, "scripts", "match_fingerprints.ts"), [
stringsDir,
"--fingerprints",
FP,
"--out",
join(workdir, "matched.md"),
]);
// Phase 4 (endpoint reverse-lookup pre-pass)
await bunRun(join(SKILL_ROOT, "scripts", "match_fingerprints.ts"), [
stringsDir,
"--fingerprints",
FP_ENDPOINTS,
"--out",
join(workdir, "endpoints.md"),
]);
// Phase 5 — pass executable basename as alias so dirs like
// ~/Library/Application Support/DoubaoIme/ are picked up too.
const exeAlias = meta.main_executable
? basename(meta.main_executable)
: "";
const inventoryArgs = [bundle];
if (exeAlias && exeAlias !== bundle) inventoryArgs.push(exeAlias);
inventoryArgs.push("--out", join(workdir, "local_data.md"));
await bunRun(join(SKILL_ROOT, "scripts", "inventory_data.ts"), inventoryArgs);
// Phase 6 — render report skeleton.
// Note: endpoint_table.md is left for Claude to write by hand based on
// matched.md + endpoints.md. If Claude has not written it yet, the report
// will say so explicitly.
await bunRun(join(SKILL_ROOT, "scripts", "render_report.ts"), [
workdir,
"--template",
TPL,
"--out",
join(workdir, "report.md"),
]);
if (opts.card) {
const filled = await renderCardPrompt({
meta,
endpointsMd: join(workdir, "endpoints.md"),
localMd: join(workdir, "local_data.md"),
stringsDir,
templatePath: CARD_TPL,
homeDir: homedir(),
});
await writeFile(join(workdir, "card_prompt.md"), filled);
if (opts.generateImage) {
await generateCard(workdir, opts.cardOpts);
} else {
console.log(`card → prompt only (pass --card without --no-image to render)`);
}
}
console.log(`\n✓ ${bundle} → ${workdir}/report.md`);
}
function optArg(args: string[], name: string): string | undefined {
const idx = args.indexOf(name);
if (idx < 0 || idx === args.length - 1) return undefined;
const v = args[idx + 1];
return v.startsWith("--") ? undefined : v;
}
async function main() {
const args = process.argv.slice(2);
const FLAGS = new Set([
"--card",
"--no-image",
"--clean",
]);
const VALUE_FLAGS = new Set([
"--image-provider",
"--image-model",
"--image-ar",
"--image-size",
"--image-quality",
"--image-imageSize",
"--out",
]);
const targets: string[] = [];
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (a.startsWith("--")) {
if (VALUE_FLAGS.has(a)) i++;
else if (!FLAGS.has(a)) {
console.error(`unknown flag: ${a}`);
process.exit(2);
}
} else targets.push(a);
}
const card = args.includes("--card");
const noImage = args.includes("--no-image");
const clean = args.includes("--clean");
if (targets.length === 0) {
console.error(
"usage: run.ts <app-path> [<app-path> ...] [--card] [--no-image]\n" +
" [--image-provider google|openai|dashscope|...]\n" +
" [--image-model <id>] [--image-ar 4:5] [--image-size WxH]\n" +
" [--image-quality normal|2k] [--image-imageSize 1K|2K|4K]\n" +
" [--out <dir>] [--clean]",
);
process.exit(2);
}
if (targets.length > 5) {
console.error(
`refused: ${targets.length} apps in one invocation (cap is 5).`,
);
process.exit(3);
}
const outRoot =
optArg(args, "--out") ||
join(homedir(), "Documents", "app-telemetry-audit");
await mkdir(outRoot, { recursive: true });
const cardOpts: CardOpts = {
provider: optArg(args, "--image-provider"),
model: optArg(args, "--image-model"),
ar: optArg(args, "--image-ar"),
size: optArg(args, "--image-size"),
quality: optArg(args, "--image-quality"),
imageSize: optArg(args, "--image-imageSize"),
};
// --card renders the prompt AND the image; --card --no-image renders the
// prompt only. Any --image-* flag implies --card.
const anyImageOpt = Object.values(cardOpts).some((v) => v !== undefined);
const cardEnabled = card || anyImageOpt;
const generateImage = cardEnabled && !noImage;
for (const t of targets) {
try {
await auditOne(t, {
card: cardEnabled,
generateImage,
clean,
outRoot,
cardOpts,
});
} catch (e) {
console.error(`[${t}] failed: ${(e as Error).message}`);
}
}
}
main().catch((e) => {
console.error(e instanceof Error ? e.message : String(e));
process.exit(1);
});
#!/usr/bin/env bun
// Phase 1: Capture immutable metadata of a target .app bundle.
// Output: meta.json with bundle_id, version, sandbox flags, ATS, entitlements, frameworks.
import { mkdir, writeFile, stat } from "node:fs/promises";
import { dirname, join, basename } from "node:path";
import { run, rejectSystemPath } from "../lib/shell.ts";
interface FrameworkEntry {
name: string;
path: string;
size_bytes: number;
archs: string[];
}
interface Meta {
app_path: string;
bundle_id: string;
version: string;
short_version: string;
sandboxed: boolean | null;
arbitrary_loads: boolean | null;
ats_exception_domains: string[];
entitlements_summary: string[];
frameworks: FrameworkEntry[];
main_executable: string | null;
main_executable_arch: string[];
collected_at: string;
}
function parseArgs(argv: string[]): { app: string; out: string } {
const args = argv.slice(2);
const app = args.find((a) => !a.startsWith("--"));
const outIdx = args.indexOf("--out");
const out = outIdx >= 0 ? args[outIdx + 1] : "";
if (!app || !out) {
console.error("usage: snapshot_app.ts <app-path> --out <meta.json>");
process.exit(2);
}
return { app, out };
}
function plistGet(text: string, key: string): string {
// plutil -p output: "key" => "value"
const re = new RegExp(`"${key}"\\s*=>\\s*"?([^"\\n}]+)"?`);
const m = text.match(re);
return m ? m[1].trim() : "";
}
function plistGetBool(text: string, key: string): boolean | null {
const re = new RegExp(`"${key}"\\s*=>\\s*(\\d|true|false|YES|NO)`);
const m = text.match(re);
if (!m) return null;
const v = m[1];
if (v === "1" || v === "true" || v === "YES") return true;
if (v === "0" || v === "false" || v === "NO") return false;
return null;
}
function plistGetArrayKeys(text: string, key: string): string[] {
// Best-effort: capture domain keys nested under NSExceptionDomains
const idx = text.indexOf(`"${key}"`);
if (idx < 0) return [];
const sub = text.slice(idx, idx + 4000);
const out: string[] = [];
const re = /"([a-z0-9.-]+\.[a-z]{2,})"\s*=>\s*\{/g;
let m: RegExpExecArray | null;
while ((m = re.exec(sub)) !== null) out.push(m[1]);
return [...new Set(out)];
}
function parseEntitlements(stderr: string): string[] {
// codesign -dv --entitlements - emits XML on stdout in newer versions or
// a key=value style on stderr for some flows. We grep the keys.
const out = new Set<string>();
const reKey = /<key>([^<]+)<\/key>\s*<(true|false|string|integer|array)/g;
let m: RegExpExecArray | null;
while ((m = reKey.exec(stderr)) !== null) out.add(m[1]);
// Also grep printable lines that look like com.apple.* keys.
for (const line of stderr.split("\n")) {
const t = line.trim();
if (/^[a-z0-9.-]+\.[a-z0-9-]+$/i.test(t) && t.includes(".")) out.add(t);
}
return [...out].slice(0, 60);
}
// For a `.framework` directory, the actual Mach-O lives at one of:
// <fw>/<Name> (flat layout)
// <fw>/Versions/Current/<Name> (versioned layout, symlink)
// <fw>/Versions/A/<Name> (versioned layout, real path)
async function resolveFrameworkBinary(fwDir: string): Promise<string | null> {
const name = basename(fwDir).replace(/\.framework$/, "");
const candidates = [
join(fwDir, name),
join(fwDir, "Versions", "Current", name),
join(fwDir, "Versions", "A", name),
];
for (const c of candidates) {
try {
const s = await stat(c);
if (s.isFile()) return c;
} catch {
/* not here */
}
}
return null;
}
async function findFrameworks(appPath: string): Promise<string[]> {
const fwRoot = join(appPath, "Contents", "Frameworks");
const out: string[] = [];
try {
const dylRes = await run(
"find",
[fwRoot, "-maxdepth", "4", "-type", "f", "-name", "*.dylib"],
{ allowNonZero: true },
);
out.push(...dylRes.stdout.split("\n").filter(Boolean));
const fwRes = await run(
"find",
[fwRoot, "-maxdepth", "3", "-type", "d", "-name", "*.framework"],
{ allowNonZero: true },
);
for (const fw of fwRes.stdout.split("\n").filter(Boolean)) {
const bin = await resolveFrameworkBinary(fw);
if (bin) out.push(bin);
}
} catch {
/* ignore */
}
return out;
}
async function archsOf(binaryPath: string): Promise<string[]> {
try {
const r = await run("file", [binaryPath]);
const out = new Set<string>();
if (r.stdout.includes("x86_64")) out.add("x86_64");
if (r.stdout.includes("arm64")) out.add("arm64");
return [...out];
} catch {
return [];
}
}
async function sizeOf(p: string): Promise<number> {
try {
const s = await stat(p);
return s.size;
} catch {
return 0;
}
}
async function main() {
const { app, out } = parseArgs(process.argv);
const denied = rejectSystemPath(app);
if (denied) {
console.error(`refused: ${denied}`);
process.exit(3);
}
await mkdir(dirname(out), { recursive: true });
const infoPlist = join(app, "Contents", "Info.plist");
const plistRes = await run("plutil", ["-p", infoPlist], {
allowNonZero: true,
});
const plistText = plistRes.stdout;
const bundle_id = plistGet(plistText, "CFBundleIdentifier");
const version = plistGet(plistText, "CFBundleVersion");
const short_version = plistGet(plistText, "CFBundleShortVersionString");
const arbitrary_loads = plistGetBool(plistText, "NSAllowsArbitraryLoads");
const ats_exception_domains = plistGetArrayKeys(
plistText,
"NSExceptionDomains",
);
// Best-effort entitlements
let ent: string[] = [];
try {
const cs = await run(
"codesign",
["-dv", "--entitlements", "-", app],
{ allowNonZero: true, timeoutMs: 30_000 },
);
ent = parseEntitlements(cs.stderr + "\n" + cs.stdout);
} catch {
/* skip */
}
const sandboxed = ent.includes("com.apple.security.app-sandbox") ? true : null;
// Main executable from CFBundleExecutable
const exeName = plistGet(plistText, "CFBundleExecutable");
const mainExe = exeName
? join(app, "Contents", "MacOS", exeName)
: null;
const mainArch = mainExe ? await archsOf(mainExe) : [];
// Frameworks
const fwPaths = await findFrameworks(app);
const frameworks: FrameworkEntry[] = [];
for (const fp of fwPaths) {
const sz = await sizeOf(fp);
const archs = await archsOf(fp);
frameworks.push({
name: basename(fp),
path: fp,
size_bytes: sz,
archs,
});
}
const meta: Meta = {
app_path: app,
bundle_id,
version,
short_version,
sandboxed,
arbitrary_loads,
ats_exception_domains,
entitlements_summary: ent,
frameworks,
main_executable: mainExe,
main_executable_arch: mainArch,
collected_at: new Date().toISOString(),
};
await writeFile(out, JSON.stringify(meta, null, 2), "utf8");
console.log(
`snapshot: ${bundle_id || "<unknown>"} ${frameworks.length} frameworks -> ${out}`,
);
}
main().catch((e) => {
console.error(e instanceof Error ? e.message : String(e));
process.exit(1);
});
# Task
Generate a **vertical 4:5 (1080×1350)** infographic / Twitter card in Chinese for a tech audience, summarizing a static reverse-engineering audit of the macOS app `{{bundle_id}}` v{{version}}. The card must show what the app does behind the scenes: embedded SDKs, upload endpoints, on-disk queues.
# Visual Style
- Dark developer / terminal / code-audit aesthetic
- Background: very dark near-black charcoal `#0f1216`
- Inner panels: slightly lighter dark gray rounded rectangles `#181c23`, 14px radius, thin `#242932` border
- Monospace font for all code / paths / URLs / English (Menlo / JetBrains Mono look)
- Sans-serif Chinese font for titles and annotations (PingFang / STHeiti look)
- High contrast, crisp, no noise, no scanlines, no vignette
- NO fake browser chrome, NO mock OS window titlebars, NO gradients, NO emojis
# Color Palette (strict)
- Text: light gray `#dce2ea`
- Dim label: `#8c96a5`
- Accent cyan: `#6ec8ff`
- Red (sensitive / privacy): `#ff646e`
- Green (decoded / outgoing): `#82dc82`
- Yellow (metric / highlight): `#ffc85a`
- Orange (panel header): `#ffa55a`
- Purple (proto / keyword): `#c882ff`
# Layout (top → bottom)
## 1. Title bar (no panel)
- Line 1 (Chinese, large, bold, white): `{{app_display_name}} 这背后到底在做什么`
- Line 2 (smaller, dim cyan, monospace): `{{bundle_id}} · v{{version}} · sandbox={{sandboxed}} · ATS={{ats}}`
## 2. TOP PANEL — 嵌入的 SDK / 框架 (≈ 35% body)
- Header strip ORANGE `#ffa55a` with near-black text: `EMBEDDED SDKS · 这些框架被打包进来`
- 6 rows, one line each, columns: `<size> <dylib / framework name> <Chinese note>`
- Render EXACTLY the lines below. Any row ending with `← RED` is privacy-relevant and MUST be rendered entirely in red `#ff646e`; drop the `← RED` marker itself.
- Size column in YELLOW, name column in CYAN monospace, Chinese note in dim gray.
```
{{sdk_rows}}
```
## 3. MIDDLE PANEL — 上报端点 (≈ 35% body)
- Header strip GREEN `#82dc82` with near-black text: `ENDPOINTS · 它对外说话的对象`
- Each row: `<proto> <host + path> → <purpose>`
- Render EXACTLY the lines below. A row starting with `★` receives per-keystroke / per-tap data and MUST be rendered entirely in RED `#ff646e` (keep the star).
- Protocol in PURPLE, host/URL in GREEN monospace, purpose in dim gray Chinese.
```
{{endpoint_rows}}
```
## 4. BOTTOM PANEL — 本地落盘 (≈ 25% body)
- Header strip PURPLE `#c882ff` with near-black text: `LOCAL DATA · 它在你磁盘上留下的痕迹`
- Each row: `<size> <kind> <path> <note>`
- Render EXACTLY the lines below. Device ids in paths were already replaced with `〈redacted〉`, keep them as-is.
- Kind column in ORANGE, path in monospace CYAN.
```
{{local_rows}}
```
## 5. Footer (very bottom, very small DIM monospace)
- `audit by happy-app-audit · static analysis only · no packet capture`
# Critical Text Rendering Rules
- Render all text **exactly** as specified. Do NOT paraphrase, do NOT translate English terms to Chinese or vice versa, do NOT invent content.
- English code / paths / URLs / dylib names must be rendered in monospace with exact spelling and case.
- Chinese characters must render as real CJK glyphs. Do not stylize.
- The `★` glyph and `→` arrow must render as actual unicode characters.
- No drop shadows, no glow, no 3D, no emojis (only literal `★` and `→`).
- No watermarks, no logos, no borders around the whole image.
# Telemetry audit — {{bundle_id}}
> Generated by `happy-app-audit` at {{generated_at}}.
> Static analysis only. No network requests, no debugger attach. Read-only.
## Overview
| Field | Value |
|---|---|
| Bundle ID | `{{bundle_id}}` |
| Version | `{{version}}` |
| Path | `{{app_path}}` |
| Sandboxed | `{{sandboxed}}` |
| `NSAllowsArbitraryLoads` | `{{arbitrary_loads}}` |
| ATS exception domains | {{ats_exception_domains}} |
| Embedded frameworks | {{frameworks_count}} |
### Notable entitlements
{{entitlements}}
### Embedded frameworks (top by size)
{{frameworks_table}}
---
## SDKs detected
(From `matched.md`. Each row's `evidence` is in the per-binary string buckets
under `strings/`.)
{{matched_md}}
---
## Endpoints
The table below MUST be hand-written by Claude based on `endpoints.md` +
`strings/*.urls.md` + `strings/*.domains.md`. Frequencies must cite a literal
source; if no source is available, write `unknown — not stated in static evidence`.
{{endpoint_table}}
### Endpoint fingerprint matches
{{endpoints_md}}
---
## Local on-disk surface
(From `local_data.md`. SQLite shows `.schema` only — no row data was read.)
{{local_data}}
---
## Privacy summary
Claude must complete this section by hand, drawing only from the evidence in
the sections above. Each bullet should cite which file under this audit
directory backs it.
- _(e.g.) Per-keystroke upload to `{{...}}` — see `endpoint_table.md` row N + `strings/<binary>.urls.md`._
- _(e.g.) Cross-product `device_id` shared via `~/Library/Group Containers/group.<vendor>.*` — see `local_data.md`._
- _(e.g.) `NSAllowsArbitraryLoads=true` plus `audio-input` entitlement — see "Overview"._
If no privacy-relevant findings exist, write a single line saying so. **Do not
invent findings to fill the section.**
---
## Audit trail
The raw working files are kept alongside this report:
- `meta.json` — Phase 1 snapshot
- `strings/` — Phase 2 bucketed strings (`*.urls.md`, `*.domains.md`, `*.paths.md`, `*.sql.md`, `*.events.md`, `*.keys.md`)
- `matched.md` — Phase 3 SDK fingerprint matches
- `endpoints.md` — Phase 4 known-endpoint matches
- `endpoint_table.md` — hand-written endpoint summary
- `local_data.md` — Phase 5 on-disk inventory