mirror of
https://github.com/usestrix/strix.git
synced 2026-08-20 18:38:57 +02:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f88b7d7d5 | ||
|
|
8d3693df8c |
@@ -167,10 +167,15 @@ strix view
|
||||
|
||||
# ...or open a specific run by name
|
||||
strix view my-run-name
|
||||
|
||||
# Expose the viewer on all IPv4 interfaces at a fixed port
|
||||
strix view --host 0.0.0.0 --port 8080 --no-open
|
||||
```
|
||||
|
||||
`strix view` starts a lightweight local server (bound to `127.0.0.1` on a random port) and opens your browser to a private, tokened link. Nothing leaves your machine: the dashboard reads the run's files straight off disk, with no cloud account or upload required. The UI ships prebuilt with Strix, so there is no extra install and no JS build step.
|
||||
|
||||
Use `--host 0.0.0.0` to make the viewer reachable from other machines. Replace `0.0.0.0` in the printed URL with the server's reachable IP or hostname. The token in that URL grants access to the selected run's scan data, history, and steering, so only share it with trusted users and restrict the port with your firewall. Requests without the token-derived session cannot read run data.
|
||||
|
||||
### What's in the dashboard
|
||||
|
||||
- **Overview**: run status, target, and a severity breakdown of everything found so far.
|
||||
|
||||
@@ -45,7 +45,11 @@ def run_view(argv: list[str]) -> None:
|
||||
default=0,
|
||||
help="Port to serve on (default: an available ephemeral port).",
|
||||
)
|
||||
parser.add_argument("--host", default="127.0.0.1", help=argparse.SUPPRESS)
|
||||
parser.add_argument(
|
||||
"--host",
|
||||
default="127.0.0.1",
|
||||
help="Host to bind to (default: 127.0.0.1; use 0.0.0.0 for all IPv4 interfaces).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-open",
|
||||
action="store_true",
|
||||
|
||||
@@ -135,8 +135,9 @@ class _ViewerState:
|
||||
# exchanged for a session cookie only when presented on the initial page
|
||||
# load. It is the request-level authorization the review asked for:
|
||||
# reachability of the port (e.g. when bound with ``--host``) is not
|
||||
# enough to steer a live scan, trigger a report, or browse history --
|
||||
# the token is never handed to a caller who merely reaches ``/``.
|
||||
# enough to read run data, steer a live scan, trigger a report, or
|
||||
# browse history -- the token is never handed to a caller who merely
|
||||
# reaches ``/``.
|
||||
self.session_token = secrets.token_urlsafe(32)
|
||||
# Finalized in ``serve()`` once the port is known (the server binds
|
||||
# after this state is constructed); see SESSION_COOKIE_PREFIX.
|
||||
@@ -234,11 +235,11 @@ def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]:
|
||||
self.end_headers()
|
||||
|
||||
def _handle_api(self, path: str, query: dict[str, list[str]]) -> None:
|
||||
# The launched run is always viewable with no verification. The
|
||||
# cross-run history list (/api/runs) unlocks its entries only for a
|
||||
# caller that holds this process's session capability *and* is email
|
||||
# verified, so merely reaching an exposed --host port never leaks the
|
||||
# run list (the payload still advertises the count as a teaser).
|
||||
# The cross-run history list (/api/runs) unlocks its entries only for
|
||||
# a caller that holds this process's session capability *and* is
|
||||
# email verified, so merely reaching an exposed --host port never
|
||||
# leaks the run list (the payload still advertises the count as a
|
||||
# teaser).
|
||||
if path == "/api/runs":
|
||||
unlocked = self._has_session() and auth.is_verified()
|
||||
payload = build_runs_payload(state.base_dir, verified=unlocked)
|
||||
@@ -253,6 +254,13 @@ def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]:
|
||||
self._handle_auth_status()
|
||||
return
|
||||
|
||||
# All remaining GET endpoints expose run metadata or scan output.
|
||||
# Require the capability even for the run used to launch the viewer;
|
||||
# reachability of an exposed --host port must not grant data access.
|
||||
if not self._has_session():
|
||||
self._send_json(HTTPStatus.FORBIDDEN, {"error": "forbidden"})
|
||||
return
|
||||
|
||||
run_values = query.get("run")
|
||||
run_param = run_values[0] if run_values else None
|
||||
run_dir = resolve_run_dir(state.base_dir, run_param, state.run_dir)
|
||||
@@ -260,18 +268,12 @@ def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]:
|
||||
self._send_json(HTTPStatus.NOT_FOUND, {"error": "unknown run"})
|
||||
return
|
||||
|
||||
# The launched run is always viewable. Any *other* run's data is part
|
||||
# of the gated history: it needs this process's session capability
|
||||
# (so merely reaching an exposed --host port is not enough) *and*
|
||||
# email verification -- otherwise knowing a run name would leak its
|
||||
# metadata, vulnerabilities, report, and transcript.
|
||||
if run_dir.resolve() != state.run_dir.resolve():
|
||||
if not self._has_session():
|
||||
self._send_json(HTTPStatus.FORBIDDEN, {"error": "forbidden"})
|
||||
return
|
||||
if not auth.is_verified():
|
||||
self._send_json(HTTPStatus.UNAUTHORIZED, {"error": "unverified"})
|
||||
return
|
||||
# Any run other than the one used to launch the viewer is part of the
|
||||
# email-gated history. The session check above applies to both paths;
|
||||
# verification adds a second gate for historical run data.
|
||||
if run_dir.resolve() != state.run_dir.resolve() and not auth.is_verified():
|
||||
self._send_json(HTTPStatus.UNAUTHORIZED, {"error": "unverified"})
|
||||
return
|
||||
|
||||
if path == "/api/run":
|
||||
self._send_json(HTTPStatus.OK, read_run_summary(run_dir))
|
||||
@@ -385,7 +387,7 @@ def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]:
|
||||
except auth.RelayError as exc:
|
||||
self._send_relay_error(exc)
|
||||
return
|
||||
# The password is returned only to the local (127.0.0.1) browser.
|
||||
# The password is returned only to a session-authorized browser.
|
||||
self._send_json(
|
||||
HTTPStatus.OK,
|
||||
{"ok": True, "password": password, "filename": filename},
|
||||
|
||||
+49
-7
@@ -11,6 +11,7 @@ from typing import TYPE_CHECKING
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from strix.core.paths import latest_run_dir, runs_base_dir
|
||||
from strix.interface.viewer.cli import run_view
|
||||
from strix.interface.viewer.server import serve
|
||||
from strix.interface.viewer.transcript import (
|
||||
build_run_state,
|
||||
@@ -48,6 +49,31 @@ def test_latest_run_dir_none_when_no_runs(tmp_path: Path, monkeypatch: pytest.Mo
|
||||
assert runs_base_dir() == tmp_path / "strix_runs"
|
||||
|
||||
|
||||
def test_view_cli_help_includes_host(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
try:
|
||||
run_view(["--help"])
|
||||
except SystemExit as exc:
|
||||
assert exc.code == 0
|
||||
else:
|
||||
raise AssertionError("--help should exit")
|
||||
|
||||
help_text = capsys.readouterr().out
|
||||
assert "--host HOST" in help_text
|
||||
assert "0.0.0.0" in help_text
|
||||
|
||||
|
||||
def test_server_can_bind_all_ipv4_interfaces(tmp_path: Path) -> None:
|
||||
run_dir = _make_run(tmp_path, "remote", status="running", end_time=None)
|
||||
|
||||
httpd, url, _ = serve(run_dir, host="0.0.0.0", open_browser=False)
|
||||
try:
|
||||
assert httpd.server_address[0] == "0.0.0.0"
|
||||
assert url == f"http://0.0.0.0:{httpd.server_address[1]}"
|
||||
finally:
|
||||
httpd.shutdown()
|
||||
httpd.server_close()
|
||||
|
||||
|
||||
def test_latest_run_dir_picks_newest_by_record_mtime(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
@@ -173,14 +199,15 @@ def test_server_serves_api_and_static(tmp_path: Path, monkeypatch: pytest.Monkey
|
||||
(assets / "assets" / "app.js").write_text("console.log(1)", encoding="utf-8")
|
||||
monkeypatch.setattr("strix.interface.viewer.server.bundle_dir", lambda: assets)
|
||||
|
||||
httpd, url, _ = serve(run_dir, open_browser=False)
|
||||
httpd, url, token = serve(run_dir, open_browser=False)
|
||||
try:
|
||||
status, ctype, body = _get(f"{url}/api/run")
|
||||
cookie = _session_cookie(url, token)
|
||||
status, ctype, body = _get(f"{url}/api/run", cookie=cookie)
|
||||
assert status == 200
|
||||
assert "application/json" in ctype
|
||||
assert json.loads(body)["finished"] is True
|
||||
|
||||
status, _, body = _get(f"{url}/api/transcript")
|
||||
status, _, body = _get(f"{url}/api/transcript", cookie=cookie)
|
||||
assert {a["id"] for a in json.loads(body)["agents"]} == {"root", "child"}
|
||||
|
||||
# Real asset is served.
|
||||
@@ -429,6 +456,22 @@ def test_unauthorized_client_cannot_acquire_capability(
|
||||
httpd.server_close()
|
||||
|
||||
|
||||
def test_run_data_requires_session(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
run_dir = _make_run(tmp_path, "private", status="completed", end_time="2026-01-01T00:00:00Z")
|
||||
_bundle(tmp_path, monkeypatch)
|
||||
|
||||
httpd, url, token = serve(run_dir, open_browser=False)
|
||||
try:
|
||||
cookie = _session_cookie(url, token)
|
||||
for path in ("/api/run", "/api/vulnerabilities", "/api/report", "/api/transcript"):
|
||||
assert _get_status(url + path) == 403, path
|
||||
assert _get_status(url + path, cookie=f"{_cookie_name(url)}=wrong") == 403, path
|
||||
assert _get_status(url + path, cookie=cookie) == 200, path
|
||||
finally:
|
||||
httpd.shutdown()
|
||||
httpd.server_close()
|
||||
|
||||
|
||||
def test_auth_status_reflects_expiry(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
run_dir = _make_run(tmp_path, "status", status="running", end_time=None)
|
||||
_bundle(tmp_path, monkeypatch)
|
||||
@@ -561,11 +604,10 @@ def test_historical_run_data_requires_verification(
|
||||
|
||||
httpd, url, token = serve(launched, open_browser=False)
|
||||
try:
|
||||
# The launched run is always viewable, no verification and no cookie.
|
||||
status, _, _ = _get(f"{url}/api/run")
|
||||
assert status == 200
|
||||
|
||||
# The launched run needs the session capability, but not email verification.
|
||||
assert _get_status(f"{url}/api/run") == 403
|
||||
cookie = _session_cookie(url, token)
|
||||
assert _get_status(f"{url}/api/run", cookie=cookie) == 200
|
||||
|
||||
# A different run needs the session capability first: a cookie-less
|
||||
# caller is forbidden even once the machine is verified.
|
||||
|
||||
Reference in New Issue
Block a user