Skip to the content.

ginlix-ai/LangAlpha - security scan

Repository: ginlix-ai/LangAlpha Commit scanned: f5232fa2 (main at scan time) Scan date: 2026-08-29 Disclosure status: disclosed — one real finding filed as a single focused public issue. No SECURITY.md at the repository root, in .github/, in docs/, or at the organisation level, and private vulnerability reporting is disabled (confirmed with an empty-payload control request, which returned 403 Repository does not have private vulnerability reporting enabled — it files nothing). A public issue is the only channel the project offers.

Summary

Severity Count
Critical 0
High 209
Medium 160
Low 0
Info 3

Total findings: 372 (1 real after curation)

This is a financial-market agent platform: a FastAPI backend, a React frontend, a Daytona-or-Docker sandbox in which the agent runs code, MCP server plumbing, and a per-workspace secret vault. It is one of the most carefully defended codebases this series has scanned, and most of this write-up is about why 371 findings did not survive.

Top findings

1. The unauthenticated preview redirect can start a stopped sandbox — the one thing its own docstring says it will not do

GET /api/v1/preview/{workspace_id}/{port} is deliberately unauthenticated. The workspace UUID is the bearer credential, and the route is meant to be embedded in shared reports. Its docstring is explicit about the risk:

Unlike the authenticated POST endpoint, the unauthenticated redirect does NOT start stopped sandboxes (to prevent denial-of-wallet via cheap GET requests).

The implementation enforces that with a single check against the database row:

if not workspace or workspace.get("status") != "running":
    raise HTTPException(status_code=404, detail="Preview not available")

and then, having passed it, calls the acquisition path:

session = await manager.get_session_for_workspace(
    workspace_id, user_id=workspace.get("user_id")
)

get_session_for_workspace is documented as “Get or restart a session for a workspace”. On the status == "running" branch it reaches _attach_running_sessionSession.initialize(sandbox_id=...)PTCSandbox.reconnect(...), whose own docstring reads: “Reconnect to a stopped sandbox. This is a fast path for session persistence — it starts a stopped sandbox.” If the sandbox is positively absent rather than stopped, the SandboxGoneError branch calls _recover_sandbox, which provisions a new billed sandbox.

So the guarantee holds only as long as the database row is never running while the sandbox is not. The reason to think that gap is real is that this repository says so itself, in three other places. Four sibling call sites — the unauthenticated wsfiles serve route and three shared-thread file routes — solve exactly this problem and refuse exactly this call:

# src/server/app/public.py:414-419
# never call get_session_for_workspace(), which would do a Daytona
# attach/restart and let an unauthenticated UUID-only request wake a sandbox
# (denial-of-wallet). A stale 'running' DB row with no warm session -> DB-only.
session = manager.get_session_if_ready(
    workspace_id, expected_sandbox_id=workspace.get("sandbox_id")
)

get_session_if_ready exists for this purpose — “Still no I/O and no wake — the unauthenticated routes must never let a UUID-only request start a sandbox” — and its expected_sandbox_id parameter was made keyword-only and mandatory specifically “so a new caller cannot omit the fence.” The preview redirect is the one unauthenticated route that never adopted it.

The majority sibling is the contract here. Four call sites treat a stale running row as a real state that must not be trusted; one treats it as sufficient proof that no wake can happen.

Recommendation: mirror the siblings — resolve the session with get_session_if_ready(workspace_id, expected_sandbox_id=workspace.get("sandbox_id")) and return the same uniform 404 when it is None. That is not a behaviour change against the documented intent: test_returns_404_for_stopped already pins “stopped → 404, no wake”, and the agent’s own GetPreviewUrl tool restarts a preview server inside a running sandbox, which is a different operation. It only closes the case the tests never cover.

Patterns observed

The interesting result here is a negative one, and it took the most work. Semgrep’s single highest-severity finding was pull_request_target with a checkout of untrusted fork code in .github/workflows/fork-integration.yml — normally a live repository-compromise path, and not something the repository tree alone can settle. Querying the GitHub environments API settles it: a fork-ci environment exists with required_reviewers set to the maintainer and prevent_self_review: true. The workflow pins ref to the immutable head SHA so a post-approval force-push cannot swap in unreviewed code, sets persist-credentials: false, and drops the token to contents: read. The author also wrote down the part almost everyone misses — that uv sync runs fork-controlled build hooks before pytest, so the reviewer must audit build config and the lockfile, not just the test files. That is a better threat model than the rule that flagged it.

The defenses are unusually specific. The unauthenticated file-serving route redacts vault secrets from any UTF-8-decodable body rather than from declared text MIME types, with a comment explaining that otherwise a secret written to a mis-named secret.png would slip through. Its CSP uses connect-src 'none' as the load-bearing control so a prompt-injected agent report cannot exfiltrate its own contents. _is_serve_blocked_path turns out to be stricter than the authenticated read and download endpoints it claims to mirror, which is the correct direction for the asymmetry. All 30 Gitleaks hits are fixtures inside the project’s own secret-redaction and leak-detection test suites.

Which is exactly why the one finding is the shape it is. On a codebase this careful, the bug that survives is not a missing check — it is two individually correct decisions in different files that stop composing. The preview redirect’s DB-status check is correct. The session acquisition path is correct. The guarantee only fails at the seam, and only because a fifth route did not inherit a fence the other four were given. No pattern rule can see that; it is visible only by tabulating every unauthenticated route against the accessor it uses.

The 102-finding SQL cluster was noise again, for the ninth time in this series. Every hit in src/server/database/ binds its values with %s placeholders; the only f-string interpolation is {_WS_COLS}, a module-level constant column list. Two reads settle the whole cluster.

Notes on the tool

The coverage warning was the lead. AI PatchLab reported that Semgrep failed to parse three workflow files — claude.yml, release.yml, and sandbox-integration.yml — and timed out two rules. Those three were precisely the workflows most likely to carry injection, so “no findings there” meant “nothing looked.” Reading them by hand is what mapped the CI surface, and it is what turned up that claude.yml deliberately passes REVIEW_BODY through the environment rather than interpolating it into a run: block. A scanner that had silently reported zero on those files would have been actively misleading.

The lockfile gap fired correctly and mattered. pip-audit resolved dependencies from pyproject.toml — declared version floors — while the project ships and installs from uv.lock. The dependency result therefore describes an install path the project does not use. The meta finding said so rather than letting an empty [] read as “clean.”

Backlog item: the two remaining run-shell-injection hits (release.yml:29, sandbox-integration.yml:288) are both reachable only via workflow_dispatch or a same-repo pull_request, where the actor already holds write access. Severity for that rule is a function of the workflow trigger, and the tool does not yet read the trigger. That would have collapsed two high-severity findings automatically instead of by hand.

Disclosure timeline

Reproduce

git clone https://github.com/elfrost/ai-patchlab
cd ai-patchlab
pip install -e ".[dev]"
python scanner/run_scan.py \
  --from-git-url "https://github.com/ginlix-ai/LangAlpha" \
  --reports-dir reports/ginlix-ai-langalpha \
  --min-severity medium --ignore-samples

The one real finding was not produced by the scanner. It came from listing every router registered in src/server/app/setup.py, keeping the four the comments mark as unauthenticated, and tabulating which session accessor each one calls. Four call get_session_if_ready. One calls get_session_for_workspace.