| 1 | <!-- π€ Public security ledger (the third status page, beside fleet + books). |
| 2 | Fully self-contained: inline CSS/JS, no external requests except |
| 3 | ./security.json (relative, so it works at any origin). The JSON is |
| 4 | COMMITTED data written at build time by deploy/security-scan.sh; the box |
| 5 | publisher only re-uploads it. |
| 6 | |
| 7 | ============== THE DISCLOSURE LINE β NON-NEGOTIABLE ====================== |
| 8 | This page states security POSTURE, never a live exploit map: |
| 9 | - Dependency findings publish as a STATUS + COUNT only ("clean" or |
| 10 | "N advisories under remediation") β never the CVE/GHSA/OSV ids, |
| 11 | affected packages, or code locations of anything UNPATCHED. That |
| 12 | would be a responsible-disclosure violation and an attacker roadmap. |
| 13 | Once patched it's history and may be named elsewhere. |
| 14 | - No secret inventory, no unpatched-hole specifics, no internal |
| 15 | component versions beyond the build sha the release feed already |
| 16 | publishes. |
| 17 | - Everything else on the page is an architectural fact (true by |
| 18 | design), a hygiene status, or an honestly-named architectural edge β |
| 19 | the deliberate, disclosed tradeoffs. |
| 20 | - No scan published β the page says "scan pending", never a fake |
| 21 | "clean" (degrade, never lie). |
| 22 | ========================================================================== --> |
| 23 | <!doctype html> |
| 24 | <html lang="en"> |
| 25 | <head> |
| 26 | <meta charset="utf-8" /> |
| 27 | <meta name="viewport" content="width=device-width, initial-scale=1" /> |
| 28 | <meta name="color-scheme" content="light dark" /> |
| 29 | <title>Eleven β security</title> |
| 30 | <link rel="icon" type="image/png" href="./icon-192.png" /> |
| 31 | <link rel="stylesheet" href="./status.css" /> |
| 32 | <style> |
| 33 | /* Security-only: tighter tiles + status tones on the values, the fact |
| 34 | checklists, and the scan-pending notice. Status colors always ride with a |
| 35 | glyph + word β never color alone. Everything shared lives in status.css. */ |
| 36 | .tiles { margin-bottom: 12px; } |
| 37 | .tile .value { font-size: 26px; } |
| 38 | .tile .value.ok { color: var(--good); } |
| 39 | .tile .value.warn { color: var(--warn); } |
| 40 | .tile .value.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 22px; } |
| 41 | |
| 42 | /* Fact checklists: the glyph reinforces, the words carry the meaning (never |
| 43 | color, or glyph, alone). Edges use the brand violet marker β disclosed |
| 44 | tradeoffs, not statuses. */ |
| 45 | .facts { list-style: none; padding: 0; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; } |
| 46 | .facts li { display: grid; grid-template-columns: 1.4em 1fr; gap: 8px; padding: 11px 16px; } |
| 47 | .facts li + li { border-top: 1px solid var(--grid); } |
| 48 | .facts .mark { color: var(--good); font-weight: 700; } |
| 49 | .facts.edges .mark { color: var(--series); } |
| 50 | .facts b { font-weight: 650; } |
| 51 | |
| 52 | #pending { display: none; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 14px 16px; margin-bottom: 12px; } |
| 53 | #pending.show { display: block; } |
| 54 | </style> |
| 55 | </head> |
| 56 | <body> |
| 57 | <div class="wrap"> |
| 58 | <header data-title="Security"></header> |
| 59 | <nav class="status-nav" aria-label="Status"></nav> |
| 60 | <script src="./status-nav.js"></script> |
| 61 | <p class="lede">Elevenβs security posture, published as it stands. This is |
| 62 | what is true by design and how the hygiene looks today β never a map of open |
| 63 | problems: if a scan finds something, this page shows a <em>count</em> until |
| 64 | itβs fixed (responsible disclosure). Found a vulnerability? Tell us |
| 65 | privately: <a href="mailto:team@elevenmessenger.com">team@elevenmessenger.com</a>.</p> |
| 66 | <p class="asof">Dependency scan as of <time id="asof" datetime="">β</time><span id="staleness"></span></p> |
| 67 | |
| 68 | <section aria-labelledby="h-commitment"> |
| 69 | <h2 id="h-commitment">The commitment</h2> |
| 70 | <p class="note explain"><strong>If our servers are stolen, the thief gets |
| 71 | nothing they can read.</strong> These are the load-bearing rules every |
| 72 | change is reviewed against β architecture, not policy:</p> |
| 73 | <ul class="facts"> |
| 74 | <li><span class="mark" aria-hidden="true">β</span><span>Our servers hold |
| 75 | <b>sealed boxes and the plumbing to deliver them</b> β never a key that |
| 76 | opens anything. Messages, names, and files are locked on your device |
| 77 | before they leave it.</span></li> |
| 78 | <li><span class="mark" aria-hidden="true">β</span><span><b>Keys live only |
| 79 | on your devices.</b> Every conversation is sealed with its own key, |
| 80 | handed only to its members; the server stores wrapped copies it cannot |
| 81 | open.</span></li> |
| 82 | <li><span class="mark" aria-hidden="true">β</span><span><b>Every space is |
| 83 | an island</b> β its own database, its own keys, no shared pool of user |
| 84 | data, no cross-user state anywhere, and no protocol tying spaces |
| 85 | together.</span></li> |
| 86 | <li><span class="mark" aria-hidden="true">β</span><span><b>Invite links |
| 87 | keep their secret in the URL fragment</b> β the part a browser never |
| 88 | sends to any server.</span></li> |
| 89 | <li><span class="mark" aria-hidden="true">β</span><span><b>Credentials |
| 90 | are never keys.</b> Admin tokens manage the roster; push credentials |
| 91 | authorize delivery of sealed bytes. Neither opens a single |
| 92 | message.</span></li> |
| 93 | <li><span class="mark" aria-hidden="true">β</span><span><b>Bot code never |
| 94 | runs inside the server.</b> Bots live on machines their operator |
| 95 | controls, as ordinary members β compromising our server yields no bot |
| 96 | keys.</span></li> |
| 97 | <li><span class="mark" aria-hidden="true">β</span><span><b>No personal |
| 98 | data in our stores</b> β no emails, no per-user analytics. Customer |
| 99 | identity for paid spaces lives with the payment processor; we hold an |
| 100 | opaque pointer and an entitlement flag.</span></li> |
| 101 | </ul> |
| 102 | </section> |
| 103 | |
| 104 | <section aria-labelledby="h-hygiene"> |
| 105 | <h2 id="h-hygiene">Dependency hygiene</h2> |
| 106 | <p class="note explain"><strong>Counted, not named:</strong> the product |
| 107 | that ships is a single Go binary (the web client is plain JavaScript it |
| 108 | serves β zero runtime npm packages; npm is test tooling only). Each release |
| 109 | build is scanned with govulncheck against the exact binary the fleet runs. |
| 110 | A finding publishes here as a count until itβs patched β naming an |
| 111 | unpatched hole would hand attackers a map.</p> |
| 112 | <p id="pending" role="status"><strong>Dependency scan pending.</strong> No |
| 113 | scan has been published for the current build (or the feed didnβt load). |
| 114 | Shown honestly rather than assumed clean β the architecture and hardening |
| 115 | facts on this page hold regardless.</p> |
| 116 | <div class="tiles"> |
| 117 | <div class="tile"> |
| 118 | <div class="label">Known vulnerabilities</div> |
| 119 | <div class="value" id="t-dep-status">β</div> |
| 120 | <div class="sub" id="t-dep-sub"></div> |
| 121 | </div> |
| 122 | <div class="tile"> |
| 123 | <div class="label">Go modules in the build</div> |
| 124 | <div class="value" id="t-modules">β</div> |
| 125 | <div class="sub">direct + indirect, a count not an inventory</div> |
| 126 | </div> |
| 127 | <div class="tile"> |
| 128 | <div class="label">Scanned build</div> |
| 129 | <div class="value mono" id="t-sha">β</div> |
| 130 | <div class="sub">the same build sha the release feed publishes</div> |
| 131 | </div> |
| 132 | </div> |
| 133 | </section> |
| 134 | |
| 135 | <section aria-labelledby="h-hardening"> |
| 136 | <h2 id="h-hardening">Hardening</h2> |
| 137 | <p class="note explain"><strong>The posture, not the address:</strong> how |
| 138 | the serving machines are run. Facts only β nothing here locates a |
| 139 | box.</p> |
| 140 | <ul class="facts"> |
| 141 | <li><span class="mark" aria-hidden="true">β</span><span><b>Firewall is |
| 142 | default-deny.</b> Inbound is web traffic and administration (SSH) |
| 143 | only; everything else is refused.</span></li> |
| 144 | <li><span class="mark" aria-hidden="true">β</span><span><b>SSH is |
| 145 | key-only.</b> Password login is disabled outright.</span></li> |
| 146 | <li><span class="mark" aria-hidden="true">β</span><span><b>OS security |
| 147 | updates apply automatically</b> (unattended upgrades).</span></li> |
| 148 | <li><span class="mark" aria-hidden="true">β</span><span><b>Services run |
| 149 | as an unprivileged user in a systemd sandbox</b> β read-only system, |
| 150 | no privilege escalation, memory-capped.</span></li> |
| 151 | <li><span class="mark" aria-hidden="true">β</span><span><b>TLS |
| 152 | everywhere.</b> Every space is served over HTTPS with automatically |
| 153 | issued and renewed certificates.</span></li> |
| 154 | <li><span class="mark" aria-hidden="true">β</span><span><b>Member content |
| 155 | at rest is ciphertext.</b> A stolen disk β or a stolen backup β is a |
| 156 | pile of locked boxes.</span></li> |
| 157 | </ul> |
| 158 | </section> |
| 159 | |
| 160 | <section aria-labelledby="h-edges"> |
| 161 | <h2 id="h-edges">Known edges, on purpose</h2> |
| 162 | <p class="note explain"><strong>What we donβt protect, and why:</strong> |
| 163 | the deliberate tradeoffs, named plainly. These are disclosed design |
| 164 | choices, not bugs.</p> |
| 165 | <ul class="facts edges"> |
| 166 | <li><span class="mark" aria-hidden="true">β</span><span><b>Link |
| 167 | previews:</b> when someone posts a link, the server fetches that URL |
| 168 | to build the preview card β so it briefly sees URLs (never message |
| 169 | text). The price of previews; gateable behind a setting later.</span></li> |
| 170 | <li><span class="mark" aria-hidden="true">β</span><span><b>GIF |
| 171 | search:</b> your browser queries Giphy directly, so Giphy sees your |
| 172 | search terms and network address; our server sees nothing. The chosen |
| 173 | GIF is sealed to the conversation like any attachment.</span></li> |
| 174 | <li><span class="mark" aria-hidden="true">β</span><span><b>Branding is |
| 175 | plaintext:</b> a spaceβs name, optional photo, and description are |
| 176 | admin-set and must render before a joiner holds any key β so they are |
| 177 | deliberately not encrypted. Everything members write still is.</span></li> |
| 178 | <li><span class="mark" aria-hidden="true">β</span><span><b>Bots are |
| 179 | members:</b> a bot you add runs on a machine its operator controls, |
| 180 | and that machine can read exactly the conversations the bot is in β |
| 181 | never anything else. Scoped, disclosed, and removable like any |
| 182 | member.</span></li> |
| 183 | <li><span class="mark" aria-hidden="true">β</span><span><b>A |
| 184 | content-blind store canβt scan content:</b> because we canβt read |
| 185 | anything, no server-side abuse filter is possible β a store we canβt |
| 186 | open is also a dead drop we canβt inspect. Abuse handling is |
| 187 | structural instead: invite-gated rosters, admin removal, and membersβ |
| 188 | own judgement. We accept that trade with open eyes.</span></li> |
| 189 | </ul> |
| 190 | </section> |
| 191 | |
| 192 | <footer> |
| 193 | <p>The commitment, hardening, and known-edges sections are attested |
| 194 | content: they live in the productβs repository and change only by reviewed |
| 195 | commit. The scan block is generated at build time against the exact binary |
| 196 | the fleet runs; unpatched findings publish as a count only, and full |
| 197 | details are held until fixed. Report vulnerabilities privately to |
| 198 | <a href="mailto:team@elevenmessenger.com">team@elevenmessenger.com</a>.</p> |
| 199 | </footer> |
| 200 | </div> |
| 201 | |
| 202 | <script> |
| 203 | // contract: the fields this page reads from ./security.json β kept in sync |
| 204 | // with deploy/security-scan.sh and checked by test/status-contract.test.mjs. |
| 205 | const FIELDS = [ |
| 206 | "generated_at", "scanned_sha", "go_modules", |
| 207 | "dependencies.status", "dependencies.advisories_under_remediation", |
| 208 | ]; |
| 209 | |
| 210 | // The status vocabulary β the ONLY values the feed may hold. Word + glyph |
| 211 | // carry the state; color is supplementary (never color alone). Anything |
| 212 | // outside the vocabulary renders as pending rather than guessing. |
| 213 | const DEP_STATES = { |
| 214 | "clean": { text: "β Clean", tone: "ok", sub: "0 known vulnerabilities in the shipped binary" }, |
| 215 | "attention": { text: "β² Attention", tone: "warn", sub: "" }, // sub filled with the count below |
| 216 | "pending": { text: "β", tone: "", sub: "scan pending" }, |
| 217 | }; |
| 218 | |
| 219 | const $ = (id) => document.getElementById(id); |
| 220 | let rendered = false; |
| 221 | |
| 222 | // All rendering is textContent / createElement β never innerHTML of fetched |
| 223 | // data, even though the feed is our own. |
| 224 | function render(d) { |
| 225 | rendered = true; |
| 226 | $("pending").classList.remove("show"); |
| 227 | |
| 228 | const t = new Date(d.generated_at); |
| 229 | $("asof").dateTime = d.generated_at; |
| 230 | $("asof").textContent = t.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }); |
| 231 | // Honesty check: the scan rides releases, not a 5-minute timer β flag a |
| 232 | // scan older than 45 days (several missed release cycles). |
| 233 | const staleEl = $("staleness"); |
| 234 | staleEl.textContent = ""; |
| 235 | staleEl.className = ""; |
| 236 | if (Date.now() - t.getTime() > 45 * 24 * 60 * 60 * 1000) { |
| 237 | staleEl.className = "stale"; |
| 238 | staleEl.textContent = " β this scan looks stale"; |
| 239 | } |
| 240 | |
| 241 | const dep = d.dependencies || {}; |
| 242 | const n = dep.advisories_under_remediation; |
| 243 | const state = DEP_STATES[dep.status] || DEP_STATES.pending; |
| 244 | const el = $("t-dep-status"); |
| 245 | el.className = "value" + (state.tone ? " " + state.tone : ""); |
| 246 | el.textContent = state.text; |
| 247 | // Count only, by design β never ids, packages, or locations (see the |
| 248 | // disclosure comment at the top of this file). |
| 249 | $("t-dep-sub").textContent = dep.status === "attention" && Number.isInteger(n) |
| 250 | ? n + " advisor" + (n === 1 ? "y" : "ies") + " under remediation β count only until patched" |
| 251 | : state.sub; |
| 252 | |
| 253 | $("t-modules").textContent = Number.isInteger(d.go_modules) ? String(d.go_modules) : "β"; |
| 254 | $("t-sha").textContent = typeof d.scanned_sha === "string" ? d.scanned_sha : "β"; |
| 255 | } |
| 256 | |
| 257 | function renderPending() { |
| 258 | // No scan visible: say so plainly β never a fake "clean" (degrade, never |
| 259 | // lie). A previously-rendered scan stays on screen instead. |
| 260 | if (rendered) return; |
| 261 | $("pending").classList.add("show"); |
| 262 | $("asof").textContent = "β no scan published"; |
| 263 | $("t-dep-status").className = "value"; |
| 264 | $("t-dep-status").textContent = "β"; |
| 265 | $("t-dep-sub").textContent = "scan pending"; |
| 266 | } |
| 267 | |
| 268 | async function refresh() { |
| 269 | try { |
| 270 | const res = await fetch("./security.json", { cache: "no-cache" }); |
| 271 | if (!res.ok) throw new Error("HTTP " + res.status); |
| 272 | render(await res.json()); |
| 273 | } catch (e) { |
| 274 | renderPending(); |
| 275 | } |
| 276 | } |
| 277 | refresh(); |
| 278 | setInterval(refresh, 60 * 1000); |
| 279 | void FIELDS; |
| 280 | </script> |
| 281 | </body> |
| 282 | </html> |
| 283 | |