Skip to main content

Coding Standards

Coding conventions, quality rules, and review practices enforced across the ThreatWeaver codebase. Every contributor β€” human and AI agent alike β€” follows these standards. When in doubt, read the existing code and match its patterns.


Language and Frameworks​

LayerStack
BackendNode.js 20+ + Express + TypeScript 5 (strict), TypeORM + PostgreSQL, Vitest
FrontendReact 18 + Vite 7 + TypeScript 5 (strict), Zustand, React Query v5, Recharts, Tailwind CSS
TestingVitest (both backend and frontend)
LintingESLint + Prettier (both layers)

TypeScript Standards​

Strict Mode is Non-Negotiable​

Both backend/tsconfig.json and frontend/tsconfig.json have "strict": true. This enables:

  • strictNullChecks β€” no implicit null/undefined
  • noImplicitAny β€” every value must have a type
  • strictFunctionTypes β€” function parameter types are checked contravariantly

Never disable strict mode or add // @ts-ignore without a documented reason in a comment.

No any​

// WRONG
function processFindings(findings: any[]): any {
return findings.map((f: any) => f.id);
}

// CORRECT
function processFindings(findings: Finding[]): string[] {
return findings.map((f) => f.id);
}

If the type is genuinely unknown at compile time (e.g., parsing JSON from an external API), use unknown and narrow with type guards:

// CORRECT β€” use unknown + type guard
function parseExternalPayload(raw: unknown): TenableAsset {
if (!isTenableAsset(raw)) {
throw new Error(`Invalid TenableAsset payload: ${JSON.stringify(raw)}`);
}
return raw;
}

function isTenableAsset(val: unknown): val is TenableAsset {
return (
typeof val === 'object' &&
val !== null &&
'id' in val &&
typeof (val as Record<string, unknown>).id === 'string'
);
}

interface vs type​

Use interface for object shapes (especially entities and DTOs). Use type for unions, intersections, and computed types.

// CORRECT β€” interface for object shape
interface CreateFindingDto {
targetUrl: string;
vulnerabilityClass: string;
severity: 'critical' | 'high' | 'medium' | 'low' | 'info';
evidence: string;
}

// CORRECT β€” type for union
type ScanStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';

// CORRECT β€” type for intersection
type AuthenticatedRequest = Request & { user: JwtPayload; tenantId: string };

Naming Conventions​

ConstructConventionExample
Files (backend services)kebab-case.service.tsaggregation.service.ts
Files (backend routes)kebab-case.routes.tsassets.routes.ts
Files (TypeORM entities)PascalCase.tsVulnerability.ts
Files (React components)PascalCase.tsxVulnerabilityTable.tsx
Files (React pages)PascalCase.tsxDashboard.tsx
ClassesPascalCaseAggregationService
InterfacesPascalCase (no I prefix)CreateAssetDto
Functions / methodscamelCasecalculateRiskScore()
Constants (module-level)SCREAMING_SNAKE_CASEMAX_SCAN_CONCURRENCY
React componentsPascalCaseVulnerabilityCard
Zustand storescamelCase + Store suffixauthStore, uiStore
CSS classesTailwind utility classes β€” no custom class names unless necessary

Explicit Return Types on Public Functions​

All public service methods and route handlers must have explicit return types:

// CORRECT
async function getAssetsByTenant(tenantId: string): Promise<Asset[]> {
return this.assetRepo.find({ where: { tenantId } });
}

// WRONG β€” implicit return type
async function getAssetsByTenant(tenantId: string) {
return this.assetRepo.find({ where: { tenantId } });
}

Private helper functions and anonymous callbacks do not require explicit return types.


File Naming Conventions​

Backend​

backend/src/
entities/ PascalCase.ts (e.g., ScanFinding.ts)
routes/ kebab-case.routes.ts (e.g., scan-findings.routes.ts)
services/ kebab-case.service.ts (e.g., finding-validator.service.ts)
middleware/ kebab-case.middleware.ts
utils/ kebab-case.ts
__tests__/ kebab-case.test.ts (e.g., aggregation.service.test.ts)

Frontend​

frontend/src/
pages/ PascalCase.tsx (e.g., VulnerabilityDetail.tsx)
components/ PascalCase.tsx (e.g., SeverityBadge.tsx)
services/ kebab-case.service.ts (e.g., assets.service.ts)
hooks/ use-kebab-case.ts (e.g., use-vulnerabilities.ts)
store/ kebab-case.store.ts (e.g., auth.store.ts)
__tests__/ kebab-case.test.tsx

Import Ordering​

Imports must be grouped in this order, separated by blank lines:

// 1. Node built-ins
import path from 'path';
import crypto from 'crypto';

// 2. External packages
import express from 'express';
import { DataSource } from 'typeorm';
import { z } from 'zod';

// 3. Internal absolute imports (from src/)
import { AppDataSource } from '@/config/database';
import { authMiddleware } from '@/middleware/auth.middleware';

// 4. Relative imports
import { Finding } from './Finding';
import { validateFinding } from '../utils/validators';

ESLint enforces this ordering. Run npx eslint --fix to auto-sort imports.


Error Handling​

Never Swallow Errors​

// WRONG β€” silent failure
try {
await syncTenableAssets(tenantId);
} catch (_err) {
// ignore
}

// WRONG β€” log without context
try {
await syncTenableAssets(tenantId);
} catch (err) {
console.error(err);
}

// CORRECT β€” log with full context, rethrow or return error result
try {
await syncTenableAssets(tenantId);
} catch (err) {
logger.error('Tenable sync failed', {
tenantId,
error: err instanceof Error ? err.message : String(err),
stack: err instanceof Error ? err.stack : undefined,
});
throw new Error(`Tenable sync failed for tenant ${tenantId}: ${(err as Error).message}`);
}

HTTP Error Responses​

Always return structured error responses from route handlers:

// CORRECT
router.get('/assets/:id', async (req, res) => {
try {
const asset = await assetService.findById(req.params.id, req.tenantId);
if (!asset) {
return res.status(404).json({ error: 'Asset not found', code: 'ASSET_NOT_FOUND' });
}
return res.json(asset);
} catch (err) {
logger.error('GET /assets/:id failed', { id: req.params.id, error: (err as Error).message });
return res.status(500).json({ error: 'Internal server error', code: 'INTERNAL_ERROR' });
}
});

Error response shape must always include error (human-readable) and optionally code (machine-readable):

{
"error": "Asset not found",
"code": "ASSET_NOT_FOUND",
"details": {}
}

Frontend Error Boundaries​

Wrap major UI sections in React error boundaries. Do not let a single component crash the entire dashboard.


Security Coding Standards​

Security is not optional. These rules are checked in every code review.

Input Validation: Always Use Zod​

Every API endpoint that accepts a request body must validate it with Zod before touching the data:

import { z } from 'zod';

const CreateAssessmentSchema = z.object({
targetUrl: z.string().url('Must be a valid URL'),
scanType: z.enum(['blackbox', 'graybox', 'whitebox']),
tenantId: z.string().uuid(),
authProfileId: z.string().uuid().optional(),
});

router.post('/assessments', authMiddleware, async (req, res) => {
const parsed = CreateAssessmentSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({
error: 'Invalid request body',
details: parsed.error.flatten(),
});
}
const { targetUrl, scanType, tenantId, authProfileId } = parsed.data;
// ... safe to use validated data
});

Never trust req.body directly. Even trusted clients can send malformed data.

SQL: Always Use TypeORM Repositories​

Never construct raw SQL strings with user input:

// WRONG β€” SQL injection vulnerability
const query = `SELECT * FROM assets WHERE name = '${req.query.name}'`;
const result = await dataSource.query(query);

// WRONG β€” even with template literal, never concatenate user input
const result = await dataSource.query(
`SELECT * FROM assets WHERE tenant_id = '${tenantId}'`
);

// CORRECT β€” use TypeORM repository with parameterized queries
const assets = await assetRepo.find({
where: { name: req.query.name as string, tenantId },
});

// CORRECT β€” if raw SQL is truly necessary, use parameters
const result = await dataSource.query(
'SELECT * FROM assets WHERE name = $1 AND tenant_id = $2',
[req.query.name, tenantId]
);

Multi-Tenant Isolation​

Every database query must be scoped to the current tenant. The tenant middleware injects req.tenantId. Never omit it:

// WRONG β€” returns data from ALL tenants
const findings = await findingRepo.find();

// CORRECT β€” always scope to tenant
const findings = await findingRepo.find({
where: { tenantId: req.tenantId },
});

TypeORM entities have a tenantId column. All repositories used in routes must filter by it.

Output Encoding​

When rendering user-controlled data in the frontend, React's JSX encoding protects against XSS by default. Do not use dangerouslySetInnerHTML unless you have sanitized the content:

// WRONG β€” XSS vulnerability
<div dangerouslySetInnerHTML={{ __html: finding.evidence }} />

// CORRECT β€” render as text (React encodes it)
<div>{finding.evidence}</div>

// IF you must render HTML (e.g., formatted scanner output), sanitize first
import DOMPurify from 'dompurify';
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(finding.evidence) }} />

Secrets and Environment Variables​

Never hardcode secrets, API keys, URLs, or passwords in source code:

// WRONG
const apiKey = 'sc-UXbKJKVWuJFf3pVP...';
const dbUrl = 'postgresql://tenable:Admin123@...';

// CORRECT β€” always use environment variables
const apiKey = process.env.TENABLE_API_KEY;
if (!apiKey) throw new Error('TENABLE_API_KEY is not configured');

The .env file is gitignored. Never commit it. The .env.example file lists all required variables with placeholder values β€” keep it updated when adding new env vars.

Logging Security​

Never log sensitive data:

// WRONG β€” logs user password
logger.info('Login attempt', { email, password });

// WRONG β€” logs auth token
logger.debug('Request headers', { headers: req.headers });

// WRONG β€” logs full request body which may contain credentials
logger.info('Incoming request', { body: req.body });

// CORRECT β€” log only safe fields
logger.info('Login attempt', { email, ip: req.ip, userAgent: req.headers['user-agent'] });
logger.debug('Request', { method: req.method, path: req.path, tenantId: req.tenantId });

When logging error objects, log err.message and err.stack β€” not the whole object, which might contain sensitive properties.

SSRF Prevention​

Scanner agents that make outbound HTTP requests must use the URL security utility:

import { validateScanTarget } from '@/utils/urlSecurity';

// Always validate before making an outbound request
const validationResult = validateScanTarget(targetUrl);
if (!validationResult.isValid) {
throw new Error(`Invalid scan target: ${validationResult.reason}`);
}

Never allow internal IP ranges (10.x.x.x, 172.16.x.x, 192.168.x.x, 127.x.x.x, 169.254.x.x) to be used as scan targets.


Testing Standards​

Test File Location​

backend/src/__tests__/
aggregation.service.test.ts
finding-validator.service.test.ts
auth.middleware.test.ts

frontend/src/__tests__/
VulnerabilityTable.test.tsx
use-vulnerabilities.test.ts

Naming Convention​

<feature-name>.test.ts      # Unit test
<feature-name>.spec.ts # Integration test (when distinction matters)

Test Structure (AAA Pattern)​

import { describe, it, expect, beforeEach, vi } from 'vitest';

describe('FindingValidatorService', () => {
describe('validateSqliFindig', () => {
it('should reject findings on multipart/form-data endpoints', () => {
// Arrange
const finding = buildFinding({
vulnerabilityClass: 'sqli',
requestHeaders: { 'Content-Type': 'multipart/form-data; boundary=...' },
});

// Act
const result = findingValidator.validate(finding);

// Assert
expect(result.isValid).toBe(false);
expect(result.rejectReason).toContain('H4c');
});
});
});

Coverage Requirements​

Code areaMinimum coverageTest type
aggregation.service.tsEvery public functionUnit tests with mocked DB
Scanner agentsEvery heuristicUnit tests with fixture requests
Auth middlewareAll token statesUnit tests
Route handlersHappy path + auth errorsIntegration tests
Frontend componentsCritical user interactionsComponent tests
Utility functions100%Unit tests

Test Building Strategy​

During implementation sprints:

  1. Write test cases as comments while building (describe the inputs/outputs)
  2. Run only build checks (npm run build) to catch type errors
  3. Write the full test suite after the feature is complete
  4. Run npm test before the first commit that includes the feature

This avoids a broken test suite blocking rapid feature development, while ensuring coverage exists before shipping.

Mocking External Dependencies​

// Mock TypeORM repositories
vi.mock('@/config/database', () => ({
AppDataSource: {
getRepository: vi.fn().mockReturnValue({
find: vi.fn(),
findOne: vi.fn(),
save: vi.fn(),
}),
},
}));

// Mock Tenable API calls
vi.mock('@/services/tenable.service', () => ({
TenableService: {
getAssets: vi.fn().mockResolvedValue([]),
},
}));

Never make real network calls or DB queries in unit tests.


Git Commit Standards​

ThreatWeaver uses Conventional Commits.

Format​

type(scope): short description [#issue]

Body: What was broken, what was fixed, audit result, impact, and reference to docs/audits/ document.

Allowed Types​

TypeWhen to Use
featNew feature or capability
fixBug fix
docsDocumentation changes only
refactorCode restructuring without behavior change
testAdding or updating tests
perfPerformance improvement
securitySecurity fix or hardening
infraInfrastructure or deployment changes
choreTooling, dependencies, CI, or maintenance

Commit Rules​

  • Maximum 72 characters for the title line
  • Mandatory body for non-trivial commits
  • One logical change per commit β€” never mix feature + fix + refactor
  • Include issue tracker number: fix(scanner): resolve BOLA FP [#23]
  • Never commit: secrets, CSVs, ZIPs, logs, .env files, or files > 5MB

Body Requirements (Non-Trivial Commits)​

Every non-trivial commit body must include:

What was broken or missing:
[Describe the symptom/gap]

What was fixed or added:
[Describe the change]

Audit result:
[Tested against X, Y, Z β€” N regressions]

Impact:
[What changes in behavior]

Ref: docs/audits/<relevant-doc>.md

Full Example​

fix(appsec): resolve SQLi FP on file upload endpoints [#47]

What was broken: sqli_auth_bypass agent flagged multipart/form-data
file upload requests as SQL injection because the boundary string
contained SQL-like characters.

What was fixed: Added H4c heuristic in findingValidator to auto-reject
sqli_auth_bypass findings when the request Content-Type is multipart.

Audit: Verified against dvws-node and crAPI targets β€” 0 regressions.
Finding count on dvws: was 35, now 35. FP count reduced by 4.
Impact: Reduces FP count by ~4 per scan on targets with file uploads.
Ref: docs/audits/R15_FP_ANALYSIS.md

Code Review Checklist​

Use this before opening a PR or requesting review.

Functionality​

  • The change does what the issue/task asked for
  • Edge cases are handled (empty lists, null values, missing auth, large inputs)
  • No regressions β€” run npm test and confirm all existing tests pass
  • New functionality has tests

Security​

  • No secrets or credentials in the diff
  • All user inputs are validated with Zod (backend routes)
  • All DB queries use TypeORM repositories or parameterized queries
  • Multi-tenant isolation: every DB query is scoped to tenantId
  • No dangerouslySetInnerHTML without sanitization
  • No new environment variables hardcoded β€” added to .env.example if needed
  • Logging does not include passwords, tokens, or full request bodies

Code Quality​

  • TypeScript compiles without errors: cd backend && npm run build
  • No any types introduced
  • No unused imports or variables
  • Error handling follows the pattern: log with context, rethrow or return structured error
  • Functions over 50 lines are broken up or explained with a comment

Standards​

  • File naming follows conventions
  • Import ordering is correct (external β†’ internal β†’ relative)
  • Commit message follows conventional commit format with a body
  • One logical change per commit β€” not a mix of feature + fix + refactor
  • All three migration scripts updated if entity schema changed
  • Issue tracker updated (docs/audits/ISSUE_TRACKER.md) if fixing a tracked issue

Performance Patterns​

Avoid N+1 Queries​

The single most common performance issue in TypeORM applications:

// WRONG β€” N+1: one query per finding
const findings = await findingRepo.find({ where: { assessmentId } });
for (const finding of findings) {
finding.asset = await assetRepo.findOne({ where: { id: finding.assetId } });
}

// CORRECT β€” one query with join
const findings = await findingRepo.find({
where: { assessmentId },
relations: ['asset'],
});

When in doubt, check the SQL being generated: set logging: true in AppDataSource options temporarily.

Always Use Pagination​

Never load an entire table into memory:

// WRONG β€” loads everything
const allFindings = await findingRepo.find({ where: { tenantId } });

// CORRECT β€” paginate
const PAGE_SIZE = 50;
const findings = await findingRepo.find({
where: { tenantId },
take: PAGE_SIZE,
skip: (page - 1) * PAGE_SIZE,
order: { createdAt: 'DESC' },
});

All list endpoints must support limit and offset (or page) query parameters and return total count alongside the items.

Select Only What You Need​

// WRONG β€” loads all columns including large JSON evidence fields
const findings = await findingRepo.find({ where: { assessmentId } });

// CORRECT β€” select only what the response needs
const findings = await findingRepo.find({
select: ['id', 'vulnerabilityClass', 'severity', 'targetUrl', 'createdAt'],
where: { assessmentId },
});

This is especially important for the Finding entity, which has a large evidence JSON column.

Scanner Agent Concurrency​

Scanner agents run concurrently. When adding a new agent or modifying concurrency limits:

// Check MAX_SCAN_CONCURRENCY in config
// Default is 5 concurrent agents per scan
// Do not exceed without testing memory usage
const MAX_SCAN_CONCURRENCY = parseInt(process.env.MAX_SCAN_CONCURRENCY ?? '5', 10);

Agents that make many outbound HTTP requests can exhaust the Render instance's memory. Test locally with htop or docker stats to verify memory stays under 400MB for a typical scan.

Caching Patterns​

Redis is used in production for caching aggregated KPI data. Locally, Redis is not required β€” the application falls back gracefully.

When adding cached operations:

// Always handle cache miss gracefully
const cacheKey = `kpi:${tenantId}:${date}`;
const cached = await redisClient?.get(cacheKey);
if (cached) return JSON.parse(cached);

const result = await computeExpensiveKpi(tenantId, date);
await redisClient?.setEx(cacheKey, 3600, JSON.stringify(result)); // 1-hour TTL
return result;

Cache TTL guidelines:

  • KPI aggregates: 1 hour
  • Asset counts: 15 minutes
  • Finding lists: no caching (must be fresh)
  • User/tenant data: no caching at the app layer (DB handles this)

Code Change Rules Summary​

  1. Read before editing β€” never modify a file without reading the relevant section first
  2. Test after changes β€” run npm test and npm run build before committing
  3. aggregation.service.ts requires extra care β€” ~2,936 lines, all KPI logic; read surrounding context, understand all callers, test thoroughly
  4. Validate against source β€” never trust documentation blindly; the source code is the truth
  5. No over-engineering β€” fix what is asked, do not add unnecessary abstractions
  6. Local first β€” validate all logic in the local environment before proposing changes to dev or prod
  7. Three migration scripts must stay in sync β€” when changing entities, update all three
  8. One logical change per commit β€” do not mix feature + fix + refactor