Secure Coding Standard
Document control
| Field | Value |
|---|---|
| Owner | Ron Benisty — CTO & CSO (ron.b@iontwrks.com) |
| Status | Approved |
| Version | 1.0 |
| Classification | Confidential |
| Approved by | Ron Benisty — CTO & CSO |
| Approval date | 2026-05-26 (Tuesday) |
| Last reviewed | 2026-05-26 |
| Review cycle | Annual — next review due May 2027 |
| Parent policy | Secure SDLC (SSDLC) |
Satisfies ISO/IEC 27001:2022 A.8.28 (secure coding) and NIST SSDF PW.5 (secure-coding practices). Closes Gap 5 of the SSDLC gap analysis.
1. Purpose
State the mandatory secure-coding rules for every developer (S.E.T-team and the contracted dev firm) writing code for S.E.T. Removes the "scanners are not a standard" gap: SAST and lint enforce some of these rules; this document is the written standard an ISO auditor samples against.
2. Scope
All code in the S.E.T monorepo:
- TypeScript — frontend (React SPA) and backend (NestJS BFF + workers); see Tech Stack.
- SQL / Drizzle ORM schemas + migrations — see Tech Stack §6.
- Terraform (HCL) — see Architecture §IaC.
- GitHub Actions workflows — see Supply Chain & CI/CD Security.
- GraphQL schema + REST OpenAPI — see Architecture §API Strategy.
3. Always-on rules
3.1 Input validation at the BFF boundary
- Every external input (HTTP body, query, params, GraphQL args, webhook payload) must be validated before any business logic runs.
- Validation lives at the NestJS BFF boundary (Architecture §BFF); the frontend SPA may add UX validation but never as the sole gate.
- Validation library: Zod — see Tech Stack §3. Schemas are defined once and shared across the React frontend, NestJS BFF, BullMQ workers, and webhook handlers.
3.2 Tenant context
- Every DB query goes through Drizzle with the tenant
search_pathset by the Tenant Context Middleware (Multi-Tenancy §Data Isolation). - Bypassing the middleware (e.g. a raw pool client without setting
search_path) is forbidden and is a CI-blocking lint rule. - Connections returned to the pool must reset
search_pathto a default; the connection-pool wrapper enforces this.
3.3 Output encoding
- React's default JSX escaping is mandatory;
dangerouslySetInnerHTMLis banned without explicit CTO & CSO approval recorded on the PR. - Server-rendered HTML (rare — we are SPA-first) must use the same sanitization library as the client. Library: DOMPurify (browser-side + Node) — used on the rare
dangerouslySetInnerHTMLexception path and any server-rendered HTML. - JSON responses use NestJS's serialization; never string-concatenate user-controlled data into JSON.
3.4 SQL / Drizzle
- Use Drizzle's typed builder for every query. The
sqltemplate tag is permitted for window functions,WITH RECURSIVE, and lateral joins (Tech Stack §6) only when interpolated values use the safe${value}form — never string concatenation. - All migrations come from
drizzle-kit; no hand-rolledALTER TABLEoutside the migration runner.
3.5 AuthN / AuthZ
- Every NestJS controller / GraphQL resolver protected by the JWT guard (Authentication).
- Authorization checks are explicit per route (decorator-driven RBAC); there is no "open by default" route.
- JWT verification is via the WorkOS JWKS only — no shared-secret HS256, ever (see Cryptography Standard §5).
3.6 Crypto
- Follow the Cryptography Standard for every algorithm choice and library call.
3.7 Secrets & configuration
- Secrets live in AWS Secrets Manager (Architecture §Secrets); read at runtime, never logged, never embedded.
- Non-secret config lives in AWS SSM Parameter Store.
.envfiles are local-only; never committed (Gitleaks + Push Protection block).
3.8 Logging
- Use the project logger (structured JSON); never
console.login committed code. - Redact passwords, raw evidence content, secret values per the redaction rule in Observability §Tier 1.
- Include a correlation ID with every log line.
3.9 Error handling
- Never return raw stack traces, DB errors, or framework-internal messages to clients — return a stable error code + a correlation ID; the operator finds the detail in Logz.io.
- Catch + classify is mandatory at every external boundary (HTTP, queue, webhook).
3.10 Banned APIs
eval,new Function(…)for any non-trivial purpose.child_process.execandchild_process.spawnwithshell: trueon any user-influenced input.Math.random()for security-relevant purposes (see Cryptography Standard §7).dangerouslySetInnerHTMLwithout sanitization + CTO & CSO approval.document.writeand direct innerHTML manipulation in committed code.process.envaccess scattered through the codebase — config goes through a typed accessor.
3.11 Dependencies
- Add dependencies via
npmtopackage.json+package-lock.jsononly. - No
postinstallscripts execute in CI (npm ci --ignore-scriptsis mandatory — Supply Chain). - Major-version upgrades require a CODEOWNER review on the PR (Dependabot patch / minor auto-PRs are routine).
3.12 GitHub Actions
- All third-party Actions pinned by 40-char commit SHA (pinact enforces).
- Least-privilege
permissions:per workflow. - Job separation — untrusted code never shares a job with credentials (per Supply Chain §Core principles).
3.13 ESLint configuration
The eslint.config.js at the repo root must include all eight mandatory plugins:
| Plugin | Role |
|---|---|
@typescript-eslint/recommended | TypeScript bug patterns |
@typescript-eslint/strict | Stricter TS rules; pairs with §4 full-strict tsconfig |
eslint-plugin-security | Node.js security antipatterns; catches some §3.10 banned APIs at lint time |
eslint-plugin-no-secrets | High-entropy strings — accidentally-committed credentials |
eslint-plugin-import | Import hygiene; no-cycle; no-relative-parent-imports |
eslint-plugin-promise | Async / promise correctness (NestJS + BullMQ are async-heavy) |
eslint-plugin-sonarjs | Cognitive complexity, duplication, identical branches, dead stores |
eslint-plugin-unicorn | Anti-patterns + modern JS idioms |
No plugin may be removed without a CTO & CSO-approved PR.
Tuning notes — applied at the first config commit and documented in eslint.config.js:
sonarjs/cognitive-complexity: threshold raised from the default 15 → 20. The default flags wide switch-on-finding-types cases that are intentionally that wide; 20 still catches genuinely tangled functions.sonarjs/prefer-immediate-return: disabled. Frequent false positive on intentionalconst result = …; return result;patterns kept for debuggability.sonarjs/no-small-switch: disabled. Explicit 2-case switches read more clearly than ternaries.unicorn/prevent-abbreviations: configured with a project-specific allowed-abbreviation list (db,ctx,req,res,dto,id,fn).
Any further deviation requires a CODEOWNER-approved PR with the rationale recorded inline in the config.
3.14 GraphQL hardening rules
Every GraphQL endpoint (the internal Frontend↔BFF API per Architecture §API Strategy) must enforce the following at server-start, validated by an integration test in CI:
| Rule | Value | Why |
|---|---|---|
| Maximum query depth | ≤ 10 | Prevents nested-query DoS ({ a { b { c { d { … } } } } }). Enforced via graphql-depth-limit or equivalent. |
| Maximum query complexity | ≤ 1 000 (configurable) | Prevents wide-and-deep query DoS — every field has a cost; the total cost per query is capped. Enforced via graphql-validation-complexity or graphql-query-complexity. |
| Pagination | Relay-style cursor pagination; max 100 per page | Matches Architecture §API Strategy. Prevents "give me everything" requests. |
| Introspection in production | Disabled | Hides the schema from attacker reconnaissance. introspection: process.env.NODE_ENV !== 'production' or equivalent. |
| Field-level authorization | Required per resolver | Authorization is checked at the resolver, not just the controller/guard. A user who can access the root query may still not be allowed to read a specific field. |
| Error messages to clients | Generic — no stack traces, no DB errors, no internal IDs | Matches §3.9. Full detail goes to Logz.io with a correlation ID. |
| Mutation audit logging | Every mutation emits an audit_events row with actor, action, target entity, tenant context | Matches Observability §Tier 1. No mutation may bypass the audit interceptor. |
These rules apply to the internal GraphQL API (admin + customer portal data fetches). The public REST API has parallel rules — rate limit per key, per-endpoint scopes, audit per mutation — already documented in Architecture §API Strategy and not restated here.
3.15 Test coverage floors
Automated test coverage is enforced per package. CI rejects any PR whose package coverage drops below the threshold for its tier. The tiering reflects risk: a cross-tenant bug in auth/ is catastrophic, an uncovered branch in a styled button is not.
| Path / package | Lines | Branches | Why |
|---|---|---|---|
packages/api/src/auth/**, packages/api/src/tenant/**, packages/api/src/crypto/** | ≥ 95% | ≥ 95% | Security-sensitive paths — any uncovered branch is a potential cross-tenant bug or auth bypass |
packages/api/** (controllers, services, workers — everything else) | ≥ 80% | ≥ 75% | Industry default for backend business logic |
packages/frontend/src/** (excluding *.tsx UI components) | ≥ 70% | ≥ 65% | Frontend logic — hooks, utilities, state stores. Lower because integration tests cover more |
packages/frontend/**/*.tsx (UI components) | No threshold | — | Visual components are better covered by Playwright integration tests than by jsdom units |
packages/shared/** (Zod schemas + cross-stack types) | ≥ 90% | — | The cross-stack contract — high coverage protects the whole system |
| Generated code (GraphQL types, OpenAPI clients) | Excluded | — | Generated; no value in covering |
Tools (locked):
- Vitest — unit + integration test runner (frontend + backend). Coverage via
@vitest/coverage-v8. Already the natural choice given our React + Vite + TypeScript stack. - Playwright — end-to-end browser tests for UI flows. Does not count toward coverage thresholds (the table above is unit + integration only) but must exist for the critical journeys: login, add vendor, fill questionnaire, generate report, run AI analysis.
Exemption process: lowering a threshold or excluding a path requires a CTO & CSO-approved PR with a documented rationale. Suppressions live in the test runner config with a linked issue tracking the exemption window (≤ 30 days unless re-approved).
Coverage gaming guard: a coverage threshold is a smoke alarm, not a quality measure. Code review still rejects PRs whose tests assert nothing (expect(fn).toHaveBeenCalled() without verifying what was called). CODEOWNERS named on the high-tier paths are responsible for catching this.
4. TypeScript discipline
The tsconfig.json (root + each workspace) must enable full strict mode:
{
"compilerOptions": {
"strict": true, // master switch — turns on 7 sub-rules
"noImplicitOverride": true, // method overrides must be explicit
"noUncheckedIndexedAccess": true, // arr[i] is T | undefined, not T
"exactOptionalPropertyTypes": true // { x?: T } cannot accept { x: undefined }
}
}
Rationale: these settings catch bug classes the test suite rarely catches — silent undefined from index lookups, accidental base-class override mismatches, and undefined-vs-missing-property confusion. Greenfield code pays no migration cost; retrofitting these flags onto existing code is expensive, so we turn them on day one.
Downgrading any of these flags requires a CODEOWNER-approved PR with a documented justification — they are part of this standard, not just a developer preference.
5. Frontend specifics (React SPA)
- Components default to functional + hooks; no class components in new code.
- Server state: TanStack Query — all backend reads go through it; no raw
fetchin component code. See Tech Stack §4.gcTime: 0(instant eviction) for queries returning sensitive data: findings, vendor data, audit logs, evidence content, customer PII, anything tenant-scoped beyond IDs.- Default
gcTime(5 min) acceptable for non-sensitive queries: framework lists, UI config, lookups. queryClient.clear()is invoked on logout, token-refresh failure, and tenant switch — guaranteed full cache wipe.- No persister installed. Disk persistence (
localStorage,IndexedDB, service-worker cache) is forbidden.
- Local UI state: Zustand — small per-feature stores; no providers, no reducers.
- Form input → Zod schema (matching §3.1) before any submit. React Hook Form + Zod resolver.
- Routing: React Router; no
window.locationmutation for in-app nav.
6. Backend specifics (NestJS BFF + workers)
- Module-per-feature; one controller + one service per module by default.
- Guards for auth/tenant; Interceptors for logging/redaction; Pipes for validation.
- Workers (
api-workers/scanner-workers) consume BullMQ; every job idempotent + dead-letter on failure (Architecture §Async).
7. Terraform / IaC
- One module per layer (Architecture §IaC).
- Per-environment tfvars; no hard-coded resource IDs across environments.
- State in shared-services S3 with DynamoDB locking — no local state.
terraform planmust run clean in CI beforeterraform apply; apply runs only via OIDC deploy role.
8. Code review (in addition to Separation of Duties)
- Every PR: at least one CODEOWNER approval; author ≠ approver.
- Heightened review for changes touching: auth (Authentication), tenant middleware (Multi-Tenancy), crypto (Cryptography Standard), CI workflows (
.github/workflows/**), Terraform (infra/**), schema migrations. CODEOWNERS names the CTO & CSO as required approver for these paths (Supply Chain §3).
9. Enforcement — which rules are tool-enforced vs. review-enforced
| Rule | Enforcement |
|---|---|
| §3.1 input validation present | Lint rule + review |
| §3.2 tenant-context bypass | Custom lint rule (CI-blocking) |
| §3.4 raw SQL string concat | Semgrep rule |
| §3.10 banned APIs | ESLint + Semgrep |
§3.11 npm ci --ignore-scripts | CI step |
| §3.12 SHA-pinned actions | pinact CI check |
| §3.7 no secrets in code | Push Protection + Gitleaks + TruffleHog |
| Everything else | CODEOWNER review |
10. Developer onboarding & training
Every developer (S.E.T-team member and contractor) must acknowledge this Secure Coding Standard before submitting their first pull request and on each subsequent annual review. The acknowledgement is a signed record (email, PDF, or in-tool) stating: "I have read the Secure Coding Standard v{X.Y} and will follow it in all contributions to S.E.T."
The CTO & CSO files the acknowledgement under "Developer security training" in the Evidence Register; records are retained ≥ 24 months. Re-acknowledgement is required:
- On every annual review.
- On every material revision of this standard (a major-version bump or a breaking-change minor bump).
- As part of the Developer Security Training Policy — the acknowledgement is one of the training-record entries per developer per cycle.
This control satisfies the "communicated and acknowledged" requirement of ISO/IEC 27001:2022 A.8.28 (secure coding) and A.8.30 (outsourced development), and the role-communication requirement of NIST SSDF PO.2.
11. Decision log (all resolved 2026-05-26)
| # | Question | Default proposal | Alternative(s) |
|---|---|---|---|
| Q1 | Backend input-validation library — Zod or class-validator? | ✅ Resolved 2026-05-26 — Zod. One rulebook across React forms, NestJS BFF, workers, and AI flows. Added to Tech Stack §3. | class-validator was rejected (duplicate rules on the frontend would drift) |
| Q2 | TypeScript strict-mode profile | ✅ Resolved 2026-05-26 — Full strict. strict: true + noImplicitOverride + noUncheckedIndexedAccess + exactOptionalPropertyTypes. See §4 above. | "Selective strict" rejected (retrofitting these flags onto existing code is expensive) |
| Q3 | HTML sanitization library (for the rare dangerouslySetInnerHTML exception path or any server-rendered HTML) | ✅ Resolved 2026-05-26 — DOMPurify (~14 KB, audited, industry default; works on browser + Node). Locked in §3.3 above. | sanitize-html rejected (heavier, no clear win at our SPA-first profile) |
| Q4 | State management library for the SPA | ✅ Resolved 2026-05-26 — TanStack Query (server) + Zustand (local), hardened defaults. No disk persistence; sensitive queries use gcTime: 0; cache cleared on logout / token-refresh failure / tenant switch. Added to Tech Stack §4; enforced in §5 above. | Redux Toolkit rejected (heavier, boilerplate); Jotai/Recoil rejected (smaller mindshare); raw fetch rejected (no chokepoint for wipe-on-logout) |
| Q5 | ESLint security plugin set | ✅ Resolved 2026-05-26 — eight mandatory plugins. @typescript-eslint/recommended + @typescript-eslint/strict + eslint-plugin-security + eslint-plugin-no-secrets + eslint-plugin-import + eslint-plugin-promise + eslint-plugin-sonarjs (tuned) + eslint-plugin-unicorn (tuned). Locked in §3.13 above with explicit tuning notes. | "Drop sonarjs" rejected (code review does not substitute for automated complexity/duplication checks); "unicorn alone" rejected (covers anti-patterns, not complexity) |
| Q6 | GraphQL schema rules in-scope for this standard? | ✅ Resolved 2026-05-26 — Yes, codified in §3.14 above. Depth ≤ 10, complexity ≤ 1 000, max page 100, introspection off in prod, field-level authz required, generic client errors, every mutation audit-logged. | "Separate API Hardening doc" rejected — content fits on half a page and developers benefit from one-stop-shop reading |
| Q7 | Mandate a frontend test floor? | ✅ Resolved 2026-05-26 — tiered coverage floors. Locked in §3.15 above. Vitest + @vitest/coverage-v8 + Playwright. Auth/tenant/crypto ≥ 95%; backend ≥ 80%; frontend logic ≥ 70%; UI components covered by Playwright; shared ≥ 90%. | "No floor" rejected (same reason as Q5 — automated enforcement does not have a manual substitute); "single global floor" rejected (under-protects security paths, over-protects UI) |
| Q8 | Does every developer acknowledge this standard before first contribution? | ✅ Resolved 2026-05-26 — Yes. Signed acknowledgement before first PR + annually + on every material revision; records retained ≥ 24 months in the Evidence Register. Locked in §10 above. | "Reasonable professional standards only" rejected — would not satisfy ISO A.8.28 / A.8.30 / SSDF PO.2 |
12. Document change history
| Version | Date | Author | Description of change | Approved by |
|---|---|---|---|---|
| 0.9-draft | 2026-05-26 | Ron Benisty (CTO & CSO) | Initial draft — pending resolution of §11 open decisions. | — |
| 1.0 | 2026-05-26 | Ron Benisty (CTO & CSO) | All eight §11 decisions resolved (Q1 Zod · Q2 full-strict TS · Q3 DOMPurify · Q4 TanStack Query + Zustand with hardened defaults · Q5 eight-plugin ESLint set with tuning · Q6 GraphQL hardening rules §3.14 · Q7 tiered coverage floors §3.15 · Q8 signed-acknowledgement §10). Promoted from draft to Approved. | Ron Benisty (CTO & CSO) |