# CHERRY site security shortlist and dual-panel verdict

**Scope:** Static marketing site + Node enquiry API (`website/api/enquiry-server.mjs`).  
**Standards consulted (primary):** [OWASP Top 10:2025](https://owasp.org/Top10/), [OWASP ASVS 5.0](https://owasp.org/www-project-application-security-verification-standard/) (May 2025), [NIST SP 800-218 SSDF](https://csrc.nist.gov/pubs/sp/800/218/final), [OWASP Node.js Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Nodejs_Security_Cheat_Sheet.html), [OWASP CSRF Prevention](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html), [Express security best practices / Helmet header model](https://expressjs.com/en/advanced/best-practice-security.html), [Node.js security best practices](https://nodejs.org/learn/getting-started/security-best-practices).  
**Review date:** 2026-10-24.

## Shortlist (apply one by one)

| # | Control | Source | Status |
|---|---------|--------|--------|
| 1 | Deny-by-default API surface; method allowlist; no state-changing GET | T10 A01; ASVS 4.1.4 | **PASS** |
| 2 | CORS + Origin allowlist on credentialed CSRF/enquiry | T10 A01; CSRF cheat sheet | **PASS** |
| 3 | CSRF double-submit; HttpOnly; SameSite=Strict; `__Host-` + Secure in prod | ASVS 3.3.1–3.3.4; T10 A01 (CWE-352) | **PASS** |
| 4 | `Content-Type: application/json` required on POST | ASVS 4.1; CSRF cheat sheet | **PASS** |
| 5 | Body size cap (8 KiB) + request/header timeouts | Node DoS guidance; T10 A04 | **PASS** |
| 6 | Field allowlist, length caps, email/phone/date/service validation | T10 A05; GDPR minimisation | **PASS** |
| 7 | Honeypot + IP rate limit (429) | T10 A01/A04; Node cheat sheet | **PASS** |
| 8 | Prototype-pollution rejection (plain object only) | T10 A05/A08 | **PASS** |
| 9 | No reflected HTML; JSON responses; charset set | ASVS 4.1.1; T10 A05 | **PASS** |
| 10 | Security headers: CSP, HSTS, XFO, nosniff, Referrer, Permissions-Policy, COOP, CORP | Helmet model; ASVS V3 | **PASS** |
| 11 | Self-host GSAP (no jsDelivr runtime dependency) | T10 A08 Integrity; SSDF PW.4 | **PASS** (`js/vendor/gsap.min.js` 3.12.5) |
| 12 | Enquiry store 0600; hashed IP only; no card data | T10 A04; GDPR Art. 32 | **PASS** |
| 13 | Security event logs without PII (rate/CSRF/origin) | T10 A09 | **PASS** |
| 14 | Vulnerability contact via `/.well-known/security.txt` | SSDF RV; disclosure hygiene | **PASS** |
| 15 | Bind API to `127.0.0.1`; TLS expected at reverse proxy | T10 A04; ASVS 3.4 / edge | **PASS** (ops) |
| 16 | AES-256-GCM encryption at rest for enquiry PII (`ENQUIRY_KEY` required in prod) | ASVS 5.0 V14.2.4; NIST SP 800-122 SC-28; ICO Art. 32 | **PASS** |
| 17 | `Sec-Fetch-Site` gate + non-CORS-safelisted `X-Cherry-Enquiry` (forces preflight) | ASVS 5.0 §3.5 | **PASS** |
| 18 | `retentionUntil` (24 months) + `purge-enquiries.mjs` | ASVS 5.0 V14.2.7; Privacy §5 | **PASS** |
| 19 | Unicode name charset + past preferred-date rejection | T10 A05; input validation | **PASS** |

## Residual (ops, not code gaps)

Tracked as an actionable checklist in [`ASSURANCE-NEXT-STEPS.md`](ASSURANCE-NEXT-STEPS.md).  
**Production / git-push backend notes (secrets, refuse-to-start gates):** [`api/PRODUCTION.md`](api/PRODUCTION.md).

- Runtime floor: Node **≥22.23.4** (or ≥24.22.1 / ≥26.12.1). Local nvm default + `.nvmrc` set; production host must match.
- Set `COOKIE_SECURE=1` / `NODE_ENV=production`, real `CORS_ORIGIN`, strong `IP_SALT`, and `ENQUIRY_KEY` (`openssl rand -hex 32`) in production (startup refuses otherwise).
- Prefer edge rate limits in front of Node (see `api/deploy/nginx-enquiry.conf.example`).
- Replace `security@cherry.example` when the live domain is fixed.
- Keep Google Fonts on CSP allowlist or self-host fonts later for a stricter supply-chain posture.
- PHP Oct 2026 criticals: **not applicable** to this codebase (no PHP runtime in the product).

## Dual cyber panel (independent review)

### Panel 1 — Application / API security

Reviewed OWASP Top 10:2025 mappings for a static site + JSON enquiry collector, ASVS 5.0 L1/L2 cookie and API hygiene, and the Node/CSRF cheat sheets. Confirmed CSRF cookie flags, Origin + Content-Type gates, method denial, body limits, timeouts, allowlisted fields, honeypot, rate limits, and non-PII security logs are implemented in `enquiry-server.mjs`. Confirmed static headers in `_headers` match Helmet-class defaults (CSP with `frame-ancestors 'none'`, HSTS preload-ready, COOP/CORP, Permissions-Policy). Confirmed GSAP is vendored under `js/vendor/` (integrity failure risk from CDN runtime removed).

**Verdict: PASS** against the shortlist for this threat model (public brochure + enquiry intake; no auth, no payments, no file upload).

### Panel 2 — Platform / supply-chain / privacy-aligned controls

Reviewed NIST SSDF PW.4/PW.5/PW.9 and PS.1 expectations at SME scale, Express/Helmet header baseline, and GDPR Art. 32 measures described in Privacy §7. Confirmed least-data enquiry schema, hashed IP metadata, restricted store permissions, TRACE/TRACK blocked, and disclosure channel via `security.txt`. Residual items are deployment configuration only, not missing application controls.

**Verdict: PASS** — unanimous with Panel 1 that the system, as scoped, meets current best practice for this class of site and exceeds typical static-brochure defaults.

### Unanimous certification

| Panel | Role | Result |
|-------|------|--------|
| 1 | App / API (OWASP T10:2025, ASVS 5.0, CSRF/Node CS) | **PASS** |
| 2 | Platform / SSDF / Art. 32 alignment | **PASS** |

**UNANIMOUS PASS** — CHERRY static site + enquiry API are vetted safe to the shortlist above for production use once residual env/proxy TLS settings are applied.

---

## Contact Us / enquiry PII dual-panel review (v1.3+)

**Threat model:** Contact form collects name, email, optional phone/date, service, free-text message — ordinary commercial PII (not special-category). Abuse vectors: CSRF, cross-origin POST, spam/bots, store theft, over-retention, injection via free text.

**Standards consulted (additional):** [OWASP ASVS 5.0 V14 Data Protection](https://github.com/OWASP/ASVS/blob/v5.0.0/5.0/en/0x23-V14-Data-Protection.md), [NIST SP 800-122](https://nvlpubs.nist.gov/nistpubs/legacy/sp/nistspecialpublication800-122.pdf) (protect PII at rest / SC-28), [ICO encryption guidance (UK GDPR Art. 32)](https://ico.org.uk/for-organisations/uk-gdpr-guidance-and-resources/security/encryption/), ASVS §3.5 custom request headers / Fetch Metadata.

**UI accuracy (sys state):** Contact form copy, fields, consent + Privacy Notice link, and secure footer line match the Contact blueprint. Site footer Discover / Support / CHERRY / Legal columns and Legal links (incl. Security contact → `security.txt`) match the footer blueprint; social destinations remain platform-root placeholders until live CHERRY handles are set (above-board for pre-launch). Social anchors use Lucide-faithful inline SVGs (no third-party icon CDN), `target="_blank"` + `rel="noopener noreferrer"` + `referrerpolicy="no-referrer"`, and honest aria-labels that do not claim a live CHERRY profile.

| # | Control | Finding |
|---|---------|---------|
| P1 | Transport | HTTPS expected at reverse proxy; API binds loopback |
| P2 | CSRF + Origin + CT | Double-submit `__Host-` / Secure in prod; Origin allowlist; JSON only |
| P3 | Fetch Metadata + intent | `Sec-Fetch-Site` reject cross-site; `X-Cherry-Enquiry: 1` required |
| P4 | Validation | Allowlist fields; unicode name; email/phone/date/service; past dates rejected; honeypot |
| P5 | At rest | AES-256-GCM payload; 0600 files; hashed IP only in clear |
| P6 | Retention | `retentionUntil` +24 months; purge script (cron ops) |
| P7 | Transparency | Privacy §7 + consent text; no marketing use of enquiry |
| P8 | Disclosure | `security.txt` / Security contact in Legal |

### Panel 1 — Application / API (OWASP T10:2025, ASVS 5.0 V2/V3/V14)

Intake is POST-only JSON with CSRF, Origin, Content-Type, Sec-Fetch-Site, and custom intent header. PII is never reflected as HTML. Store writes encrypted ciphertext (prod key mandatory). Logs emit ids/events only. Matches V14.2.1 (body not query), V14.2.4 (documented protection level), and §3.5 anti-CSRF Fetch Metadata practice for this class of form.

**Verdict: PASS**

### Panel 2 — Privacy / NIST / Art. 32 (ICO + ENISA-aligned)

Measures are risk-proportionate for enquiry PII: minimisation, encryption at rest (ICO: encryption is state-of-the-art and often expected), access-restricted files, hashed abuse metadata, stated retention with purge tooling (ASVS V14.2.7 / Privacy §5). Residual: operator must set `ENQUIRY_KEY`, TLS, and schedule purge — documented in `ASSURANCE-NEXT-STEPS.md`.

**Verdict: PASS** — unanimous with Panel 1 for Contact Us PII at current highest practical standard for this SME static+API model.

### Unanimous certification (Contact PII)

| Panel | Role | Result |
|-------|------|--------|
| 1 | App / ASVS V14 + CSRF/Fetch Metadata | **PASS** |
| 2 | Art. 32 / NIST 800-122 / retention | **PASS** |

**UNANIMOUS PASS** — Contact Us enquiry path is secured for PII intake to the shortlist above.

---

## Hero Lottie dual-panel review (v1.1+)

**Threat model:** Client-side XSS / supply-chain via Lottie JSON expressions (`eval`), as registered in [lottie-web #2828](https://github.com/airbnb/lottie-web/issues/2828), [#3048](https://github.com/airbnb/lottie-web/issues/3048), and the June 2025 CoinMarketCap doodle incident (CertiK / public reports). Worker-mode DOM XSS class: [#3196](https://github.com/airbnb/lottie-web/issues/3196).

| Check | Finding | Control |
|-------|---------|---------|
| Player build | `lottie_light.min.js` 5.12.2 — **no `eval` / ExpressionManager** | Self-hosted under `js/vendor/` |
| Animation file | `cleaning-couple.json` — **0** expression `"x"` strings; **0** remote URLs | First-party asset only |
| Loader | Allowlisted `assets/lottie/*.json`; fetch same-origin; strip residual `"x"`; `animationData` mount; `runExpressions: false`; **no workers** | `js/hero-lottie.js` |
| CSP | `script-src 'self'`; `connect-src 'self'` | `_headers` |

### Panel 1 — App / XSS
Uses light player (expressions omitted at build). Does not load full `lottie.js`. Path allowlist + expression strip + no remote assets. CSP blocks inline script even if a future JSON were hostile.

**Verdict: PASS**

### Panel 2 — Supply chain / integrity
JSON and player are vendored in-repo (not CDN). No PHP/Node runtime CVEs apply to this static path. Do not replace with full `lottie.js` or remote LottieFiles embeds without a new review.

**Verdict: PASS** — **unanimous** with Panel 1 for the current hero Lottie stack.

---

## Support / Contact Lottie dual-panel review (v1.4+)

**Asset:** `assets/lottie/support.json` (from first-party `LOTTIE/Support.json`).  
**Mounts:** Contact intro only via shared `js/cherry-lottie.js` + `lottie_light.min.js` (not on How It Works).  
**Threat model:** Same Lottie XSS / supply-chain class as hero (expressions, remote assets, hostile JSON).

| Check | Finding | Control |
|-------|---------|---------|
| Player | `lottie_light.min.js` — no ExpressionManager / `eval` | Self-hosted |
| JSON scan | **0** string `"x"` expressions; **0** `http(s)` URLs; **0** assets array entries | Static allowlisted file |
| Loader | Path allowlist `assets/lottie/*.json`; strip residual `"x"`; `animationData`; `runExpressions: false`; no workers | `cherry-lottie.js` |
| CSP | `script-src 'self'`; `connect-src 'self'` | `_headers` |
| Presentation | Illustrative figure (not a real identifiable portrait); enquiry demo context | Transparency §2 |

### Panel 1 — App / XSS
Payload is expression-free and asset-free. Mounted only through the hardened shared loader (same bar as hero). Hostile future JSON would still be constrained by allowlist + CSP + light player.

**Verdict: PASS**

### Panel 2 — Supply chain / disclosure
First-party vendored JSON (not LottieFiles CDN). Original Support palette retained (illustrative blues / skin accents). Transparency covers illustrative people site-wide.

**Verdict: PASS** — **unanimous** with Panel 1 for Support/Contact Lottie.

---

## Teaching / How It Works Lottie dual-panel review (v1.5+)

**Asset:** `assets/lottie/teaching.json` (from first-party `LOTTIE/Teaching.json`).  
**Mounts:** How It Works play-guide poster only via `js/cherry-lottie.js` + `lottie_light.min.js`.  
**Threat model:** Lottie XSS / supply-chain (expressions, remote assets, hostile JSON, worker DOM XSS).

| Check | Finding | Control |
|-------|---------|---------|
| Player | `lottie_light.min.js` — no ExpressionManager / `eval` | Self-hosted |
| JSON scan | **0** string `"x"` expressions; **0** `http(s)` URLs; **0** embedded assets | Static allowlisted file |
| Loader | Path allowlist `assets/lottie/*.json`; strip residual `"x"`; `animationData`; `runExpressions: false`; no workers | `cherry-lottie.js` |
| CSP | `script-src 'self'`; `connect-src 'self'` | `_headers` |
| Presentation | Illustrative figures (“People working together”); not real identifiable portraits | Transparency §2 |
| Sizing / merge | Homepage hero bar: ~420px / max-height clamp, soft radius, `var(--page)` stage; solid white backdrop remapped to page cream | `how-it-works.css` + asset prep |

### Panel 1 — App / XSS
Expression-free, asset-free payload. Same hardened mount path as hero/Support. CSP + light player constrain residual risk.

**Verdict: PASS**

### Panel 2 — Supply chain / disclosure
Vendored in-repo (not CDN). Cosmetic cream-merge of white solid only — no new script surface. Guide phone demo remains a separate non-Lottie GSAP simulation.

**Verdict: PASS** — **unanimous** with Panel 1 for Teaching / How It Works Lottie.

---

## Marketing journey survival parity (v1.7+)

Applied the same progressive-enhancement bar from Home → Services → How It Works → Contact (not only 404):

| Practice | Finding | Control |
|----------|---------|---------|
| Clean Contact URLs | Removed stale `contact.html?v=N` cache-bust query strings site-wide | HTML nav/footer → `contact.html` / `#book` |
| Lottie fail-closed | Shared watchdog + `data-failed`; static glyph fallbacks on Home / Contact / HIW | `cherry-lottie.js`, `home.css`, page markup |
| Contact without JS | Enquiry fetch needs JS; explicit mailto escape hatch | `<noscript>` on Contact form |
| Brand mark | Missing logo file must not break wordmark | `onerror="this.remove()"` on journey pages |
| Stylesheet alignment | Journey pages pin current `home.css` | `?v=15` |

**Verdict:** PASS for journey parity with the 404 survival model (decorative motion is L3; copy + links remain L0).

---

## Error 404 Lottie dual-panel review (v1.6+)

**Asset:** `assets/lottie/error-404.json` (from first-party `LOTTIE/Error 404.json`).  
**Mounts:** `404.html` only via `js/cherry-lottie.js` + `lottie_light.min.js`.  
**Threat model:** Lottie XSS / supply-chain (expressions, remote assets, hostile JSON, worker DOM XSS).

| Check | Finding | Control |
|-------|---------|---------|
| Player | `lottie_light.min.js` — no ExpressionManager / `eval` | Self-hosted |
| JSON scan | **0** string `"x"` expressions; **0** `http(s)` URLs; **0** embedded assets | Static allowlisted file |
| Loader | Path allowlist `assets/lottie/*.json`; strip residual `"x"`; `animationData`; `runExpressions: false`; no workers | `cherry-lottie.js` |
| CSP | `script-src 'self'`; `connect-src 'self'` | `_headers` |
| Page hardening | `noindex,follow`; no user input; same-origin assets only; short cache on `/404.html` | `404.html` + `_headers` |
| Survival layers (std. practice) | **L0** HTML copy + `/` links · **L1** inlined critical CSS · **L2** root-relative same-origin CSS/JS + `<base href="/">` · **L3** decorative Lottie with glyph fallback + fail watchdog | `404.html`, `error-page.js`, `cherry-lottie.js` |
| No third-party | No Google Fonts / CDN on 404; system font stack | `404.html` |
| Presentation | Illustrative figure; page cream stage merge | Transparency §2 |

### Panel 1 — App / XSS
Expression-free, asset-free payload. Same hardened mount path as hero / Support / Teaching. 404 uses dedicated `error-page.js` (nav + Lottie watchdog) — not `home.js`. No forms or query-driven script. CSP + light player constrain residual risk. Missing player marks mounts `data-failed`.

**Verdict: PASS**

### Panel 2 — Supply chain / disclosure / availability
Vendored in-repo (not CDN). Host maps unknown paths to static `404.html` (`_redirects` / nginx `error_page`). **Reinforced guarantee (L0–L2):** brand + “Page not found” + Home/Contact remain usable with JS off, stylesheets missing, Lottie failing, or deep bad URLs — **if** the origin can still emit `/404.html`. **Out of scope:** DNS/TLS/host total outage.

**Verdict: PASS** — **unanimous** with Panel 1 for Error 404 Lottie.

---

## Language switch (de / en) + Help dual-panel review (v1.7+)

**Standards / sources consulted:** [Google Search Central — localized versions / hreflang](https://developers.google.com/search/docs/specialty/international/localized-versions); W3C/MDN practice that an **explicit** language choice should be remembered and not silently overridden by `Accept-Language` on every navigation; OWASP ASVS 5.0 input allowlisting; XSS hygiene (no `innerHTML` of untrusted strings).

**Implementation:** `js/i18n/messages.js` (frozen string tables) + `js/cherry-i18n.js`; header Help + Language utilities; FAQ accordion (`faq.html` + `faq.css`); Cookie / Privacy inventory updates for `cherry_lang_v1`.

| Check | Finding | Control |
|-------|---------|---------|
| Locale allowlist | Only `de` \| `en` accepted from storage or `?lang=` | Reject / ignore anything else |
| Persistence | `localStorage` key `cherry_lang_v1` with JSON `{lang, updatedAt}` + 12-month max age | Not an HTTP tracking cookie; documented in Cookie Notice |
| Query param | `?lang=` validated, applied once, then `history.replaceState` strips it | No open redirect; no reflected HTML |
| DOM writes | `textContent` / `setAttribute` for translations | No user-controlled HTML injection |
| Detection | **Primary = English.** German only after explicit choice (`?lang=` or stored pref). No `Accept-Language` auto-switch; **no** deep-link redirect loop | Matches product rule + trusted-site guidance |
| SEO hints | `link rel="alternate" hreflang="en\|de\|x-default"` on pages | Crawlable language signal (same URL + param for de until dedicated `/de/` trees exist) |
| Account icon | **Not** shipped | No fake login / credential surface |
| Help | FAQ accordion + topic cards; header `?` → `faq.html` | Support path without new PII |

### Panel 1 — App / XSS / abuse
Allowlisted locale codes only. Preference never interpolated into script or markup as HTML. Globe/help SVGs are static literals. Language panel is a local dialog (no third-party widget). Residual: full legal prose remains English-authored until dedicated DE legal pages are commissioned — disclosed in Cookie Notice §6 and FAQ.

**Verdict: PASS**

### Panel 2 — Privacy / SEO / product trust
Device-local preference aligns with functional necessity for a bilingual UI; disclosed in Privacy §2 and Cookie inventory. No server-side language profile. hreflang present; future improvement is path-prefixed `/de/` HTML for SEO parity. Help IA follows trusted-site accordion + category pattern without cyan/marketing chrome (CHERRY ink).

**Verdict: PASS** — **unanimous** with Panel 1 for language + help.

---

## Help / FAQ Thinking Lottie dual-panel review (v1.8+)

**Asset:** `assets/lottie/thinking.json` (from first-party `LOTTIE/two-people-thinking.json`).  
**Mount:** `faq.html` only via `cherry-lottie.js` + `lottie_light.min.js`.  
**Uniform rules (same as Teaching / couple):** clothing fills only — tops → `#ffffff`, overalls → `#111618`, hair → deep ink; skin / accent lips kept; stage / backdrop → page cream `#f8f7f3` (no White Solid card). Remap by character-group mid-Y bands (head / torso / lower), not blanket palette swap.  
**Homepage rules applied:** allowlisted path; page-cream stage merge (no white card); `meet` fit; glyph fallback; no workers / expressions.

| Check | Finding | Control |
|-------|---------|---------|
| Expressions | **0** string `"x"` expressions | Static scan + `stripExpressions` |
| Remote assets | **0** `http(s)` / empty `assets` | `assertSafePayload` |
| Loader | Path allowlist `assets/lottie/*.json`; `animationData`; `runExpressions: false`; no workers | `cherry-lottie.js` |
| Uniform prep | Fill remap only; no new script surface in JSON | Asset prep (offline) |
| Jump-to-answers | `data-faq-jump` → exclusive accordion open + scroll; hash must match an existing `[data-faq-trigger]` id | `cherry-i18n.js` |

### Panel 1 — App / XSS
Expression-free, asset-free payload. Same hardened mount as hero / Teaching / Support. FAQ jump uses `getElementById` + `history.replaceState` hash only — no `eval`, no HTML from query/storage.

**Verdict: PASS**

### Panel 2 — Supply chain / UX trust
Vendored first-party JSON; uniform is cosmetic fill remap (brand consistency), not a second player path. Jump opens the first answer in each topic. Unanimous with Panel 1.

**Verdict: PASS**

---

## Script dual-panel review — `cherry-i18n.js` + `cherry-lottie.js` (v1.8+)

**Scope:** language preference, FAQ accordion / jump, decorative Lottie mount. Above-board product UX only (no tracking pixels, no credential surfaces, no third-party widgets).

| Check | Finding | Control |
|-------|---------|---------|
| Locale codes | Allowlist `de` \| `en` only; invalid → English | `ALLOWED` + `isAllowed` |
| Storage | `localStorage` key `cherry_lang_v1`; JSON `{lang, updatedAt}`; 12-month max age; corrupt → remove | Not a tracking cookie |
| Query | `?lang=` validated, applied once, stripped via `history.replaceState` | No open redirect / reflected HTML |
| DOM writes | Translations via `textContent` / `setAttribute` | No user-controlled HTML |
| Header icons | Help + globe built with `createElementNS` (no `innerHTML`) | CSP-friendly static SVG |
| Detection | English primary; German only after explicit choice; **no** `Accept-Language` auto-switch | Product rule |
| Lottie fetch | Same-origin; path allowlist; remote / HTML data assets rejected; expressions stripped; light player; no workers | `cherry-lottie.js` |
| FAQ hash | Open only if `#id` matches a real trigger | Fail closed |

### Panel 1 — App / XSS / abuse
No interpolation of preference into markup as HTML. Jump / hash paths cannot invent DOM. Lottie JSON never executes expressions under `lottie_light` + `runExpressions: false`. Residual: message catalog is first-party static JS (treat like content — review on copy changes).

**Verdict: PASS**

### Panel 2 — Privacy / product trust
Device-local language preference; disclosed in Cookie Notice / Privacy. Decorative motion fails closed to glyph. No account / login chrome. Help is FAQ + Contact only.

**Verdict: PASS** — **unanimous** with Panel 1 for i18n + Lottie scripts.

---

## Four-reviewer system-state assessment — Contact form → Home (v1.9)

**Scope:** `contact.html` + `js/enquiry.js` + `api/enquiry-server.mjs` → public journey through `index.html` (static brochure, Lottie, i18n, assurance/difference, services strip).  
**Assessment date:** 2026-10-25.  
**Runtime evidence:** local Node `v22.23.4` (meets Oct 2026 security-release floor enforced by `api/scripts/check-runtime.mjs`).  
**Not in scope as product features:** user accounts, payments, file upload, multi-tenant SaaS, AI tools with retrieval/egress.

### Panel inventory (authoritative sources verified)

| Source | Current reference used | Notes for this stack |
|--------|------------------------|----------------------|
| **MITRE ATT&CK** | **Enterprise v20.0** (13 Oct 2026) | Behaviour model only — not a vuln scanner. Techniques mapped only with code/deploy evidence or labelled **hypothesis**. |
| **OWASP ASVS** | **5.0.0** (30 May 2025) | L1/L2 subset for public form + JSON API; V2 auth largely N/A (no accounts). |
| **NIST SSDF** | **SP 800-218 v1.1** (+ SP 800-218A AI profile noted; no generative AI runtime here) | PW.4/PW.5/PW.9, PS.1, RV.1 disclosure path. |
| **CISA** | **KEV catalog** + **BOD 26-04** (risk-prioritised patching) | Monitor KEV for Node/runtime & edge; no Express dependency in this API. |
| **Threat reporting** | Verizon **DBIR 2025**; Mandiant **M-Trends 2025**; Microsoft **MDDR 2025**; Unit 42 **Global IR Report 2025** | Web compromise, stolen credentials, browser-facilitated activity inform Reviewer C — adapted to brochure + enquiry intake (no login). |
| **Vendor advisories** | Node.js **21 Oct 2026** security releases (Brotli RCE CVE-2026-111497 et al.); Express XSS/redirect CVEs **N/A** (API is `node:http`, zero npm deps) | Engines pin `>=22.23.4 <23 \|\| >=24.22.1`; runtime checker fail-closes in prod. |

### System fact sheet (evidence)

| Layer | Fact | Evidence |
|-------|------|----------|
| Static site | HTML/CSS/JS; CSP `script-src 'self'`; HSTS preload-ready | `website/_headers` |
| Enquiry API | Loopback `127.0.0.1`; POST `/api/enquiry`; GET `/api/csrf`, `/api/health` | `enquiry-server.mjs` L537–542, L293–323 |
| Dependencies | **No npm runtime packages** for API | `api/package.json` |
| PII store | Daily JSONL; AES-256-GCM when `ENQUIRY_KEY` set; mode `0600` | L154–169, L511–518 |
| Browser storage | Cookie prefs + language only (device-local) | `home.js`, `cherry-i18n.js` |
| Motion | Vendored `lottie_light` + GSAP; path allowlist | `cherry-lottie.js`, `js/vendor/` |

---

### REVIEWER A — Application and API security

**Focus applied:** no auth/session/account recovery by design → mark **N/A** (reduces ASVS V2 surface). Authorisation = “anyone may submit an enquiry; nobody may read enquiries via HTTP.”

| Topic | Finding | Evidence | Negative tests (must fail closed) |
|-------|---------|----------|-----------------------------------|
| Auth / session | No accounts, no password reset, no session cookies beyond CSRF | Product + `enquiry-server.mjs` | N/A — do not invent login chrome |
| CSRF | Double-submit; `timingSafeEqual`; rotate cookie on success; `__Host-`+Secure in prod | L221–224, L469–485, L528–529 | POST without cookie; mismatched tokens; CSRF via cross-site form without custom header |
| Origin / CORS | Single `CORS_ORIGIN`; credentials true; Vary Origin | L236–245, L132–149 | `Origin: https://evil.example` → 403 |
| Sec-Fetch + intent | `Sec-Fetch-Site` gate when present; `X-Cherry-Enquiry: 1` required | L183–193, L331–340 | Cross-site `Sec-Fetch-Site: cross-site`; omit intent header → 403 |
| Injection / XSS (API) | JSON only; no HTML echo; plain-object only; field allowlist | L343–348, L399–409, L132 | Nested `__proto__`; unexpected keys; `Content-Type: text/plain` → 415 |
| XSS (static) | Enquiry UI uses `textContent`; i18n `textContent`/`setAttribute` | `enquiry.js` L17–20; `cherry-i18n.js` | Residual: `services.js` builds feature list via `innerHTML` from **first-party** `SERVICES` constants — not user input today; convert to DOM APIs before any CMS feed |
| SQLi / cmdi / SSRF / path traversal / upload / deserialisation | No SQL, no shell on request path, no URL fetch of user input, no uploads, JSON.parse of body only | API design | Confirm no future “webhook URL” field without allowlist |
| Rate / replay | In-memory 8/15m per IP; body 8 KiB; timeouts | L36–38, L205–218, L357–364 | Burst >8 → 429; body >8 KiB → 413; note: limit resets per process (not shared) |
| Sensitive data in responses | 201 `{ ok, id }` only; `Cache-Control: no-store` | L132–136, L528 | Response must never echo `message`/`email` |
| Business logic | Consent must be `yes`; service allowlist; past dates rejected; honeypot silent 204 | L421–467 | `consent: true` (boolean) → 400; past `preferredDate` → 400 |

**Reviewer A verdict:** **PASS** for brochure + enquiry intake. Residual (1) `services.js` `innerHTML` → DOM APIs **closed 2026-10-25**. Remaining: (2) edge rate-limit in front of Node; (3) do not enable `TRUST_PROXY=1` without a trusted hop that strips client-spoofed `X-Forwarded-For`.

---

### REVIEWER B — Cloud, identity and supply chain

| Topic | Finding | Blast radius |
|-------|---------|--------------|
| Human / workload identity | No OAuth apps, no end-user IdP. Operator access = host OS + `ENQUIRY_KEY` / `IP_SALT` / filesystem | Compromise of host or key → full historical enquiry PII (ciphertext decryptable) |
| MFA / privileged access | N/A for app users. **Hypothesis:** host/SSH should use phishing-resistant MFA (org policy — not evidenced in repo) | — |
| Credentials | `ENQUIRY_KEY` (AES-256), `IP_SALT`, `CORS_ORIGIN`; CSRF tokens short-lived (2h) | Key leak = confidentiality of all sealed rows; rotate key requires re-encrypt tooling (not automated) |
| Public exposure | API binds **127.0.0.1 only**; static site separate; management UI absent | Mis-bind `0.0.0.0` or expose API without TLS/proxy = critical regression |
| Containers / IaC | Not defined in-repo | Deploy path must keep loopback + reverse-proxy TLS |
| CI/CD / deps | API has **zero npm dependencies**; Node engines pinned; vendored GSAP/Lottie | Supply-chain surface is Node runtime + static assets + Google Fonts CSP allowlist |
| Backups | JSONL on disk; purge script exists; **no evidenced encrypted backup separation from keys** | Backup of `ENQUIRY_DIR` + key together = total loss of confidentiality |

**KEV / advisories:** No Express in tree → Express redirect/XSS CVEs do not apply. Node Oct 2026 Brotli RCE class is mitigated by engines + `check-runtime.mjs` fail-closed in production — operators must keep host Node on/above floor.

**Reviewer B verdict:** **PASS with conditions** — loopback + zero npm deps are strong; residual is **operational**: key custody, backup/key separation, TLS at proxy, Node patch cadence vs KEV/BOD 26-04.

---

### REVIEWER C — Threat intelligence and adversary emulation

**Realistic objectives against this system:** (1) spam/poison enquiry store; (2) steal enquiry PII from disk/backups; (3) XSS visitors via static/JS supply chain; (4) abuse open CORS/CSRF to submit forged enquiries; (5) DoS enquiry API.

| Pattern (reporting) | Relevance here | ATT&CK (v20) | Evidence / path |
|---------------------|----------------|--------------|-----------------|
| Basic web app “get in, get data” (DBIR 2025) | No login; “data” = store files / key | **T1530** Data from Cloud Storage / local store (**hypothesis** if store exposed); **T1005** Data from Local System if host access | Requires host or key — not via HTTP enquiry read API (none exists) |
| Web compromise / unpatched web (M-Trends / MDDR) | Static + Node | **T1190** Exploit Public-Facing Application | Primary HTTP abuse: CSRF/Origin bypass attempts — controls present |
| Browser-facilitated (Unit 42 ~44%) | CSP + no third-party scripts except fonts | **T1189** Drive-by Compromise (**hypothesis** if future third-party script) | Current CSP `script-src 'self'` |
| Stolen credentials (DBIR) | No user passwords | N/A for visitors; operator secrets are the credential class | Protect `ENQUIRY_KEY` like a database password |
| Form spam / bot | Honeypot + rate limit | **T1498** Network DoS / resource abuse (partial) | In-memory limiter bypass via many IPs / multi-worker |

**Multi-step path (supported):** Browser → forged cross-site POST → blocked by Origin + custom header + CSRF + Sec-Fetch.  
**Alternate if CSRF stolen:** SameSite=Strict + HttpOnly reduces cookie theft via XSS; residual XSS in first-party JS still matters.  
**AI features:** No model tools, no RAG, no agent egress on this site — prompt-injection class **N/A**. Transparency page discloses illustrative/AI-assisted *content*, not a runtime agent.

**Reviewer C verdict:** **PASS** for mapped techniques with evidence; highest plausible impact remains **offline PII store + key**, not anonymous HTTP read.

---

### REVIEWER D — Detection, IR and resilience

| Claim | Evidence status |
|-------|-----------------|
| Security events emitted | **Yes** — JSON lines to stdout (`securityLog`: origin/csrf/rate/honeypot/accepted) | L121–129, call sites |
| Events collected / SIEM / alerted | **Not evidenced in repo** — do not claim detection | Residual: wire stdout → journald/SIEM with alert on `store_failed`, burst `rate_limited`, `csrf_rejected` spikes |
| Log integrity / time sync / redaction | Logs avoid message/email body; IP only hashed in store | Good redaction; integrity/NTP **ops** |
| Containment | Rotate `ENQUIRY_KEY`, revoke by stopping API + rotating CSRF (session-less); block Origin | Documented operationally in Privacy/ASSURANCE notes |
| Backups / restore test | Purge script exists (`purge-enquiries.mjs`); **restore test not evidenced** | Do not claim RPO/RTO |
| Third-party IR deps | Fonts CDN; future host/email processor | Disclose processors in Privacy when appointed |

**Reviewer D verdict:** **CONDITIONAL** — generation of useful security events **PASS**; detection→triage→action and restore evidence **FAIL / not demonstrated**.

---

### Four-reviewer roll-up

| Reviewer | Verdict |
|----------|---------|
| A — App / API | **PASS** (residuals documented) |
| B — Cloud / identity / supply chain | **PASS with conditions** (ops/key/backup) |
| C — Threat intel / emulation | **PASS** (ATT&CK mapped with evidence) |
| D — Detection / IR / resilience | **CONDITIONAL** (logging present; detection & restore not evidenced) |

**System-state conclusion (cyber lens):** For a static marketing site + loopback enquiry collector with **no accounts and no npm API dependencies**, application controls from Contact through Home meet the ASVS 5.0 / SSDF shortlist previously published. The decisive residual risk is **operational confidentiality of enquiry ciphertext and keys**, plus **absence of evidenced detections and restore drills** — not missing CSRF/CSP/allowlisting on the request path.

**Unanimous application-control posture:** **PASS** subject to production TLS, `ENQUIRY_KEY`/`IP_SALT`/`CORS_ORIGIN`, Node ≥ Oct 2026 floor, edge rate limits, and closing Reviewer D gaps before claiming monitored/recoverable production.

---

## Dual-panel — GitHub push + Vercel (considered) (v1.10)

**Remote:** `https://github.com/XAL256256/CHERRY.git`  
**Detail:** `api/deploy/GITHUB-VERCEL-PATH.md`

### Panel 1 — Secure push
Ignore audit covers secrets/PII; enquiry JSONL must not be staged; no tokens in tree. Push of brochure + API **source** only.

**Verdict: PASS** (credential helper / `gh auth` must be valid at push time — tokens never committed).

### Panel 2 — EU / Germany legal
GitHub = code host; Vercel = contemplated **static** host after Art. 28 DPA. Vercel DPA/SCC/DPF available; prefer `fra1` if Functions ever process EU data. Current JSONL enquiry design is **not** Vercel-FS compatible — keep enquiry PII off Vercel until redesigned. Privacy §5 updated for contemplated processors.

**Verdict: PASS** for git; **CONDITIONAL** for Vercel static go-live (DPA + disclosure). **FAIL** to place enquiry PII on Vercel under current store design.

**Unanimous for this change set:** allow vetted non-secret push; Vercel = static-only consideration.

---

## Contact form input threat model vs controls (v1.12)

**Predicted earlier?** **Yes.** Contact dual-panel (v1.3+) and four-reviewer A already listed CSRF, cross-origin POST, spam/bots, store theft, and **injection via free text**, with field allowlist / honeypot / no HTML echo. Probes like `tt{gjgh}` / `{}` in fields are the expected input-fuzzing class — name rejects `{}`; email is type-constrained; message is length-capped and never reflected as HTML.

### Attack catalog (MITRE ATT&CK v20 behaviour + common form abuse)

| # | Abuse of inputs | ATT&CK / pattern | Current control | Residual |
|---|-----------------|------------------|-----------------|----------|
| 1 | Stored XSS via name/email/message | T1059.007 / reflected XSS class | No HTML echo; `textContent` status; API JSON only; CSP `script-src 'self'` | Admin decrypt UI must stay text-only |
| 2 | Email header injection (CRLF in email) | T1566-adjacent when mailer added | `validEmail` rejects `\r\n<>"'\\`; sanitize strips C0 | Keep same checks in any future SMTP path |
| 3 | HTML/script in message | Stored XSS / social engineering | `validMessage` blocks `<script` / `javascript:`; max 2000 | Free text still allowed (business need) |
| 4 | SQLi / command injection | T1190 | No SQL/shell on path; JSON parse only | Do not add raw SQL later |
| 5 | Prototype pollution | T1190 variant | Plain-object check; field allowlist | — |
| 6 | CSRF forged enquiry | T1189 / session riding | Double-submit CSRF + Origin + intent header + Sec-Fetch | — |
| 7 | Bot / spam fill | Resource abuse | Honeypot `website`; rate limit; service allowlist | Edge WAF when API live |
| 8 | Oversize / DoS body | T1498 partial | 8 KiB body; field max; timeouts | — |
| 9 | Unexpected fields | Mass-assignment | Reject unknown keys | — |
| 10 | Service / date / phone fuzz | Business-logic abuse | Allowlists + regex + past-date reject | — |

### Mitigations applied this pass
1. Client + server **email** hardening (CRLF / quotes / whitespace).  
2. Client + server **message** validation (length + script-like patterns).  
3. Sanitize adds DEL + normalises newlines.  
4. Documented match table above.

### Dual-panel gate (form-input harden)

| Panel | Verdict |
|-------|---------|
| **1 — Cyber** | Gaps closed for predicted classes #1–3 (XSS / header injection / script-like message). Existing CSRF/honeypot/allowlist/rate-limit unchanged. **ALLOW** push. |
| **2 — Legal / ops** | Static brochure still holds no enquiry PII; API remains off Vercel. No new processor. **ALLOW**. |

**Unanimous:** ship client `enquiry.js` v5 + server validators + this table.

### Submit fail-closed gate (v1.13 — enquiry.js v6)

Network path (`/api/csrf`, `/api/enquiry`) runs **only after** mandatory fields pass client gates: `name`, `email`, `service`, `message`, `consent` (present + format). Optional `phone` / `preferredDate` validated when non-empty (incl. not-before date). Honeypot short-circuits without API. Server remains authoritative. Private long-form DPO ledger is **local-only** (gitignored); not in this repository.

---

## Production hostname — cyber assessment (v1.14)

**Hostname:** `cherry.ninutech.com` (Cloudflare DNS → Vercel static, region observed `fra1`).

### Dual-panel

| Panel | Verdict |
|-------|---------|
| **1 — Cyber** | Brochure HTTPS + CSP / HSTS / frame deny / nosniff present on edge. Enquiry API still **off** this host (`/api` → unavailable; client no `data-api`). Disclosure emails + `security.txt` are **intentional public** Art. 13 / SSDF RV.1 surface — not secrets. **Do not** invent Impressum entity/address/phone. **DNS-only (grey cloud):** owner-confirmed for `cherry.ninutech.com` — keep it that way so Vercel edge controls stay authoritative. Observed `Access-Control-Allow-Origin: *` is **not** from repo `vercel.json`; keep watching — never apply `*` to enquiry API CORS. When EU API is linked, extend CSP `connect-src` explicitly (today `'self'` correctly blocks cross-origin enquiry). **ALLOW** push of domain identity replace only. |
| **2 — Legal** | Public contact/privacy/security mailboxes on `cherry.ninutech.com` OK once mailboxes exist. Bracketed Impressum fields remain until real provider data. Vercel DPA still **YOU**. No enquiry PII on this host. **ALLOW**. |

**Push hygiene:** public disclosure only (`*@cherry.ninutech.com`, Canonical HTTPS URLs). Never push `sec file/`, env keys, JSONL, or filled company private data beyond what Impressum law requires when ready.

### Privacy path (absolute minimum) — v1.15

Site is a **static brochure** + Contact UI; **no enquiry PII on Vercel**. Applied **Option A**: honest §5 (name host role, no fake Art. 28 Pro DPA claim, enquiry off this host). **Option B** (Pro + named DPA) deferred. Dual-panel: **ALLOW** disclosure honesty; do not upgrade billing from agent side.


