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β
| Layer | Stack |
|---|---|
| Backend | Node.js 20+ + Express + TypeScript 5 (strict), TypeORM + PostgreSQL, Vitest |
| Frontend | React 18 + Vite 7 + TypeScript 5 (strict), Zustand, React Query v5, Recharts, Tailwind CSS |
| Testing | Vitest (both backend and frontend) |
| Linting | ESLint + 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/undefinednoImplicitAnyβ every value must have a typestrictFunctionTypesβ 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β
| Construct | Convention | Example |
|---|---|---|
| Files (backend services) | kebab-case.service.ts | aggregation.service.ts |
| Files (backend routes) | kebab-case.routes.ts | assets.routes.ts |
| Files (TypeORM entities) | PascalCase.ts | Vulnerability.ts |
| Files (React components) | PascalCase.tsx | VulnerabilityTable.tsx |
| Files (React pages) | PascalCase.tsx | Dashboard.tsx |
| Classes | PascalCase | AggregationService |
| Interfaces | PascalCase (no I prefix) | CreateAssetDto |
| Functions / methods | camelCase | calculateRiskScore() |
| Constants (module-level) | SCREAMING_SNAKE_CASE | MAX_SCAN_CONCURRENCY |
| React components | PascalCase | VulnerabilityCard |
| Zustand stores | camelCase + Store suffix | authStore, uiStore |
| CSS classes | Tailwind 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 area | Minimum coverage | Test type |
|---|---|---|
aggregation.service.ts | Every public function | Unit tests with mocked DB |
| Scanner agents | Every heuristic | Unit tests with fixture requests |
| Auth middleware | All token states | Unit tests |
| Route handlers | Happy path + auth errors | Integration tests |
| Frontend components | Critical user interactions | Component tests |
| Utility functions | 100% | Unit tests |
Test Building Strategyβ
During implementation sprints:
- Write test cases as comments while building (describe the inputs/outputs)
- Run only build checks (
npm run build) to catch type errors - Write the full test suite after the feature is complete
- Run
npm testbefore 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β
| Type | When to Use |
|---|---|
feat | New feature or capability |
fix | Bug fix |
docs | Documentation changes only |
refactor | Code restructuring without behavior change |
test | Adding or updating tests |
perf | Performance improvement |
security | Security fix or hardening |
infra | Infrastructure or deployment changes |
chore | Tooling, 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,
.envfiles, 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 testand 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
dangerouslySetInnerHTMLwithout sanitization - No new environment variables hardcoded β added to
.env.exampleif needed - Logging does not include passwords, tokens, or full request bodies
Code Qualityβ
- TypeScript compiles without errors:
cd backend && npm run build - No
anytypes 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β
- Read before editing β never modify a file without reading the relevant section first
- Test after changes β run
npm testandnpm run buildbefore committing - aggregation.service.ts requires extra care β ~2,936 lines, all KPI logic; read surrounding context, understand all callers, test thoroughly
- Validate against source β never trust documentation blindly; the source code is the truth
- No over-engineering β fix what is asked, do not add unnecessary abstractions
- Local first β validate all logic in the
localenvironment before proposing changes todevorprod - Three migration scripts must stay in sync β when changing entities, update all three
- One logical change per commit β do not mix feature + fix + refactor
Related Documentationβ
- Onboarding Guide β environment setup for new developers
- Git Workflow β branching, PR, and deployment workflow
- Architecture Diagrams β system design
- Environment Variables β all config vars
- Database Schema β entity relationships