Skip to the content.

shy3130/tick-stock-panel — security scan

Repository: shy3130/tick-stock-panel Commit scanned: 869c49bb0be4af6825e3797c9cde380d34488ad9 Scan date: 2026-08-31 Disclosure status: public — issue #224

Tick Stock Panel (TSP) is a self-hosted, zero-ops personal quantitative workbench for China A-shares — screening, monitoring, backtesting, with an LLM strategy layer (4.0k★, MIT, personal open-source). It stores and queries market data through DuckDB, and that engine is the whole story of this scan.

Summary

Severity Count
Critical 0
High 24
Medium 50
Low 0
Info 3

Total findings: 77 (1 of interest after curation)

The high tier is entirely dependency-CVE version drift from backend/uv.lock and frontend/pnpm-lock.yaml (Starlette SSRF-on-Windows-UNC, three Pillow DoS CVEs, and so on) — worth a lockfile refresh, but nothing reachable in a way that beats “update your deps.” The one finding that matters came from reading the code, not the tool output.

Top finding

POST /api/screener/run — custom-SQL screener is file-write RCE, not just file reads

This is the sink the original #150 reporter listed as “sink #1” and the maintainer refuted — correctly for api/screener.py, but the real conditions/order_by/execute() combination lives in the other screener.py, under services/, and the quote_ident fix never reached it.

Patterns observed

The interesting thing about this repo is how well-defended it already is, which is why the scanner’s 77 findings collapse to one. The #150 SQL-injection fix was model behaviour: the maintainer verified each claimed sink by hand, refuted the one that did not exist, centralised the fix into a shared quote_ident primitive, added 24 security tests including end-to-end COPY TO / UNION assertions, and documented why they rejected the blunt “just turn off enable_external_access” fix (it would disable read_parquet, the app’s core data access). The auth layer is similarly careful: the X-Forwarded-For trust is gated on the direct peer being loopback/LAN first (so a public client cannot spoof 127.0.0.1 — the verify-then-branch order that so many projects get backwards), login has rate-limiting with lockout, sessions are HttpOnly cookies, and the pre-setup window explicitly 403s public clients to prevent takeover. The CORS config — allow_origins=* with allow_credentials=false — is the correct trade for a self-hosted LAN tool: a malicious web page can reach unauthenticated endpoints but cannot ride the operator’s session cross-origin.

So the one surviving finding is not a rule miss in the usual sense — semgrep did flag services/screener.py:349. It is that no rule can see the three facts that compose into the finding: (1) the sink is operator-SQL by design, (2) the containment the maintainer documented in a comment is factually wrong about read-vs-write, and (3) there is a pre-setup, LAN-adjacent window where a non-operator reaches it. Each is individually invisible; the finding is their product. It also needed the one thing static analysis cannot do — running the COPY TO primitive against a real :memory: connection to prove the comment wrong.

The enable_external_access-on posture is a genuine double-edged design choice. It is load-bearing (read_parquet is how the app reads its own data), which is exactly why the #150 fix escaped identifiers instead of disabling it. The lesson this scan adds: that global constraint does not apply to the isolated query connections that never read files — those can and should turn it off.

Notes on the tool

Disclosure timeline

Reproduce

git clone https://github.com/shy3130/tick-stock-panel /tmp/scan-target
python scanner/run_scan.py --repo /tmp/scan-target --reports-dir ./reports/shy3130-tick-stock-panel --min-severity medium