npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@psync/anti-jailbreak

v0.14.1

Published

A lightweight and reliable React Native Nitro Module to detect rooted Android devices and jailbroken iOS devices using C++, Kotlin, and Swift. Built for security-focused mobile applications.

Readme

@psync/anti-jailbreak

npm version License: MIT

A React Native Nitro Module (New Architecture only) for detecting rooted (Android) and jailbroken (iOS) devices, emulators/simulators, attached debuggers, and runtime instrumentation frameworks (Frida, Zygisk, LSPosed, Riru). Features a scored risk API, stable signal catalog, and optional periodic security watchdog.

Security Note: Client-side detection is a defense-in-depth heuristic, not a guarantee. Determined attackers can hook or bypass checks. Never use client booleans as sole authorization for sensitive actions—pair with backend Play Integrity / App Attest verification.

Store-origin note: The library exposes a best-effort getInstallOrigin() provenance getter, but it does not fire detection signals on iOS and the Android finding is purely informational. Android reads the installer record (Google Play vs. explicitly non-Play vs. unknown for ADB/system installs). iOS uses StoreKit 2 AppTransaction on iOS 16+, where StoreKit cryptographically verifies the transaction before it is surfaced as .verified (.unverified and errors never claim an origin), and falls back to the legacy App Store receipt on iOS 15 (app_store for StoreKit/receipt, testflight for StoreKit/sandboxReceipt, unknown for everything else). The iOS 15 fallback is existence-only and spoofable, and absence is legitimate in many benign states (Xcode/dev, simulator, sideloaded, enterprise, transiently-missing), so it is never treated as a finding — pair with DeviceCheck / App Attest for cryptographic install verification.


Screenshots

| Legacy boolean checks | Scored result, watchdog & diagnostics | | --- | --- | | | |

The demo app in example/ exercising both the legacy boolean API and the scored checkDetailed() API on an iOS simulator.


Installation

# npm
npm install @psync/anti-jailbreak react-native-nitro-modules

# bun (recommended for development)
bun add @psync/anti-jailbreak react-native-nitro-modules

(Requires React Native 0.83+ New Architecture and react-native-nitro-modules >=0.35.10 <0.37.0 — see the compatibility matrix below)

Compatibility

@psync/anti-jailbreak is built on react-native-nitro-modules HybridObjects implemented in shared C++. The C++ must override NitroModules HybridObject virtuals with exact signatures, so compatibility is pinned to the NitroModules C++ ABI.

| react-native-nitro-modules | Status | Notes | | --- | --- | --- | | < 0.35.0 | ❌ Not supported | HybridObject virtuals differ; outside the peer range. | | 0.35.00.35.x | ✅ Supported | HybridObject::getExternalMemorySize() is the override target; do not use the legacy getMemorySize() name. | | 0.36.x | ✅ Supported | Same HybridObject::getExternalMemorySize() API; verified against 0.36.1. No code changes required. | | 0.37.x and above | ⚠️ Unverified | Not yet validated. Re-run bun run specs and a clean native build (iOS pod build + Android Gradle clean) before adopting, since NitroModules may rename or remove HybridObject virtuals again. |

Why this matters: NitroModules once exposed HybridObject::getMemorySize() and later renamed it to getExternalMemorySize(). Because iOS consumes NitroModules as a source-built CocoaPod (live headers) while Android consumes it via a prefab/prebuilt header snapshot, an API rename can fail the iOS build while a cached Android build still passes. Whenever you bump NitroModules, clear both caches and rebuild natively.

iOS & Expo

  • iOS: Run cd ios && pod install
  • Expo: Custom dev client or EAS Build required (cannot run in Expo Go). The package ships an Expo config plugin (app.plugin.js) that adds narrowly scoped <queries> entries for known root-manager apps on Android; it is wired automatically, but can be referenced explicitly with plugins: ["@psync/anti-jailbreak"] in app.json if package plugins are not auto-resolved. @expo/config-plugins is an optional peer (listed in peerDependencies and marked optional via peerDependenciesMeta, so npm surfaces it without requiring installation); Expo prebuild always provides it as a transitive dependency of expo.

Quick Start

import {
  checkDetailed,
  isDeviceCompromised,
  startSecurityWatchdog,
} from '@psync/anti-jailbreak';

// 1. Primary scored API — full structured assessment
const result = await checkDetailed();
console.log(result.score, result.compromised, result.confidence);

// 2. Legacy boolean helper (derives from checkDetailed)
if (await isDeviceCompromised()) {
  console.warn('Device compromised!');
}

// 3. Optional periodic background watchdog
startSecurityWatchdog({ intervalMs: 5000, protectionMode: 'LOG_ONLY' });

Usage examples

App-startup security gate

Run a single structured pass at startup, derive every value you need from the one result, and avoid calling multiple wrappers (each wrapper would re-run the native pass):

import { useEffect } from 'react';
import { Alert } from 'react-native';
import { checkDetailed, getDetectionReasons } from '@psync/anti-jailbreak';

function useStartupSecurityGate() {
  useEffect(() => {
    let cancelled = false;

    (async () => {
      try {
        const result = await checkDetailed();
        if (cancelled) return;

        // One pass — derive every boolean locally instead of calling
        // isDeviceCompromised()/isEmulator()/isDebuggerAttached() again.
        const isEmu = result.signals.some(
          (s) => s.id.startsWith('android.emulator') || s.id.startsWith('ios.simulator'),
        );

        if (result.compromised) {
          const reasons = await getDetectionReasons();
          Alert.alert(
            'Security warning',
            `Device score ${Math.round(result.score)}/100.\n${reasons.join('\n')}`,
          );
        }

        console.log({
          compromised: result.compromised,
          score: result.score,
          confidence: result.confidence,
          emulator: isEmu,
          debugger: result.debuggerDetected,
          partial: result.partial,
        });
      } catch (error) {
        // checkDetailed() propagates native errors — decide your own policy.
        console.error('Security check failed:', error);
      }
    })();

    return () => {
      cancelled = true;
    };
  }, []);
}

Working with the scored API

checkDetailed() (or its alias assessRisk()) returns a CompromiseAssessment. Iterate signals for analytics, gating, or telemetry. Always filter out unavailable signals — they mean "the check couldn't run", not "evidence of compromise":

import { checkDetailed } from '@psync/anti-jailbreak';

const result = await checkDetailed();

// Positive findings only — skip unavailable rows.
const positiveSignals = result.signals.filter(
  (s) => s.unavailable !== true && s.detected,
);

for (const signal of positiveSignals) {
  console.log({
    id: signal.id,           // stable, e.g. 'android.mount.magisk'
    platform: signal.platform, // 'android' | 'ios'
    category: signal.category, // 'mount' | 'injection' | ... (see enum below)
    severity: signal.severity, // 'low' | 'medium' | 'high'
    score: signal.score,      // weight this signal contributed
    reliability: signal.reliability, // 0..1 — backend policy hint
    evidence: signal.evidence, // present only if includeEvidence is enabled
  });
}

// Partial results: the timeoutMs budget ran out before every check could run.
// Treat as non-authoritative and re-check before deciding.
if (result.partial) {
  console.warn('Detection pass was partial; some checks did not complete.');
}

// Confidence reflects how complete and convergent the evidence is.
// 'extreme' is reserved for passes with multiple high-severity signals
// from independent categories with a score near 100.
if (result.confidence === 'extreme' || result.score >= 80) {
  // Strong local indicator — still cross-check server-side.
}

Configuring thresholds and behavior

configure() updates the native HybridObject in place; subsequent checkDetailed() passes and watchdog ticks observe the new values. Pass undefined for any field you want to leave untouched:

import { configure } from '@psync/anti-jailbreak';

configure({
  // `compromised` becomes true at or above this score (default 40).
  // Lower → stricter (more devices flagged); higher → looser.
  minScore: 50,

  // Total wall-clock budget per pass in ms (default 600).
  // Overrun checks return `unavailable` signals and `partial: true`.
  timeoutMs: 600,

  // Redacted per-signal evidence hints. Development/debug only — the native
  // core forces this off in release (NDEBUG) builds regardless.
  includeEvidence: __DEV__,

  // Off by default. Fold debugger attachment into `compromised` in addition
  // to surfacing it on `debuggerDetected`. A debugger alone is not an attack.
  treatDebuggerAsCompromise: false,
});

iOS URL-scheme probing

iOS URL-scheme checks use UIApplication.canOpenURL, which only sees schemes declared in the host app's LSApplicationQueriesSchemes — an undeclared scheme always returns false, silently, whether or not a handler is installed. The cap is shared with the host app's own queries: apps linked on iOS 15+ may declare at most 50 schemes; apps linked on iOS 27+ are limited to 25 (where canOpenURL is also deprecated, though still functional). Keep the list minimal and configurable.

import { configure } from '@psync/anti-jailbreak';

configure({
  urlSchemes: {
    // Defaults are the four most common jailbreak-store schemes.
    schemes: ['cydia', 'sileo', 'zbra', 'filza'],
    // Set to [] to disable URL-scheme probing entirely.
    // schemes: [],

    // Additionally emit one informational `ios.urlscheme.<scheme>` signal
    // per responding scheme. The aggregate `ios.urlscheme.jailbreak_store`
    // signal (and its score contribution) is emitted in both modes.
    perSchemeSignals: true,
  },
});

For bare React Native, declare the schemes in ios/<App>/Info.plist:

<key>LSApplicationQueriesSchemes</key>
<array>
  <string>cydia</string>
  <string>sileo</string>
  <string>zbra</string>
  <string>filza</string>
</array>

Undeclared schemes always return false — the probe fails closed (never a false positive), but a missing declaration silently disables that scheme's check.

For Expo, the config plugin's own urlSchemes prop (separate from configure()) merges schemes into Info.plist during prebuild and enforces the entry cap (50 by default; pass schemeCap: 25 if your app links on iOS 27+):

{
  "expo": {
    "plugins": [
      ["@psync/anti-jailbreak", { "urlSchemes": ["cydia", "sileo"], "schemeCap": 25 }]
    ]
  }
}

Pass urlSchemes: [] to skip adding any schemes, or omit the prop to use the defaults. The plugin never requests QUERY_ALL_PACKAGES and only adds narrowly scoped <queries> entries for known root-manager apps on Android.

Background watchdog

The watchdog consumes checkDetailed() on its own background thread using the configured threshold. It does not duplicate detection logic. Use LOG_ONLY for safe testing — never TERMINATE in automated tests:

import {
  startSecurityWatchdog,
  stopSecurityWatchdog,
  type LegacySecurityWatchdogOptions,
} from '@psync/anti-jailbreak';

const options: LegacySecurityWatchdogOptions = {
  intervalMs: 5000, // milliseconds between ticks (legacy alias: `interval`)
  protectionMode: 'LOG_ONLY', // 'LOG_ONLY' | 'THROW_EXCEPTION' | 'TERMINATE'
};

startSecurityWatchdog(options);

// Later (e.g. on logout, or once your server has re-attested the device):
stopSecurityWatchdog();

Notes on protection modes:

  • LOG_ONLY — Safe everywhere. Use for testing and when JS-side policy is the source of truth.
  • THROW_EXCEPTION — Demoted to a logged warning on the background thread (it cannot throw into the JS runtime). Retained for API completeness; poll checkDetailed() from JS to actually react.
  • TERMINATE — Ends the process via std::terminate(). Destructive; do not exercise in tests.

Error handling per wrapper

The wrappers preserve v1 error semantics for backwards compatibility. isDeviceCompromised() is the only one that rethrows — the others return safe fallbacks so a faulty probe can never crash your app:

import {
  isDeviceCompromised,
  isEmulator,
  isDebuggerAttached,
  getDetectionReasons,
  checkDetailed,
} from '@psync/anti-jailbreak';

// checkDetailed() — propagates native errors directly.
try {
  const result = await checkDetailed();
} catch (error) {
  // Handle or surface to the user.
}

// isDeviceCompromised() — logs and RETHROWS. Always wrap in try/catch.
try {
  if (await isDeviceCompromised()) {
    /* … */
  }
} catch (error) {
  /* Native failure — decide policy (fail open or closed). */
}

// isEmulator() / isDebuggerAttached() / getDetectionReasons() — log and
// return safe fallbacks (false / false / []). They never throw.
const isEmu = await isEmulator(); // never throws
const reasons = await getDetectionReasons(); // never throws

Recommended pattern: pair with backend attestation

Client heuristics are bypassable. For sensitive decisions, send the score and signal ids to your backend as a hint, and combine with hardware-backed attestation that the server verifies:

import { checkDetailed } from '@psync/anti-jailbreak';

async function fetchSessionToken() {
  const assessment = await checkDetailed();

  const response = await fetch('/api/session', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      // Hints only — never the sole authorization factor.
      clientRiskScore: assessment.score,
      clientConfidence: assessment.confidence,
      clientPartial: assessment.partial,
      clientSignalIds: assessment.signals
        .filter((s) => s.unavailable !== true && s.detected)
        .map((s) => s.id),

      // Authoritative signal (acquire separately via Play Integrity / App
      // Attest and verify server-side). This library does not issue tokens.
      // integrityToken: await getPlayIntegrityToken(),
    }),
  });

  return response.json();
}

API Summary

  • checkDetailed(): Promise<CompromiseAssessment> — Runs all enabled checks within a timeout budget (default 600ms). Overrun checks return unavailable: true with partial: true rather than throwing.
  • assessRisk(): Promise<CompromiseAssessment> — Alias for checkDetailed(); prefer whichever name reads better in your codebase.
  • configure(options: RootJailDetectOptions): void — Configure minScore (default 40), timeoutMs, includeEvidence, treatDebuggerAsCompromise, and iOS urlSchemes.
  • isDeviceCompromised(): Promise<boolean> — Returns true if score >= minScore. Rethrows native errors.
  • isEmulator(): Promise<boolean> — Returns true if running in an emulator/simulator. Returns false on error.
  • isDebuggerAttached(): Promise<boolean> — Returns true if a debugger is attached. Returns false on error.
  • getDetectionReasons(): Promise<string[]> — Returns human-readable reasons for fired signals. Returns [] on error.
  • getInstallOrigin(): Promise<InstallOrigin> — Best-effort install provenance. Returns 'google_play' | 'other' | 'unknown' on Android (PackageManager installer record) and 'app_store' | 'testflight' | 'unknown' on iOS (StoreKit 2 AppTransaction verification on iOS 16+, legacy App Store receipt on iOS 15). Returns 'unknown' on error. Informational only — never affects the scored CompromiseAssessment and cannot independently set compromised. Use DeviceCheck / App Attest for cryptographic install verification.
  • setDetectionCallback(cb): void — Register a callback invoked after each checkDetailed()/assessRisk() pass with the CompromiseAssessment and { platform, timestampMs } metadata. Pass undefined to deregister. Callback exceptions are logged, never thrown.
  • startSecurityWatchdog(options): void — Periodically runs checks in background. protectionMode is 'LOG_ONLY' | 'THROW_EXCEPTION' | 'TERMINATE'. Note: THROW_EXCEPTION is demoted to a logged warning on the background thread (it cannot throw into the JS runtime); TERMINATE ends the process. To react in app code, poll checkDetailed() / isDeviceCompromised() from JS.
  • stopSecurityWatchdog(): void — Stops the security watchdog thread.

Result & option shapes

checkDetailed() returns a CompromiseAssessment (DeviceRiskResult is kept as a deprecated alias):

interface CompromiseAssessment {
  platform: 'android' | 'ios';
  compromised: boolean;              // score >= minScore
  score: number;                     // 0–100, clamped
  confidence: 'low' | 'medium' | 'high' | 'extreme';
  signals: DetectionSignal[];        // all fired signals, including `unavailable: true` ones
  debuggerDetected: boolean;         // informational; folds into `compromised` only if configured
  elapsedMs: number;                 // total detection pass time
  partial: boolean;                  // true when the timeoutMs budget ran out before all checks finished
}

interface DetectionSignal {
  id: string;               // platform-prefixed, e.g. `android.mount.magisk`
  platform: 'android' | 'ios';
  category: SignalCategory; // see enum below
  severity: 'low' | 'medium' | 'high';
  score: number;            // weight contributed to the aggregated score
  detected: boolean;        // true for a positive finding; false when returning a clean row
  reliability: number;      // 0..1 stable estimate of how reliable this signal is
  evidence?: string;        // only when RootJailDetectOptions.includeEvidence = true
  unavailable?: boolean;    // true when the check could not complete; never evidence of compromise
}

// Source of truth: src/specs/SignalCategory.ts. Closed enum.
type SignalCategory =
  | 'filesystem'   // root-manager dirs, su binaries, jailbreak artifact paths
  | 'sandbox'      // TrollStore persistence, URL-scheme canOpenURL hits, sandbox write probes
  | 'mount'        // Magisk overlays, hidden bind-mounts in app namespace
  | 'process'      // cmdline tokens, local sockets, loopback SSH/ADB listeners
  | 'injection'    // Frida/Zygisk/Riru maps artifacts, loopback Frida port
  | 'hook'         // LSPosed/Xposed/MobileSubstrate/Substitute/libhooker/ellekit
  | 'property'     // ro.debuggable, service.adb.root, ro.secure, SELinux state
  | 'package'      // PackageManager enumeration of root-manager apps
  | 'signature'    // bootloader unlocked, test-keys, verified boot, emulator
  | 'debugger';    // TracerPid, sysctl P_TRACED, *.check.* availability markers

configure() accepts a RootJailDetectOptions partial:

interface RootJailDetectOptions {
  minScore?: number;                 // default 40 — `compromised` becomes true at or above this
  timeoutMs?: number;                // default 600 — total wall-clock budget per pass
  includeEvidence?: boolean;         // default false — see "Evidence redaction" below
  treatDebuggerAsCompromise?: boolean; // default false
  enablePlayIntegrity?: boolean;     // default false — server-attested; not yet wired
  urlSchemes?: {
    schemes?: string[];              // default ['cydia', 'sileo', 'zbra', 'filza']; [] disables
    perSchemeSignals?: boolean;      // default false — adds per-scheme detail signals (score unchanged)
  };
}

Error semantics

Wrapper behavior is preserved from v1 for backwards compatibility:

  • isDeviceCompromised() logs and rethrows native errors. Callers who call it directly must catch and decide policy.
  • isEmulator(), isDebuggerAttached(), and getDetectionReasons() log the error and return safe fallbacks (false, false, []) — they never throw.
  • checkDetailed() and configure() propagate native errors directly without swallowing them.
  • startSecurityWatchdog() / stopSecurityWatchdog() keep the legacy synchronous signature by firing the async native methods without awaiting; Promise rejections are logged, not rethrown.

Tests in src/__tests__/index.test.tsx pin these semantics; treat any change as a breaking change.


Evidence redaction

DetectionSignal.evidence carries redacted, human-readable hints about what was observed (e.g. "known-jailbreak-artifact", "selinux=enforce:0"). It is off by default and is additionally forced off in release builds:

#if defined(NDEBUG)
    const bool includeEvidence = false;   // release builds: evidence is never attached
#else
    const bool includeEvidence = options.includeEvidence;  // debug builds: opt in via configure()
#endif

Leave includeEvidence disabled (the default) in production. The redacted hints are still meaningful to an attacker monitoring logcat or console output; treat them as development-only.


Signal Catalog

| Severity | Signal ID | Weight | Description | | --- | --- | ---: | --- | | high | android.mount.magisk | 35 | Magisk / KernelSU / APatch mount overlay | | high | android.maps.zygisk | 30 | Zygisk library mapped in process memory | | high | android.maps.lsposed | 30 | LSPosed / Xposed library mapped in memory | | high | android.maps.frida | 30 | Frida agent / artifact mapped in memory | | high | android.maps.riru | 30 | Riru framework library mapped in memory | | high | android.selinux.permissive | 25 | SELinux permissive mode on production device | | high | android.cmdline.instrumentation | 30 | Instrumentation token in process command line | | high | android.socket.instrumentation | 30 | Instrumentation token in local sockets | | high | android.network.frida | 30 | Frida server responding on loopback 27042 | | high | android.network.ssh | 30 | SSH server responding on loopback | | low | android.network.adb | 10 | ADB daemon on loopback (emulator / rare TCP-mode adbd) | | medium | android.root_manager.dir | 20 | Root-manager directory accessible | | medium | android.bootloader.unlocked | 20 | Unlocked bootloader / verified boot orange | | medium | android.emulator | 20 | Android emulator indicators | | low | android.su.binary | 10 | su binary at conventional location | | low | android.build.test_keys | 10 | ro.build.tags reports test-keys | | low | android.build.debuggable | 5 | ro.debuggable set (dev build or Shamiko) | | low | android.build.adb_root | 5 | service.adb.root set (dev build or Shamiko) | | low | android.build.ro_secure_zero | 5 | ro.secure is 0 (dev build or Shamiko) | | low | android.mount.overlay | 10 | Hidden mount overlay in app namespace | | low | android.install.origin.other | 10 | Explicit non-Google-Play installer; hypothesis signal | | high | android.sandbox.write | 30 | Sandbox write to /data/local/tmp succeeded (weak corroboration) | | high | android.package_manager.root | 25 | Known root-management package installed (Magisk, SuperSU, KingRoot, etc.) | | medium | android.package_manager.hma | 15 | Hiding or hooking-related package visible to PackageManager | | low | android.package_manager.risky | 5 | Risky patching or piracy-related package (informational) | | low | android.modules.magisk | 10 | Readable Magisk module tree with module metadata (candidate corroboration) | | low | android.modules.hiding | 10 | Hiding-oriented module manifest found (candidate corroboration) | | low | android.modules.spoofing | 10 | Integrity/property-spoofing module manifest found (candidate corroboration) | | medium | android.addon_d.magisk | 20 | Magisk persistence script under /system/addon.d | | low | android.install_recovery | 5 | Conventional install-recovery script present (weak, stock-compatible marker) | | low | android.hosts.writable | 5 | System hosts file is writable by the app process | | low | android.custom_rom | 5 | Custom-ROM property marker (provenance, not root proof) | | low | android.lineage | 5 | LineageOS property marker (provenance, not root proof) | | low | android.lsposed.cache | 10 | Accessible LSPosed cache/module marker (candidate corroboration) | | low | android.maps.anon_injection | 10 | Cluster of ≥2 unnamed executable mappings (hypothesis, fixture-gated; named ART/JIT regions excluded) | | low | android.props.inconsistent_debuggable | 5 | Debuggable/build-type or secure-property inconsistency (hypothesis) | | low | android.props.inconsistent_verifiedboot | 5 | Verified-boot and vbmeta state inconsistency (hypothesis) | | low | android.props.inconsistent_fingerprint | 5 | Fingerprint/build tags/type inconsistency (hypothesis) | | low | android.magisk.disable_prop | 10 | Magisk-specific property visible (corroboration) | | low | android.zygisk.variant.official | 5 | Candidate official Zygisk property marker | | low | android.zygisk.variant.assistant | 5 | Candidate Zygisk Assistant property marker | | low | android.zygisk.variant.next | 5 | Candidate Zygisk Next property marker | | low | android.zygisk.variant.rezygisk | 5 | Candidate ReZygisk property marker | | high | android.sandbox.write.system_dir | 30 | Write to an immutable system directory succeeded | | low | android.cmdline.su_exec | 10 | su executable present in the process PATH | | low | android.cmdline.magisk_exec | 10 | magisk executable present in the process PATH | | low | android.env.path_magisk | 5 | Process PATH contains a candidate injected directory | | low | android.mount.magisk_chain | 5 | Layered suspicious mount candidate (hypothesis) | | low | android.mount.denylist_unmount | 5 | ≥ 2 distinct canonical system partitions mounted as tmpfs — structural residue of unmount-style root hiding (hypothesis; see limitations below) | | medium | android.mount.overlayfs | 10 | overlay/overlayfs at an exact system-partition root, not stock-OEM-backed (adb remount, GSI/DSU, systemless-overlay root; see limitations below) | | informational | android.debugger.tracerpid | 0 | TracerPid non-zero (diagnostic) | | high | ios.dyld.hook | 30 | Suspicious injection framework loaded (Frida, MobileSubstrate, Substitute, libhooker, ellekit, rosalie, renamed gadgets) | | high | ios.network.frida | 30 | Frida server responding on loopback 27042 | | high | ios.network.ssh | 30 | SSH server responding on loopback (22 or 44) | | medium | ios.jailbreak.artifact | 20 | Classic jailbreak file or directory accessible | | medium | ios.jailbreak.rootless | 20 | Rootless bootstrap symlink present (/var/jb or /private/jb; a dangling link counts — bootstrap laid down, jailbreak off) | | medium | ios.jailbreak.dopamine | 20 | Dopamine profile marker — probe parked pending verified observables (Dopamine is caught by the rootless signal) | | medium | ios.jailbreak.palera1n | 20 | palera1n profile marker — probe parked pending verified observables (palera1n is caught by the rootless signal) | | low | ios.sideload.trollstore | 5 | TrollStore indicator — parked at hypothesis weight with no active probe (no sandboxed-app observable exists; see Threat Model) | | medium | ios.urlscheme.jailbreak_store | 15 | Jailbreak-store URL scheme responded to canOpenURL | | high | ios.sandbox.write | 30 | A write outside the app sandbox succeeded — the process sandbox is absent or escaped (unsandboxing tweak, escaped entitlements, or active tampering). High precision, high FN: ordinary rootless jailbreaks keep apps sandboxed | | medium | ios.simulator | 20 | iOS simulator environment | | informational | ios.debugger.sysctl | 0 | sysctl reports P_TRACED (diagnostic) | | informational | *.check.* | 0 | Check timed out / unavailable (not compromise) |

Signal ids are part of the public contract — they are never renamed or reused for a different meaning once published. Tuning a weight or severity is allowed; repurposing an id is a breaking change. The added Android module, anonymous-map, property-consistency, and Zygisk-variant checks are intentionally low-weight hypotheses until clean-device fixtures establish their false-positive profile.

The Android PackageManager lists are subject to Android package visibility and to hiding tools such as Hide My Applist. A package that is not returned is not proof that it is absent. Similarly, /data/adb/modules is commonly unreadable to ordinary app UIDs; that state is reported as unavailable rather than clean. The custom-ROM and LineageOS signals identify build provenance and are not, by themselves, proof of root.


Threat Model & Policy

  • Client heuristics are non-authoritative: Always bind sensitive decisions to short-lived server sessions with backend attestation (Play Integrity / App Attest).
  • Legitimate custom ROMs & devs: Unlocked bootloaders, test-keys, and permissive SELinux can occur on legitimate developer devices. Tune minScore appropriately.
  • iOS jailbreak coverage map (2026): rootless jailbreaks (Dopamine 2/3, palera1n rootless — iOS 15+ through 26.0.x) share the /var/jb symlink convention and are detected (including dangling symlinks). Rootful layouts (palera1n rootful, classic tools) write to rootfs paths and are detected via the classic artifact list. roothide-class environments are a documented false-negative ceiling: the bootstrap lives at a randomized /var/containers/Bundle/.../.jbroot-<id> path and is invisible to path checks — only loaded roothide tweak images are caught (dyld provenance rule). TrollStore is not reliably detectable from a sandboxed app: it installs apps into normal containers and hijacks the system apple-magnifier:// scheme specifically to defeat scheme probes; its signal is parked at hypothesis weight with no active probe.
  • Hybrid rootless + rootful devices report both artifact classes (40 combined weight) — two independent evidence classes, deliberately not collapsed.
  • Simulator runs are structurally non-representative: the iOS simulator branch emits ios.simulator and skips all device-only checks (artifacts, dyld, sandbox, schemes); a clean simulator result says nothing about device behavior.
  • Rootless jailbreaks and TrollStore: iOS rootless jailbreaks (Dopamine, palera1n) deliberately avoid classic paths and use the /var/jb symlink convention. TrollStore is a sideloading tool, not a jailbreak, and its signal (ios.sideload.trollstore) is parked — see the coverage map above.
  • Renamed Frida gadgets: Memory-map and _dyld scans include common rename patterns (libgadget, gadget.dylib, etc.), but a determined attacker can rename further. Treat these as defensive signals, not proof.
  • iOS URL schemes: The default probe list (cydia, sileo, zbra, filza) respects the LSApplicationQueriesSchemes cap shared with the host app (50 entries for apps linked on iOS 15+, 25 for iOS 27+). Configure RootJailDetectOptions.urlSchemes.schemes to change or disable the list. Undeclared schemes always return false — never a false positive, but a missing LSApplicationQueriesSchemes declaration silently disables that scheme's check.
  • Confidence levels: low/medium/high reflect how complete and convergent the pass was. extreme is reserved by the aggregator for combinations of multiple high-severity, independent-category signals that together push the score very high (≈ 80).
  • Modern Magisk DenyList caveat: a correctly functioning Magisk v24+ DenyList (revert_unmount) removes every framework mount — including its own tmpfs — from the denied app's namespace, so android.mount.denylist_unmount is an expected no-fire there. The signal catches legacy MagiskHide, Magisk forks (e.g. Kitsune), third-party unmount modules, and partial cleanups (EBUSY). It is not DenyList-proof and ships at low hypothesis weight pending on-device measurement.
  • Stock OEM overlay caveat: stock Xiaomi HyperOS/MIUI devices ship overlay mounts as part of their OEM resource layering (backed by /mnt/vendor/mi_ext and /product/pangu, typically at subpaths such as /system/app). The android.mount.overlayfs scanner only matches exact partition roots and suppresses fully OEM-backed overlays; the suppression list is deliberately small and grows only with real-device evidence. Other OEMs layering overlays at partition roots in the future would be reported — report such devices so the corpus can grow.

Roadmap

The scored baseline plus the additive Android static/runtime probes described above is shipped. Remaining work is optional / future:

  • Native C++ unit tests in CI — the local bun run native-test command covers pure parser fixtures; CI integration for the full native test matrix remains future work. Jest covers the TypeScript wrapper layer today.
  • OEM / benign allowlist — small, documented table to suppress specific low-severity test-keys / SELinux signals on legitimate preview/OEM builds. High-severity memory/mount signals are never allowlisted.
  • Mount-namespace reshape — the android.mount.overlay namespace-only check is effectively dead code today because /proc/1/mountinfo is unreadable by untrusted apps on stock Android (see comment in cpp/ProcParsers.cpp). A future reshape would use statx(2) with STATX_ATTR_MOUNT_ROOT and self-namespace path/content diffs.
  • Play Integrity / App Attest — optional client token acquisition behind enablePlayIntegrity, paired with a server verifier (see "Recommended pattern: pair with backend attestation"). This is server-side attestation work, not local detection.
  • meta.tcp.* advanced probes — richer loopback banner/fingerprinting beyond basic connect probes (P3).

The library never claims root/jailbreak detection is foolproof. Treat client heuristics as hints and bind sensitive decisions to short-lived server sessions with hardware-backed attestation.


License

MIT © Psync