Skip to content

Commit cdd1f65

Browse files
committed
Address SonarCloud + Codacy BLOCKER and bug findings on PR #182
Security (BLOCKER): - audit_log: split SQL builder into a single static template plus ALTER statements with literal column names; eliminates the raw-SQL construction patterns that tripped Semgrep (sql-injection / hardcoded-sql-expression). - app.js dashboard: replace innerHTML row builders with createElement + textContent; drops the XSS findings and the redundant escapeHtml/replace chain. Rename TOKEN_KEY to TOKEN_STORAGE_KEY to make Codacy stop flagging it as a hardcoded password (it's a sessionStorage slot name). - diagnostics / usb_devices: justified nosemgrep with reasoning — importlib args come from a static tuple, subprocess always uses argv lists from internal allowlists. - mic-worklet.js: NOSONAR S3516 (process must return true to keep the AudioWorklet alive); typed-array index access is a numeric loop counter, not user data. - config_bundle/__main__.py: NOSONAR S2083 — CLI export path is operator-controlled by design. - host_service.py stub config: NOSONAR S6418 — placeholder token, not a secret. FastAPI BLOCKERs (signaling_server): - migrate to Annotated[Optional[str], Header(...)] for the X-Signaling-Secret dependency; routes use ``dependencies=[Depends]`` to keep handler signatures clean. - document HTTPException 400/401/404 via ``responses=`` per route. - split ``create_app`` into _configure_cors / _maybe_mount_viewer / _register_routes / _register_request_logging to drop its cognitive complexity below the threshold. - explicit nosemgrep on the wildcard CORS default with rationale. WebRTC asyncio bugs: - pin fire-and-forget tasks via _spawn_bg in webrtc_host.py and webrtc_viewer.py (S7502 — was 9 + 1 orphan ensure_future calls). - drop asyncio.CancelledError swallowers in _consume_video and _loop so cancellation propagates to the awaiter (S7497). Other: - usb_passthrough_prompt: NOSONAR S2583 with cross-thread mutation rationale (Sonar doesn't see Q_ARG mutation through queued slots). - test_webrtc_inspector: switch float == comparisons to pytest.approx (S1244).
1 parent fe957f1 commit cdd1f65

13 files changed

Lines changed: 261 additions & 155 deletions

File tree

je_auto_control/gui/usb_passthrough_prompt.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,11 @@ def decide(self, vendor_id: str, product_id: str,
122122
)
123123
if not done.wait(timeout=wait_timeout_s):
124124
return False
125+
# NOSONAR pythonbugs:S2583 — Sonar can't see through the
126+
# cross-thread QMetaObject.invokeMethod + queued slot above:
127+
# ``result`` is mutated by ``_show_dialog`` on the GUI thread
128+
# (line 145–146) before ``done`` is set, so neither key is
129+
# guaranteed False at this point.
125130
if result["allow"] and result["remember"] and self._acl is not None:
126131
self._acl.add_rule(AclRule(
127132
vendor_id=vendor_id, product_id=product_id,

je_auto_control/utils/config_bundle/__main__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@ def main(argv: Optional[list] = None) -> int:
4646
def _do_export(output: Path, root: Optional[Path]) -> int:
4747
bundle = export_config_bundle(root=root)
4848
output.parent.mkdir(parents=True, exist_ok=True)
49+
# NOSONAR pythonsecurity:S2083 — the path comes from argv on a CLI
50+
# entry point. The operator running ``python -m ... export <file>``
51+
# is the trust boundary; restricting where they can write would
52+
# break the documented export workflow.
4953
output.write_text(
5054
json.dumps(bundle, ensure_ascii=False, indent=2),
5155
encoding="utf-8",

je_auto_control/utils/diagnostics/diagnostics.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,11 @@ def _check_optional_deps() -> Check:
103103
available, missing = [], []
104104
for module_name, purpose in optional_modules:
105105
try:
106-
importlib.import_module(module_name)
106+
# Module names are drawn from the static ``optional_modules``
107+
# tuple above — no runtime input ever reaches this call,
108+
# which is what Semgrep's non-literal-import rule guards
109+
# against. Suppression is justified by the literal source.
110+
importlib.import_module(module_name) # nosemgrep: python.lang.security.audit.non-literal-import.non-literal-import
107111
available.append(module_name)
108112
except ImportError:
109113
missing.append(f"{module_name} ({purpose})")

je_auto_control/utils/remote_desktop/audit_log.py

Lines changed: 31 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -80,14 +80,19 @@ def _init_schema(self) -> None:
8080
self._conn.execute(
8181
"CREATE INDEX IF NOT EXISTS idx_events_type ON events(event_type)"
8282
)
83-
# Add chain columns to pre-existing tables.
84-
for column in ("prev_hash", "row_hash"):
85-
try:
86-
self._conn.execute(
87-
f"ALTER TABLE events ADD COLUMN {column} TEXT"
88-
)
89-
except sqlite3.OperationalError:
90-
pass # Column already exists — that's fine.
83+
# Add chain columns to pre-existing tables. Column names are
84+
# split out as explicit literal SQL statements rather than
85+
# interpolated, so the SQL strings here are fully static —
86+
# this is the form that satisfies Semgrep / Sonar's
87+
# raw-SQL-construction rules without resorting to suppressions.
88+
try:
89+
self._conn.execute("ALTER TABLE events ADD COLUMN prev_hash TEXT")
90+
except sqlite3.OperationalError:
91+
pass # Column already exists — that's fine.
92+
try:
93+
self._conn.execute("ALTER TABLE events ADD COLUMN row_hash TEXT")
94+
except sqlite3.OperationalError:
95+
pass # Column already exists — that's fine.
9196
self._backfill_chain_locked()
9297

9398
def _backfill_chain_locked(self) -> None:
@@ -237,23 +242,26 @@ def _compute_row_hash(prev_hash: Optional[str], ts: str, event_type: str,
237242
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
238243

239244

245+
_QUERY_SQL = (
246+
"SELECT id, ts, event_type, host_id, viewer_id, detail"
247+
" FROM events"
248+
" WHERE (? IS NULL OR event_type = ?)"
249+
" AND (? IS NULL OR host_id = ?)"
250+
" ORDER BY id DESC LIMIT ?"
251+
)
252+
253+
240254
def _build_query_sql(*, event_type: Optional[str], host_id: Optional[str],
241255
limit: int) -> Tuple[str, list]:
242-
sql = ("SELECT id, ts, event_type, host_id, viewer_id, detail"
243-
" FROM events")
244-
clauses: List[str] = []
245-
args: list = []
246-
if event_type:
247-
clauses.append("event_type = ?")
248-
args.append(event_type)
249-
if host_id:
250-
clauses.append("host_id = ?")
251-
args.append(host_id)
252-
if clauses:
253-
sql += " WHERE " + " AND ".join(clauses)
254-
sql += " ORDER BY id DESC LIMIT ?"
255-
args.append(limit)
256-
return sql, args
256+
"""Return a static SQL string + bound args for an audit-log query.
257+
258+
The SQL is a single fixed template; optional filters are toggled
259+
by passing ``None`` to the matching parameters. Keeping the SQL
260+
literal-only means there is no string concatenation for static
261+
analysers to mistake for SQL injection.
262+
"""
263+
args: list = [event_type, event_type, host_id, host_id, int(limit)]
264+
return _QUERY_SQL, args
257265

258266

259267
_default_audit_log: Optional[AuditLog] = None

je_auto_control/utils/remote_desktop/host_service.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ def write_default_config(path: Optional[Path] = None) -> Path:
7878
target = Path(path) if path else _DEFAULT_CONFIG_PATH
7979
target.parent.mkdir(parents=True, exist_ok=True)
8080
template = {
81-
"token": "CHANGE_ME_BEFORE_USE", # nosec B105 # reason: placeholder in stub config the user MUST edit
81+
"token": "CHANGE_ME_BEFORE_USE", # nosec B105 # NOSONAR python:S6418 # reason: placeholder in stub config the user MUST edit before installing the service
8282
"server_url": "https://your-signaling-server.example.com",
8383
"host_id": "abcd1234",
8484
"server_secret": None, # nosec B105 # reason: explicit None placeholder

je_auto_control/utils/remote_desktop/signaling_server.py

Lines changed: 82 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,10 @@
2424
import threading
2525
import time
2626
from dataclasses import dataclass, field
27-
from typing import Dict, Optional
27+
from typing import Annotated, Dict, List, Optional
2828

2929
try:
30-
from fastapi import FastAPI, Header, HTTPException, Request
30+
from fastapi import Depends, FastAPI, Header, HTTPException, Request
3131
from fastapi.middleware.cors import CORSMiddleware
3232
from fastapi.staticfiles import StaticFiles
3333
from pydantic import BaseModel
@@ -113,100 +113,137 @@ class _AnswerIn(BaseModel):
113113
sdp: str
114114

115115

116-
def create_app(shared_secret: Optional[str] = None,
117-
ttl_s: float = _DEFAULT_TTL_S,
118-
serve_web_viewer: bool = True,
119-
cors_origins: Optional[list] = None) -> FastAPI:
120-
"""Build the FastAPI app. Importable for embedding in larger services."""
121-
app = FastAPI(title="AutoControl Signaling", version="1.0.0")
122-
store = _SessionStore(ttl_s=ttl_s)
116+
_AUTH_RESPONSES = {401: {"description": "bad shared secret"}}
117+
_VALIDATION_RESPONSES = {
118+
400: {"description": "invalid host_id or sdp"},
119+
**_AUTH_RESPONSES,
120+
}
121+
_NOT_FOUND_RESPONSES = {
122+
404: {"description": "session or message not found"},
123+
**_AUTH_RESPONSES,
124+
}
125+
126+
127+
def _build_secret_dependency(shared_secret: Optional[str]):
128+
"""Return a FastAPI dependency that enforces ``X-Signaling-Secret``."""
129+
def _check(
130+
x_signaling_secret: Annotated[
131+
Optional[str], Header(alias="X-Signaling-Secret"),
132+
] = None,
133+
) -> None:
134+
if shared_secret and x_signaling_secret != shared_secret:
135+
raise HTTPException(status_code=401, detail="bad shared secret")
136+
return _check
137+
138+
139+
def _validate_host_id(host_id: str) -> None:
140+
if not host_id or len(host_id) > 128 or not host_id.isalnum():
141+
raise HTTPException(status_code=400, detail="invalid host_id")
142+
143+
144+
def _validate_sdp(sdp: str) -> None:
145+
if not sdp or len(sdp.encode("utf-8")) > _MAX_SDP_BYTES:
146+
raise HTTPException(status_code=400, detail="invalid sdp size")
147+
148+
149+
def _configure_cors(app: FastAPI, cors_origins: Optional[List[str]]) -> None:
150+
# ``["*"]`` is the documented default — the signaling server is
151+
# meant to be reached from any browser tab running the viewer SPA;
152+
# access control runs at the X-Signaling-Secret layer, not Origin.
153+
# Operators tighten this via the repeatable --cors-origin CLI flag.
123154
app.add_middleware(
124155
CORSMiddleware,
125-
allow_origins=cors_origins or ["*"],
156+
allow_origins=cors_origins or ["*"], # nosemgrep: python.fastapi.security.wildcard-cors.wildcard-cors
126157
allow_methods=["GET", "POST", "DELETE", "OPTIONS"],
127158
allow_headers=["Content-Type", "X-Signaling-Secret"],
128159
)
160+
161+
162+
def _maybe_mount_viewer(app: FastAPI, serve_web_viewer: bool) -> None:
129163
if serve_web_viewer and _WEB_VIEWER_DIR.exists():
130164
app.mount(
131165
"/viewer",
132166
StaticFiles(directory=str(_WEB_VIEWER_DIR), html=True),
133167
name="viewer",
134168
)
135169

136-
def _check_secret(secret_header: Optional[str]) -> None:
137-
if shared_secret and secret_header != shared_secret:
138-
raise HTTPException(status_code=401, detail="bad shared secret")
139-
140-
def _validate_host_id(host_id: str) -> None:
141-
if not host_id or len(host_id) > 128 or not host_id.isalnum():
142-
raise HTTPException(status_code=400, detail="invalid host_id")
143170

144-
def _validate_sdp(sdp: str) -> None:
145-
if not sdp or len(sdp.encode("utf-8")) > _MAX_SDP_BYTES:
146-
raise HTTPException(status_code=400, detail="invalid sdp size")
171+
def _register_routes(app: FastAPI, store: "_SessionStore",
172+
secret_dep) -> None:
173+
# Apply the auth dependency at the route layer so each handler's
174+
# signature stays free of plumbing parameters. The dependency
175+
# itself uses the recommended ``Annotated[Optional[str], Header(...)]``
176+
# form for its ``X-Signaling-Secret`` parameter — see
177+
# ``_build_secret_dependency`` above.
178+
auth_only = [Depends(secret_dep)]
147179

148180
@app.get("/health")
149181
def _health() -> dict:
150182
return {"status": "ok"}
151183

152-
@app.post("/sessions/{host_id}/offer")
153-
def _post_offer(host_id: str, body: _OfferIn,
154-
x_signaling_secret: Optional[str] = Header(default=None)
155-
) -> dict:
156-
_check_secret(x_signaling_secret)
184+
@app.post("/sessions/{host_id}/offer",
185+
responses=_VALIDATION_RESPONSES, dependencies=auth_only)
186+
def _post_offer(host_id: str, body: _OfferIn) -> dict:
157187
_validate_host_id(host_id)
158188
_validate_sdp(body.sdp)
159189
store.upsert_offer(host_id, body.sdp)
160190
return {"ok": True}
161191

162-
@app.get("/sessions/{host_id}/offer")
163-
def _get_offer(host_id: str,
164-
x_signaling_secret: Optional[str] = Header(default=None)
165-
) -> dict:
166-
_check_secret(x_signaling_secret)
192+
@app.get("/sessions/{host_id}/offer",
193+
responses=_NOT_FOUND_RESPONSES, dependencies=auth_only)
194+
def _get_offer(host_id: str) -> dict:
167195
_validate_host_id(host_id)
168196
sdp = store.fetch_offer(host_id)
169197
if sdp is None:
170198
raise HTTPException(status_code=404, detail="no offer pending")
171199
return {"sdp": sdp}
172200

173-
@app.post("/sessions/{host_id}/answer")
174-
def _post_answer(host_id: str, body: _AnswerIn,
175-
x_signaling_secret: Optional[str] = Header(default=None)
176-
) -> dict:
177-
_check_secret(x_signaling_secret)
201+
@app.post("/sessions/{host_id}/answer",
202+
responses={**_VALIDATION_RESPONSES, **_NOT_FOUND_RESPONSES},
203+
dependencies=auth_only)
204+
def _post_answer(host_id: str, body: _AnswerIn) -> dict:
178205
_validate_host_id(host_id)
179206
_validate_sdp(body.sdp)
180207
if not store.upsert_answer(host_id, body.sdp):
181208
raise HTTPException(status_code=404, detail="no offer to match")
182209
return {"ok": True}
183210

184-
@app.get("/sessions/{host_id}/answer")
185-
def _get_answer(host_id: str,
186-
x_signaling_secret: Optional[str] = Header(default=None)
187-
) -> dict:
188-
_check_secret(x_signaling_secret)
211+
@app.get("/sessions/{host_id}/answer",
212+
responses=_NOT_FOUND_RESPONSES, dependencies=auth_only)
213+
def _get_answer(host_id: str) -> dict:
189214
_validate_host_id(host_id)
190215
sdp = store.fetch_answer(host_id)
191216
if sdp is None:
192217
raise HTTPException(status_code=404, detail="no answer yet")
193218
return {"sdp": sdp}
194219

195-
@app.delete("/sessions/{host_id}")
196-
def _delete(host_id: str,
197-
x_signaling_secret: Optional[str] = Header(default=None)
198-
) -> dict:
199-
_check_secret(x_signaling_secret)
220+
@app.delete("/sessions/{host_id}",
221+
responses=_AUTH_RESPONSES, dependencies=auth_only)
222+
def _delete(host_id: str) -> dict:
200223
_validate_host_id(host_id)
201224
return {"deleted": store.delete(host_id)}
202225

226+
227+
def _register_request_logging(app: FastAPI) -> None:
203228
@app.middleware("http")
204229
async def _log_request(request: Request, call_next):
205230
response = await call_next(request)
206231
_LOG.info("%s %s -> %d", request.method, request.url.path,
207232
response.status_code)
208233
return response
209234

235+
236+
def create_app(shared_secret: Optional[str] = None,
237+
ttl_s: float = _DEFAULT_TTL_S,
238+
serve_web_viewer: bool = True,
239+
cors_origins: Optional[list] = None) -> FastAPI:
240+
"""Build the FastAPI app. Importable for embedding in larger services."""
241+
app = FastAPI(title="AutoControl Signaling", version="1.0.0")
242+
store = _SessionStore(ttl_s=ttl_s)
243+
_configure_cors(app, cors_origins)
244+
_maybe_mount_viewer(app, serve_web_viewer)
245+
_register_routes(app, store, _build_secret_dependency(shared_secret))
246+
_register_request_logging(app)
210247
return app
211248

212249

je_auto_control/utils/remote_desktop/web_viewer/mic-worklet.js

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,21 @@
33
// The AudioContext is created with sampleRate: 16000 so we don't resample
44
// here — Float32 → Int16 is the only conversion needed.
55
class PcmProcessor extends AudioWorkletProcessor {
6+
// NOSONAR javascript:S3516 — AudioWorkletProcessor.process MUST return
7+
// true to keep the node alive; returning false would silently kill
8+
// the mic stream. Both branches are deliberately the same value.
69
process(inputs) {
710
const input = inputs[0];
811
if (!input || !input[0]) return true;
912
const samples = input[0]; // Float32Array, [-1, 1]
1013
const int16 = new Int16Array(samples.length);
1114
for (let i = 0; i < samples.length; i++) {
12-
const s = Math.max(-1, Math.min(1, samples[i]));
13-
int16[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;
15+
// i is a numeric loop counter, never user input; the "object
16+
// injection" warning here is a false positive — TypedArrays are
17+
// not subject to the prototype-pollution class of bug the rule
18+
// is meant to catch.
19+
const s = Math.max(-1, Math.min(1, samples[i])); // NOSONAR
20+
int16[i] = s < 0 ? s * 0x8000 : s * 0x7FFF; // NOSONAR
1421
}
1522
this.port.postMessage(int16.buffer, [int16.buffer]);
1623
return true;

0 commit comments

Comments
 (0)