Skip to content

Test a Tauri app

cxg pentest run --target-type tauri reads a Tauri app’s Rust source and GuardLink threat model and writes a map of its IPC surface: every registered command, the parameters it takes, the capability grants, and the security-relevant tauri.conf.json fields. Add --tauri-prove and cxg also builds a test harness from the app’s own crate and drives the commands with hostile arguments. This guide runs both against a small app with two deliberate flaws.

flowchart TB
    RS["Rust source: #[tauri::command]<br/>and generate_handler!"] --> MAP["IPC surface map:<br/>ipc://command hypotheses"]
    CAP["capabilities/*.json<br/>and tauri.conf.json"] --> MAP
    GL["guardlink findings.sarif"] --> MAP
    MAP --> R1["report: tauri_surface_mapped,<br/>nothing invoked"]
    MAP -->|"--tauri-prove"| H["copy of the app, test module,<br/>headless MockRuntime"]
    H --> R2["report: tauri_proved,<br/>confirmed, refuted, skipped"]

    class R1,R2 emphasis
  • cxg and the pipeline installed: cxg pentest install. The web-application pentest lists the rest of the setup, but a Tauri run needs less of it: no running target URL, no captured auth profile, no --app-cmd, and no AI CLI, because neither the map nor the proof writes templates.
  • guardlink, to write the app’s findings.sarif. See feed guardlink findings into cxg.
  • For --tauri-prove only: a Rust toolchain and everything the app needs to build, including frontend assets and icons its compile-time context reads.

The example is a Tauri 2 app whose src-tauri/src/lib.rs registers three commands. read_note joins a renderer-supplied name onto a directory without checking it, and export_all lets the renderer name its own plan:

// @asset App.Notes (#notes) -- "Note files under the app's notes directory"
// @threat Path_Traversal (#traversal) [high] cwe:CWE-22 -- "A renderer-supplied name escapes the notes directory"
// @threat Privilege_Escalation (#privesc) [high] cwe:CWE-285 -- "The renderer chooses its own plan"
// @exposes #notes to #traversal [high] -- "name is joined onto the notes directory unchecked"
#[tauri::command]
fn read_note(name: String) -> Result<String, String> {
let path = Path::new("notes").join(name);
fs::read_to_string(path).map_err(|e| e.to_string())
}
// @exposes #notes to #privesc [high] -- "the plan comes from the renderer"
#[tauri::command]
fn export_all(plan: String) -> Result<String, String> {
if plan == "admin" {
Ok("exported".into())
} else {
Err("export needs the admin plan".into())
}
}
#[tauri::command]
fn app_version() -> String {
"0.1.0".into()
}
pub fn run() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![read_note, export_all, app_version])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

Its one capability file, src-tauri/capabilities/default.json, grants core:default to the main window, and tauri.conf.json sets withGlobalTauri: true.

  1. From the app’s repository root, write the threat model where cxg reads it:

    Terminal window
    $ guardlink sarif . -p notes-app -o whitebox/findings.sarif
    ✓ Wrote SARIF to whitebox/findings.sarif
    SARIF: 2 result(s) — 2 error(s), 0 warning(s)
  2. Run the map. --target is not needed, because the run never navigates anywhere:

    Terminal window
    cxg pentest run --target-type tauri --codebase .
    [1] guardlink: 2 SARIF hypotheses, 2 inline, 0 endpoints
    [1h] guardlink declared context: 2 result(s); chains from codeFlows on 0, typed notes from relatedLocations on 0, a function name on 2. @comment and @validates are still read from source comments.
    · 1 capability file(s) read
    [1f] code graph: not used — no code graph has been built for this codebase; build one with `bravos graph build ~/notes-app` to use it
    [1a] tauri surface: 3 registered command(s) of 3 defined, 1 capability file(s)
    [1c] routeless threats: 2 correlated to candidate command(s), 0 review-only
    [scope] audit log → ~/.cert-x-gen/sessions/pentest-20261019-215549/audit.jsonl
    [scope] ⚠ no --attestation supplied. For real engagements, include written authorization text via --attestation or scope.yaml.
    [tauri] IPC surface mapped: 3 command(s), 1 capability file(s). NOT live-probed — see the enumerated_not_live_probed caveat.
    report → ~/.cert-x-gen/sessions/pentest-20261019-215549/report.json

    The offensive-tooling banner and run header that come first are omitted. The session directory is timestamped and differs on every run. The command exits 0.

[1c] is the threat-model join. A GuardLink threat with no HTTP route is matched to the registered commands in the same source file, so both @exposes lines above land on commands. A threat with no command match would be listed under review_only_threats with a reason.

report.json holds the surface under tauri_ipc_surface, one entry per registered command:

{
"command": "read_note",
"http_path": "ipc://read_note",
"file": "~/notes-app/src-tauri/src/lib.rs",
"line": 9,
"params": "name: String",
"reachable_from_renderer": true,
"reachable_via": "__TAURI_INTERNALS__.invoke"
}

reachable_from_renderer records that the command is registered in generate_handler!. It is a fact about the source, not a test that a particular window can invoke the command. The grants are under tauri_capabilities, and the configuration fields under tauri_security:

"tauri_security": {
"with_global_tauri": true,
"csp": null,
"dangerous_remote_domain_ipc_access": null,
"freeze_prototype": null
}

These are configuration facts to review, not confirmed findings. Commands that are registered but never defined, and registrations cxg could not resolve, are reported as extraction diagnostics, so treat the map as a reading of the source rather than exhaustive runtime discovery.

--tauri-prove turns the map into a test. cxg copies the app to the session’s tauri-harness/ directory, injects a test module beside the generate_handler! call, enables Tauri’s test feature and runs cargo test. The module drives each registered command through Tauri’s real IPC dispatch on a headless MockRuntime, with arguments chosen from each parameter’s name and type. Your source tree is not edited.

Terminal window
cxg pentest run --target-type tauri --codebase . \
--tauri-prove --tauri-prove-timeout 1200
[tauri] --tauri-prove: building in-process IPC proof harness (cargo test against the app crate)…
✓ CONFIRMED ipc://export_all command 'export_all' made an authorization decision from the renderer-controlled argument 'plan': th
[tauri] proof: 1 confirmed, 1 refuted, 0 unevaluated, 1 skipped
report → ~/.cert-x-gen/sessions/pentest-20261019-215559/report.json

Truncated before the [tauri] lines, which repeat the map run above. The confirmed line is cut short by cxg itself. The command exits 2, because a finding was confirmed. --tauri-prove-timeout is the budget in seconds for the harness build and run together, and 1200 is its default.

The status is now tauri_proved. That status alone does not mean a command was exercised; the verdicts are in the report:

Command Where it lands What the report says
export_all confirmed_findings made an authorization decision from the renderer-controlled argument ‘plan’: the privileged value ‘admin’ succeeded while every benign value was rejected
read_note mitigation_verifications rejected a ‘../’ path argument (“No such file or directory (os error 2)”); path confinement held for this probe
app_version tauri_skipped_commands no probeable argument (no path/authz parameter cxg could drive)

The probes are deterministic, not AI-written, and they confirm only on definitive evidence. A path parameter gets a ../ chain aimed at a sentinel file cxg plants, and confirms only if the sentinel’s content comes back. An authorization-shaped parameter gets privileged and benign values, and confirms only if a privileged value succeeds where every benign one was rejected.

read_note is the lesson in this run. It is written to be vulnerable, and the probe still came back refuted: the joined path did not reach the sentinel, and the handler returned an error. A refutation covers the hostile arguments tried, not the whole command. Read caveats, ambiguous and tauri_skipped_commands beside the findings.

A harness that does not build is reported, not hidden. A first run of this example without an icons/icon.png failed in Tauri’s generate_context! macro, and the report said so: status tauri_proved, the caveat in_process_prove_unavailable, and every command listed in tauri_skipped_commands with the compiler error as its reason. Nothing was refuted. Fix the build and run again.