relift

Relift — Desktop Application

relift desktop starts one loopback HTTP server and points a window at it. The server is the same Python process, on the same machine, calling the same engine functions as the CLI: load_scope, scope.authorize, make_plan, execute, write_run. There is no desktop-specific scan path, and therefore no desktop-specific way around the scope gate.

It is not an Electron app. The UI is three static files, the backend is the standard library, and the native window is provided by pywebview (WebView2 on Windows, WebKitGTK on Linux) when it is installed. When it is not installed the same URL opens in the default browser and nothing else changes.


1. Architecture

relift desktop
      |
      +-- ThreadingHTTPServer on 127.0.0.1:<ephemeral port>
      |        |
      |        +-- GET  /            app.html  (+ injected bootstrap)
      |        +-- GET  /app.css     app.css
      |        +-- GET  /app.js      app.js
      |        +-- /api/*            JSON, token required
      |
      +-- pywebview window  ---or---  webbrowser.open(url)
File Role
src/relift/api.py server, router, job runner, static asset serving
src/relift/desktop.py launcher: bind port, start server, open window or browser
src/relift/desktop/app.html single page
src/relift/desktop/app.css styles
src/relift/desktop/app.js UI logic, calls /api/* with the token

Only those three static files are served, from a fixed whitelist keyed by exact path. There is no path joining, no directory listing and no template rendering of user input. favicon.ico returns 204.

The HTML is served with a small script injected immediately before </head>:

<script>window.RELIFT_BOOT={token:"…",version:"0.2.0"};</script>

app.js reads window.RELIFT_BOOT.token and sends it on every call.


2. Security model

The desktop app holds the same line as the CLI, plus a few local-interface precautions.

Same gate. A scan is only started after load_scope(path) parses and validates the file and scope.authorize(target.host) accepts the target. A refusal is returned as HTTP 403 with the same ScopeError text the CLI prints. The UI has no field that can widen a scope; the only way to change one is to edit the file. POST /api/init-scope writes a scope with allow_intrusive: false and refuses to create an intrusive one from the app at all — that stays a hand edit.

Loopback only. The server binds 127.0.0.1. It is not reachable from the network, including from another container on the same host unless that container shares the host network namespace.

One-time token. A secrets.token_urlsafe(24) value is generated at launch and required in the X-Relift-Token header on every /api/* request (secrets.compare_digest). A request without it gets 401. The token is never placed in a URL, never logged and never written to disk.

No CORS. No Access-Control-Allow-* header is ever sent, and OPTIONS returns 405. A page on another origin cannot read the token or the responses. When an Origin header is present it must match the request's own Host, which blocks a form-post style CSRF attempt even with a guessed token.

No request logging. Handler.log_message is a no-op so a token or a run id cannot end up in a terminal, a log file or a syslog collector.

Response hardening. Cache-Control: no-store, X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy: no-referrer. JSON request bodies are capped at 256 KiB.

No credential persistence. POST /api/scan accepts credentials in the request body for a single run (the same one-attempt verification the CLI does with --db-creds). They are held in memory for that job, never written to run.json, the audit log or the UI's storage.

Loopback job state. Up to 20 jobs are kept in memory; run artefacts still go to .relift-runs/<run_id>/run.json like any CLI run, and the audit log is the same ~/.relift/audit.jsonl.


3. API

All /api responses are JSON. Errors are {"error": "..."} with a status code (400 bad input, 401 token, 403 scope refusal, 404 unknown run/job, 413 body too large, 500 unexpected).

Method Path Body Response
GET /api/meta — version, banner, catalogue counts, categories, profiles, provider matrix, runs_root, audit_path, cwd
GET /api/catalog — { "techniques": [ … ] } — the same objects as relift list --json
GET /api/providers — { "selected": "offline", "providers": [ … ] }
GET /api/runs — { "runs_root": "…", "runs": [ {run_id, target, started, profile, engagement, findings, chains, severities} ] } (50 newest)
GET /api/audit — { "path": "…", "lines": [ … ] } — last 120 audit lines
POST /api/scope { "path": "scope.json" } the parsed scope document (to_dict())
POST /api/init-scope { "engagement", "hosts": [], "denied": [], "path", "authorized_by", "organization", "expires_in_days", "max_risk", "ports": [], "notes" } { "path": "…", "scope": { … } }
POST /api/plan { "target", "scope", "profile" } the plan (make_plan(...).to_dict()), no traffic beyond DNS
POST /api/scan { "target", "scope", "profile", "ports", "timeout", "insecure", "db_creds": [], "sqli_url", "sqli_param" } { "job_id": "…" }
GET /api/jobs/<id> — job state: id, status (queued/running/done/refused/error), lines, run_id, error, run (the full run payload when finished)
GET /api/runs/<id> — the stored run payload, with chains recomputed if the artefact predates them
GET or POST /api/runs/<id>/report { "format": "md" | "text" | "json" } { "format", "text", "run_id" }
POST /api/runs/<id>/narrative {} { "run_id", "narrative", "provider", "notes" } — re-runs the analyst against the stored run; the UI's Regenerate narrative button uses it

Notes:

  • db_creds in /api/scan is the list of credential objects, not a path. Same shape as the credentials array in a CLI credentials file (engine, host, port, username, password, database).
  • profile: "lab" is rejected unless the scope itself allows intrusive work, so the UI cannot talk its way past the tier ceiling.
  • Narrative regeneration is a read of the stored run plus a model call: it never re-scans, so it cannot send traffic. With --no-ai or an offline provider it is deterministic (the rule engine), and the same text can be produced from the CLI with relift report --run <id> --no-ai.

Example

# start headless on a chosen port
relift desktop --no-window --port 8765

# in another shell, with the token the server printed
curl -s -H "X-Relift-Token: $TOKEN" http://127.0.0.1:8765/api/meta | jq .
curl -s -X POST http://127.0.0.1:8765/api/plan \
  -H "X-Relift-Token: $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"target":"postgres://127.0.0.1:5432/appdb","scope":"scope.json","profile":"safe"}' | jq '.techniques[].id'

4. Running it

# native window when pywebview is available, browser otherwise
relift desktop

# the console-script alias installed by pip
relift-desktop

# serve only, print the URL, no window (useful over SSH)
relift desktop --no-window --port 8765
Flag Default Meaning
--port N 0 0 picks a free ephemeral port
--no-window off serve only; print the URL instead of opening a window

Ctrl+C stops the server. If a native window is used, closing the window stops the server.

Native window

python -m pip install -e ".[desktop]"

That pulls in pywebview. The window is created at 1240×860 (minimum 980×680) and loads the same loopback URL. If importing or starting pywebview fails for any reason — missing GTK, missing WebView2, headless session — the launcher falls back to the default browser instead of failing.

Platform Window runtime Package
Windows 10/11 WebView2 (Evergreen) preinstalled on 11; install the Evergreen Runtime on 10
Debian/Ubuntu WebKitGTK sudo apt install python3-gi gir1.2-webkit2-4.1
Fedora WebKitGTK sudo dnf install python3-gobject webkit2gtk4.1
Arch WebKitGTK sudo pacman -S python-gobject webkit2gtk-4.1

None of this is required for the CLI, and none of it changes what the tool is allowed to do.


5. Building the desktop binaries

The release pipeline builds two windowed executables from the same source:

Asset Platform
relift-desktop-windows-x64.exe Windows 10/11 x64
relift-desktop-linux-x64 Linux x86_64 (built on Ubuntu 22.04)

Local build, Linux (PyInstaller's --add-data separator is :):

python -m pip install --upgrade pyinstaller "pywebview>=4"
python -m PyInstaller --onefile --windowed --clean --noconfirm \
  --name relift-desktop \
  --paths src \
  --add-data "src/relift/desktop:relift/desktop" \
  packaging/desktop_entry.py
./dist/relift-desktop --no-window --port 8765

Windows (separator is ;):

python -m PyInstaller --onefile --windowed --clean --noconfirm ^
  --name relift-desktop ^
  --paths src ^
  --add-data "src\relift\desktop;relift\desktop" ^
  packaging\desktop_entry.py

relift.desktop.static_root() looks for the assets under sys._MEIPASS/relift/desktop first, which is why the --add-data target is relift/desktop. Without it the window loads and every asset request 500s with UI asset missing.

The CLI binaries (relift-windows-x64.exe, relift-linux-x64) are built the same way without --windowed or --add-data. Both are unsigned: SmartScreen will warn on first launch on Windows.


6. What the desktop app does not add

It does not add capability, only a second front end. Everything it can do, the CLI can do — and the CLI is the reference for flags, tiers and output. If the two ever disagree, the CLI is correct and the API is the bug.