Skip to content

Commit 7d3fa0e

Browse files
authored
copilot review of security and tests
2 parents 3d96c45 + 537e93c commit 7d3fa0e

11 files changed

Lines changed: 376 additions & 40 deletions

File tree

.github/workflows/ci.yml

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [ main ]
6+
pull_request:
7+
workflow_dispatch: {}
8+
9+
jobs:
10+
unit:
11+
name: Unit tests (fast)
12+
runs-on: ubuntu-latest
13+
steps:
14+
- uses: actions/checkout@v4
15+
- name: Set up Python
16+
uses: actions/setup-python@v4
17+
with:
18+
python-version: '3.11'
19+
- name: Show environment
20+
run: |
21+
python --version
22+
uname -a
23+
pwd
24+
ls -la
25+
- name: Cache pip
26+
uses: actions/cache@v4
27+
with:
28+
path: ~/.cache/pip
29+
key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}
30+
restore-keys: |
31+
${{ runner.os }}-pip-
32+
- name: Install runtime/test deps
33+
run: |
34+
python -m pip install --upgrade pip
35+
pip install -r requirements.txt
36+
- name: Run unit tests (skip integration)
37+
run: |
38+
cd helper
39+
PYTHONPATH=. pytest -q -m "not integration"
40+
41+
integration:
42+
name: Integration tests (slow)
43+
needs: unit
44+
runs-on: ubuntu-latest
45+
if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main') }}
46+
steps:
47+
- uses: actions/checkout@v4
48+
- name: Set up Python
49+
uses: actions/setup-python@v4
50+
with:
51+
python-version: '3.11'
52+
- name: Show environment
53+
run: |
54+
python --version
55+
uname -a
56+
pwd
57+
ls -la
58+
- name: Cache pip
59+
uses: actions/cache@v4
60+
with:
61+
path: ~/.cache/pip
62+
key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}
63+
restore-keys: |
64+
${{ runner.os }}-pip-
65+
- name: Install runtime/test deps
66+
run: |
67+
python -m pip install --upgrade pip
68+
pip install -r requirements.txt
69+
- name: Run integration tests
70+
run: |
71+
cd helper
72+
PYTHONPATH=. pytest -q -m integration

CONTRIBUTING.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
Contributing
2+
============
3+
4+
Quick developer guide for running tests and understanding CI.
5+
6+
Running tests locally
7+
---------------------
8+
It's recommended to use a virtual environment for Python work. From the repository root:
9+
10+
- Create and activate a venv (macOS/Linux):
11+
python -m venv .venv
12+
source .venv/bin/activate
13+
14+
- Install test dependencies:
15+
pip install --upgrade pip
16+
pip install pytest pyserial cryptography
17+
18+
- Run unit tests only (fast):
19+
cd helper
20+
pytest -q -m "not integration"
21+
22+
- Run integration tests (slower, network-like):
23+
cd helper
24+
pytest -q -m integration
25+
26+
- Run the full test suite:
27+
cd helper
28+
pytest -q
29+
30+
Test markers
31+
------------
32+
The test suite separates fast unit tests from slower integration tests using a pytest marker named "integration". Unit/test runs exclude integration tests by default in CI; use the -m flag shown above to include or exclude them locally.
33+
34+
GitHub Actions CI
35+
-----------------
36+
The repository includes a GitHub Actions workflow at .github/workflows/ci.yml that runs a fast "unit" job on PRs and pushes, and an "integration" job (dependent on unit) that runs only on main pushes or when manually triggered. The CI caches pip downloads to speed up repeated runs.
37+
38+
If you add new Python test dependencies, update the install steps in the workflow or add a requirements file at the repository root for a more stable cache key.
39+
40+
If you want help adding more information to this guide (e.g., contributing style, commit message conventions, or how to run tests on macOS runners), say which section to expand.

helper/dashtouch_helper/cli.py

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,15 @@ def find_and_flash(prompt_prefix: str = "") -> str:
163163
str(REPO / "firmware" / "dashtouch")], check=True)
164164
subprocess.run(["arduino-cli", "upload", "--fqbn", FQBN, "-p", port,
165165
str(REPO / "firmware" / "dashtouch")], check=True)
166+
# Erase the plaintext secrets file on the Mac now that flashing
167+
# completed successfully so a pairing key isn't sitting on disk.
168+
try:
169+
if SECRETS_PATH.exists():
170+
SECRETS_PATH.unlink()
171+
except OSError:
172+
# If erase fails for any reason, leave the file — better to let
173+
# user recover than to crash the workflow. The file was mode 0600.
174+
pass
166175
return "flashed"
167176
except subprocess.CalledProcessError:
168177
return "failed"
@@ -291,8 +300,8 @@ def cmd_run(args) -> int:
291300

292301

293302
def _webui_url() -> str | None:
294-
"""The full tokened link to the helper's page, or None if the helper
295-
has never run (that file is written on startup)."""
303+
"""The full link to the helper's page (no session token in the URL),
304+
or None if the helper has never run (that file is written on startup)."""
296305
try:
297306
url = webui.URL_PATH.read_text().strip()
298307
except (OSError, UnicodeDecodeError):
@@ -320,8 +329,8 @@ def cmd_where(args) -> int:
320329
return 1
321330
print(url)
322331
if sys.stderr.isatty():
323-
print("\nThat link includes your session key — treat it like a password.\n"
324-
"`dashtouch enroll` opens it for you.", file=sys.stderr)
332+
print("\nThis link opens the helper's page on localhost; the session token is handled locally and is not included in the URL.\n"
333+
"Use `dashtouch enroll` to open it for you.", file=sys.stderr)
325334
return 0
326335

327336

@@ -366,7 +375,14 @@ def _daemon_post_setting(key: str, value: int) -> tuple[int, dict]:
366375
"""POST /api/settings against the running helper. Raises on any
367376
transport failure — callers decide how to report that."""
368377
base = _daemon_base_url()
369-
token = webui.TOKEN_PATH.read_text().strip()
378+
# Use the Keychain-stored session token. If Keychain access fails,
379+
# the operation cannot proceed securely and we fail with a clear message.
380+
try:
381+
from . import keychain
382+
token = keychain.get_session_token()
383+
except Exception as e:
384+
raise RuntimeError("Session token unavailable in Keychain. Ensure the helper is running and can access the Keychain.") from e
385+
370386
req = urllib.request.Request(
371387
base + "api/settings",
372388
data=json.dumps({"key": key, "value": value}).encode(),

helper/dashtouch_helper/keychain.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,23 @@
99

1010
SERVICE_PASSWORD = "DashboardTouch"
1111
SERVICE_PAIRING = "DashboardTouch-pairing"
12+
SERVICE_SESSION = "DashboardTouch-session"
13+
14+
15+
def set_session_token(token: str) -> None:
16+
"""Store the running helper's session token in the macOS Keychain.
17+
18+
Uses a single account name "session" under SERVICE_SESSION.
19+
"""
20+
_set(SERVICE_SESSION, "session", token)
21+
22+
23+
def get_session_token() -> str:
24+
"""Retrieve the stored session token from the Keychain.
25+
26+
Raises KeychainError if it couldn't be read.
27+
"""
28+
return _get(SERVICE_SESSION, "session")
1229

1330

1431
class KeychainError(Exception):

helper/dashtouch_helper/webui.py

Lines changed: 62 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@
1818
WEB_ROOT = pathlib.Path(__file__).parents[1] / "web"
1919
LABELS_PATH = pathlib.Path.home() / ".dashtouch" / "labels.json"
2020
URL_PATH = pathlib.Path.home() / ".dashtouch" / "webui-url"
21-
TOKEN_PATH = pathlib.Path.home() / ".dashtouch" / "token"
21+
# NOTE: session tokens are stored in the macOS Keychain in production.
22+
# Tests may still set webui.TOKEN_PATH (see tests/conftest.py) for their
23+
# fake keychain backing; production code does not read/write TOKEN_PATH.
24+
TOKEN_PATH = pathlib.Path.home() / ".dashtouch" / "token" # retained for tests; production uses Keychain only
2225

2326
# The one and only outbound network call this software ever makes, and only
2427
# when a person clicks the "check for updates" button — see docs/security.md.
@@ -149,6 +152,14 @@ def log_message(self, *a):
149152
def _json(self, status, obj):
150153
body = json.dumps(obj).encode()
151154
self.send_response(status)
155+
# Security headers: CSP limits where resources can be loaded from,
156+
# X-Frame-Options prevents embedding, Referrer-Policy avoids leaking
157+
# the local URL in Referer headers, and nosniff stops MIME sniffing.
158+
self.send_header("Content-Security-Policy",
159+
"default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; object-src 'none'; frame-ancestors 'none';")
160+
self.send_header("X-Frame-Options", "DENY")
161+
self.send_header("Referrer-Policy", "no-referrer")
162+
self.send_header("X-Content-Type-Options", "nosniff")
152163
self.send_header("Content-Type", "application/json")
153164
self.send_header("Content-Length", str(len(body)))
154165
self.end_headers()
@@ -161,6 +172,12 @@ def _static(self, name, ctype):
161172
self.send_error(404)
162173
return
163174
self.send_response(200)
175+
# Same security headers on static responses.
176+
self.send_header("Content-Security-Policy",
177+
"default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; object-src 'none'; frame-ancestors 'none';")
178+
self.send_header("X-Frame-Options", "DENY")
179+
self.send_header("Referrer-Policy", "no-referrer")
180+
self.send_header("X-Content-Type-Options", "nosniff")
164181
self.send_header("Content-Type", ctype)
165182
self.send_header("Content-Length", str(len(body)))
166183
self.end_headers()
@@ -187,6 +204,10 @@ def do_GET(self):
187204
with daemon._events_lock:
188205
events = list(daemon.events)
189206
self._json(200, {"events": events})
207+
elif path == "/api/token":
208+
# Same-origin page can fetch the session token; do NOT set CORS
209+
# headers so remote origins cannot read this response.
210+
self._json(200, {"token": token})
190211
else:
191212
self.send_error(404)
192213

@@ -353,17 +374,28 @@ def start(daemon, port: int = 3274) -> str:
353374
except ValueError:
354375
# Invalid env value: ignore, use default
355376
pass
356-
# Reuse existing token if present and valid; otherwise generate one now
357-
# and persist it later, once we know we're not stepping on a live helper.
358-
# Token stays valid across restarts by design; delete ~/.dashtouch/token to force fresh one.
377+
# Reuse existing token from the macOS Keychain if available; otherwise
378+
# generate a fresh one. In production we store the token only in Keychain
379+
# (no on-disk token file) to avoid leaking it via the filesystem.
359380
token = None
381+
# Production: always use the macOS Keychain as the persistent
382+
# storage for the session token. If Keychain access fails, keep the
383+
# token in memory for the running process but do not persist to disk.
360384
try:
361-
existing = TOKEN_PATH.read_text().strip()
362-
if len(existing) >= 16: # plausible token length
385+
from . import keychain
386+
try:
387+
existing = keychain.get_session_token()
388+
except Exception:
389+
existing = None
390+
if existing and len(existing) >= 16:
363391
token = existing
364-
except (OSError, UnicodeDecodeError):
365-
# File missing, directory in the way, unreadable, or corrupted — generate fresh token
366-
pass
392+
except Exception:
393+
# Keychain import failed (non-macOS environment) — do not persist to disk.
394+
existing = None
395+
396+
token_freshly_generated = token is None
397+
if token is None:
398+
token = secrets.token_urlsafe(24)
367399

368400
token_freshly_generated = token is None
369401
if token is None:
@@ -385,7 +417,13 @@ def start(daemon, port: int = 3274) -> str:
385417

386418
threading.Thread(target=server.serve_forever, daemon=True).start()
387419
actual = server.server_address[1]
388-
url = f"http://127.0.0.1:{actual}/?token={token}"
420+
# Publish a token-less URL so the session token is not leaked in the
421+
# browser address bar, history, or Referer. The page fetches the token
422+
# from /api/token (same-origin) after it loads. For programmatic callers
423+
# (and tests) we still return the full token-bearing URL, but the
424+
# persisted link on disk intentionally omits the token.
425+
serve_url = f"http://127.0.0.1:{actual}/"
426+
returned_url = f"http://127.0.0.1:{actual}/?token={token}"
389427

390428
# Don't blindly overwrite the shared link/token files: if they already
391429
# point at a DIFFERENT port and something is actually answering there,
@@ -414,18 +452,24 @@ def start(daemon, port: int = 3274) -> str:
414452
print(msg)
415453

416454
if token_freshly_generated and not other_helper_live:
455+
# Persist the token to the macOS Keychain. If Keychain write fails,
456+
# do not persist to disk — keep the new token in memory only.
417457
try:
418-
TOKEN_PATH.parent.mkdir(parents=True, exist_ok=True)
419-
TOKEN_PATH.write_text(token + "\n")
420-
TOKEN_PATH.chmod(0o600)
421-
except OSError:
422-
# Write failed (e.g., parent is a file, permission denied) — proceed with in-memory token
423-
# A working daemon with unpersisted token beats a dead one; dashtouch enroll degradation is acceptable
458+
from . import keychain
459+
try:
460+
keychain.set_session_token(token)
461+
except Exception:
462+
# Can't write to Keychain — intentionally do not persist to disk
463+
pass
464+
except Exception:
465+
# Keychain not available — intentionally keep token in-memory
424466
pass
425467

426468
if not other_helper_live:
427469
URL_PATH.parent.mkdir(parents=True, exist_ok=True)
428-
URL_PATH.write_text(url + "\n")
470+
# Persist the token-less serve URL so the filesystem/stored link can't
471+
# leak the session token; return the token-bearing URL to the caller.
472+
URL_PATH.write_text(serve_url + "\n")
429473
URL_PATH.chmod(0o600)
430474

431-
return url
475+
return returned_url

helper/pytest.ini

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
[pytest]
2+
markers =
3+
integration: slow/integration tests that touch network or external services

helper/tests/conftest.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,49 @@
44

55
@pytest.fixture(autouse=True)
66
def isolate_dashtouch_home(tmp_path, monkeypatch):
7+
"""Isolate filesystem usage for tests and install a fake keychain module
8+
that uses the test token file under tmp_path. Tests may still set
9+
webui.TOKEN_PATH and the fake keychain will read/write that path, so
10+
per-test monkeypatching of TOKEN_PATH continues to work.
11+
"""
12+
import dashtouch_helper.keychain as keychain
13+
714
monkeypatch.setattr(webui, "URL_PATH", tmp_path / "webui-url")
815
monkeypatch.setattr(webui, "LABELS_PATH", tmp_path / "labels.json")
916
monkeypatch.setattr(webui, "TOKEN_PATH", tmp_path / "token")
17+
18+
# Instead of replacing the keychain module in sys.modules, directly
19+
# monkeypatch the keychain functions used by the application. This is a
20+
# more explicit mock and avoids messing with module resolution.
21+
def fake_get_session_token():
22+
try:
23+
return webui.TOKEN_PATH.read_text().strip()
24+
except Exception:
25+
# Mirror the KeychainError semantics by raising the module's
26+
# KeychainError so production code handles it the same way.
27+
raise keychain.KeychainError("no token")
28+
29+
def fake_set_session_token(token):
30+
webui.TOKEN_PATH.parent.mkdir(parents=True, exist_ok=True)
31+
webui.TOKEN_PATH.write_text(token + "\n")
32+
try:
33+
webui.TOKEN_PATH.chmod(0o600)
34+
except OSError:
35+
pass
36+
37+
# Wrap the existing _run to convert FileNotFoundError (missing 'security'
38+
# binary on non-macOS CI runners) into KeychainError while preserving the
39+
# ability for tests to patch subprocess.run and exercise normal flows.
40+
orig_run = keychain._run
41+
42+
def safe_run(args, input_value=None, detach_tty=False):
43+
try:
44+
return orig_run(args, input_value=input_value, detach_tty=detach_tty)
45+
except FileNotFoundError as e:
46+
raise keychain.KeychainError("security not available in CI") from e
47+
48+
monkeypatch.setattr(keychain, "_run", safe_run)
49+
50+
monkeypatch.setattr(keychain, "get_session_token", fake_get_session_token)
51+
monkeypatch.setattr(keychain, "set_session_token", fake_set_session_token)
52+

0 commit comments

Comments
 (0)