Skip to content

Security and Privacy

Nothing is deployed, there are no users, no client data and no revenue, so nothing on this page is on fire. Every item here is priced in the difference between fixing it now and fixing it after somebody is depending on it — which for most of this list is the difference between an afternoon and a contract renegotiation.

Read this section first. The security work already in the tree is unusually good for a one-engineer, pre-launch company, and most of it was clearly done deliberately rather than by framework default. Do not churn it.

  • No secret in any working tree. I scanned every reachable revision in all five repositories for AWS access key ids (AKIA…), OpenAI-style sk- keys, Stripe live keys (sk_live_/pk_live_), GitHub tokens (ghp_, github_pat_), Slack tokens (xox[baprs]-), Google API keys (AIzaSy…), SendGrid keys (SG.…) and PEM private-key headers, plus every path ever tracked matching .env*, *.pem, *.key, *.p12, *.jks, *.keystore, id_rsa and credentials. The only such paths ever committed are .env.example files. There is one exception in history, covered in the next section — the current trees are clean.
  • Every lockfile is committedcomposer.lock, package-lock.json (web and mobile), Cargo.lock, pnpm-lock.yaml. Builds are reproducible and an audit tool has something to read.
  • Sanctum expiry is hardened against Laravel’s own default. config/sanctum.php:58 sets env('SANCTUM_EXPIRATION', 60 * 24 * 30) — stock Laravel ships null, meaning tokens never expire. routes/console.php then schedules sanctum:prune-expired --hours=168 so dead rows do not accumulate, with a comment explaining that the week-long retention is what makes “when did this device stop working” answerable during an incident.
  • Token abilities are scoped, and ingest:write is deliberately withheld. app/Support/TokenAbility.php declares five abilities and forInteractiveDevice() hands a phone or desktop only four of them. A stolen device token cannot reach a future ingest route even if that route forgets its own check. Sanctum’s default is a single * ability.
  • Password reset kills every device token, and a test covers it. AuthController::resetPassword calls $user->tokens()->delete() inside the reset closure, with a comment noting the attacker’s token “would otherwise keep working in the attacker’s hands”. tests/Feature/Api/AuthApiTest.php:318 asserts it.
  • A 12-character password policy shared by both faces. config/demostatics.php:21-26 sets min => 12, mixed_case, numbers and symbols all true; PasswordController::rules() builds the rule object once and the API controller calls the same static, so web and API cannot drift apart.
  • Forgot-password is deliberately non-enumerating. AuthController::forgotPassword returns the identical body whether or not the address exists, and folds the throttled status in too, because a distinct throttle response leaks the same fact. The reasoning is written in the comment.
  • No file-upload surface at all. Nothing in app/, routes/ or resources/views/ calls ->store(), hasFile() or touches UploadedFile. The single largest source of web vulnerabilities is simply absent.
  • No unescaped Blade output. grep -rn '{!!' resources/views/ returns nothing across the whole view tree.
  • Raw SQL is disciplined. The seven whereRaw/orderByRaw call sites all bind their user input (whereRaw('LOWER(title) LIKE ?', [$term])), and the one place a column name is interpolated — ApiController::applySearch — takes it from a hardcoded PHP array default, never from the request.
  • Email addresses are scoped to their owner. UserResource wraps email in $this->when($request->user()?->id === $this->id, …), so a forum listing never leaks addresses. (Everything else in that resource is public — see the privacy section.)
  • The desktop QUIC transport validates properly. crates/ds-transport/src/quic.rs validates against the Mozilla WebPKI roots and the hostname, supports optional SHA-256 certificate pinning on top, and requires an explicit insecure opt-in for a self-signed dev server — the file’s own comment says “the old always-insecure behavior is gone”.
  • The mobile token lives in the OS keychain. src/lib/storage.ts uses expo-secure-store, i.e. iOS Keychain and Android Keystore, not AsyncStorage.
  • cargo-deny runs in CI for the Rust workspace (.github/workflows/ci.yml:91-95), covering advisories, licences and bans.
  • No third-party analytics or tracking SDK anywhere. No gtag, GTM, Plausible, PostHog, Matomo, Segment, Mixpanel, Amplitude, Firebase or Hotjar in the web views, the mobile source or the docs site. That is a privacy position most companies have to unwind later; you never took it on.

The gap register records that no secret has ever been committed. That is not quite true, and the exception is worth ten minutes today.

A Google API key beginning AIzaSy was hardcoded as protected string $apiKey in app/Console/Commands/ScrapNewsCommand.php (added in commit 42d60bd) and in app/Console/Commands/FindArticleLinks.php (added in 8eebd0c). Both files were deleted in commit 3fbafed and are not in HEAD — but the key is still readable in roughly two dozen reachable revisions of demostatics-web, and that history was pushed to github.com/Demostatics/demostatics-web. An unauthenticated request to the GitHub API for that repository returns 404, so it is private or absent, which bounds the exposure to whoever has clone access. It does not remove it.

Do this:

  1. Revoke the key in the Google Cloud console. Deleting a file does not revoke a credential — only the console does.
  2. Check the billing history on the project it belongs to. If it was ever unrestricted, that is where abuse would show.
  3. Decide whether to rewrite history. Rewriting is only worth it if the repository will ever be made public or handed to a third party; on a private repo with one contributor, revocation is the control that matters and a rewrite is mostly ceremony.

Dependency vulnerabilities — the only time-sensitive item

Section titled “Dependency vulnerabilities — the only time-sensitive item”

composer audit in demostatics-web, run against the current lockfile:

Found 28 security vulnerability advisories affecting 11 packages.

Eleven packages: guzzlehttp/guzzle, guzzlehttp/psr7, laravel/framework, league/commonmark, phpunit/phpunit, symfony/http-foundation, symfony/mailer, symfony/mime, symfony/polyfill-intl-idn, symfony/routing, symfony/yaml. Four are rated high, and two of those sit directly in the path the roadmap does next.

AdvisoryPackageWhy it matters here
CVE-2026-45067symfony/mimeEmail header / SMTP command injection via CRLF in Address.
GHSA-5vg9-5847-vvmq (CVE-2026-48019)laravel/frameworkCRLF injection in the default email validation rule.
CVE-2025-64500symfony/http-foundationIncorrect PATH_INFO parsing leading to limited authorization bypass.
CVE-2026-24765phpunit/phpunitUnsafe deserialization in PHPT coverage handling. Dev-only — phpunit arrives transitively through Pest in require-dev and is not installed in production.

The first two are one issue with two names, and it lands on the exact change the deployment checklist calls the top blocker: flipping MAIL_MAILER from log to a real SMTP provider, on a registration endpoint that accepts an attacker-supplied email string. The day real SMTP credentials are configured, the registration form becomes a possible mail-injection vector against your own sending domain and its reputation — and sending reputation is slow to rebuild.

The third sits in the component that parses the request path and therefore decides whether a request for /api/v1/reports reaches the api.verified gate. Those are the two surfaces the business plans to charge for.

Only demostatics-pc_application has any dependency scanning. demostatics-web, demostatics-mobile_application and demostatics-docs have no .github/ directory at all — no CI, no Dependabot, no audit step. Nothing would have told you about any of the above. npm audit in demostatics-mobile_application reports 11 moderate advisories; demostatics-docs has not been audited here and should be (pnpm audit, its tracked lockfile is pnpm-lock.yaml).

Terminal window
cd demostatics-web
composer update # patches are within the ^11.31 / ^4.0 constraints
composer audit # confirm; anything left needs a constraint bump
php artisan test # the suite is the regression net for the update
cd ../demostatics-mobile_application
npm audit fix && npm audit

Then add an audit job to each repository so this never accumulates silently again. A minimal one for demostatics-web:

.github/workflows/audit.yml
name: audit
on: [push, pull_request, schedule]
jobs:
composer-audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: composer install --no-interaction --prefer-dist
- run: composer audit

Pin third-party actions to a commit SHA rather than a moving tag — the release workflow in demostatics-pc_application currently uses @v4/@v2 refs with permissions: contents: write at workflow level, which means a moved tag on any of five actions gets code execution in a job that can rewrite published installers.

Effort for the whole section: hours. When: now, before the mail switch.

database/seeders/DemoSeeder.php creates admin@demostatics.test with Hash::make('password') and assignRole(UserRole::ADMIN) (lines 93, 115, 119). It also calls ReportSeeder and DatabaseItemSeeder, writing fabricated rows into the two tables the company intends to sell. grep -n 'environment(' database/seeders/ returns nothing — no seeder in the directory has an environment guard, an --force requirement or any production check.

Three things make this worse than a normal demo fixture:

  • The credentials are published in at least three places: README.md:86, docs/api-v1.md:122, and the public docs site at /components/web/. They are also printed to the console by the seeder itself.
  • DemoSeeder is the only thing that invokes demo:world, which builds the geo store the Reports and Database filters read. An operator setting up a new host, finding every filter dropdown empty, has a direct and reasonable-looking incentive to run exactly this command.
  • There is no login logging (below), so an admin account with a globally published password could be used on production and leave no trace. And because reports and database_items carry no source column, the fabricated rows would be indistinguishable from real ones.

Two lines at the top of DemoSeeder::run(), and the same in ReportSeeder, DatabaseItemSeeder and any future demo seeder:

if (app()->isProduction()) {
throw new RuntimeException('DemoSeeder must never run in production.');
}

Then split the geo build out of the demo path so an operator can populate the world store without the demo accounts — php artisan demo:world already exists as its own command; the docs and the README should point at it instead of at db:seed --class=DemoSeeder. Finally, replace the fixed 'password' with a value read from the environment or generated and printed once, so the published credential stops being a credential.

Effort: hours. When: now — it is cheaper than remembering later.

Grouped by what a reader of a vendor security questionnaire would ask about. Effort figures are engineering time only.

GapConsequenceEffortWhen
No threat model in any repositoryInformation-barrier, publication-ordering and order-allocation designs get built against nobody in particular, then rebuilt when counsel asks what they defend againstdaysnow
No security event logging — the app makes exactly one Log:: call, to single driver, LOG_LEVEL=debug, no rotation, no off-host sinkA credential used to read everything produces zero errors and zero alerts; afterwards there is no answer to which account, from where, when it started, or what else they toucheddaysbefore first customer
No 2FA anywhere, including admin, and no signed-in-devices screenOne phished admin password is total control; the first enterprise questionnaire asks about MFA for administrative access and the answer is noweeksbefore first customer
Changing a password revokes nothing — updatePassword never deletes tokens, and AuthenticateSession is not registeredA user who suspects compromise changes their password, sees “Password updated”, and the attacker’s token keeps working for up to 30 dayshoursbefore first customer
No rate limit and no per-account usage accounting on the paid surfacesOne subscription becomes an unlimited licence — pull the whole dataset over a weekend, cancel, resell. Also makes usage-based pricing unbuildable, because no usage record exists to price againstdaysbefore first customer
No trusted-proxy configurationThe moment TLS is terminated in front of the app, every auth throttle shares one bucket of five attempts per minute for the entire internet, and every log line records the proxy’s addresshoursbefore first customer
app:delete-unverified-users is an unguarded mass-delete with no backup behind itUnder the documented MAIL_MAILER=log default nobody can verify, so its selection criterion matches the entire user table. No dry-run, no confirmation, no logging, no soft delete, and nothing to restore fromhoursnow

The password-change fix is one line — $request->user()->tokens()->delete(); — and the correct reasoning is already written down four methods away in the same file. The throttle fix is a RateLimiter::for() definition plus throttleApi() in bootstrap/app.php, which currently never calls it.

GapConsequenceEffortWhen
No SECURITY.md, no security contact, no security.txtA researcher who finds something in a published installer has nowhere to send it and drops it publicly insteadhoursbefore first customer
No incident containment capabilityNothing revokes all tokens, disables registration, freezes one account or puts the API read-only. The only blunt instrument is php artisan down, which takes down the customers who are finedaysbefore first customer
CORS defaults to * (no config/cors.php) and no CSP, HSTS, X-Frame-Options, X-Content-Type-Options or Referrer-PolicyAny hostile page can drive /api/v1 from a visitor’s browser; one missed {{ }} in Blade becomes account takeover instead of a contained bughoursbefore first customer
ReportExporter does not neutralise spreadsheet formulas — csv() and xlsx() pass values through raw while doc() escapes every cellThe paid deliverable becomes the delivery mechanism for an attack on the customer, once the worker tier fills those tables from scraped texthoursbefore first customer
Desktop client takes the account password as --password on argv with no alternativeThe credential lands in ps, shell history and enterprise EDR telemetry on exactly the managed workstations the client targetshoursbefore first customer
The release workflow builds --features gui only, so the shipped binary falls through to DevAuthProvider and accepts the literal password demoThe first tagged release ships a client where tier is decided client-side; nothing is tagged yet, so this is entirely preventablehoursbefore first release
Desktop caches the data snapshot to disk as plaintext JSON with no wipe on logout or downgradeThe token is protected carefully; the thing the token protects is not. A lapsed subscription leaves the last Enterprise snapshot readablehoursbefore scale
The AI panel POSTs live indicators and workspace screenshots to any URL passed on the command lineAn unaudited outbound channel from the product to a third party, with no allowlist, redaction or record — sharpest for a firm whose only edge is proprietary informationdaysbefore scale

There is no privacy notice, no lawful-basis position, no record of processing activities, no data map, no sub-processor list, no retention policy, and no code path anywhere that could produce a data export for a person who asks what you hold about them. A grep across all five repositories for KVKK, GDPR, aydınlatma, açık rıza, VERBİS, data controller, data processor, DPO, retention, erasure and subject access returns nothing but Laravel’s stock session-cookie comments and one forum rule about not posting other people’s phone numbers.

The system holds more personal data than an inventory-free team would guess. This table is not the inventory — it is the start of one, and it needs to be checked and completed by whoever writes the notice.

WhereWhatNote
usersname, email, password hash, email_verified_at
personal_access_tokens.namedevice nameThe mobile client sends Device.deviceName (src/stores/session.ts:38), which is routinely a person’s real first name
bans.reasonfree text up to 10,000 characters about an identified individualNo expiry column, no appeal field, no banned_by
sessionsip_address, user_agent
password_reset_tokenskeyed by email addressNever deleted by any deletion path
posts, commentsuser-authored content up to 40,000 characters
votes(user_id, poll_id, poll_option_id), unique on (user_id, poll_id)See below
storage/logs/laravel.logevery outbound mail while MAIL_MAILER=log, including verification and reset linksUnrotated, LOG_LEVEL=debug, no retention, unreachable by any deletion path

Account deletion is incomplete, and the two paths differ

Section titled “Account deletion is incomplete, and the two paths differ”

Both were verified directly.

AuthController::destroyAccount (API) validates the password, calls $user->tokens()->delete() with a comment explaining that tokens outlive the row otherwise, then $user->delete().

ProfileController::destroy (web) validates the password, calls Auth::logout(), $user->delete(), and invalidates the session. It never touches tokens.

That difference is load-bearing, because personal_access_tokens declares no foreign key to users at all — the live schema is ("id", "tokenable_type", "tokenable_id", "name", "token", "abilities", "last_used_at", "expires_at", …) with no constraint. So deleting your account from the website leaves your token rows, including the device name, on the server until sanctum:prune-expired --hours=168 catches them, which is a week after expiry and up to 30 days out — and only if the host actually installed the scheduler cron, which nothing verifies.

Two more tables survive either path. model_has_roles has a foreign key to roles and none to users, so a row saying model_type = App\Models\User, model_id = 42, role_id = moderator outlives user 42 — a leftover record and a latent authorisation surprise if ids are ever reused. password_reset_tokens is keyed by email and is never touched, so a pending reset leaves the deleted person’s address in the database.

Meanwhile the test suite certifies only the half that works: tests/Feature/Api/AuthApiTest.php:405-420 asserts the users row is gone and the token no longer authenticates, and asserts nothing about any other table.

Poll votes are the highest-sensitivity table in the schema

Section titled “Poll votes are the highest-sensitivity table in the schema”

votes stores (poll_id, poll_option_id, user_id) with a unique index on (user_id, poll_id). The ballot is identified by construction, not incidentally. Nothing anonymises it, nothing ages it out, nothing tells the voter it is identified — PollOptionResource exposes only votes_count, so the interface shows an aggregate while the database holds the name — and there is no consent record of any kind.

The subject matter is not incidental either: QuestionSeeder seeds 66 classification prompts across politics, security, demographics, economy and ecology, inside a forum attached to a geopolitical-risk product. This is the only place in the platform where a person’s opinion is durably bound to their identity, and it is on nobody’s list.

Separately, UserResource publishes is_banned, roles, is_online and created_at unconditionally, and GET /api/v1/posts, /polls and /comments/{id}/replies are in the public group. An anonymous scraper can build a list of every participant’s display name, join date, role, live online state and whether they are currently sanctioned. Only email is gated.

Türkiye’s regime is KVKK; GDPR applies to users in the EU, and the platform is on the open internet with no geographic gate. The jurisdiction question itself is still open — see Regulatory Posture. This page states no legal conclusion and none should be inferred from it.

What to put in front of counsel, in one packet:

  1. The data inventory above, completed.
  2. The votes table specifically, described as identified opinion records on political and security topics with no retention limit and no consent record, and the question of whether a heightened category attaches to it under either regime.
  3. The published ban flag, as an adverse statement about an identified individual, unattributed, unappealable and world-readable.
  4. The mail log, as a store of addresses and account-recovery tokens with no retention period that no deletion request can reach.
  5. The conflict below.

Counsel cannot raise any of these on their own, because nobody has told them the tables exist.

The right to erasure and the append-only record-keeping the investment business requires destroy each other, and nobody has chosen which wins.

Today deletion is one behaviour and it is total. The live schema declares on delete cascade against users on posts, comments, polls, votes and bans, and no model uses SoftDeletes. So erasing a user simultaneously over-deletes and under-deletes: it destroys posts other people replied to and the entire record that this person was sanctioned and why, while leaving the orphans described above. There is no soft delete, no anonymisation path, no tombstone and no suppression list, and the cascade forecloses all four.

Regulatory Posture records that the investment business will need append-only records sufficient to reconstruct decisions and communications, and lists client communications — potentially including forum posts by staff — among them. That is the collision: the same post is both a person’s data and part of a record the firm may be required to keep.

The concrete case, so it is not abstract: a user is banned for harassing another user. Months later they request erasure. Granting it deletes the ban, the reason and the posts that evidenced it, so the victim’s complaint now has nothing behind it — and the same address is free to register again, because users.email is unique only over live rows. Refusing it means the firm has no documented basis for refusing.

This is a question for counsel, not an engineering decision. The usual resolution is a lawful-basis carve-out for records the firm is required to keep, paired with anonymisation rather than deletion and a minimal suppression record so a re-registration can be recognised. State that as the expected shape, not as the answer. The point is that it has to be decided before the schema is designed around it, rather than discovered during the first erasure request — because the schema decisions (soft delete, anonymisation, suppression list, tombstone) are all cheap now and are all a data migration over live user content later.

This is not the generic version. It is structurally sharpened by a firm that trades its own book on data it also produces and sells, staffed by one person.

That one person can change database_items.value, which has no provenance, no source column and no revision history — so an edited number leaves no trace at all — and will also be building the side of the business that trades on it. Roles are assigned only through php artisan tinker on the host, so the only record of someone granting themselves the admin role is shell history on a machine they control. There is no CODEOWNERS file in any repository, no documented review requirement, no credential inventory, no rotation procedure, no separation of duties and no four-eyes requirement on anything.

Two failure modes, and the second is far more likely than the first. The malicious one: a number is nudged, a position is taken, and no artefact exists that could establish what the value was before. The ordinary one: the laptop is compromised or the person is unavailable, and there is no list of what credentials exist to rotate — every API key, database password and signing secret lives in one person’s .env files and keychain with no inventory anywhere. The credential in git history above is a small instance of exactly this.

A regulator’s first operational-risk question for a firm running proprietary trading alongside client business is about segregation of duties, and the only available answer today is “we trust him”. That is survivable while there are no clients and no licence. It stops being survivable at the same moment the licence application does.

The technical half of the answer — provenance columns, revision history, an audit trail, and the information-barrier and publication-ordering rules — is already sequenced in the roadmap as the conflicts work. The operational half is not sequenced anywhere: a credential inventory, a rotation procedure, a written record of privilege grants, and a second person or a documented escrow for the accounts that cannot be recovered without one. The credential inventory is an afternoon and is the single highest-value item in this paragraph, because everything else depends on knowing what exists.

Nothing above requires a licence, an entity or a customer to fix, and most of it is measured in hours. The sequencing — which of these blocks the mail switch, which blocks the first paying account, which blocks the licence application — is in What To Do Next. Every item here also appears in the gap register with its full evidence, and the ones that could end the company rather than embarrass it are carried in the risk register.