Clean up iOS test setup

This commit is contained in:
Tim Perry
2026-08-07 10:25:58 +02:00
parent c33b43da1e
commit 7332c8a878
10 changed files with 4275 additions and 636 deletions
+31 -7
View File
@@ -1,10 +1,5 @@
name: CI
on:
push:
# TEMPORARY: skipped on ios-tests while the iOS spike is being iterated on there. Drop this
# before merging, and note it does not apply once that branch has a PR open.
branches-ignore: [ios-tests]
pull_request:
on: [push, pull_request]
jobs:
build:
name: Build & test
@@ -130,4 +125,33 @@ jobs:
./test/android/setup-emulator.sh
echo "Emulator ready to test"
cd test/android && npm install && npm test -- --retries 3
echo "Tests completed."
echo "Tests completed."
test-ios:
name: Test on iOS simulator
runs-on: macos-latest
steps:
- uses: actions/checkout@v7
- name: Set up Python
uses: actions/setup-python@v7
with:
python-version: '3.11'
- name: Set up Node.js
uses: actions/setup-node@v7
with:
node-version: '24'
cache: 'npm'
cache-dependency-path: 'test/ios/package-lock.json'
# Unlike Android, iOS runs our scripts via Frida's gadget, not the CLI, so this is here
# only for its copy of the ObjC bridge, which the gadget can't provide itself:
- name: Install Frida tools
run: pip install --user frida-tools==14.10.4
- name: Set up the simulator
run: ./test/ios/setup-simulator.sh
- name: Run tests
run: cd test/ios && npm install && npm test -- --retries 3
-629
View File
@@ -1,629 +0,0 @@
name: iOS gadget spike
# A spike, not a test suite: this answers one question, which is whether our scripts can intercept
# an app in the iOS simulator via Frida's gadget, with no jailbreak and no app changes. If they
# can, automated iOS testing is worth building on top of it. If they can't, nothing else matters.
#
# It runs a real proxy and checks that the app's HTTPS traffic reaches it, so this covers the whole
# chain: injection, hooking, connection redirection and certificate trust.
#
on:
workflow_dispatch:
push:
paths:
- '.github/workflows/ios-gadget-spike.yml'
- 'ios/**'
- 'config.js'
- 'native-*.js'
env:
# Gadget 17.16.4 segfaults in its own initialisation on this simulator, with no script of ours
# loaded at all, while 16.7.19 does not. So rather than pinning a version, we walk back from
# the newest until one survives, which also tells us where it broke.
GADGET_VERSIONS: >-
17.16.4 17.16.0 17.15.0 17.14.0 17.12.0 17.10.0 17.8.0 17.6.0 17.4.0 17.2.0 17.0.0 16.7.19
FRIDA_TOOLS_VERSION: 14.10.4 # Matched to the pin in ci.yml, for the ObjC bridge below
APP_NAME: SpikeApp
BUNDLE_ID: com.httptoolkit.gadget-spike
jobs:
gadget-spike:
name: Load our scripts via Frida gadget in the simulator
runs-on: macos-latest
steps:
- uses: actions/checkout@v7
- name: Boot a simulator
run: |
UDID=$(xcrun simctl list devices available -j | python3 -c "
import json, sys
devices = json.load(sys.stdin)['devices']
for runtime, entries in sorted(devices.items()):
for device in entries:
if 'iPhone' in device['name']:
print(device['udid'], device['name'], runtime, file=sys.stderr)
print(device['udid'])
sys.exit(0)
sys.exit('No iPhone simulator available')
")
echo "UDID=$UDID" >> $GITHUB_ENV
xcrun simctl boot "$UDID"
xcrun simctl bootstatus "$UDID"
- name: Build an app to inject into
run: |
# Deliberately a purpose-built app rather than the iOS pinning demo. The demo's own
# dependencies (AFNetworking, TrustKit) don't currently build against the runner's SDK,
# and this spike is about whether our scripts work under the gadget, not about the
# demo's build health. Testing the demo's pinning cases needs UI automation we don't
# have on iOS yet in any case - see IMPROVEMENTS.md.
APP="$RUNNER_TEMP/$APP_NAME.app"
mkdir -p "$APP"
# It makes a repeated HTTPS request, so we can check that real app traffic (not just
# traffic our injected script generates) ends up intercepted:
cat > "$RUNNER_TEMP/main.swift" <<'SWIFT'
import UIKit
class AppDelegate: NSObject, UIApplicationDelegate {
var window: UIWindow?
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions options: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
window = UIWindow(frame: UIScreen.main.bounds)
let controller = UIViewController()
controller.view.backgroundColor = .white
window?.rootViewController = controller
window?.makeKeyAndVisible()
NSLog("SPIKE-APP: launched")
// Repeated, so that the gadget's exact setup timing doesn't matter:
Timer.scheduledTimer(withTimeInterval: 10, repeats: true) { _ in
let url = URL(string: "https://example.com/from-the-app")!
URLSession.shared.dataTask(with: url) { _, response, error in
if let error = error {
NSLog("SPIKE-APP: request failed: \(error.localizedDescription)")
} else if let http = response as? HTTPURLResponse {
NSLog("SPIKE-APP: request got status \(http.statusCode)")
}
}.resume()
}
return true
}
}
UIApplicationMain(
CommandLine.argc,
CommandLine.unsafeArgv,
nil,
NSStringFromClass(AppDelegate.self)
)
SWIFT
cat > "$APP/Info.plist" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key><string>$APP_NAME</string>
<key>CFBundleIdentifier</key><string>$BUNDLE_ID</string>
<key>CFBundleName</key><string>$APP_NAME</string>
<key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleShortVersionString</key><string>1.0</string>
<key>CFBundleVersion</key><string>1</string>
<key>CFBundleSupportedPlatforms</key><array><string>iPhoneSimulator</string></array>
<key>DTPlatformName</key><string>iphonesimulator</string>
<key>MinimumOSVersion</key><string>15.0</string>
<key>UIDeviceFamily</key><array><integer>1</integer></array>
<key>UILaunchScreen</key><dict/>
</dict>
</plist>
EOF
plutil -lint "$APP/Info.plist"
# For the simulator we booted, which runs the runner's own architecture:
xcrun --sdk iphonesimulator swiftc \
-sdk "$(xcrun --sdk iphonesimulator --show-sdk-path)" \
-target "$(uname -m)-apple-ios15.0-simulator" \
-swift-version 5 \
-o "$APP/$APP_NAME" \
"$RUNNER_TEMP/main.swift"
# Ad-hoc signing, which is all the simulator asks for:
codesign --force --sign - "$APP"
file "$APP/$APP_NAME"
echo "APP_PATH=$APP" >> $GITHUB_ENV
- name: Install the app
run: |
xcrun simctl install "$UDID" "$APP_PATH"
# Confirms the simulator accepted the bundle, rather than finding out at launch:
xcrun simctl listapps "$UDID" | grep -q "$BUNDLE_ID"
- name: Start a proxy to intercept through
run: |
# A real proxy, so we can prove interception end to end rather than just that our hooks
# were installed. It provides the CA that config.js is configured to trust, below:
mkdir -p "$RUNNER_TEMP/proxy" && cd "$RUNNER_TEMP/proxy"
npm init -y > /dev/null
npm install mockttp --silent
cat > proxy.mjs <<'EOF'
import * as mockttp from 'mockttp';
import * as fs from 'fs/promises';
const ca = await mockttp.generateCACertificate();
await fs.writeFile(process.env.RUNNER_TEMP + '/ca.pem', ca.cert);
const server = mockttp.getLocal({
https: ca,
socks: true,
passthrough: ['unknown-protocol']
});
await server.forAnyRequest().thenCallback((req) => {
console.log(`PROXY-SAW: ${req.url}`);
return { statusCode: 200, body: 'Mocked by the spike' };
});
await server.on('tls-client-error', (e) =>
console.log(`PROXY-TLS-REJECTED: ${e.tlsMetadata.sniHostname}`));
await server.start(8000);
console.log(`PROXY-READY on port ${server.port}`);
EOF
node proxy.mjs > "$RUNNER_TEMP/proxy.log" 2>&1 &
for _ in $(seq 30); do
grep -q PROXY-READY "$RUNNER_TEMP/proxy.log" && break
sleep 1
done
cat "$RUNNER_TEMP/proxy.log"
# Otherwise the app's failure to connect later would look like a hooking problem:
grep -q PROXY-READY "$RUNNER_TEMP/proxy.log"
- name: Assemble the scripts to inject
run: |
# Only for its copy of the ObjC bridge - see below:
python3 -m pip install --quiet --break-system-packages "frida-tools==$FRIDA_TOOLS_VERSION"
# The app's own data container: somewhere it can definitely write, so our scripts can
# report that they ran without depending on log output being captured:
DATA_DIR="$(xcrun simctl get_app_container "$UDID" "$BUNDLE_ID" data)"
echo "DATA_DIR=$DATA_DIR" >> $GITHUB_ENV
export DATA_DIR
python3 - <<'EOF'
import os, re, pathlib, frida_tools
# The proxy's own CA, so that trusting it is a real test of our TLS hooks:
cert = open(os.environ['RUNNER_TEMP'] + '/ca.pem').read().strip()
config = open('config.js').read()
config = re.sub(r'(?<=const CERT_PEM = `)[^`]+(?=`)', lambda _: cert, config, flags=re.S)
config = re.sub(r"(?<=const PROXY_HOST = ')[^']+(?=')", '127.0.0.1', config)
config = re.sub(r'(?<=const PROXY_PORT = )\d+(?=;)', '8000', config)
# Frida 17 unbundled the language bridges. The CLI hands them to the script on demand
# when it touches ObjC, but the gadget has no host to ask, so a script that uses ObjC
# (as ios-disable-detection.js does) has to bring its own or fail with a ReferenceError.
# This is the same prebuilt bundle that `frida -l` would have supplied:
bridge_path = pathlib.Path(frida_tools.__file__).parent / 'bridges' / 'objc.js'
objc_bridge = (
'(() => {\n' +
bridge_path.read_text() +
'\nglobalThis.ObjC = bridge;\n' +
'})();'
)
# Once the hooks are in, make a request through the app's own networking stack. That
# proves the whole chain (injection, hooking, redirection, TLS) without needing to
# drive the app's UI, which is a separate problem for the tests proper:
send_a_request = '''
console.log("SPIKE-MARKER: all scripts loaded");
// Written as well as logged, since whether the gadget's console output reaches the
// simulator log is a separate question from whether our scripts ran:
try {
const marker = new File("__MARKER_PATH__", "w");
marker.write("all scripts loaded\\n");
marker.close();
} catch (e) {
console.log(`SPIKE-MARKER: could not write the marker file: ${e}`);
}
setTimeout(() => {
if (!ObjC.available) return console.log("SPIKE-REQUEST: no ObjC runtime");
try {
const url = ObjC.classes.NSURL.URLWithString_("https://example.com/spike");
// Kept globally, so it isn't collected before the request completes:
globalThis.spikeHandler = new ObjC.Block({
retType: "void",
argTypes: ["object", "object", "object"],
implementation: (data, response, error) => {
try {
if (error) console.log(`SPIKE-REQUEST: failed: ${error.localizedDescription()}`);
else console.log(`SPIKE-REQUEST: got status ${response.statusCode()}`);
} catch (e) {
console.log(`SPIKE-REQUEST: completed, but could not read it: ${e}`);
}
}
});
ObjC.classes.NSURLSession.sharedSession()
.dataTaskWithURL_completionHandler_(url, globalThis.spikeHandler)
.resume();
console.log("SPIKE-REQUEST: sent");
} catch (e) {
console.log(`SPIKE-REQUEST: could not send: ${e}`);
}
}, 5000);
'''
# The gadget's console output doesn't reach the simulator log at all (established by the
# script mode check below), so we tee it to a file in the app's own data container,
# which we can read back afterwards. Without this our scripts fail silently:
preamble = '''
const SPIKE_LOG = "__LOG_PATH__";
function spikeLog(line) {
try {
const file = new File(SPIKE_LOG, "a");
file.write(line + "\\n");
file.close();
} catch (e) {}
}
console.log = function () { spikeLog(Array.prototype.join.call(arguments, " ")); };
console.warn = console.log;
console.error = console.log;
console.debug = console.log;
'''.replace('__LOG_PATH__', os.environ['DATA_DIR'] + '/gadget.log')
# The gadget loads a single script, so we combine them exactly as the README's iOS
# command does, plus the marker & request above. Each is announced, so a failure can be
# placed even if it happens before anything is logged:
scripts = [
('the ObjC bridge', objc_bridge),
('config.js', config),
('ios/ios-connect-hook.js', open('ios/ios-connect-hook.js').read()),
('ios/ios-disable-detection.js', open('ios/ios-disable-detection.js').read()),
('native-tls-hook.js', open('native-tls-hook.js').read()),
('native-connect-hook.js', open('native-connect-hook.js').read()),
('the spike additions',
send_a_request.replace('__MARKER_PATH__',
os.environ['DATA_DIR'] + '/scripts-loaded.txt'))
]
body = '\n'.join(
f'spikeLog("SPIKE-STEP: running {name}");\n{source}'
for name, source in scripts
)
# N.b. one try block around all of them, not one each: they're written to share a single
# top-level scope, and a block per script would hide each script's declarations from the
# next. This still reports the error, which the gadget otherwise swallows entirely.
with open(os.environ['RUNNER_TEMP'] + '/spike.js', 'w') as output:
output.write(
preamble +
'\ntry {\n' + body + '\n} catch (error) {\n' +
' spikeLog("SPIKE-FAILED: " + error + "\\n" + (error && error.stack));\n' +
'}\n'
)
EOF
# Script mode, so the gadget runs our script at startup instead of pausing the app to
# wait for a client to attach:
cat > "$RUNNER_TEMP/frida-gadget.config" <<EOF
{
"interaction": {
"type": "script",
"path": "$RUNNER_TEMP/spike.js",
"on_change": "ignore"
}
}
EOF
wc -l "$RUNNER_TEMP/spike.js"
- name: Check the app runs without the gadget
run: |
# Otherwise a broken app and broken injection are indistinguishable, since both show up
# as nothing at all in the logs.
#
# N.b. checked by pid throughout: matching the app by name also matches our own log
# stream commands, whose predicate contains the app's name.
xcrun simctl launch "$UDID" "$BUNDLE_ID" | tee "$RUNNER_TEMP/baseline.log"
sleep 5
ps -p "$(awk '{print $2}' "$RUNNER_TEMP/baseline.log")"
xcrun simctl terminate "$UDID" "$BUNDLE_ID"
- name: Find a gadget version that can run a script here
run: |
# Recent gadgets segfault on this simulator: 17.16.4 dies during its own initialisation
# with nothing of ours loaded, and 17.16.0 survives that but dies as soon as it runs a
# script. So rather than pinning a version, walk back from the newest until one works,
# which gets us running and shows where this broke.
#
# N.b. tested in script mode with a trivial script, not listen mode: script mode is what
# we actually need, and 17.16.0 proves surviving without one means nothing.
BUNDLE_DIR="$(xcrun simctl get_app_container "$UDID" "$BUNDLE_ID" app)"
echo "BUNDLE_DIR=$BUNDLE_DIR" >> $GITHUB_ENV
echo "Installed at: $BUNDLE_DIR"
# Inside the bundle, which is where a gadget is normally shipped, and is somewhere the
# app can definitely read from:
mkdir -p "$BUNDLE_DIR/Frameworks"
cp "$RUNNER_TEMP/spike.js" "$BUNDLE_DIR/Frameworks/spike.js"
cat > "$BUNDLE_DIR/Frameworks/trivial.js" <<EOF
console.log("SPIKE-TRIVIAL: script mode works");
const file = new File("$DATA_DIR/trivial-ran.txt", "w");
file.write("ran\n");
file.close();
EOF
printf '%s\n' \
"{ \"interaction\": { \"type\": \"script\", \"path\": \"$BUNDLE_DIR/Frameworks/trivial.js\", \"on_change\": \"ignore\" } }" \
> "$BUNDLE_DIR/Frameworks/FridaGadget.config"
WORKING=""
for VERSION in $GADGET_VERSIONS; do
echo
echo "### Frida $VERSION"
if ! curl -sSfL -o "$RUNNER_TEMP/gadget.dylib.xz" \
"https://github.com/frida/frida/releases/download/$VERSION/frida-gadget-$VERSION-ios-simulator-universal.dylib.xz"
then
echo " no simulator gadget published for this version"
continue
fi
rm -f "$RUNNER_TEMP/gadget.dylib"
unxz "$RUNNER_TEMP/gadget.dylib.xz"
cp "$RUNNER_TEMP/gadget.dylib" "$BUNDLE_DIR/Frameworks/FridaGadget.dylib"
codesign --force --sign - "$BUNDLE_DIR" # The bundle's contents just changed
xcrun simctl terminate "$UDID" "$BUNDLE_ID" > /dev/null 2>&1 || true
rm -f "$DATA_DIR/trivial-ran.txt" # Otherwise a previous version's marker counts
if ! SIMCTL_CHILD_DYLD_INSERT_LIBRARIES="$BUNDLE_DIR/Frameworks/FridaGadget.dylib" \
xcrun simctl launch "$UDID" "$BUNDLE_ID" > "$RUNNER_TEMP/attempt.log" 2>&1
then
echo " launch refused: $(cat "$RUNNER_TEMP/attempt.log")"
continue
fi
# N.b. by pid: matching the app by name also matches our own log stream commands,
# whose predicate contains the app's name.
PID="$(awk '{print $2}' "$RUNNER_TEMP/attempt.log")"
sleep 10
if ! ps -p "$PID" > /dev/null 2>&1; then
echo " the app crashed (pid $PID)"
continue
fi
if [ ! -f "$DATA_DIR/trivial-ran.txt" ]; then
echo " the app survived, but the gadget never ran the script (pid $PID)"
continue
fi
echo " the app survived & ran our script (pid $PID)"
WORKING="$VERSION"
xcrun simctl terminate "$UDID" "$BUNDLE_ID" > /dev/null 2>&1 || true
break
done
if [ -z "$WORKING" ]; then
echo
echo "No gadget version worked at all - see the crash reports below"
exit 1
fi
echo
echo "Using Frida $WORKING"
echo "GADGET_VERSION=$WORKING" >> $GITHUB_ENV
- name: Check whether the gadget's output reaches the simulator log
run: |
# The search above leaves the working gadget & the trivial script in place, and has
# already proved script mode runs, so this answers only the remaining question: does the
# gadget's console output show up in the log? That decides how much the log-based checks
# below are worth, and so whether our scripts need to report on themselves.
rm -f "$DATA_DIR/trivial-ran.txt"
xcrun simctl spawn "$UDID" log stream --level debug \
--predicate 'eventMessage CONTAINS "SPIKE-"' > trivial.log 2>&1 &
LOG_PID=$!
sleep 5
xcrun simctl terminate "$UDID" "$BUNDLE_ID" > /dev/null 2>&1 || true
SIMCTL_CHILD_DYLD_INSERT_LIBRARIES="$BUNDLE_DIR/Frameworks/FridaGadget.dylib" \
xcrun simctl launch "$UDID" "$BUNDLE_ID"
sleep 10
kill $LOG_PID 2>/dev/null || true
xcrun simctl terminate "$UDID" "$BUNDLE_ID" > /dev/null 2>&1 || true
echo "=== log output:"
cat trivial.log
if grep -q "SPIKE-TRIVIAL" trivial.log; then
echo "=> the gadget's console output does reach the simulator log"
else
echo "=> the gadget's console output does NOT reach the simulator log, so only the" \
"marker file & proxy evidence mean anything below"
fi
- name: Launch the app with the gadget injected
run: |
# Started before the app & given time to attach, since our scripts log within
# milliseconds of the process starting. The predicate also matches our markers by
# message, in case the gadget's output reaches the log by some other route:
xcrun simctl spawn "$UDID" log stream --level debug \
--predicate "processImagePath CONTAINS \"$APP_NAME\"
OR eventMessage CONTAINS \"SPIKE-\"
OR eventMessage CONTAINS \"== Hooked\"
OR eventMessage CONTAINS \"== Redirecting\"" > simulator.log 2>&1 &
LOG_PID=$!
sleep 5
# DYLD_INSERT_LIBRARIES via SIMCTL_CHILD_ injects into the app with no modification to
# it at all - no repackaging, no re-signing.
#
# N.b. run in the foreground: simctl launch returns as soon as the app is spawned, and
# its exit status is the only direct evidence that the launch was accepted at all.
# So that what we read back afterwards is definitely from this run:
rm -f "$DATA_DIR/gadget.log" "$DATA_DIR/scripts-loaded.txt"
# Both the gadget & our script from inside the app bundle, which is how a gadget would
# normally be shipped, and rules out the app being unable to read them:
printf '%s\n' \
"{ \"interaction\": { \"type\": \"script\", \"path\": \"$BUNDLE_DIR/Frameworks/spike.js\", \"on_change\": \"ignore\" } }" \
> "$BUNDLE_DIR/Frameworks/FridaGadget.config"
# N.b. not piped to tee, as the default shell doesn't set pipefail, which would hide a
# failure here behind tee's exit status:
SIMCTL_CHILD_DYLD_INSERT_LIBRARIES="$BUNDLE_DIR/Frameworks/FridaGadget.dylib" \
xcrun simctl launch --terminate-running-process \
"$UDID" "$BUNDLE_ID" > launch.log 2>&1 || {
echo "simctl launch failed:"
cat launch.log
exit 1
}
cat launch.log
APP_PID="$(awk '{print $2}' launch.log)"
echo "APP_PID=$APP_PID" >> $GITHUB_ENV
echo "=== app process immediately after launch:"
ps -p "$APP_PID" || echo "(gone - it did not survive startup)"
# The app requests every 10s, so this covers several:
sleep 45
kill $LOG_PID 2>/dev/null || true
echo "=== app process still running?"
ps -p "$APP_PID" || echo "(gone - the app is not running)"
- name: Report what happened
if: always() # Especially when the launch itself failed
run: |
echo "=== launch output:"
cat launch.log || true
echo
echo "=== simulator log:"
cat simulator.log || true
echo
echo "=== our scripts' own output, from inside the app:"
cat "$DATA_DIR/gadget.log" || echo "(nothing - our scripts never ran at all)"
echo
echo "=== proxy log:"
cat "$RUNNER_TEMP/proxy.log" || true
echo
echo "=== crash reports, if any:"
# .ips reports are two JSON documents, and the interesting fields (why it died, and
# where) come long after the boilerplate, so we pick them out rather than truncating:
python3 - <<'EOF'
import glob, json, os, time
reports = [
path for path in
sorted(glob.glob(os.path.expanduser('~/Library/Logs/DiagnosticReports/*.ips')))
if time.time() - os.path.getmtime(path) < 900
]
if not reports:
print('(none)')
for path in reports:
print(f'--- {os.path.basename(path)}')
try:
with open(path) as file:
file.readline() # The metadata header, which we've already got
report = json.loads(file.read())
except Exception as error:
print(f' (could not parse: {error})')
continue
for key in ('exception', 'termination', 'exitReason', 'asi', 'asiBacktraces'):
if key in report:
print(f' {key}: {json.dumps(report[key])[:1500]}')
images = report.get('usedImages', [])
threads = report.get('threads', [])
faulting = report.get('faultingThread')
if faulting is not None and faulting < len(threads):
print(' faulting thread:')
for frame in threads[faulting].get('frames', [])[:15]:
index = frame.get('imageIndex', -1)
image = images[index] if 0 <= index < len(images) else {}
name = image.get('name') or image.get('path') or '?'
print(f" {name} + {frame.get('imageOffset')} {frame.get('symbol', '')}")
EOF
- name: Check the result
run: |
# N.b. includes the gadget log our scripts write themselves: their console output does
# not reach the simulator log, so that file is the only place their messages appear.
OUTPUT="$(cat launch.log simulator.log "$DATA_DIR/gadget.log" \
"$RUNNER_TEMP/proxy.log" 2>/dev/null || true)"
check() {
if grep -qF "$1" <<< "$OUTPUT"; then
echo "PASS: $2"
else
echo "FAIL: $2 (expected to find '$1')"
FAILED=1
fi
}
# The marker proves every script ran to completion; the rest prove they did something.
# N.b. these match the scripts' success messages specifically - "libboringssl.dylib"
# alone would also match the message logged when hooking it fails:
# The marker file, rather than the log line, is what proves our scripts ran to
# completion: it doesn't depend on the gadget's output being captured at all.
if [ -f "$DATA_DIR/scripts-loaded.txt" ]; then
echo "PASS: our scripts ran to completion under the gadget"
else
echo "FAIL: our scripts did not run to completion (no marker file written)." \
"Their own output, above, shows which script they got to & why it stopped."
FAILED=1
fi
check "SPIKE-APP: launched" "the app started with the gadget injected"
check "SPIKE-MARKER: all scripts loaded" "our scripts ran under the gadget"
check "== Redirecting all TCP connections to 127.0.0.1:8000 ==" \
"native-connect-hook hooked, with our config applied"
check "== Redirecting Network framework connections to 127.0.0.1:8000 ==" \
"ios-connect-hook hooked Network framework"
check "== Hooked native TLS lib libboringssl.dylib ==" "native-tls-hook hooked iOS's TLS"
# The real question: does traffic actually end up intercepted? Redirection and
# certificate trust both have to work for the proxy to see these, and a
# PROXY-TLS-REJECTED line distinguishes the two if it doesn't.
#
# The app's own request is the case that matters; the script-generated one also
# confirms the bundled ObjC bridge works, since it goes through it:
check "PROXY-SAW: https://example.com/from-the-app" "the app's own HTTPS request was intercepted"
check "SPIKE-APP: request got status 200" "the app got the proxy's response back"
check "PROXY-SAW: https://example.com/spike" "our script's HTTPS request was intercepted"
check "SPIKE-REQUEST: got status 200" "our script got the proxy's response back"
if ! ps -p "$APP_PID" > /dev/null 2>&1; then
echo "FAIL: the app is not running - it may have crashed (see the report above)"
FAILED=1
else
echo "PASS: the app survived injection"
fi
exit ${FAILED:-0}