Threat Models
STRIDE-aligned models for Pathfinder's four trust boundaries. Assets, vectors, mitigations, and owner pathkeepers per row.
Jump to a surface:
IPC — Tauri IPC Surface
Surface: #[tauri::command] handlers reached via invoke() from the embedded React webview. ~180 commands across src-tauri/src/commands/.
Trust root: the macOS user account running the app.
In-scope adversaries:
- A malicious or compromised npm package pulled into
pathfinder-ui/that runs JavaScript inside the Tauri webview. - An attacker who controls a workspace SQLite file path (e.g. swaps in a malicious DB before the user opens that workspace).
- A future attacker who can reach the webview via
tauri.conf.jsonURL allow-list expansion (today we ship local-content only).
Explicitly out of scope:
- An attacker with arbitrary code execution as the local macOS user (they own the Keychain, the binary, and the trust root).
- macOS-level lateral movement.
STRIDE
| Threat | Asset | Vector | Mitigation | Owner pathkeeper |
|---|---|---|---|---|
| S Webview spoofs identity of user | Connector OAuth tokens | Malicious npm injects invoke('connection_save_oauth', ...) |
Tauri capabilities allow-list, command auth scope review (E3.2), audit_log_chain append-only proof of intent |
architecture/tauri-ipc, domain/security |
| T Tamper with SQLite mid-transaction | Workspace DB | Concurrent process writes while orchestrator runs | SQLite WAL + per-workspace exclusive lock at open; integrity_check on every workspace_open |
architecture/db-migrations, domain/orchestrator |
| T Tamper with audit log | Ed25519-signed Merkle chain | Direct SQLite write outside the writer | Chain seal forces prev_hash linkage; replay/verify detects gaps. Mitigation expanded by E1-S3. |
domain/audit-log-chain |
| R Repudiation of who triggered a migration | Audit log | User claims "I never approved this" | Every migrate_* command writes an audit_log row with actor + intent; Ed25519 seal proves chain integrity |
domain/audit-log-chain |
| I Information disclosure via error strings | Keychain secrets, OAuth tokens | Error from a command leaks token in Result<_, String> |
error::redact() on every IPC return path; lint catches format!("{token}") patterns (E3.5) |
architecture/error-handling |
| D Denial of service via batch size | Memory / SQLite | Webview invokes migrate_run(batch_size=10_000_000) |
Each command clamps user inputs; orchestrator has memory ceiling + checkpoint pause | domain/orchestrator |
| E Privilege escalation: webview invokes wrong command | All commands | npm package guesses an unprotected admin command | Capability allow-list per Tauri webview window; lifecycle/code-reviewer blocks new commands w/o capability scope |
architecture/tauri-ipc |
Notable hardening already in place
- All IPC commands are
async fn ... -> Result<T, String>— no panics cross the boundary. - Keychain access uses
security-frameworkwith prompt-on-first-use; the webview never sees raw secrets, only opaque connection IDs. - Workspace SQLite path is selected via macOS file picker (user-driven), not webview-driven, so a malicious page cannot pick a different DB.
- Tauri v2
dragDropEnabled: falsekeeps the OS file-drop channel out of the webview pointer pipeline — seeDND_MIGRATION_PLAN.md.
Gaps tracked by Enterprise Hardening Plan
- E3.2 — Per-command capability allow-list audit. Today: every command is reachable from any window. Target: migration-write commands reachable only from the
mainwindow. - E3.5 — Lint that rejects
format!("...{secret}...")patterns across IPC return paths. - E3.7 — IPC fuzz harness on the 20 highest-arity commands.
Review checklist for new IPC commands
When pathkeeper-steward or architecture/tauri-ipc reviews a new #[tauri::command]:
- Return type is
Result<T, String>andStringis redacted. - All user-supplied integers/strings have explicit bounds.
- Capability allow-list entry added (
tauri.conf.json). - Audit-log write if the command mutates persistent state.
- Frontend wrapper in
pathfinder-ui/src/lib/api.ts. - This threat model refreshed if the command opens a new asset/vector.
MCP — MCP Server Surface
Surface: pathfinder-mcp binary, rmcp stdio transport. Consumed by a local MCP broker (Claude Desktop, IDE-side agent, etc.).
Trust root: the macOS user account that launches the MCP broker. The broker spawns pathfinder-mcp as a child process over stdio — no network listener is ever opened.
In-scope adversaries:
- A malicious MCP client prompt (LLM-generated tool calls) that tries to drive Pathfinder into an unsafe state.
- A malicious or compromised MCP broker that re-routes stdio.
- A future attacker who induces the user to install a hostile
claude_desktop_config.jsonblock pointing atpathfinder-mcpwith weaponized arguments.
Explicitly out of scope:
- Network MCP transports (we do not ship one).
- Multi-user broker scenarios.
STRIDE
| Threat | Asset | Vector | Mitigation | Owner pathkeeper |
|---|---|---|---|---|
| S LLM spoofs user intent | Migration approval | LLM calls migrate_run "on behalf of" the user |
Two-tier tool set: read tools open, write tools require explicit confirmation flag in payload and UI consent recorded in audit log | domain/mcp-server |
| T Tamper with stdio frame | Tool result | Broker truncates a JSON-RPC frame mid-stream | rmcp framing has Content-Length validation; client-side broker reports parse errors loudly |
domain/mcp-server |
| T Tamper with API version | Protocol compat | Client sends spoofed api_version=2 |
pathfinder-mcp/src/api_version.rs::validate_api_version rejects unknown versions; see E1-A4 for the 90-day deprecation policy |
architecture/versioning |
| R Repudiation of LLM-triggered action | Audit log | "The LLM did it, not me" | Every MCP tool call writes an audit_log row tagged actor=mcp:{client_name}; chain seal proves provenance |
domain/audit-log-chain |
| I Info disclosure via tool result | Connector creds | LLM asks "show me the Salesforce token" and tool returns it verbatim | pathfinder-mcp/src/tools.rs never reads from Keychain on the read path; only connection IDs cross MCP |
domain/security |
| I Schema exfil via discovery | Customer schemas | LLM dumps every object schema and forwards via broker | This is expected and authorized — the whole point of the MCP. Hardening is per-broker, not per-tool. We document this in the user-facing MCP guide. | lifecycle/documentation |
| D Denial of service via long-running tool | Process | LLM calls fabric_run_pattern with a huge dataset |
Each tool has a per-call deadline; cancelable via stdio close | domain/mcp-server |
| E Tool that should be admin-only is exposed unauthenticated | All tools | A new tool is added that wraps an unsafe IPC command | Roster of MCP tools is allow-listed in pathfinder-mcp/src/tools.rs::register_tools; lifecycle/code-reviewer blocks new tools without a listed risk tier |
domain/mcp-server |
Notable hardening already in place
- Stdio-only transport. No TCP, no HTTP, no WebSocket. The attacker must already control the broker process.
- API version gate.
validate_api_version(added in E1-A4) refuses requests outside[MIN_SUPPORTED_API_VERSION, CURRENT_API_VERSION]and returns a structuredunsupported_api_versionerror. - Tool registry is allow-list. Adding a new tool requires editing
register_tools; thepathkeeper-coverage-analystflags drift between registered tools and exposed Tauri commands. - No Keychain access from MCP path. MCP tools that need credentials go through the same connection-id indirection that IPC uses.
Gaps tracked by Enterprise Hardening Plan
- E3.3 — Write tools currently honor a
confirmed=trueflag in the payload; we want a per-broker confirmation UX so the LLM cannot silently set the flag. - E3.6 — Fuzz the MCP JSON-RPC parser with malformed frames; the current test suite covers happy-path framing only.
- E4.2 — Emit an
mcp.tool_callmetric per call, taggedclient_name + tool + risk_tier, for post-hoc audit dashboards.
Review checklist for new MCP tools
- Risk tier set:
read | write | admin. - Write/admin tier requires explicit confirmation flag.
- Tool result schema documented (
rmcp::schemars). - Audit-log write on every call.
api_version.rsupdated if request/response shape changes.- This threat model refreshed if a new asset is exposed.
HTTP — Outbound HTTP Surface
Surface: Outbound HTTP from the connector SDK (connectors/src/sdk/http.rs) to third-party SaaS APIs. There is no inbound HTTP listener in any Pathfinder process.
Trust root: TLS certificate chains via rustls + the OS trust store. Endpoints are pinned via per-connector base URLs configured at connection creation time.
In-scope adversaries:
- A network attacker between the device and the SaaS API (corporate proxy, hostile coffee-shop wifi).
- A compromised SaaS endpoint (Salesforce instance hijacked, RENXT hostile API response).
- A malicious connector config that points at an attacker-controlled domain.
Explicitly out of scope:
- DNS rebinding on inbound traffic — there is no inbound traffic.
- Server-side authorization at the SaaS — that is the SaaS's job.
STRIDE
| Threat | Asset | Vector | Mitigation | Owner pathkeeper |
|---|---|---|---|---|
| S TLS spoof | OAuth tokens, customer data | MITM swaps cert chain | rustls with OS trust store, no custom CA injection; per-connector base URL is the only allowed origin |
domain/connector |
| S Connector points at attacker domain | OAuth tokens | User pastes hostile instance_url |
Base URL allow-list per connector type (Salesforce → *.my.salesforce.com, RENXT → api.sky.blackbaud.com, etc.); UI rejects unknown patterns |
domain/connector |
| T Hostile API response tampers extracted records | Migration source data | SaaS returns malformed JSON | Schema-coerce step runs after extract; type mismatches surface as conflicts rather than silent data loss | domain/connector, domain/quality |
| R Repudiation of an extracted record | Audit log | Customer claims "this record never came from RENXT" | Extract phase writes a schema_snapshot (E1-A5) tagged with source connector + timestamp; chain seal proves it |
domain/schema-drift, domain/audit-log-chain |
| I Token leak in HTTP error log | OAuth tokens | reqwest debug log dumps Authorization header |
connectors/src/sdk/http.rs strips Authorization, Cookie, X-Api-Key before logging; covered by redact_headers test |
architecture/error-handling |
| I Token leak in URL | OAuth tokens | Connector author puts token in query string | SDK exposes bearer(token) builder that pins to Authorization header; URL builder rejects ?access_token= patterns at lint time |
domain/connector |
| D Rate-limit storm at customer | Customer SaaS quota | Connector hammers API ignoring 429 | SDK has shared RateLimiter + token-bucket; per-connector defaults in sdk/http.rs::default_rate_limit; respects Retry-After |
domain/connector, architecture/retry |
| D Slowloris on extract | Process | Hostile API drips bytes 1/sec | reqwest::Client::builder().timeout(Duration::from_secs(60)) set workspace-wide; per-call deadlines override down |
domain/connector |
| E Connector escalates from read to write scope | Target system | A read-only OAuth scope gets upgraded to write | OAuth scope is captured at connect time and pinned in the connection record; UI shows the granted scope before every migration | domain/security, domain/connector |
Notable hardening already in place
rustls-tlsfeature onreqwest— no OpenSSL exposure; sidesteps several alerts that closed during the H6 Dependabot triage.- Token transport. All known connectors put bearer tokens in the
Authorizationheader, never in query strings or path segments. - Auth header redaction. Both the structured logger and the trace exporter mask
Authorization/Cookieheaders — verified byredact_headerstest insdk/http.rs. - Retry classifier. Network errors retry with jittered backoff; auth errors do not retry (avoids token-burn at SaaS).
Gaps tracked by Enterprise Hardening Plan
- E3.8 — Per-connector base-URL allow-list is informal; tighten to a match-pattern in code and enforce at connection-save time.
- E3.9 — Add a request-recording mock fixture so we can re-play any customer-supplied trace without going live to their SaaS.
- E4.3 — Emit
connector.request_duration_ms+.rate_limit_remainingmetrics per (connector, endpoint), for support runbooks.
Review checklist for new connectors
- Base URL pinned at connection-save time (no runtime mutation).
- OAuth scope captured + persisted on the connection record.
- All API errors classified
retriable | terminal | auth_revoked. - Rate-limiter configured with the connector's documented quota.
redact_headerscovers any new auth header the SDK adds.- This threat model refreshed if a new auth pattern is introduced.
Connector — Connector Surface
Surface: The 10 source/target connectors in connectors/src/. Each holds long-lived credentials (OAuth refresh tokens, API keys) and is the sole point of trust for extracted/loaded customer data.
Trust root: The macOS Keychain entry for each connection. The Keychain entry is opened via security-framework with prompt-on-first-use.
In-scope adversaries:
- A user who connected to a malicious SaaS instance (intentionally or via social engineering).
- A compromised SaaS account belonging to the legitimate customer.
- A connector author (us) who accidentally ships a credential leak.
Explicitly out of scope:
- SaaS-side authorization (their problem).
- macOS Keychain compromise (root-level OS compromise; outside our trust boundary).
STRIDE
| Threat | Asset | Vector | Mitigation | Owner pathkeeper |
|---|---|---|---|---|
| S Connection spoofing | Keychain entry | A different connector reuses another connector's Keychain key | Key namespacing: pathfinder.<connector_type>.<connection_id>; collision lint covered by keychain::tests::reject_duplicate_service |
domain/security |
| T Tamper with stored connection config | SQLite connections row |
Direct DB write swaps instance_url after consent |
connections table has last_verified_at + last_verified_url; mismatch on next test_connection forces re-consent |
domain/connector |
| T Tamper with field mappings during load | Migration | Hostile process edits field_mappings between approve and run |
Mapping checksum captured at approve; orchestrator verifies on run start; mapping_validate (E1-S4) covers the type-coerce surface | domain/mapping, domain/transform-typecheck |
| R Repudiation: "I never approved this load" | Migration | Customer denies authorizing a load to their target | Approval flow writes a row to audit_log with mapping checksum + actor; Ed25519 chain seal proves provenance (E1-S3) |
domain/audit-log-chain |
| I Credential leak in DB | OAuth tokens | A developer accidentally persists a token in connections.config_json |
connections schema has a secret_keys allow-list checked by connection_save — tokens cannot be written to DB; only Keychain |
domain/security |
| I Credential leak in logs | OAuth tokens | tracing::debug!("config: {:?}", config) dumps a token |
ConnectionConfig implements Debug manually to redact secret fields; covered by tests::debug_redacts_secrets |
domain/security, architecture/error-handling |
| I Credential leak in audit log | OAuth tokens | Audit writer persists raw params for a connection_save call |
audit_log_chain_writer::WriteParams serializer applies the same redactor used for Debug; pinned by property test |
domain/audit-log-chain |
| D Memory pressure from huge schemas | Process | RENXT returns 1M-field describe | Schema-discovery pipeline truncates at 50K fields per object with a structured "schema too large" surface; covered by schema_too_large_returns_explicit_error |
domain/quality, domain/connector |
| D OAuth refresh storm | Customer SaaS quota | Refresh loop runs every retry | Refresh has its own backoff cell distinct from request-level retry; if refresh returns invalid_grant, the connection is marked re_consent_required and stops retrying |
domain/connector |
| E Connector marks itself "verified" without verification | All assets | Author copies-pastes test_connection() { Ok(()) } from an example |
lifecycle/code-reviewer checklist requires a real round-trip; contract harness (E2.9) exercises the happy path per connector |
domain/connector |
Notable hardening already in place
- Keychain-only storage. Tokens never persist to SQLite or env vars. The
connectionsrow storesinstance_url,client_id, refresh cookie ID (not the refresh token itself), and connector type. - No env-var fallback for production secrets. A few legacy connectors read from env vars for CI fixtures only; this is in scope for E3.4 to either delete or gate behind a
#[cfg(test)]. - Schema-coerce, not schema-trust. Every extracted record passes through
pathfinder-core::transformtypecheck (E1-S4); a hostile API cannot smuggle wrong-type data into the loader. - Contract harness (E2.9). Every connector must pass the same shared invariant sweep before merging.
Gaps tracked by Enterprise Hardening Plan
- E3.4 — Full Keychain inventory + env-var cleanup audit.
- E3.5 — Add
secret_keysallow-list check to the codegen path so a new connector cannot accidentally widen the set. - E5.3 — Bench the OAuth refresh path under simulated 429 storms.
Review checklist for new connectors
- Credentials live in Keychain only; nothing secret in
connections.config_json. Debugimpl manually redacts every secret field.test_connection()actually hits the API and verifies a roundtrip.- OAuth scope captured + persisted; UI shows it before every migration.
- Schema-discovery has an upper bound (no unbounded growth).
- Contract harness target added (see
connectors/tests/contract.rs). - This threat model refreshed if a new auth/secret pattern lands.
Have procurement questions?
Email security@colbysdatamovers.com or book a discovery call.
Book a discovery call →