diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index 161d550..909ffd4 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -4,9 +4,16 @@ import { DatabaseModule } from '../../../database/database.module'; import { HealthModule } from './modules/health/health.module'; import { NotificationsModule } from './modules/notifications/notifications.module'; import { ReportingModule } from './modules/reporting/reporting.module'; +import { SecurityPostureModule } from './modules/security-posture/security-posture.module'; @Module({ - imports: [DatabaseModule, HealthModule, NotificationsModule, ReportingModule], + imports: [ + DatabaseModule, + HealthModule, + NotificationsModule, + ReportingModule, + SecurityPostureModule, + ], controllers: [AppController], }) export class AppModule {} diff --git a/apps/backend/src/modules/security-posture/interfaces/security-posture.interface.ts b/apps/backend/src/modules/security-posture/interfaces/security-posture.interface.ts new file mode 100644 index 0000000..11514f3 --- /dev/null +++ b/apps/backend/src/modules/security-posture/interfaces/security-posture.interface.ts @@ -0,0 +1,32 @@ +/** Individual scoring factor with a name, weight (0–1), and raw value (0–100). */ +export interface ScoringFactor { + name: string; + weight: number; + value: number; + /** Weighted contribution: value * weight */ + weightedScore: number; +} + +/** A single point-in-time security posture snapshot. */ +export interface PostureSnapshot { + /** ISO 8601 timestamp when the snapshot was recorded. */ + recordedAt: string; + /** Overall weighted score, 0–100. */ + score: number; +} + +/** Full security posture response including score, factors, and history. */ +export interface SecurityPostureResult { + /** ISO 8601 timestamp of this calculation. */ + calculatedAt: string; + /** Overall weighted security score, 0–100. Higher is better. */ + score: number; + /** Qualitative label derived from the score. */ + grade: 'A' | 'B' | 'C' | 'D' | 'F'; + /** Individual weighted factors that make up the score. */ + factors: ScoringFactor[]; + /** Historical snapshots ordered oldest → newest. */ + history: PostureSnapshot[]; + /** Simple trend relative to the previous snapshot. */ + trend: 'improving' | 'stable' | 'degrading'; +} diff --git a/apps/backend/src/modules/security-posture/security-posture.controller.ts b/apps/backend/src/modules/security-posture/security-posture.controller.ts new file mode 100644 index 0000000..e7ed35c --- /dev/null +++ b/apps/backend/src/modules/security-posture/security-posture.controller.ts @@ -0,0 +1,24 @@ +import { Controller, Get } from '@nestjs/common'; +import { SecurityPostureService } from './security-posture.service'; +import { PostureSnapshot, SecurityPostureResult } from './interfaces/security-posture.interface'; + +/** + * Exposes security posture scoring endpoints. + * + * GET /security-posture — current score, factors, trend, and history + * GET /security-posture/history — historical snapshots only + */ +@Controller('security-posture') +export class SecurityPostureController { + constructor(private readonly securityPostureService: SecurityPostureService) {} + + @Get() + getPosture(): SecurityPostureResult { + return this.securityPostureService.getPosture(); + } + + @Get('history') + getHistory(): PostureSnapshot[] { + return this.securityPostureService.getHistory(); + } +} diff --git a/apps/backend/src/modules/security-posture/security-posture.module.ts b/apps/backend/src/modules/security-posture/security-posture.module.ts new file mode 100644 index 0000000..dda9b4e --- /dev/null +++ b/apps/backend/src/modules/security-posture/security-posture.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { SecurityPostureController } from './security-posture.controller'; +import { SecurityPostureService } from './security-posture.service'; + +@Module({ + controllers: [SecurityPostureController], + providers: [SecurityPostureService], + exports: [SecurityPostureService], +}) +export class SecurityPostureModule {} diff --git a/apps/backend/src/modules/security-posture/security-posture.service.spec.ts b/apps/backend/src/modules/security-posture/security-posture.service.spec.ts new file mode 100644 index 0000000..4edd82e --- /dev/null +++ b/apps/backend/src/modules/security-posture/security-posture.service.spec.ts @@ -0,0 +1,99 @@ +import 'reflect-metadata'; +import { Test, TestingModule } from '@nestjs/testing'; +import { SecurityPostureService } from './security-posture.service'; + +describe('SecurityPostureService', () => { + let service: SecurityPostureService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [SecurityPostureService], + }).compile(); + + service = module.get(SecurityPostureService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('getPosture', () => { + it('returns a score between 0 and 100', () => { + const result = service.getPosture(); + expect(result.score).toBeGreaterThanOrEqual(0); + expect(result.score).toBeLessThanOrEqual(100); + }); + + it('returns a valid grade', () => { + const result = service.getPosture(); + expect(['A', 'B', 'C', 'D', 'F']).toContain(result.grade); + }); + + it('returns a valid ISO timestamp in calculatedAt', () => { + const result = service.getPosture(); + expect(new Date(result.calculatedAt).toISOString()).toBe(result.calculatedAt); + }); + + it('returns factors with weights that sum to 1.0', () => { + const result = service.getPosture(); + const totalWeight = result.factors.reduce((sum, f) => sum + f.weight, 0); + expect(totalWeight).toBeCloseTo(1.0, 5); + }); + + it('each factor has a valid weighted score equal to value * weight', () => { + const result = service.getPosture(); + result.factors.forEach(f => { + expect(f.weightedScore).toBeCloseTo(f.value * f.weight, 1); + }); + }); + + it('returns a non-empty history array', () => { + const result = service.getPosture(); + expect(Array.isArray(result.history)).toBe(true); + expect(result.history.length).toBeGreaterThan(0); + }); + + it('returns a valid trend value', () => { + const result = service.getPosture(); + expect(['improving', 'stable', 'degrading']).toContain(result.trend); + }); + + it('grade A corresponds to score >= 90', () => { + const result = service.getPosture(); + if (result.grade === 'A') { + expect(result.score).toBeGreaterThanOrEqual(90); + } + }); + + it('history length does not exceed 30 snapshots', () => { + // Call getPosture many times to fill history + for (let i = 0; i < 30; i++) { + service.getPosture(); + } + const result = service.getPosture(); + expect(result.history.length).toBeLessThanOrEqual(30); + }); + }); + + describe('getHistory', () => { + it('returns an array of snapshots', () => { + const history = service.getHistory(); + expect(Array.isArray(history)).toBe(true); + }); + + it('each snapshot has a numeric score and ISO timestamp', () => { + const history = service.getHistory(); + history.forEach(snap => { + expect(typeof snap.score).toBe('number'); + expect(new Date(snap.recordedAt).toISOString()).toBe(snap.recordedAt); + }); + }); + + it('returns a copy — mutations do not affect internal state', () => { + const h1 = service.getHistory(); + h1.push({ recordedAt: new Date().toISOString(), score: 0 }); + const h2 = service.getHistory(); + expect(h2.length).toBe(h1.length - 1); + }); + }); +}); diff --git a/apps/backend/src/modules/security-posture/security-posture.service.ts b/apps/backend/src/modules/security-posture/security-posture.service.ts new file mode 100644 index 0000000..2f58807 --- /dev/null +++ b/apps/backend/src/modules/security-posture/security-posture.service.ts @@ -0,0 +1,107 @@ +import { Injectable } from '@nestjs/common'; +import { + PostureSnapshot, + ScoringFactor, + SecurityPostureResult, +} from './interfaces/security-posture.interface'; + +/** + * Computes a weighted security posture score for monitored organisations. + * + * In production each factor value would be derived from live data (alert + * counts, vulnerability scans, etc.). The static baselines used here are + * the minimum viable implementation that satisfies the scoring model, + * weighted-factors, and historical-tracking requirements from issue #153. + */ +@Injectable() +export class SecurityPostureService { + /** + * Scoring factor definitions: name, weight (must sum to 1.0), and a base + * value that simulates a live measurement. + */ + private readonly FACTOR_DEFINITIONS: { name: string; weight: number; baseValue: number }[] = [ + { name: 'unresolvedCriticalAlerts', weight: 0.35, baseValue: 70 }, + { name: 'mempoolThreatDensity', weight: 0.25, baseValue: 80 }, + { name: 'watchlistCoverage', weight: 0.2, baseValue: 90 }, + { name: 'notificationReliability', weight: 0.1, baseValue: 95 }, + { name: 'auditLogCompleteness', weight: 0.1, baseValue: 85 }, + ]; + + /** In-memory history store (last 30 snapshots). */ + private readonly history: PostureSnapshot[] = this.buildInitialHistory(); + + /** Calculate and return the current security posture. */ + getPosture(): SecurityPostureResult { + const factors = this.buildFactors(); + const score = this.computeScore(factors); + const snapshot: PostureSnapshot = { recordedAt: new Date().toISOString(), score }; + + this.recordSnapshot(snapshot); + + return { + calculatedAt: snapshot.recordedAt, + score, + grade: this.toGrade(score), + factors, + history: [...this.history], + trend: this.computeTrend(), + }; + } + + /** Return stored historical snapshots. */ + getHistory(): PostureSnapshot[] { + return [...this.history]; + } + + // --------------------------------------------------------------------------- + // Internal helpers + // --------------------------------------------------------------------------- + + private buildFactors(): ScoringFactor[] { + return this.FACTOR_DEFINITIONS.map(({ name, weight, baseValue }) => ({ + name, + weight, + value: baseValue, + weightedScore: Math.round(baseValue * weight * 100) / 100, + })); + } + + private computeScore(factors: ScoringFactor[]): number { + const raw = factors.reduce((sum, f) => sum + f.weightedScore, 0); + return Math.min(100, Math.max(0, Math.round(raw))); + } + + private toGrade(score: number): 'A' | 'B' | 'C' | 'D' | 'F' { + if (score >= 90) return 'A'; + if (score >= 80) return 'B'; + if (score >= 70) return 'C'; + if (score >= 60) return 'D'; + return 'F'; + } + + private recordSnapshot(snapshot: PostureSnapshot): void { + this.history.push(snapshot); + if (this.history.length > 30) { + this.history.shift(); + } + } + + private computeTrend(): 'improving' | 'stable' | 'degrading' { + if (this.history.length < 2) return 'stable'; + const prev = this.history[this.history.length - 2].score; + const curr = this.history[this.history.length - 1].score; + if (curr > prev + 1) return 'improving'; + if (curr < prev - 1) return 'degrading'; + return 'stable'; + } + + /** Seed 7 days of synthetic history so the trend endpoint is useful on first boot. */ + private buildInitialHistory(): PostureSnapshot[] { + const now = Date.now(); + const baseScores = [72, 74, 73, 76, 78, 80, 82]; + return baseScores.map((score, i) => ({ + recordedAt: new Date(now - (baseScores.length - 1 - i) * 24 * 60 * 60 * 1000).toISOString(), + score, + })); + } +}