Audit date: 2026-05-12 Auditor: Claude Code (claude-sonnet-4-6) Scope: Phase 1 — Call Infrastructure (main.py, config.py, openjustice.py, sms.py, Dockerfile, requirements.txt) Remediation: PR #3 (hotline-fixes-and-security) addresses SEC-001 through SEC-008 — see summary table for current statuses.
JusticeLine exposes two unauthenticated public endpoints on Cloud Run: a POST /twilio-webhook that generates TwiML and a WebSocket /media that opens a live Gemini session. Any attacker with the Cloud Run URL can hit either endpoint without presenting any credential. The system handles caller phone numbers (PII) and routes AI-generated tool calls to external APIs (OpenJustice, Twilio SMS), creating injection and abuse surfaces. The service account key (hackathon-key.json) sits on disk alongside application code and is baked into the Docker image at build time with no .dockerignore guard.
Threat: Any actor who discovers the Cloud Run URL can POST arbitrary form data to /twilio-webhook, spoofing a Twilio call. This lets an attacker: (a) poison _pending_callers with a controlled phone number so the next real WebSocket caller's SMS goes to an attacker-chosen number, (b) probe the endpoint for information disclosure, (c) trigger downstream side effects at zero cost.
Location: main.py:90-107
Current state: The handler calls await request.form() and reads CallSid / From directly with no X-Twilio-Signature header check and no call to twilio.request_validator.RequestValidator. Any HTTP client can hit this endpoint.
Recommended fix:
- Import
twilio.request_validator.RequestValidatorand instantiate it withTWILIO_AUTH_TOKEN. - In the handler, read
X-Twilio-Signaturefrom headers and the full request URL. - Call
validator.validate(url, form_params, signature)— return HTTP 403 immediately on failure. - Add a test asserting that a request without a valid signature is rejected.
Threat: The /media WebSocket endpoint accepts connections from any client, not just Twilio's media gateway. An attacker can open a WebSocket, send a crafted start event with a fake callSid, then send media events to drive a Gemini Live session at the operator's GCP cost. One Gemini Live session costs real money; this is an unbounded cost-amplification attack.
Location: main.py:142-271
Current state: await websocket.accept() is unconditional (line 144). No origin check, no shared secret, no validation that the connecting IP is a Twilio media gateway range. The start event's callSid is looked up in _pending_callers (line 228), so a missing entry only means caller_number is None — the Gemini session still opens and runs.
Recommended fix:
- Require a short-lived token in the WebSocket URL query parameter, generated by
/twilio-webhookand stored alongside the pending caller entry; reject connections whose token is absent or expired (60 s TTL is sufficient for Twilio's connection latency). - Alternatively, validate the connecting IP against Twilio's published IP ranges in Cloud Run network policy.
- If
callSidis not in_pending_callers, close the WebSocket immediately before opening a Gemini session.
Threat: hackathon-key.json contains a live GCP service account private key (project: [redacted], key ID: [redacted]). Because there is no .dockerignore, COPY . . in the Dockerfile copies this key into every built image. Anyone who pulls the image from the registry — including via a misconfigured Cloud Run service or a leaked image tag — obtains full service account credentials.
Location: Dockerfile:5 (COPY . .), absent .dockerignore
Current state: .dockerignore does not exist. hackathon-key.json is present in the working directory (confirmed by ls). The Dockerfile unconditionally copies everything.
Recommended fix:
- Create
.dockerignoreimmediately and add at minimum:hackathon-key.json,.env,.venv/,*.json(or specifically*-key.json). - Rotate the service account key in GCP Console right now — treat it as compromised.
- Switch to Workload Identity Federation on Cloud Run (no key file needed at all; the runtime service account is attached to the Cloud Run service).
- Verify the image is not already published to Artifact Registry with the key inside.
Threat: The OpenJustice API key is printed to stdout on every query. On Cloud Run, stdout goes to Cloud Logging, which may be accessible to any project member or exported to third-party SIEM systems. This permanently exposes the secret in log storage.
Location: openjustice.py:18
Current state:
print("Using key and flow:", OJ_KEY, OJ_FLOW_ID, flush=True)This line runs on every call to query_openjustice, printing the full value of OJ_KEY unconditionally.
Recommended fix:
- Delete line 18 entirely. The flow ID is already logged on line 24 (truncated), which is sufficient for debugging.
- If key presence must be confirmed at startup, log
bool(OJ_KEY)or a redacted prefix:OJ_KEY[:4] + "****".
Threat: /twilio-webhook and /media have no request rate limiting. An attacker (or a runaway Twilio misconfiguration) can open unlimited concurrent Gemini Live sessions, exhausting Vertex AI quota and incurring unbounded GCP billing. One session holds a streaming connection open for up to 10 minutes (<Pause length="600" />).
Location: main.py:90 (webhook), main.py:142 (WebSocket), requirements.txt (no rate-limit library)
Current state: No middleware, no slowapi, no Cloud Run concurrency cap enforced in application logic.
Recommended fix:
- Set Cloud Run
--max-instancesand--concurrencylimits in deployment config to cap simultaneous sessions at an affordable number. - Add
slowapior Cloud Armor rate limiting on/twilio-webhook(e.g., 10 req/min per IP). - Track active Gemini sessions in a counter; reject new WebSocket connections beyond a configured maximum.
Threat: Caller phone numbers are PII. Logging them verbatim to Cloud Logging means they are stored indefinitely in GCP, potentially accessible to log viewers, exported to BigQuery, or included in support tickets, creating unnecessary data retention risk under PIPEDA / Quebec Law 25.
Location: main.py:97, main.py:134, main.py:229; sms.py:52, sms.py:55
Current state: Three separate print statements emit the full E.164 number. The number is not stored beyond the call (it is popped from _pending_callers at WebSocket start — this part is correct), but log persistence is uncontrolled.
Recommended fix:
- Redact the middle digits before logging: e.g.,
+1***5678using a helper_mask(n): return n[:3]+"***"+n[-4:]. - Set Cloud Logging retention to the minimum required (30 days or less).
- Document the data flows and retention policy in a privacy notice.
Why PARTIAL: The in-memory lifetime is correctly bounded (pop at WebSocket start), but log persistence is not addressed.
Threat: CallSid and From are taken verbatim from the POST body with no format validation. A spoofed request (see SEC-001) could inject an arbitrarily long or crafted string into _pending_callers. While this does not directly cause injection into SQL or shell, a very long CallSid key could cause unbounded memory growth if many fake requests arrive.
Location: main.py:93-96
Current state: form.get("CallSid", "") and form.get("From", "") — no length check, no regex validation (e.g., CA[0-9a-f]{32} for CallSid, E.164 for From).
Recommended fix:
- Validate
CallSidmatches^CA[0-9a-f]{32}$before inserting into_pending_callers. - Validate
Frommatches an E.164 pattern (^\+[1-9]\d{7,14}$) before storing. - Both validations become moot once SEC-001 (signature validation) is implemented, but defense-in-depth is still valuable.
Why PARTIAL: No injection risk beyond memory, but input is fully unvalidated.
Threat: traceback.print_exc() is called in three error handlers. In Cloud Run, stdout is Cloud Logging. Stack traces can reveal internal file paths, library versions, and variable state, giving an attacker information useful for further exploitation.
Location: main.py:216, main.py:257, main.py:267
Current state: Exceptions in the Gemini receiver, WebSocket handler, and top-level Gemini connection all call traceback.print_exc(). These traces go to Cloud Logging, not HTTP responses, so they are not directly exposed to callers.
Recommended fix:
- In production, replace
traceback.print_exc()with structured logging that includes a correlation ID but suppresses the full trace, or gate verbose tracing on aDEBUGenv flag. - Ensure Cloud Logging IAM grants are restricted to operators only (no public log sink).
Why PARTIAL: Traces go to logs, not HTTP responses, so the blast radius is limited — but log access controls are outside this codebase.
Threat: If the OpenJustice base URL or path were caller-controlled, an attacker could redirect HTTP requests to internal GCP metadata endpoints or other internal services.
Location: openjustice.py:4, openjustice.py:26
Current state: OJ_BASE is read from OPENJUSTICE_BASE_URL env var at startup (not per-request), and the path /dialog-flow-executions/run is a hardcoded string literal. The situation and jurisdiction strings from Gemini are placed in the JSON body — not in the URL — so they cannot redirect the HTTP call. No caller-controlled URL construction occurs.
Status: MITIGATED — no action required.
Threat: If caller-spoken text were directly interpolated into the SMS body, an attacker could craft speech to inject malicious content into outbound SMS messages.
Location: sms.py:34-36, main.py:132-138
Current state: The SMS body is selected exclusively from the hardcoded _RESOURCES dict by _body_for_lang(). The language_code parameter routes only to a fixed key lookup (prefix = language_code[:2].lower()). No caller-provided text appears in any SMS body. The situation and jurisdiction fields go only to the OpenJustice JSON payload body.
Status: MITIGATED — no action required.
Threat: hackathon-key.json committed to git would expose credentials to everyone with repo access and permanently in git history.
Location: .gitignore:2 (hackathon-key.json)
Current state: The key is listed in .gitignore. Git log confirms it has never been committed to any branch in this repo. The risk is local-disk only (addressed separately in SEC-003 / Docker layer).
Status: MITIGATED for git history. Docker image exposure remains open (SEC-003).
Threat: Unpinned packages in requirements.txt mean that a future pip install could pull in a dependency version with a known CVE, silently changing the attack surface between builds.
Location: requirements.txt (all 9 dependencies are unpinned)
Current state: Every package (fastapi, uvicorn, google-genai, twilio, etc.) is listed without a version specifier. The google-genai and twilio libraries in particular have had security-relevant releases.
Recommended fix:
- Run
pip freeze > requirements.txt(or usepip-compilefrompip-tools) to lock all transitive dependencies to exact versions. - Integrate
pip-auditor Dependabot into CI to flag newly disclosed CVEs against the locked set.
Why PARTIAL: No currently known exploitable vulnerability was identified, but the unpinned state makes the risk unmanageable over time.
None formally accepted at this time. SEC-005 (rate limiting) and SEC-006 (PII logging) may be acceptable for a hackathon context with operator acknowledgment, but must be re-evaluated before any production launch handling real caller data.
| ID | Title | Status |
|---|---|---|
| SEC-001 | No Twilio signature validation on /twilio-webhook | MITIGATED (PR #3) |
| SEC-002 | /media WebSocket open to any client | MITIGATED (PR #3) |
| SEC-003 | Service account key baked into Docker image | PARTIAL — .dockerignore added; key rotation still required |
| SEC-004 | API key logged in plaintext | MITIGATED (PR #3) |
| SEC-005 | No rate limiting | PARTIAL — session cap added; Cloud Armor not yet configured |
| SEC-006 | Caller phone number logged in plaintext | MITIGATED (PR #3) |
| SEC-007 | Unvalidated Twilio form fields | MITIGATED (PR #3) |
| SEC-008 | Stack traces in Cloud Logging | MITIGATED (PR #3) |
| SEC-009 | SSRF via OpenJustice URL | MITIGATED |
| SEC-010 | Prompt injection into SMS body | MITIGATED |
| SEC-011 | Service account key in git history | MITIGATED |
| SEC-012 | No pinned dependency versions | PARTIAL |