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.
What is already right
Section titled “What is already right”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-stylesk-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_rsaandcredentials. The only such paths ever committed are.env.examplefiles. There is one exception in history, covered in the next section — the current trees are clean. - Every lockfile is committed —
composer.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:58setsenv('SANCTUM_EXPIRATION', 60 * 24 * 30)— stock Laravel shipsnull, meaning tokens never expire.routes/console.phpthen schedulessanctum:prune-expired --hours=168so 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:writeis deliberately withheld.app/Support/TokenAbility.phpdeclares five abilities andforInteractiveDevice()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::resetPasswordcalls$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:318asserts it. - A 12-character password policy shared by both faces.
config/demostatics.php:21-26setsmin => 12,mixed_case,numbersandsymbolsall 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::forgotPasswordreturns 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/orresources/views/calls->store(),hasFile()or touchesUploadedFile. 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/orderByRawcall 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.
UserResourcewrapsemailin$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.rsvalidates against the Mozilla WebPKI roots and the hostname, supports optional SHA-256 certificate pinning on top, and requires an explicitinsecureopt-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.tsusesexpo-secure-store, i.e. iOS Keychain and Android Keystore, not AsyncStorage. cargo-denyruns 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 one secret that did get committed
Section titled “The one secret that did get committed”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:
- Revoke the key in the Google Cloud console. Deleting a file does not revoke a credential — only the console does.
- Check the billing history on the project it belongs to. If it was ever unrestricted, that is where abuse would show.
- 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.
| Advisory | Package | Why it matters here |
|---|---|---|
| CVE-2026-45067 | symfony/mime | Email header / SMTP command injection via CRLF in Address. |
| GHSA-5vg9-5847-vvmq (CVE-2026-48019) | laravel/framework | CRLF injection in the default email validation rule. |
| CVE-2025-64500 | symfony/http-foundation | Incorrect PATH_INFO parsing leading to limited authorization bypass. |
| CVE-2026-24765 | phpunit/phpunit | Unsafe 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).
The fix
Section titled “The fix”cd demostatics-webcomposer update # patches are within the ^11.31 / ^4.0 constraintscomposer audit # confirm; anything left needs a constraint bumpphp artisan test # the suite is the regression net for the update
cd ../demostatics-mobile_applicationnpm audit fix && npm auditThen add an audit job to each repository so this never accumulates silently again. A minimal
one for demostatics-web:
name: auditon: [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 auditPin 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.
The demo seeder
Section titled “The demo seeder”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. DemoSeederis the only thing that invokesdemo: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
reportsanddatabase_itemscarry no source column, the fabricated rows would be indistinguishable from real ones.
The fix
Section titled “The fix”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.
What is missing
Section titled “What is missing”Grouped by what a reader of a vendor security questionnaire would ask about. Effort figures are engineering time only.
Detection, response and access
Section titled “Detection, response and access”| Gap | Consequence | Effort | When |
|---|---|---|---|
| No threat model in any repository | Information-barrier, publication-ordering and order-allocation designs get built against nobody in particular, then rebuilt when counsel asks what they defend against | days | now |
No security event logging — the app makes exactly one Log:: call, to single driver, LOG_LEVEL=debug, no rotation, no off-host sink | A 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 touched | days | before first customer |
| No 2FA anywhere, including admin, and no signed-in-devices screen | One phished admin password is total control; the first enterprise questionnaire asks about MFA for administrative access and the answer is no | weeks | before first customer |
Changing a password revokes nothing — updatePassword never deletes tokens, and AuthenticateSession is not registered | A user who suspects compromise changes their password, sees “Password updated”, and the attacker’s token keeps working for up to 30 days | hours | before first customer |
| No rate limit and no per-account usage accounting on the paid surfaces | One 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 against | days | before first customer |
| No trusted-proxy configuration | The 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 address | hours | before first customer |
app:delete-unverified-users is an unguarded mass-delete with no backup behind it | Under 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 from | hours | now |
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.
Also on the list
Section titled “Also on the list”| Gap | Consequence | Effort | When |
|---|---|---|---|
No SECURITY.md, no security contact, no security.txt | A researcher who finds something in a published installer has nowhere to send it and drops it publicly instead | hours | before first customer |
| No incident containment capability | Nothing 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 fine | days | before first customer |
CORS defaults to * (no config/cors.php) and no CSP, HSTS, X-Frame-Options, X-Content-Type-Options or Referrer-Policy | Any hostile page can drive /api/v1 from a visitor’s browser; one missed {{ }} in Blade becomes account takeover instead of a contained bug | hours | before first customer |
ReportExporter does not neutralise spreadsheet formulas — csv() and xlsx() pass values through raw while doc() escapes every cell | The paid deliverable becomes the delivery mechanism for an attack on the customer, once the worker tier fills those tables from scraped text | hours | before first customer |
Desktop client takes the account password as --password on argv with no alternative | The credential lands in ps, shell history and enterprise EDR telemetry on exactly the managed workstations the client targets | hours | before first customer |
The release workflow builds --features gui only, so the shipped binary falls through to DevAuthProvider and accepts the literal password demo | The first tagged release ships a client where tier is decided client-side; nothing is tagged yet, so this is entirely preventable | hours | before first release |
| Desktop caches the data snapshot to disk as plaintext JSON with no wipe on logout or downgrade | The token is protected carefully; the thing the token protects is not. A lapsed subscription leaves the last Enterprise snapshot readable | hours | before scale |
| The AI panel POSTs live indicators and workspace screenshots to any URL passed on the command line | An 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 information | days | before scale |
Privacy and data protection
Section titled “Privacy and data protection”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.
| Where | What | Note |
|---|---|---|
users | name, email, password hash, email_verified_at | |
personal_access_tokens.name | device name | The mobile client sends Device.deviceName (src/stores/session.ts:38), which is routinely a person’s real first name |
bans.reason | free text up to 10,000 characters about an identified individual | No expiry column, no appeal field, no banned_by |
sessions | ip_address, user_agent | |
password_reset_tokens | keyed by email address | Never deleted by any deletion path |
posts, comments | user-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.log | every outbound mail while MAIL_MAILER=log, including verification and reset links | Unrotated, 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.
Which regime, and what to ask counsel
Section titled “Which regime, and what to ask counsel”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:
- The data inventory above, completed.
- 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.
- The published ban flag, as an adverse statement about an identified individual, unattributed, unappealable and world-readable.
- The mail log, as a store of addresses and account-recovery tokens with no retention period that no deletion request can reach.
- The conflict below.
Counsel cannot raise any of these on their own, because nobody has told them the tables exist.
The conflict that has to be decided
Section titled “The conflict that has to be decided”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.
Insider risk
Section titled “Insider risk”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.
Where this sits in the plan
Section titled “Where this sits in the plan”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.