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
|
# ...or open a specific run by name
|
||||||
strix view my-run-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.
|
`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
|
### What's in the dashboard
|
||||||
|
|
||||||
- **Overview**: run status, target, and a severity breakdown of everything found so far.
|
- **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,
|
default=0,
|
||||||
help="Port to serve on (default: an available ephemeral port).",
|
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(
|
parser.add_argument(
|
||||||
"--no-open",
|
"--no-open",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
|
|||||||
@@ -135,8 +135,9 @@ class _ViewerState:
|
|||||||
# exchanged for a session cookie only when presented on the initial page
|
# exchanged for a session cookie only when presented on the initial page
|
||||||
# load. It is the request-level authorization the review asked for:
|
# load. It is the request-level authorization the review asked for:
|
||||||
# reachability of the port (e.g. when bound with ``--host``) is not
|
# reachability of the port (e.g. when bound with ``--host``) is not
|
||||||
# enough to steer a live scan, trigger a report, or browse history --
|
# enough to read run data, steer a live scan, trigger a report, or
|
||||||
# the token is never handed to a caller who merely reaches ``/``.
|
# browse history -- the token is never handed to a caller who merely
|
||||||
|
# reaches ``/``.
|
||||||
self.session_token = secrets.token_urlsafe(32)
|
self.session_token = secrets.token_urlsafe(32)
|
||||||
# Finalized in ``serve()`` once the port is known (the server binds
|
# Finalized in ``serve()`` once the port is known (the server binds
|
||||||
# after this state is constructed); see SESSION_COOKIE_PREFIX.
|
# after this state is constructed); see SESSION_COOKIE_PREFIX.
|
||||||
@@ -234,11 +235,11 @@ def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]:
|
|||||||
self.end_headers()
|
self.end_headers()
|
||||||
|
|
||||||
def _handle_api(self, path: str, query: dict[str, list[str]]) -> None:
|
def _handle_api(self, path: str, query: dict[str, list[str]]) -> None:
|
||||||
# The launched run is always viewable with no verification. The
|
# The cross-run history list (/api/runs) unlocks its entries only for
|
||||||
# cross-run history list (/api/runs) unlocks its entries only for a
|
# a caller that holds this process's session capability *and* is
|
||||||
# caller that holds this process's session capability *and* is email
|
# email verified, so merely reaching an exposed --host port never
|
||||||
# verified, so merely reaching an exposed --host port never leaks the
|
# leaks the run list (the payload still advertises the count as a
|
||||||
# run list (the payload still advertises the count as a teaser).
|
# teaser).
|
||||||
if path == "/api/runs":
|
if path == "/api/runs":
|
||||||
unlocked = self._has_session() and auth.is_verified()
|
unlocked = self._has_session() and auth.is_verified()
|
||||||
payload = build_runs_payload(state.base_dir, verified=unlocked)
|
payload = build_runs_payload(state.base_dir, verified=unlocked)
|
||||||
@@ -253,6 +254,13 @@ def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]:
|
|||||||
self._handle_auth_status()
|
self._handle_auth_status()
|
||||||
return
|
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_values = query.get("run")
|
||||||
run_param = run_values[0] if run_values else None
|
run_param = run_values[0] if run_values else None
|
||||||
run_dir = resolve_run_dir(state.base_dir, run_param, state.run_dir)
|
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"})
|
self._send_json(HTTPStatus.NOT_FOUND, {"error": "unknown run"})
|
||||||
return
|
return
|
||||||
|
|
||||||
# The launched run is always viewable. Any *other* run's data is part
|
# Any run other than the one used to launch the viewer is part of the
|
||||||
# of the gated history: it needs this process's session capability
|
# email-gated history. The session check above applies to both paths;
|
||||||
# (so merely reaching an exposed --host port is not enough) *and*
|
# verification adds a second gate for historical run data.
|
||||||
# email verification -- otherwise knowing a run name would leak its
|
if run_dir.resolve() != state.run_dir.resolve() and not auth.is_verified():
|
||||||
# metadata, vulnerabilities, report, and transcript.
|
self._send_json(HTTPStatus.UNAUTHORIZED, {"error": "unverified"})
|
||||||
if run_dir.resolve() != state.run_dir.resolve():
|
return
|
||||||
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
|
|
||||||
|
|
||||||
if path == "/api/run":
|
if path == "/api/run":
|
||||||
self._send_json(HTTPStatus.OK, read_run_summary(run_dir))
|
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:
|
except auth.RelayError as exc:
|
||||||
self._send_relay_error(exc)
|
self._send_relay_error(exc)
|
||||||
return
|
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(
|
self._send_json(
|
||||||
HTTPStatus.OK,
|
HTTPStatus.OK,
|
||||||
{"ok": True, "password": password, "filename": filename},
|
{"ok": True, "password": password, "filename": filename},
|
||||||
|
|||||||
+49
-7
@@ -11,6 +11,7 @@ from typing import TYPE_CHECKING
|
|||||||
from urllib.parse import urlsplit
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
from strix.core.paths import latest_run_dir, runs_base_dir
|
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.server import serve
|
||||||
from strix.interface.viewer.transcript import (
|
from strix.interface.viewer.transcript import (
|
||||||
build_run_state,
|
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"
|
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(
|
def test_latest_run_dir_picks_newest_by_record_mtime(
|
||||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
) -> None:
|
) -> 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")
|
(assets / "assets" / "app.js").write_text("console.log(1)", encoding="utf-8")
|
||||||
monkeypatch.setattr("strix.interface.viewer.server.bundle_dir", lambda: assets)
|
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:
|
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 status == 200
|
||||||
assert "application/json" in ctype
|
assert "application/json" in ctype
|
||||||
assert json.loads(body)["finished"] is True
|
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"}
|
assert {a["id"] for a in json.loads(body)["agents"]} == {"root", "child"}
|
||||||
|
|
||||||
# Real asset is served.
|
# Real asset is served.
|
||||||
@@ -429,6 +456,22 @@ def test_unauthorized_client_cannot_acquire_capability(
|
|||||||
httpd.server_close()
|
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:
|
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)
|
run_dir = _make_run(tmp_path, "status", status="running", end_time=None)
|
||||||
_bundle(tmp_path, monkeypatch)
|
_bundle(tmp_path, monkeypatch)
|
||||||
@@ -561,11 +604,10 @@ def test_historical_run_data_requires_verification(
|
|||||||
|
|
||||||
httpd, url, token = serve(launched, open_browser=False)
|
httpd, url, token = serve(launched, open_browser=False)
|
||||||
try:
|
try:
|
||||||
# The launched run is always viewable, no verification and no cookie.
|
# The launched run needs the session capability, but not email verification.
|
||||||
status, _, _ = _get(f"{url}/api/run")
|
assert _get_status(f"{url}/api/run") == 403
|
||||||
assert status == 200
|
|
||||||
|
|
||||||
cookie = _session_cookie(url, token)
|
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
|
# A different run needs the session capability first: a cookie-less
|
||||||
# caller is forbidden even once the machine is verified.
|
# caller is forbidden even once the machine is verified.
|
||||||
|
|||||||
Reference in New Issue
Block a user