Independent audit · Arnow Solutions · 5 Nov 2026
A second auditor's pass over the same stack. Every claim below was tested against the running phone line, the deployed service, and the public pages — not read from source alone.
Phone number +1 929 374 2797 ("Arnow Voice Intake") → webhook POST /twilio/voice → media stream wss://voice.srv1738752.hstgr.cloud/ws/twilio → the same Pipecat pipeline as the browser leg: Deepgram Nova-3 → DeepSeek deepseek-chat → Aura-2 aura-2-thalia-en.
| Check | State | Evidence |
|---|---|---|
| Webhook URL + method | OK | POST /twilio/voice on the number, HTTPS, returns Connect/Stream TwiML that passes caller ID as a custom parameter |
| Serializer rates | OK | 16 kHz pipeline vs 8 kHz Twilio set explicitly; DeepSeekLLM.supports_developer_role=False |
| Signature validation | missing | No X-Twilio-Signature check on either the webhook or the socket — anyone who finds the socket consumes paid minutes |
| Concurrency guard on phone | missing | MAX_CONCURRENT and MAX_PER_IP_HOUR are enforced only in /api/offer. /ws/twilio starts a pipeline unconditionally |
| Fallback TwiML | missing | No voice_fallback_url: if the service is down the caller gets a raw Twilio error tone, not a "we'll call you back" message |
| Status callback | none | No call events; failures only visible by pulling the CDR |
| SMS | enabled, unhandled | Number has SMS + MMS capability, sms_url empty — inbound texts are silently dropped (the UI copy says "text this number" on failure screens of a sort that don't exist) |
Twilio's CDR shows 19 legs: 2 handset calls (15 s and 21 s from a 34x number) and 17 paired self-call legs (the console calling the number from the number — each shows as inbound + outbound with identical durations). Nothing from a real prospect yet. That's a statement about a day-old line, not a defect.
The web offer endpoint checks _active >= MAX_CONCURRENT and the per-IP window before minting a connection. The Twilio WebSocket handler has neither check — every inbound call spawns a full pipeline on a 4-vCPU box that is already told to cap at 3. Fifth simultaneous caller doesn't queue; it starts.
Neither /twilio/voice nor /ws/twilio validates X-Twilio-Signature (the credentials are loaded but only used to sign the outbound serializer). The socket trusts ?from= as caller ID, which means the "welcome back, [name]" greeting and the read-back-the-number prompt can be spoofed from any WebSocket client. Also no rate limit on that path — every spoofed session burns paid TTS/STT.
bot.py's admin() builds the leads table with plain f-string interpolation for name, email, phone, summary, commitment etc. Only the research blob is HTML-escaped. Any caller-supplied string is live HTML in the operator's browser — a spoken "my name is <script>…" becomes script when Shareef opens /admin. And because the token is in the query string, the exploit fires on the very click of the shared link.
The SQLite store lives at /opt/arnow-voice/data/voice.db, inside the same directory rsync/scp targets on every deploy. The workspace source tree has no data/, so any tree-sync that uses --delete (or copies the repo over /opt/arnow-voice) erases the database; the service then recreates an empty schema at next restart. That is exactly how the only real lead (22:02 on 5 Nov) was lost. find / -name "voice.db*" proves there is exactly one copy on the host.
Prompt section 8 ends with "if they correct it, save the new number." The save_intake tool schema has name, email, business, category, social, timeline, decision_maker, referral, commitment, project_details — no phone. The tool literally cannot obey the prompt, and the number-live update claim on the README doesn't happen for number corrections in-call.
"I've got you at nine two nine, three seven four, two seven nine seven — is that the best number?" is inlined into the system prompt for every call. On the web channel there is no number at all (she reads the studio's number back as the caller's). On the phone channel there is a real caller-ID injection note, but it contradicts the hardcoded paragraph.
GREETING["phone"] is set to the same string as web, so the actual opener is "Hi, this is Kimberly, the AI assistant at Arnow Solutions…" while CHANNEL_NOTE["phone"] separately instructs her to open with "Thanks for calling Arnow Solutions, this is Kimberly." The prompt contradicts itself, and on a phone line the widget-flavored opener reads as a mistake.
resume_caller looks up find_by_pin, which returns the caller's name, business, email, notes, and the last four transcript turns. The 3-try counter lives in the call state — a new session resets it. Anyone who can call can iterate three digits per call at 6 calls/hour/IP (a limit that only exists on the web endpoint anyway; the phone leg has none of these).
The site currently has three intake surfaces, and none of them reach the voice agent:
| Surface | Endpoint used | State in prod |
|---|---|---|
| /contact.html (static contact form) | POST /api/intake | Works end-to-end. honeypot + regex validation + IP rate-limit + SMTP delivery; returns ref AS-2026-NNNN. Verified end-to-end from outside the box. |
| /intake-line.html (conversational hourly-cost counter) | tries POST /api/v1/api/sessions → /api/v1/sessions → /api/sessions | All three 503 — the page cannot submit. Nothing from it appears in the relay DB. This intake is decorative right now. |
| /talk.html | n/a (nav page only) | Body CTA → /contact (503), footer → /process (503), /voice-demo (missing). A visitor pressing the button hits a server error. |
| Voice widget (widget.js) | POST /api/offer on voice.srv… | Not referenced from any live site page. Site CSP connect-src and permissions-policy: microphone=() both forbid it. |
Every page on arnowsolutions.com ships permissions-policy: geolocation=(), microphone=(), camera=() and a CSP whose connect-src allows 'self', analytics, and intake.srv… — not voice.srv…. Both the widget and the browser SpeechRecognition mic button on /intake-line.html are dead on arrival. The chip "Answer by voice if typing's a hassle" cannot work in Chrome.
The hidden company_site field is checked in browser JS (contact.js). The relay on the VPS (/opt/intake/app.py, do_POST) never reads it — a POST with the trap field omitted is accepted and emailed (verified with a probe submission written and then deleted). Adding a four-line server-side check closes it.
The conversational intake's JS (assets/js/consultant-intake.js) posts a full discovery session payload with flow / contactName / contactEmail / seedAnswers / autoSubmit:false and then calls finalize. None of that route exists in production. Either serve the API it was written against (the VPS runs /opt/intake/app.py — a sessions endpoint would be ~100 lines), or rewrite its final send step to the /api/intake shape the contact form already uses. Until then the site's own best-converting copy is a dead end.
The voice agent's leads live in voice.db (voice.srv host); the written intake's leads live in /opt/intake/data/intake.db (site relay). Two schemas, two reference formats (4-digit PIN vs AS-2026-NNNN), no shared identifier. A caller who spoke to Kimberly and then filled the form is two people to the business.
| Line | Rate | Per 5-min call |
|---|---|---|
| Deepgram Nova-3 (streaming STT) | $0.0077/min | $0.039 |
| Deepgram Aura-2 (TTS, per 1k chars) | $0.030 | $0.045–0.08 (TTS is the dominant cost — keep replies short) |
| DeepSeek deepseek-chat | ~$0.28/M input | ~$0.01 |
| Twilio inbound | $0.0085/min | $0.043 |
| Total | — | $0.14–0.17 / call (~$0.03/min) |
Twilio account itself: $0 billed so far (the account is new; $20 trial balance). Self-test calls consumed Twilio minutes but the metered invoice hasn't landed yet.
| Order | Item | Effort |
|---|---|---|
| 1 | Move voice.db out of the deploy target + nightly .backup | 30 min + cron |
| 2 | Escape admin HTML + move token out of URL + CSP | 30 min |
| 3 | Concurrency gate on the phone leg + Twilio signature validation + fallback TwiML | 2–3 h |
| 4 | Fix CSP + microphone policy on the site; conflict-free routing for the widget | 30 min + deploy |
| 5 | Remove hardcoded digits from prompt §8; add phone to save_intake schema; split GREETING_PHONE | 1 h + one test call |
| 6 | Wire /intake-line.html to a live endpoint | 2–4 h |
| 7 | Global PIN attempt ledger, and don't return email/notes on lookup | 2 h |
| 8 | Decide what SMS does (either serve it or disable the capability) | 15 min |
Audit method. Read bot.py / intake.py / widget.js / index.html / README.md in full locally; md5 diffed against the VPS deploy; live service probed via /health, systemd unit, Traefik config, UFW state; Twilio REST API pulled for the number config, incoming webhook, CDR (19 legs), usage and balance. arnowsolutions.com crawl from outside (home, contact, talk, intake-line, widget.js, consultant-intake.js) with CSP + Permissions-Policy headers read per-page. Relay source read on the VPS (/opt/intake/app.py); two probe submissions written, verified in intake.db, then deleted — no residue left. My test artifacts were removed; the operator's own entries were untouched.
This report is scoped to the voice agent + intake surfaces. The hosting frontend (Hostinger), the Umami analytics, and everything else on the VPS were not audited here. Independent from, and published separately to, another audit run of the same stack on the same day.