Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion apps/backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
Original file line number Diff line number Diff line change
@@ -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';
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -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 {}
Original file line number Diff line number Diff line change
@@ -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>(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);
});
});
});
107 changes: 107 additions & 0 deletions apps/backend/src/modules/security-posture/security-posture.service.ts
Original file line number Diff line number Diff line change
@@ -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,
}));
}
}
Loading