Skip to main content

Secure Coding Standard

Document control

FieldValue
OwnerRon Benisty — CTO & CSO (ron.b@iontwrks.com)
StatusApproved
Version1.0
ClassificationConfidential
Approved byRon Benisty — CTO & CSO
Approval date2026-05-26 (Tuesday)
Last reviewed2026-05-26
Review cycleAnnual — next review due May 2027
Parent policySecure 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:

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_path set 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_path to a default; the connection-pool wrapper enforces this.

3.3 Output encoding

  • React's default JSX escaping is mandatory; dangerouslySetInnerHTML is 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 dangerouslySetInnerHTML exception 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 sql template 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-rolled ALTER TABLE outside 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

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.
  • .env files are local-only; never committed (Gitleaks + Push Protection block).

3.8 Logging

  • Use the project logger (structured JSON); never console.log in 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.exec and child_process.spawn with shell: true on any user-influenced input.
  • Math.random() for security-relevant purposes (see Cryptography Standard §7).
  • dangerouslySetInnerHTML without sanitization + CTO & CSO approval.
  • document.write and direct innerHTML manipulation in committed code.
  • process.env access scattered through the codebase — config goes through a typed accessor.

3.11 Dependencies

  • Add dependencies via npm to package.json + package-lock.json only.
  • No postinstall scripts execute in CI (npm ci --ignore-scripts is 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:

PluginRole
@typescript-eslint/recommendedTypeScript bug patterns
@typescript-eslint/strictStricter TS rules; pairs with §4 full-strict tsconfig
eslint-plugin-securityNode.js security antipatterns; catches some §3.10 banned APIs at lint time
eslint-plugin-no-secretsHigh-entropy strings — accidentally-committed credentials
eslint-plugin-importImport hygiene; no-cycle; no-relative-parent-imports
eslint-plugin-promiseAsync / promise correctness (NestJS + BullMQ are async-heavy)
eslint-plugin-sonarjsCognitive complexity, duplication, identical branches, dead stores
eslint-plugin-unicornAnti-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 intentional const 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:

RuleValueWhy
Maximum query depth≤ 10Prevents 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.
PaginationRelay-style cursor pagination; max 100 per pageMatches Architecture §API Strategy. Prevents "give me everything" requests.
Introspection in productionDisabledHides the schema from attacker reconnaissance. introspection: process.env.NODE_ENV !== 'production' or equivalent.
Field-level authorizationRequired per resolverAuthorization 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 clientsGeneric — no stack traces, no DB errors, no internal IDsMatches §3.9. Full detail goes to Logz.io with a correlation ID.
Mutation audit loggingEvery mutation emits an audit_events row with actor, action, target entity, tenant contextMatches 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 / packageLinesBranchesWhy
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 thresholdVisual 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)ExcludedGenerated; 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 fetch in 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.location mutation 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 plan must run clean in CI before terraform 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

RuleEnforcement
§3.1 input validation presentLint rule + review
§3.2 tenant-context bypassCustom lint rule (CI-blocking)
§3.4 raw SQL string concatSemgrep rule
§3.10 banned APIsESLint + Semgrep
§3.11 npm ci --ignore-scriptsCI step
§3.12 SHA-pinned actionspinact CI check
§3.7 no secrets in codePush Protection + Gitleaks + TruffleHog
Everything elseCODEOWNER 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)

#QuestionDefault proposalAlternative(s)
Q1Backend 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)
Q2TypeScript strict-mode profileResolved 2026-05-26 — Full strict. strict: true + noImplicitOverride + noUncheckedIndexedAccess + exactOptionalPropertyTypes. See §4 above."Selective strict" rejected (retrofitting these flags onto existing code is expensive)
Q3HTML 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)
Q4State management library for the SPAResolved 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)
Q5ESLint security plugin setResolved 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)
Q6GraphQL 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
Q7Mandate 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)
Q8Does 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

VersionDateAuthorDescription of changeApproved by
0.9-draft2026-05-26Ron Benisty (CTO & CSO)Initial draft — pending resolution of §11 open decisions.
1.02026-05-26Ron 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)