You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
sims-hq/docs/18-RED-TEAM-REVIEW.md

260 lines
36 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 18 — Red-Team Security Review (2026-07-10)
Adversarial review of the SiMS Next build by five parallel reviewers, each reading the
code and firing proof-of-concept requests at the live dev server (`:5181`). Every finding
is labelled **CONFIRMED** (proven with a PoC) or **THEORETICAL**. Nothing was fixed —
this is assessment; remediation is the founder's call (see plan at the end).
## The one-sentence verdict
**The store-server authenticates requests but does not *authorize* them and does not
*validate or anchor* the business data in them** — so any logged-in cashier (the lowest
role) can, with `curl`, rewrite prices, evade GST, edit the item master, and forge the
audit trail. The React app enforces all these rules; the server trusts the client to have
done so. Nothing is shipped to a real store yet, so this is the right moment to find it.
**Why "the server re-verifies every bill to the paisa" (R5) gave false confidence:** it
proves the client's total matches the client's *own* line prices — internal consistency,
not authenticity against the item master. The supervisor gate we verified in the browser
is real in the UI and unenforced on the server.
## Remediation status — P0 CLOSED (2026-07-10)
The P0 workstreams below are **REMEDIATED**: the trust boundary now lives inside the
store-server (item-master anchoring, per-route authorization, input validation, bound
approval tokens). The dev DB was reset and every PoC re-fired against the fresh server;
each that used to succeed now fails closed. A real-browser E2E confirms the honest counter
flow still works. **P1 and P2 remain open** (see the plan at the end).
| PoC | Before (assessment) | After (2026-07-10) | Evidence |
|---|---|---|---|
| **C1** cashier → `POST /items` (edit master) | 200, item written | **403 E-6105** | authz middleware; a cashier may still `POST /items` a *draft* (200) — now with a **server-forced tax class + price cap + `DRAFT_SALE` audit** (see the 2026-07-13 follow-up below) |
| **C1** cashier → `GET /audit` | 200, full log | **403 E-6105** | `AUDIT_VIEW` gate |
| **C1** cashier → `GET /purchases/last-costs`, `/purchases` | 200, supplier costs | **403 E-6105** | `PURCHASE_ENTRY`/`REPORT_VIEW` gate |
| **C2** cashier bills a ₹275 item at ₹1 | committed, no audit | **403 E-1302** (deviation needs approval) | server anchors master price; nothing committed |
| **C3** 12% item sent as `taxClassCode:ZERO` | committed, ₹0 GST | **committed at GST12** (CGST₹14.73+SGST₹14.73 on the ₹275 bill) | tax class read from master, request ignored |
| **C4** cashier `POST /purchases` w/ `newSalePricePaise` | master price rewritten | **403 E-6105** (route denied); a `PURCHASE_ENTRY`-only role receives stock but the price change is ignored (needs `MASTER_EDIT`) | authz + `MASTER_EDIT` split |
| **H4** bound token for item A reattached to item B | audit falsely records the approver | **403 E-6108** (bound mismatch); same token used correctly on item A → 200 | token binds item/kind/before/after |
| **H5** `itemId:"DOES-NOT-EXIST"` | phantom bill + stock | **400 E-1105** | item verified vs (id, tenant) |
| **H6** `businessDate` `2050-07-10` / `not-a-date` | postdated bill / garbage series | **400 E-1106** | ISO + windowed validation |
| **H7** owner/supervisor self-authorised override | no audit row at all | **audited** from the session identity (`selfAuthorized:true`) | server auto-detects + audits every deviation |
| **M6** `/items?limit=-1` | whole table | **clamped** (≤ 1 row) | limit clamped `[1,MAX]`, offset ≥ 0 |
| **M7** wrong types | 500 + stack + install path | **400 `{error,code}`** generic, no stack | hand-rolled validators + 4-arg error handler |
| **M8** negative/mismatched payment legs, fractional qty | slipped through | **400** (leg > 0, allowlisted mode, sums to payable; unit-decimals enforced) | payment + qty guards |
| **M12** giant line count | unbounded | **≤ 500 lines** (400 over cap) | `assertArray` cap |
| **M14** `X-Powered-By: Express` banner | present | **removed** | `app.disable('x-powered-by')` |
Real-browser E2E (fresh DB, Chrome): Ramesh `4728` → normal Surf Excel bill `ST1C2/26-000004`
commits at ₹145; Ramesh F3 rate ₹145→₹140 → Suresh `8265`**bound-token** commit
`…000005`, audited `approvedBy=Suresh`; Thomas `9174` owner self-price-change → commit
`…000006`, audited `approvedBy=Thomas, selfAuthorized:true`. Tests 207 green; typecheck +
all three app builds clean.
## HIGH follow-up — cashier draft quick-add path CLOSED (2026-07-13)
The independent verifier found that the C1 remediation left one **HIGH** hole inside the
quick-add convenience it deliberately kept open (an unknown-barcode item must not stop the
queue): a cashier (`BILL_CREATE`, no `MASTER_EDIT`) could `POST /items` with `status:'draft'`,
choosing **an arbitrary `salePricePaise` AND an arbitrary `taxClassCode`**, then bill it.
Because `commitBill` anchors each line to the just-authored master, this **reproduced C2
(arbitrary price) and C3 (GST evasion — a taxable good self-added as `ZERO`, sold at ₹0 GST)
with no deviation/override audit row** — the only trace was `ITEM_CREATE`. Proven live: draft
"Redteam Whiskey" `ZERO` tax @ ₹2000 → billed with ₹0 GST.
**Remediated (2026-07-13)** — the quick-add convenience is kept, the fraud is removed:
| Vector | Fix | Evidence (re-fired live as cashier u1/4728, fresh DB `:5181`) |
|---|---|---|
| **C3 via draft** — self-add taxable good as `ZERO` | Server **forces** the tax class on a non-`MASTER_EDIT` caller's draft to the store default (`catalog.defaultTaxClass`, resolved system→tenant, default `GST18`); the client's `taxClassCode` is ignored. A `MASTER_EDIT` role (manager/owner) still sets it. | draft `ZERO`→server assigned **`GST18`**; billed the draft → **CGST+SGST = 30508 paise** (was ₹0) |
| **C2 via draft** — self-price an expensive unknown | A non-`MASTER_EDIT` draft's `salePricePaise` is **capped** (`catalog.draftMaxPricePaise`, default ₹5,000 = 500000); over-cap is denied with a clear message. A `MASTER_EDIT` role may exceed it. | draft @ ₹9,999 → **400 E-1108** "call a supervisor"; owner @ ₹9,999 → 200 |
| **Invisible draft sale** — self-authored item sold with no trail | Every bill line whose master row is still `status:'draft'` writes a **`DRAFT_SALE`** audit row (entity `bill`, `{itemId, name, pricePaise, docNo}`) **in the commit transaction**. Back office already shows draft status on Items. | selling the two drafts wrote **2 `DRAFT_SALE`** rows; an active-item sale writes none |
| **Cross-counter bill read** (residual LOW/MED) | `GET /bills` for a caller **without `REPORT_VIEW`** is pinned server-side to the session user id — a cashier sees only their **own** bills; a `REPORT_VIEW`/manager keeps the full view. | Ramesh → only `u1` rows; Divya → only `u4` rows; owner → all cashiers |
Regression re-check (same run): **C2** existing-item ₹275→₹1 no-approval → **403 E-1302**;
**C3** existing-item `ZERO`@master → committed **taxed at GST12** (CGST+SGST 2946 paise);
**C1** cashier `POST /items status:'active'`**403 E-6105**, cashier `GET /audit`**403**.
Legit quick-add still works end-to-end (unknown barcode → draft @ ₹30 → 2× bill `ST1C2/26-000004`
₹60). **220 tests green** (9 new: draft tax forced, over-cap E-1108, `DRAFT_SALE` audit,
cashier `/bills` scoped, owner exceptions); typecheck + all three app builds clean. Back office
must complete each draft's real HSN/tax class later (the forced default is a safe placeholder,
not a substitute for classification).
## P1 edge & transport hardening — CLOSED (2026-07-13)
The P1 "lock down the edges + TLS" workstream is **REMEDIATED**: **H1, M1, M2, M4, M13, M14**
are closed and **M15** is noted (closed once prod is HTTPS). Server changes stayed in
`server.ts` + `api.ts` plus three pure, unit-tested modules (`print-guard.ts`, `rate-limit.ts`,
`session-policy.ts`); client edits were limited to logout wiring + the POS post-login bootstrap
re-fetch. No new runtime dep except **helmet** (justified). All PoCs were re-fired live against
`:5181` (the HTTP dev instance, unchanged); the session-TTL and rate-limit PoCs ran against a
short-lived isolated instance (`:5182`, own data dir) so the dev DB and `:5181` stayed clean.
(M5 — tenant-scoping `commitBill`'s store/counter lookup — is owned by the money-logic
workstream and is NOT part of this pass.)
| # | Before | After (2026-07-13) | Evidence (live) |
|---|---|---|---|
| **H1** SSRF via `POST /api/print` | authed cashier opened a socket to ANY host:port + bytes (internal port-scan / egress) | destination constrained: **LAN-only IP** (127/8,10/8,172.16/12,192.168/16; link-local/public rejected), **port allowlist** {9100,9101,9102} (+`SIMS_PRINTER_PORTS`), **≤64 KB** payload; the host is resolved and every resolved IP checked, then we connect to the resolved IP (no DNS-rebind). Now an authenticated route inside `apiRouter` | authed → `8.8.8.8:80` **400 E-2202**, `8.8.8.8:9100` **400 E-2203** (no socket opened), `127.0.0.1:9100` **502 ECONNREFUSED** (guard passed → socket attempted); unauth → **401** |
| **M1** `/bootstrap` leaks roster roles + GSTIN pre-auth | the unauthenticated payload carried each user's **role** + the tenant **GSTIN** (targeting data) | the pre-auth payload **drops role + gstin** (keeps id+name for PIN login); the SAME endpoint restores both **when called WITH a session** — the POS re-fetches after login, back office is always authed | unauth `/api/bootstrap``store` has **no gstin**, users are `{id,name}` only (grep for `gstin`/`role`: none); authed → gstin `32ABCDE1234F1Z5` + roles present |
| **M2** verify-pin unauth PIN oracle / token mint | callable with NO session — a raw PIN-guessing + token-minting endpoint | now behind **requireAuth** (a logged-in caller) **and rate-limited**; the approver PIN still rides in the body | unauth `POST /auth/verify-pin`**401 E-6103**; authed cashier caller + Suresh/8265 → **200 + bound approvalId**; wrong approver PIN → still 401 |
| **M4** no session expiry / logout / revocation | a token was valid until the process restarted | sessions carry **created + last-seen**; `requireAuth` evicts past a **12 h absolute / 30 m idle** TTL (→ 401 E-6110); **`POST /api/auth/logout`** deletes the session (POS "Lock" + back-office "Logout" call it); **global + per-IP `/auth/*` rate limit** (20/min/IP, 200/min global, env-tunable) layered on the per-user lockout | logout: `/items` 200 → logout 200 → `/items` **401**; idle (1 s-TTL instance): after 1.4 s → **401 E-6110**; rate limit (cap 3): attempts 1-3 = 401, **4-5 = 429** |
| **M13** plaintext HTTP | bearer token rides the LAN in cleartext | **HTTPS available**: `SIMS_TLS_CERT`+`SIMS_TLS_KEY` set ⇒ `https.createServer`; unset ⇒ HTTP dev (unchanged). Dev-cert one-liner + "prod MUST supply certs" documented in `server.ts`. HSTS + `upgrade-insecure-requests` emitted **only** under TLS (so dev HTTP is never told to upgrade to a port that serves no HTTPS) | dev `:5181` stays HTTP (verification/browser flow intact); the TLS path is code-reviewed, not forced on dev |
| **M14** no security headers / `X-Powered-By` | no CSP, no nosniff/frame/referrer headers | **helmet** on with a CSP tuned for the SPAs (`'self'` scripts + `'unsafe-inline'` styles for React inline styles + `blob:`/`data:` for the invoice/label tabs + `data:` fonts; the blob tabs' only inline script — `window.print()` — is allowed by a single **`'unsafe-hashes'` sha256**, NOT by opening script-src to `'unsafe-inline'`); **`X-Frame-Options: DENY`** (helmet only ships SAMEORIGIN) + `frame-ancestors 'none'`; nosniff; `Referrer-Policy: no-referrer`; `x-powered-by` already gone | `curl -i /api/health` shows the full header set, no `X-Powered-By`, no HSTS on HTTP; **back office + POS both load in a real browser with ZERO CSP console violations**, and the `window.print()` inline handler is not blocked |
| **M15** SW cache-first over HTTP → MITM poisoning | THEORETICAL, needs LAN MITM | **noted** — closes automatically once prod serves HTTPS (M13); no code change needed for the dev slice | n/a (documentation) |
**Tests:** two new files — `edge-guards.test.ts` (pure: LAN/port/byte print validator,
sliding-window rate limiter, session-TTL check) and `edge.test.ts` (HTTP against a mounted
apiRouter: bootstrap minimize, verify-pin auth, TTL eviction, logout, rate-limit 429, print
allowlist). **245 tests green** (was 220), typecheck clean on root + all three apps, all three
apps build, store-server rebuilt + restarted on `:5181` (`/api/health` ok). Browser check: back
office login + POS login screens render with **no CSP violations**; the POS login tiles now show
**no role subtitle** (the pre-auth roster no longer carries roles — the expected M1 effect).
**Residue:** the live PoCs added one benign, unused `override_approval` row to the `:5181` dev DB
(a verify-pin mint); no bills/items/stock were written. No git commits.
## P2 data-integrity & crypto — CLOSED (2026-07-13)
The P2 "harden-before-cloud" data-integrity slice is **REMEDIATED**: **H2, M3, M5, M9, M10** are
closed (M11 client-PII and M14 helmet closed earlier). Only **H3** (encryption at rest) remains,
deliberately owned by a separate agent next. Changes were confined to `apps/store-server/src/{db.ts,
repos.ts, api.ts, server.ts}`, `packages/auth/src/pin.ts`, `packages/domain/src/doc-series.ts`, plus
`dev-start.mjs` + `.env.example`; the transport/helmet/TLS and `requireAuth`/rate-limit/session code
from the P1 pass was left intact (only `writeAudit` and the commit/series paths were touched).
| # | What changed | Evidence (live on the reset `:5181`, `SIMS_SEED_DEMO=1`) |
|---|---|---|
| **H2** audit hash chain | `audit_log` gained `prev_hash`+`entry_hash` (CREATE TABLE + PRAGMA-guarded migrate). `writeAudit` links each row into the tenant's chain **inside the existing commit transactions** (`entry_hash = sha256(canonical(prev_hash, tenant_id, at_wall, user_id, action, entity, entity_id, before_json, after_json))`, genesis `''`, insertion order = SQLite rowid under the single-writer model). `verifyAuditChain(db, tenant)` recomputes and returns `{ok, brokenAtId?}`; `GET /api/audit/verify` (AUDIT_VIEW) exposes it. **Tamper-EVIDENCE, not prevention** — the file is still writable (H3). | Owner (thomas/9174) `GET /api/audit/verify` after a real bill → **`{ok:true}`**; then a direct better-sqlite3 `UPDATE audit_log SET after_json=…` on one row → verify → **`{ok:false, brokenAtId:<that row>}`** (brokenAtId matched the tampered row exactly). |
| **M3** PIN pepper | `pin.ts` HMAC-SHA256s the secret with `SIMS_PIN_PEPPER` before scrypt in both `hashPin`/`verifyPin`; unset ⇒ raw-secret fallback (byte-identical to pre-M3) + one-time warning. `.env.example` documents it; enabling requires PIN re-enrolment. | Unit tests: round-trip with a pepper; a peppered hash **fails** to verify once the pepper is gone (a DB-only theft is uncrackable) and again with a **different** pepper (rotation invalidates); unset path still verifies. Dev logins on `:5181` unchanged (pepper unset). |
| **M5** tenant-scope commit | store AND counter selected `WHERE id=? AND tenant_id=?`; unknown-for-tenant → **400 E-1110** before any money logic (kills the undefined-deref 500). Both `/bills` and `/bills/offline` covered. | `POST /api/bills` with `storeId:"no-such-store"`**`400 {"code":"E-1110"}`** (not 500). Unit: a store/counter belonging to another tenant is rejected; honest same-tenant commit still bills. |
| **M9** 4-digit FY | `seriesPrefix` embeds the 4-digit FY start year; the counter↔FY `/` is dropped so the doc no fits the GST 16-char rule at 6-digit sequences (`ST1C22026-000123`). Old `ST1C2/26-…` collided a century apart. | Fresh bill on `:5181` → doc no **`ST1C22026-000001`** (length **16**, matches `^ST1C22026-\d{6}$`). Unit: `seriesPrefix('ST1','C2','2026-27')`≠`…'2126-27'`, both ≤16 at seq 999999. |
| **M10** demo-seed gate | `seedIfEmpty`/`ensureBatchTrackingSeed` run only when `SIMS_SEED_DEMO=1`; dev `npm start``dev-start.mjs` sets it. | Isolated instance, flag **unset** → healthy but **0 tenants**, thomas login **401**. `:5181` with the flag set → seeds MegaMart + 4 users as before. |
**Tests:** **260 green** (was 245): new `audit-chain.test.ts` (5: chain continuity + UPDATE/DELETE/INSERT
detection + per-tenant scope), `tenant-scope.test.ts` (4: nonexistent/cross-tenant store/counter reject,
honest commit), a `/audit/verify` HTTP case in `edge.test.ts`, 4 PIN-pepper cases in `auth.test.ts`, and
a 4-digit-FY collision + 16-char case in `domain.test.ts`. Typecheck clean on root + all three apps; all
three apps build; the dev DB was reset and the store-server restarted on `:5181` with `SIMS_SEED_DEMO=1`
(`/api/health` ok, seeded, `audit/verify` ok on the clean chain). **H3 (encryption at rest) is the only
open P2 item.** No git commits.
## H3 — encryption at rest — CLOSED (2026-07-13)
The last open finding is **REMEDIATED**: the store DB is now **full-DB encrypted at rest with
SQLCipher**, which closes H3, the **F5 at-rest concern**, and the **M11 residual** (the offline
queue's lines/amounts sitting in plaintext until drained — the server tier they drain into is now
encrypted). `better-sqlite3` was swapped for the API-compatible drop-in
**`better-sqlite3-multiple-ciphers`** (same API + `pragma('key=…')`; it ships its own
DefinitelyTyped-derived declarations, so `DB` and every call type unchanged — **no shim, no loosened
strictness**). The change was confined to `apps/store-server/src/db.ts`, `package.json`,
`build-server.mjs` and `.env.example`; none of the earlier hardening (pepper, hash chain,
tenant-scope, seed gate, TLS/helmet, print allowlist) was touched.
**Path taken: full-DB encryption.** The native package installed from a **prebuilt binary** (no
node-gyp build) on this Windows / Node 22 box, so the app-level field-encryption fallback was **not**
needed.
| Vector | Fix | Evidence (live on the reset, keyed `:5181`) |
|---|---|---|
| **Whole file readable if copied** — a stolen/snooped `sims.db` (+ WAL) yielded phones/GSTINs (DPDP), costs & margins, full sales history, party ledgers, PIN salts/hashes | In `openDb()`, when **`SIMS_DB_KEY`** is set the DB is keyed (`cipher='sqlcipher'`, `key=…`) **before any read**, so every page — main db **and** the WAL/SHM sidecars — is ciphertext. The key lives ONLY in the server env (a machine-scoped DPAPI/keychain secret), never in the repo or the DB; single quotes in it are escaped so an arbitrary passphrase is a safe SQL literal. | `head -c 16 data/sims.db``28 85 83 94 c9 b5 4f 93 …` (random), **not** `53 51 4c 69 …` ("SQLite format 3\0"). Grep of `sims.db` / `-wal` / `-shm` for `MegaMart`, `Lakshmi`, the phone `9840012345`, and the tenant GSTIN `32ABCDE1234F1Z5`**none found** in cleartext. |
| **A stolen copy must be unreadable without the key** | Wrong/absent key fails closed | Opening the live `data/sims.db` **without** the key → `file is not a database`; with a **wrong** key → `file is not a database`; with the **correct** key → opens (tenant count 1, parties `["Lakshmi","Joseph",…]` decrypt). |
| **The app must still fully work through the cipher** | The keyed DB is transparent to the app | Cashier Ramesh (u1/4728) PIN login **200**; `GET /items?all=1` **200** (11 items decrypt); `POST /bills` **200 → `ST1C22026-000001`** (payable ₹5.00); owner Thomas (u3/9174) `GET /audit/verify`**`{ok:true}`** on a chain written through the encrypted DB. `/api/health` ok. |
| **Dev fallback unchanged** | Key UNSET ⇒ open unencrypted exactly as before + a one-time warning that prod MUST set it (mirrors the `SIMS_PIN_PEPPER` pattern) | Unit test: key unset ⇒ file **is** plaintext (`SQLite format 3\0`) and the phone **is** present in cleartext — a positive control proving the byte-scan genuinely detects plaintext. The one-time boot warning fires only when unset; with the dev key set on `:5181` it does not. |
**Tests:** **265 green** (was 260; +5 in new `encryption.test.ts`: a keyed file has no SQLite magic +
no cleartext PII across db/WAL, the keyed data round-trips, opening without / with the wrong key throws,
and the plaintext-fallback positive control). Typecheck clean on root + store-server; all three apps
build (esbuild externals the ciphers native module; vite unaffected). The dev DB was reset (`data/`
deleted — it held red-team junk + a deliberately-tampered audit row) and the store-server restarted on
`:5181` **with a dev `SIMS_DB_KEY`**, so encryption is live and proven end-to-end while the `:5181` flow
still works.
**A production deploy still MUST:** (1) supply `SIMS_DB_KEY` as a **machine-scoped secret** (Windows
DPAPI or an OS keychain) — never a plaintext file beside the DB, never in the repo; (2) **migrate any
existing plaintext `sims.db` once, offline** (`ATTACH … KEY …; SELECT sqlcipher_export('enc'); DETACH`
— documented in `openDb()` and `.env.example`) before the first encrypted boot; a fresh/empty DB is
created encrypted automatically; (3) treat key **rotation** as a re-key (`PRAGMA rekey`) or an
export/reimport, and back up the key **with** the DB (an unrecoverable key = an unrecoverable DB); (4)
still tighten the data-dir ACLs as defence-in-depth. No git commits.
## Clean bill of health (verified defended — do not "fix")
| Area | Verdict |
|---|---|
| SQL injection | None — every query is a bound better-sqlite3 prepared statement; `DROP TABLE` payloads inert |
| XSS / HTML injection | None — every invoice/label/receipt sink escapes; React auto-escaping intact; zero `dangerouslySetInnerHTML` |
| Dependency supply chain | `npm audit` 0 vulnerabilities across 542 deps; lockfile committed |
| CSRF / CORS | Not vulnerable — bearer-token-in-header design (not cookies) defends both |
| doc-series under load | Crash-safe & gap-free by transaction design (genuine strength, S7-proven) |
| Approval token entropy/TTL/single-use | Correct — 5-min TTL + atomic single-use enforced server-side |
| Engine numeric guards | Rejects negative/zero/NaN qty, negative price, >100% discount, payment mismatch, >1 MB body |
## Findings — ranked
### CRITICAL — exploitable now by any cashier session
| # | Finding | Evidence | Fix |
|---|---------|----------|-----|
| C1 ✅ **REMEDIATED** | **No server-side authorization.** RBAC (`permissions.ts` `can()`/`gateFor()`) is called only in client code; `api.ts` never imports it. Every route past `requireAuth` checks only that a token exists. | Cashier token created items, read full `/audit`, dumped supplier costs, listed all counters' bills — all 200. `api.ts:138` is the lone gate. | Per-route authorization middleware mapping route→`ActionCode`, `can(session, action)` server-side, deny by default. |
| C2 ✅ **REMEDIATED** | **Bill line prices trusted from client; the override/discount gate is client-only and `curl`-bypassable with no audit.** `commitBill` recomputes from `input.lines` and never reads `item.sale_price_paise`/`mrp`. | Cashier sold a ₹270 item at ₹1 — committed, no override, no PIN, no audit row (`repos.ts:223`, `compute.ts:41-61`). | Load each line's master price; reject any deviation unless a bound override token is redeemed; hard-block price > MRP. |
| C3 ✅ **REMEDIATED** | **Client-chosen tax class → GST evasion.** `taxClassCode` comes from the request; `resolveTaxClass` trusts it. | A 12% item sent as `ZERO` committed with ₹0 GST (`compute.ts:81`). Scales to the whole ledger. | Resolve tax class from the item master, never the request. |
| C4 ✅ **REMEDIATED** | **Purchase posting rewrites master price/MRP with no role check.** | Cashier posted a purchase with `newSalePricePaise:99999`; the item master changed (`api.ts` purchase route → `repos-purchase.ts:99-106`). | Require `PURCHASE_ENTRY`; separate "receive stock" from "change master price". |
### HIGH
| # | Finding | Evidence | Fix |
|---|---------|----------|-----|
| H1 ✅ **REMEDIATED** (2026-07-13) | **Authenticated SSRF via `POST /api/print`** — arbitrary host:port + bytes; clean internal port-scan oracle. (Correctly auth-gated: unauth → 401.) | Cashier token opened sockets to chosen host:port; closed→502, open→504 (`server.ts:37-58`). | **DONE**: LAN-only IP + port allowlist ({9100,9101,9102}, `SIMS_PRINTER_PORTS`) + ≤64 KB cap; host resolved and each resolved IP LAN-checked, connect to the resolved IP (no rebind). `print-guard.ts` + the `/print` route in `api.ts`. See the P1 section. |
| H2 ✅ **REMEDIATED** (2026-07-13) | **Audit log & "immutable" bills are tamper-evident by convention only** — no hash chain, no signing; plain SQLite file. | Any file-level `UPDATE`/`DELETE` rewrites bills, deletes override evidence, forges approvals (`repos.ts:431-443`, schema). Undercuts the GST/MCA audit-trail claim. | **DONE**: per-tenant hash chain on `audit_log` (`prev_hash`/`entry_hash`; `entry_hash = sha256(canonical(prev_hash, tenant_id, at_wall, user_id, action, entity, entity_id, before_json, after_json))`), written in-transaction by `writeAudit`; `verifyAuditChain` + `GET /api/audit/verify` (AUDIT_VIEW) recompute it. Any out-of-band UPDATE/DELETE/INSERT breaks the chain visibly. Tamper-EVIDENCE, not prevention — sealing the writable file is H3. See the P2 section. |
| H3 ✅ **REMEDIATED** (2026-07-13) | **No encryption at rest** — PIN hashes, phone numbers (DPDP), GSTINs, costs, full sales history in a plain `sims.db` (+ WAL). | A copied file yields the whole business + every staff PIN hash (`db.ts` `new Database(file)`). | **DONE**: full-DB **SQLCipher** via the drop-in `better-sqlite3-multiple-ciphers`; `openDb()` keys the DB from **`SIMS_DB_KEY`** (env-only, machine-scoped) before any read, so `sims.db` + WAL/SHM are ciphertext; key unset ⇒ plaintext dev fallback + one-time warning. Proven live: header not `SQLite format 3`, no cleartext PII in db/WAL, open-without-key fails, app (login+bill+audit/verify) works with the key. Prod must supply the key via DPAPI/keychain + migrate an existing plaintext DB once (`sqlcipher_export`). See the H3 section. |
| H4 ✅ **REMEDIATED** | **Approval token not bound to the override it authorizes** — redirectable. Token stores only approver+time+used, not item/kind/amount/cashier. | A real "Milk ₹50→45" approval was reattached to "Whiskey ₹2000→200"; audit falsely records the supervisor (`repos.ts:184-190, 303-337`). Audited before/after are client-supplied too. | Bind item/kind/before/after (+cashier) into the token at mint; re-verify at redeem. |
| H5 ✅ **REMEDIATED** | **Item never validated → phantom / cross-tenant lines.** No `SELECT item`, no FK on `stock_movement.item_id`. | `itemId:"DOES-NOT-EXIST"` committed a bill + phantom stock movement (`repos.ts:192-343`). | Verify `itemId` belongs to tenant+store; add FK. |
| H6 ✅ **REMEDIATED** | **Unvalidated `businessDate` → antedated/postdated GST invoices + garbage series.** | `2050-07-10` postdated a bill; `"not-a-date"` produced doc_no `ST1C2/N--000001` (`repos.ts:239`). | Validate ISO date, reject NaN FY, bound to the open business day. |
| H7 ✅ **REMEDIATED** | **Self-authorized overrides (owner/supervisor at POS) are never audited** end-to-end. | `gateFor==='allow'` applies the edit with no override entry, so nothing reaches the server or the log (`BillingScreen.tsx:431`). The least-supervised roles leave no trail. | Emit an audited (server-recomputed) override for every price/discount change, including self-authorized. |
### MEDIUM
| # | Finding | Fix |
|---|---------|-----|
| M1 ✅ **REMEDIATED** (2026-07-13) | Unauthenticated `/api/bootstrap` leaks the full user roster (ids, names, roles, who has a PIN) + store GSTIN → targeted PIN attack. | **DONE**: pre-auth payload drops **role + gstin** (keeps id+name for PIN login); both restored on the SAME endpoint when called WITH a session (POS re-fetches post-login). See the P1 section. |
| M2 ✅ **REMEDIATED** (2026-07-13) | `/auth/verify-pin` is an **unauthenticated PIN oracle** that also mints approval tokens and writes DB rows on each call. | **DONE**: now behind `requireAuth` (a logged-in caller) + the `/auth/*` rate limiter; approver PIN still in the body. |
| M3 ✅ **REMEDIATED** (2026-07-13, pepper) | **Weak PIN throttle** — in-memory, per-user, no global/IP limit, resets on restart; 4-digit space; scrypt cost immaterial vs the tiny keyspace; no server-side pepper. | **DONE (pepper)**: `pin.ts` HMAC-SHA256s the secret with a server-held pepper (`SIMS_PIN_PEPPER`) BEFORE scrypt, in both `hashPin`/`verifyPin`, so a stolen DB can't be brute-forced against the 10k PIN keyspace without the env-only pepper. Unset ⇒ raw-secret fallback (dev unchanged) + one-time warning. `.env.example` added; enabling requires re-enrolling PINs (dev re-seed does it). The IP/global `/auth/*` limiter already landed in M2/M4; longer approver PINs deferred (policy). See the P2 section. |
| M4 ✅ **REMEDIATED** (2026-07-13) | **No session expiry, logout, or revocation** — a leaked token is valid until the process restarts; a fired employee can't be cut off. | **DONE**: 12 h absolute + 30 m idle TTL evicted in `requireAuth` (→ 401 E-6110); `POST /api/auth/logout` deletes the session (POS Lock + BO Logout wired); global + per-IP `/auth/*` rate limit (`rate-limit.ts`, `session-policy.ts`). |
| M5 ✅ **REMEDIATED** (2026-07-13) | **`commitBill` store/counter lookup not tenant-scoped** → cross-tenant reference (latent, single-tenant dev DB) + a bad `storeId` 500s the handler. | **DONE**: `commitBill` selects store AND counter `WHERE id=? AND tenant_id=?` and throws **400 E-1110** ("Unknown store or counter for this tenant") BEFORE any money logic — killing the old undefined-deref 500 and any cross-tenant reference. Both `/bills` and `/bills/offline` funnel through it. (E-1110, not the task-suggested E-1109, because E-1109 is already the payments-≠-payable code.) See the P2 section. |
| M6 ✅ | **Pagination bypass**`limit=-1` / huge / NaN returns whole tables → unbounded stall/OOM on `/items`,`/bills`,`/audit`. | Clamp limit to `[1,MAX]`, offset ≥ 0, reject NaN. |
| M7 ✅ | **No request schema validation**`as` casts; wrong types 500 with **full stack traces + absolute install paths**. | zod/valibot per route + a generic error handler that never leaks the stack; `NODE_ENV=production`. |
| M8 ✅ | Numeric edges slip through: fractional qty on integer units, qty > 2^53 (precision loss / ledger corruption), negative payment legs when the sum still matches. | Enforce unit-decimals; clamp qty/total < 2^53; require every payment leg > 0 with an allowlisted mode. |
| M9 ✅ **REMEDIATED** (2026-07-13) | **doc-series `fyShort` 2-digit truncation** → FYs 100 years apart collide, forcing `UNIQUE` violations that can block billing on a prefix. | **DONE**: `seriesPrefix` now embeds the full **4-digit FY start year** ("2026" vs "2126" no longer collide). To keep the GST 16-char rule at 6-digit sequences the counter↔FY `/` is dropped (the sequence `-` stays): `ST1C22026-000123` (16 chars). doc_no format changes → dev reseed handles it; prod starts the new format at cutover. Caveat: store+counter codes must total ≤ 5 chars (the same tightness the old format had at C10). See the P2 section. |
| M10 ✅ **REMEDIATED** (2026-07-13) | **Hardcoded demo owner credentials** (`thomas`/PIN 9174/pw `sims`) auto-seed on an empty DB. | **DONE**: `seedIfEmpty`/`ensureBatchTrackingSeed` run ONLY when `SIMS_SEED_DEMO=1`; a prod empty DB never auto-creates the known-credential owner (stays empty for onboarding). Dev keeps seeding via `npm start``dev-start.mjs` (sets the flag). Proven live: flag unset ⇒ 0 tenants, thomas login 401. See the P2 section. |
| M11 ✅ **REMEDIATED** (client PII) | **Unencrypted customer PII in the offline IndexedDB** (names, GSTINs, line items) on shared counter PCs. | POS client fixed: the queued bill now persists **only `customerId`** — no `customerName`/`buyerGstin` — and the server re-resolves the buyer's name/GSTIN/place-of-supply from that id at drain, exactly as for an online bill (`repos.ts:296-298`). Catalog + staff-roster cache is wiped on Lock/logout (`clearCatalogCache`), while un-drained queued bills are preserved. Verified in a real browser IndexedDB (queued record carries `customerId` only; injected PII dropped; cache cleared on lock; queue survived). Files: `apps/pos/src/offline.ts`, `App.tsx` `onLock`, `BillingScreen.tsx` `queueOffline`; test `apps/pos/test/offline.test.ts`. **Residual (now closed):** the queued lines/amounts sat in a plain IndexedDB until drained; the server tier they drain into is now SQLCipher-encrypted at rest (H3, closed 2026-07-13), so the standing at-rest exposure is removed end-to-end. |
| M12 ✅ (line cap) | Single-threaded synchronous server: one 5,200-line bill blocked `/api/health` 155 ms; no per-bill line cap. | Cap `lines.length`; clamp result sizes (M6). |
| M13 ✅ **REMEDIATED** (2026-07-13) | **Plaintext HTTP** — bearer token rides the LAN cleartext; a sniffer unlocks H1 + all authed reads. *The single highest-leverage fix.* | **DONE (available, not forced on dev)**: `SIMS_TLS_CERT`+`SIMS_TLS_KEY` ⇒ HTTPS (`https.createServer`); unset ⇒ HTTP dev. Dev-cert one-liner + "prod MUST supply certs" in `server.ts`. Prod deploy must set the certs. |
| M14 ✅ **REMEDIATED** (2026-07-13) | No `helmet` / security headers; `X-Powered-By: Express` banner. | **DONE**: `helmet` on (SPA-compatible CSP; nosniff; `Referrer-Policy: no-referrer`) + explicit `X-Frame-Options: DENY` + `frame-ancestors 'none'`; `x-powered-by` already disabled (P0). Both apps load with zero CSP violations. |
| M15 ✅ **NOTED** (2026-07-13) | Service-worker cache-first over HTTP → MITM-injected bundle cached persistently (THEORETICAL; needs LAN MITM). | Closed once prod is HTTPS (M13). No dev-slice code change. |
### LOW
- Bearer tokens + purchase-entry draft in web storage (readable by same-origin JS; not exploitable today since XSS is refuted). Move session to an `httpOnly` cookie eventually.
## Root cause & remediation shape
Almost every Critical/High traces to **one architectural fact**: the store-server was
built as a trusted-client data layer (correct while the only client was our own verified
React app in the browser) but it exposes a REST API that trusts the client for **identity,
business data, and input shape**. The fix is one coherent theme, not 30 scattered patches:
**Move the trust boundary into the server.**
| Priority | Workstream | Closes |
|---|---|---|
| P0 ✅ **DONE** | **Anchor every bill line to the item master** (price, tax class, MRP, existence, unit) inside `commitBill`; a deviation requires a redeemed, *bound* override token | C2, C3, H4, H5, H7, M8 |
| P0 ✅ **DONE** | **Server-side authorization**: per-route `ActionCode` middleware, deny-by-default | C1, C4 |
| P0 ✅ **DONE** | **Input validation layer** (hand-rolled guards, no new deps) + generic error handler; clamp limit/offset; validate businessDate; cap lines | H6, M6, M7, M8, M12 |
| P1 ✅ **DONE** | Lock down the edges: `/api/print` allowlist, `/bootstrap` minimize, `/auth/verify-pin` behind auth + rate limit, session TTL/logout, tenant-scope store/counter (M5 closed with the P2 pass) | H1, M1, M2, M4, M5 |
| P1 ✅ **DONE** | **TLS** on the store LAN (available via env; prod must supply certs) | M13, M15, amplifies all |
| P2 ✅ **DONE** (except H3) | Harden-before-cloud (D14): PIN pepper, audit hash chain, demo-seed gate, 4-digit-FY series, tenant-scope commit — plus offline-cache PII (M11) + helmet (M14) already closed | H2, M3, M5, M9, M10, M11, M14 |
| P2 ✅ **DONE** (2026-07-13) | **Encryption at rest** — full-DB SQLCipher via `better-sqlite3-multiple-ciphers`, keyed from a machine-scoped `SIMS_DB_KEY`; prod must supply the key (DPAPI/keychain) + tighten data-dir ACLs | H3 |
## Reviewer note on test artifacts
The PoCs left benign junk in the **dev** DB (test bills `000017``000023`, two garbage-FY
bills, a corrupted Carry Bag stock balance, a self-clearing u4 lockout). Reset the dev DB
(delete `apps/store-server/data/`, restart) before any demo. No source was modified.