From 8a48eed93334d01103cede3ad0ae67ca1c788713 Mon Sep 17 00:00:00 2001 From: Samuel1505 Date: Wed, 19 Aug 2026 09:21:23 +0100 Subject: [PATCH 1/8] feat(security): add vulnerability data model and scan result parser Adds the Vulnerability/SecurityEvent entities and a parser that normalizes SARIF (Semgrep/Trivy/ZAP), npm-audit, and Gitleaks reports into a common shape, deduplicated by a fingerprint of (source, rule, component, location) so repeat scans update a finding instead of duplicating it. Part of #70. --- .../entities/security-event.entity.ts | 32 ++++ .../security/entities/vulnerability.entity.ts | 93 ++++++++++ .../security/parsers/scan-result.parser.ts | 172 ++++++++++++++++++ 3 files changed, 297 insertions(+) create mode 100644 backend/src/security/entities/security-event.entity.ts create mode 100644 backend/src/security/entities/vulnerability.entity.ts create mode 100644 backend/src/security/parsers/scan-result.parser.ts diff --git a/backend/src/security/entities/security-event.entity.ts b/backend/src/security/entities/security-event.entity.ts new file mode 100644 index 0000000..421a3bd --- /dev/null +++ b/backend/src/security/entities/security-event.entity.ts @@ -0,0 +1,32 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, Index } from 'typeorm'; +import { VulnerabilitySeverity } from './vulnerability.entity'; + +export enum SecurityEventType { + SCAN_INGESTED = 'SCAN_INGESTED', + VULNERABILITY_DETECTED = 'VULNERABILITY_DETECTED', + VULNERABILITY_ASSIGNED = 'VULNERABILITY_ASSIGNED', + VULNERABILITY_RESOLVED = 'VULNERABILITY_RESOLVED', + VULNERABILITY_IGNORED = 'VULNERABILITY_IGNORED', +} + +@Entity('security_events') +@Index(['event_type']) +export class SecurityEvent { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'enum', enum: SecurityEventType }) + event_type: SecurityEventType; + + @Column({ type: 'enum', enum: VulnerabilitySeverity, nullable: true }) + severity: VulnerabilitySeverity; + + @Column({ type: 'text' }) + description: string; + + @Column({ type: 'jsonb', nullable: true }) + metadata: Record; + + @CreateDateColumn() + created_at: Date; +} diff --git a/backend/src/security/entities/vulnerability.entity.ts b/backend/src/security/entities/vulnerability.entity.ts new file mode 100644 index 0000000..cea97b7 --- /dev/null +++ b/backend/src/security/entities/vulnerability.entity.ts @@ -0,0 +1,93 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index } from 'typeorm'; + +export enum VulnerabilitySeverity { + CRITICAL = 'CRITICAL', + HIGH = 'HIGH', + MEDIUM = 'MEDIUM', + LOW = 'LOW', + INFO = 'INFO', +} + +export enum VulnerabilityType { + CODE = 'CODE', + DEPENDENCY = 'DEPENDENCY', + CONTAINER = 'CONTAINER', + INFRASTRUCTURE = 'INFRASTRUCTURE', + SECRET = 'SECRET', + DAST = 'DAST', +} + +export enum VulnerabilitySource { + SEMGREP = 'SEMGREP', + TRIVY = 'TRIVY', + NPM_AUDIT = 'NPM_AUDIT', + GITLEAKS = 'GITLEAKS', + ZAP = 'ZAP', + MANUAL = 'MANUAL', +} + +export enum VulnerabilityStatus { + OPEN = 'OPEN', + IN_PROGRESS = 'IN_PROGRESS', + RESOLVED = 'RESOLVED', + IGNORED = 'IGNORED', +} + +@Entity('vulnerabilities') +@Index(['severity']) +@Index(['status']) +@Index(['type']) +export class Vulnerability { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'enum', enum: VulnerabilitySeverity }) + severity: VulnerabilitySeverity; + + @Column({ type: 'enum', enum: VulnerabilityType }) + type: VulnerabilityType; + + @Column({ type: 'enum', enum: VulnerabilitySource }) + source: VulnerabilitySource; + + @Column({ type: 'text' }) + description: string; + + @Column() + affected_component: string; + + @Column({ nullable: true }) + location: string; + + @Column({ nullable: true }) + cve_id: string; + + @Column({ type: 'decimal', precision: 3, scale: 1, nullable: true }) + cvss_score: number; + + @Column({ type: 'text', nullable: true }) + remediation: string; + + @Column({ type: 'enum', enum: VulnerabilityStatus, default: VulnerabilityStatus.OPEN }) + status: VulnerabilityStatus; + + @Column({ nullable: true }) + assigned_to: string; + + @Column({ nullable: true }) + resolution: string; + + // Stable identity for a finding (source + rule + component + location) so repeat + // scans update the same row instead of duplicating it, while preserving triage state. + @Column({ unique: true }) + fingerprint: string; + + @CreateDateColumn() + created_at: Date; + + @UpdateDateColumn() + updated_at: Date; + + @Column({ type: 'timestamp', nullable: true }) + resolved_at: Date; +} diff --git a/backend/src/security/parsers/scan-result.parser.ts b/backend/src/security/parsers/scan-result.parser.ts new file mode 100644 index 0000000..656d01b --- /dev/null +++ b/backend/src/security/parsers/scan-result.parser.ts @@ -0,0 +1,172 @@ +import { Injectable } from '@nestjs/common'; +import * as crypto from 'crypto'; +import { + VulnerabilitySeverity, + VulnerabilitySource, + VulnerabilityType, +} from '../entities/vulnerability.entity'; + +export interface ParsedVulnerability { + severity: VulnerabilitySeverity; + type: VulnerabilityType; + source: VulnerabilitySource; + description: string; + affected_component: string; + location?: string; + cve_id?: string; + cvss_score?: number; + remediation?: string; + fingerprint: string; +} + +const CVE_PATTERN = /CVE-\d{4}-\d{4,}/i; + +function fingerprint(parts: (string | undefined)[]): string { + return crypto.createHash('sha256').update(parts.filter(Boolean).join('|')).digest('hex'); +} + +function severityFromCvss(score: number): VulnerabilitySeverity { + if (score >= 9) return VulnerabilitySeverity.CRITICAL; + if (score >= 7) return VulnerabilitySeverity.HIGH; + if (score >= 4) return VulnerabilitySeverity.MEDIUM; + if (score > 0) return VulnerabilitySeverity.LOW; + return VulnerabilitySeverity.INFO; +} + +function severityFromSarifLevel(level: string | undefined): VulnerabilitySeverity { + switch (level) { + case 'error': + return VulnerabilitySeverity.HIGH; + case 'warning': + return VulnerabilitySeverity.MEDIUM; + case 'note': + return VulnerabilitySeverity.LOW; + default: + return VulnerabilitySeverity.MEDIUM; + } +} + +function severityFromNpmAudit(severity: string | undefined): VulnerabilitySeverity { + switch ((severity || '').toLowerCase()) { + case 'critical': + return VulnerabilitySeverity.CRITICAL; + case 'high': + return VulnerabilitySeverity.HIGH; + case 'moderate': + return VulnerabilitySeverity.MEDIUM; + case 'low': + return VulnerabilitySeverity.LOW; + default: + return VulnerabilitySeverity.INFO; + } +} + +@Injectable() +export class ScanResultParser { + /** + * Parses a SARIF report (Semgrep, Trivy, and OWASP ZAP's SARIF export all emit this format). + */ + parseSarif( + sarif: any, + source: VulnerabilitySource, + type: VulnerabilityType, + ): ParsedVulnerability[] { + const findings: ParsedVulnerability[] = []; + const runs = sarif?.runs || []; + + for (const run of runs) { + const results = run?.results || []; + for (const result of results) { + const ruleId: string = result.ruleId || 'unknown-rule'; + const message: string = result.message?.text || ruleId; + const location = result.locations?.[0]?.physicalLocation; + const uri: string | undefined = location?.artifactLocation?.uri; + const line: number | undefined = location?.region?.startLine; + const locationStr = uri ? `${uri}${line ? `:${line}` : ''}` : undefined; + + const securitySeverity = result.properties?.['security-severity']; + const cvssScore = securitySeverity ? parseFloat(securitySeverity) : undefined; + const severity = + cvssScore !== undefined && !Number.isNaN(cvssScore) + ? severityFromCvss(cvssScore) + : severityFromSarifLevel(result.level); + + const cveMatch = `${ruleId} ${message}`.match(CVE_PATTERN); + + findings.push({ + severity, + type, + source, + description: message, + affected_component: uri || ruleId, + location: locationStr, + cve_id: cveMatch?.[0]?.toUpperCase(), + cvss_score: cvssScore, + remediation: result.fixes?.[0]?.description?.text, + fingerprint: fingerprint([source, ruleId, uri, String(line)]), + }); + } + } + + return findings; + } + + /** + * Parses `npm audit --json` output (npm's "auditReportVersion": 2 schema). + */ + parseNpmAudit(report: any): ParsedVulnerability[] { + const findings: ParsedVulnerability[] = []; + const vulnerabilities = report?.vulnerabilities || {}; + + for (const [pkgName, entry] of Object.entries(vulnerabilities)) { + const via = Array.isArray(entry.via) ? entry.via : []; + const advisories = via.filter((v: any) => typeof v === 'object'); + const description = + advisories.map((a: any) => a.title).filter(Boolean).join('; ') || + `Vulnerable dependency: ${pkgName}`; + const cveId = advisories.map((a: any) => a.cve?.[0] || a.title).find((c: string) => + c ? CVE_PATTERN.test(c) : false, + ); + const url = advisories.find((a: any) => a.url)?.url; + + findings.push({ + severity: severityFromNpmAudit(entry.severity), + type: VulnerabilityType.DEPENDENCY, + source: VulnerabilitySource.NPM_AUDIT, + description, + affected_component: `${pkgName}@${entry.range || 'unknown'}`, + cve_id: cveId?.match(CVE_PATTERN)?.[0]?.toUpperCase(), + remediation: entry.fixAvailable + ? `Run \`npm audit fix\`${typeof entry.fixAvailable === 'object' ? ` (upgrade to ${entry.fixAvailable.name}@${entry.fixAvailable.version})` : ''}` + : url, + fingerprint: fingerprint([VulnerabilitySource.NPM_AUDIT, pkgName, entry.range]), + }); + } + + return findings; + } + + /** + * Parses Gitleaks' JSON report (array of leaked-secret findings). + */ + parseGitleaks(report: any[]): ParsedVulnerability[] { + const items = Array.isArray(report) ? report : []; + + return items.map((finding) => { + const file = finding.File || finding.file; + const line = finding.StartLine ?? finding.startLine; + const ruleId = finding.RuleID || finding.rule || 'secret'; + + return { + severity: VulnerabilitySeverity.CRITICAL, + type: VulnerabilityType.SECRET, + source: VulnerabilitySource.GITLEAKS, + description: finding.Description || finding.description || `Secret detected: ${ruleId}`, + affected_component: file || 'unknown-file', + location: line ? `${file}:${line}` : file, + remediation: 'Revoke the exposed secret immediately and remove it from git history.', + fingerprint: fingerprint([VulnerabilitySource.GITLEAKS, ruleId, file, String(line)]), + }; + }); + } +} From c4f7b5b3aa4fdfe2877dcbf788c9e5ac475d0879 Mon Sep 17 00:00:00 2001 From: Samuel1505 Date: Wed, 19 Aug 2026 09:21:32 +0100 Subject: [PATCH 2/8] feat(security): add vulnerability management and alerting services VulnerabilityManagementService ingests parsed scan results (upserting by fingerprint, preserving triage status on re-scan), exposes assign/resolve/ ignore lifecycle transitions, and aggregates a dashboard (open counts by severity/type, 30-day trend). SecurityAlertService logs and optionally webhooks (SECURITY_ALERT_WEBHOOK_URL) every CRITICAL finding. Part of #70. --- .../services/security-alert.service.ts | 39 +++ .../vulnerability-management.service.ts | 246 ++++++++++++++++++ 2 files changed, 285 insertions(+) create mode 100644 backend/src/security/services/security-alert.service.ts create mode 100644 backend/src/security/services/vulnerability-management.service.ts diff --git a/backend/src/security/services/security-alert.service.ts b/backend/src/security/services/security-alert.service.ts new file mode 100644 index 0000000..3836094 --- /dev/null +++ b/backend/src/security/services/security-alert.service.ts @@ -0,0 +1,39 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Vulnerability } from '../entities/vulnerability.entity'; + +@Injectable() +export class SecurityAlertService { + private readonly logger = new Logger(SecurityAlertService.name); + + async notifyCriticalVulnerability(vulnerability: Vulnerability): Promise { + this.logger.error( + `Critical vulnerability detected: ${vulnerability.id} (${vulnerability.source}) in ${vulnerability.affected_component}`, + ); + + const webhookUrl = process.env.SECURITY_ALERT_WEBHOOK_URL; + if (!webhookUrl) { + return; + } + + try { + await fetch(webhookUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + text: `🚨 Critical vulnerability detected: ${vulnerability.affected_component}`, + vulnerability: { + id: vulnerability.id, + source: vulnerability.source, + type: vulnerability.type, + severity: vulnerability.severity, + description: vulnerability.description, + affected_component: vulnerability.affected_component, + cve_id: vulnerability.cve_id, + }, + }), + }); + } catch (error) { + this.logger.warn(`Failed to deliver security alert webhook: ${(error as Error).message}`); + } + } +} diff --git a/backend/src/security/services/vulnerability-management.service.ts b/backend/src/security/services/vulnerability-management.service.ts new file mode 100644 index 0000000..e0b2cf6 --- /dev/null +++ b/backend/src/security/services/vulnerability-management.service.ts @@ -0,0 +1,246 @@ +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { + Vulnerability, + VulnerabilitySeverity, + VulnerabilitySource, + VulnerabilityStatus, + VulnerabilityType, +} from '../entities/vulnerability.entity'; +import { SecurityEvent, SecurityEventType } from '../entities/security-event.entity'; +import { ScanResultParser, ParsedVulnerability } from '../parsers/scan-result.parser'; +import { SecurityAlertService } from './security-alert.service'; +import { IngestScanDto, ScanFormat } from '../dto/ingest-scan.dto'; + +export interface SecurityDashboard { + totalOpen: number; + bySeverity: Record; + byType: Record; + criticalCount: number; + highCount: number; + trend: Array<{ date: string; count: number }>; +} + +export interface IngestSummary { + received: number; + created: number; + updated: number; +} + +@Injectable() +export class VulnerabilityManagementService { + private readonly logger = new Logger(VulnerabilityManagementService.name); + + constructor( + @InjectRepository(Vulnerability) + private readonly vulnerabilityRepository: Repository, + @InjectRepository(SecurityEvent) + private readonly securityEventRepository: Repository, + private readonly parser: ScanResultParser, + private readonly alertService: SecurityAlertService, + ) {} + + async ingestScan(dto: IngestScanDto): Promise { + let parsed: ParsedVulnerability[]; + + switch (dto.format) { + case ScanFormat.SARIF: + parsed = this.parser.parseSarif( + dto.payload, + this.inferSarifSource(dto.payload), + dto.type || VulnerabilityType.CODE, + ); + break; + case ScanFormat.NPM_AUDIT: + parsed = this.parser.parseNpmAudit(dto.payload); + break; + case ScanFormat.GITLEAKS: + parsed = this.parser.parseGitleaks(dto.payload); + break; + default: + parsed = []; + } + + const summary: IngestSummary = { received: parsed.length, created: 0, updated: 0 }; + + for (const vuln of parsed) { + const wasCreated = await this.upsertVulnerability(vuln); + if (wasCreated) summary.created += 1; + else summary.updated += 1; + } + + await this.securityEventRepository.save( + this.securityEventRepository.create({ + event_type: SecurityEventType.SCAN_INGESTED, + description: `Ingested ${parsed.length} findings from ${dto.format} scan`, + metadata: { format: dto.format, ...summary }, + }), + ); + + return summary; + } + + private inferSarifSource(sarif: any): VulnerabilitySource { + const driverName: string = sarif?.runs?.[0]?.tool?.driver?.name || ''; + const name = driverName.toLowerCase(); + if (name.includes('trivy')) return VulnerabilitySource.TRIVY; + if (name.includes('zap')) return VulnerabilitySource.ZAP; + return VulnerabilitySource.SEMGREP; + } + + private async upsertVulnerability(vuln: ParsedVulnerability): Promise { + const existing = await this.vulnerabilityRepository.findOne({ + where: { fingerprint: vuln.fingerprint }, + }); + + if (existing) { + await this.vulnerabilityRepository.update(existing.id, { + severity: vuln.severity, + description: vuln.description, + cve_id: vuln.cve_id, + cvss_score: vuln.cvss_score, + remediation: vuln.remediation, + // status/assigned_to are intentionally left untouched so re-scans don't clobber triage. + }); + return false; + } + + const saved = await this.vulnerabilityRepository.save( + this.vulnerabilityRepository.create({ + ...vuln, + status: VulnerabilityStatus.OPEN, + }), + ); + + await this.securityEventRepository.save( + this.securityEventRepository.create({ + event_type: SecurityEventType.VULNERABILITY_DETECTED, + severity: saved.severity, + description: saved.description, + metadata: { vulnerabilityId: saved.id, source: saved.source }, + }), + ); + + if (saved.severity === VulnerabilitySeverity.CRITICAL) { + await this.alertService.notifyCriticalVulnerability(saved); + } + + return true; + } + + async findAll(filters: { severity?: string; status?: string; type?: string }): Promise { + const where: Record = {}; + if (filters.severity) where.severity = filters.severity; + if (filters.status) where.status = filters.status; + if (filters.type) where.type = filters.type; + + return this.vulnerabilityRepository.find({ where, order: { created_at: 'DESC' } }); + } + + async findOne(id: string): Promise { + const vulnerability = await this.vulnerabilityRepository.findOne({ where: { id } }); + if (!vulnerability) { + throw new NotFoundException(`Vulnerability ${id} not found`); + } + return vulnerability; + } + + async assign(id: string, assignee: string): Promise { + await this.findOne(id); + await this.vulnerabilityRepository.update(id, { + assigned_to: assignee, + status: VulnerabilityStatus.IN_PROGRESS, + }); + + await this.securityEventRepository.save( + this.securityEventRepository.create({ + event_type: SecurityEventType.VULNERABILITY_ASSIGNED, + description: `Vulnerability ${id} assigned to ${assignee}`, + metadata: { vulnerabilityId: id, assignee }, + }), + ); + + return this.findOne(id); + } + + async resolve(id: string, resolution: string): Promise { + await this.findOne(id); + await this.vulnerabilityRepository.update(id, { + status: VulnerabilityStatus.RESOLVED, + resolution, + resolved_at: new Date(), + }); + + await this.securityEventRepository.save( + this.securityEventRepository.create({ + event_type: SecurityEventType.VULNERABILITY_RESOLVED, + description: `Vulnerability ${id} resolved: ${resolution}`, + metadata: { vulnerabilityId: id }, + }), + ); + + return this.findOne(id); + } + + async ignore(id: string, reason: string): Promise { + await this.findOne(id); + await this.vulnerabilityRepository.update(id, { + status: VulnerabilityStatus.IGNORED, + resolution: reason, + }); + + await this.securityEventRepository.save( + this.securityEventRepository.create({ + event_type: SecurityEventType.VULNERABILITY_IGNORED, + description: `Vulnerability ${id} ignored: ${reason}`, + metadata: { vulnerabilityId: id }, + }), + ); + + return this.findOne(id); + } + + async getDashboard(): Promise { + const openVulns = await this.vulnerabilityRepository.find({ + where: { status: VulnerabilityStatus.OPEN }, + }); + + const bySeverity = this.groupCount(openVulns, (v) => v.severity); + const byType = this.groupCount(openVulns, (v) => v.type); + const trend = await this.getVulnerabilityTrend(); + + return { + totalOpen: openVulns.length, + bySeverity, + byType, + criticalCount: bySeverity[VulnerabilitySeverity.CRITICAL] || 0, + highCount: bySeverity[VulnerabilitySeverity.HIGH] || 0, + trend, + }; + } + + private groupCount(items: T[], key: (item: T) => string): Record { + return items.reduce((acc: Record, item) => { + const k = key(item); + acc[k] = (acc[k] || 0) + 1; + return acc; + }, {}); + } + + private async getVulnerabilityTrend(): Promise> { + const since = new Date(); + since.setDate(since.getDate() - 30); + + const rows = await this.vulnerabilityRepository + .createQueryBuilder('v') + .select("to_char(v.created_at, 'YYYY-MM-DD')", 'date') + .addSelect('COUNT(*)', 'count') + .where('v.created_at >= :since', { since }) + .groupBy('date') + .orderBy('date', 'ASC') + .getRawMany(); + + return rows.map((row) => ({ date: row.date, count: parseInt(row.count, 10) })); + } +} From bf778b5835b339fc7a677b5debbed526b0cbc202 Mon Sep 17 00:00:00 2001 From: Samuel1505 Date: Wed, 19 Aug 2026 09:21:43 +0100 Subject: [PATCH 3/8] feat(security): expose vulnerability management API and wire module SecurityController adds POST /security/scans/ingest (guarded by a shared x-scan-token header via ScanIngestGuard, for CI rather than user sessions), GET /security/dashboard, GET/POST /security/vulnerabilities* for listing, assigning, resolving, and ignoring findings behind the existing admin JWT guards. SecurityModule is registered in AppModule. Part of #70. --- backend/src/app.module.ts | 2 + .../controllers/security.controller.ts | 67 +++++++++++++++++++ .../security/dto/assign-vulnerability.dto.ts | 6 ++ backend/src/security/dto/ingest-scan.dto.ts | 22 ++++++ .../security/dto/resolve-vulnerability.dto.ts | 11 +++ .../src/security/guards/scan-ingest.guard.ts | 27 ++++++++ backend/src/security/security.module.ts | 16 +++++ 7 files changed, 151 insertions(+) create mode 100644 backend/src/security/controllers/security.controller.ts create mode 100644 backend/src/security/dto/assign-vulnerability.dto.ts create mode 100644 backend/src/security/dto/ingest-scan.dto.ts create mode 100644 backend/src/security/dto/resolve-vulnerability.dto.ts create mode 100644 backend/src/security/guards/scan-ingest.guard.ts create mode 100644 backend/src/security/security.module.ts diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index df17b51..d2ebe7f 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -15,6 +15,7 @@ import { MetricsModule } from './common/metrics/metrics.module'; import { DistributedLedgerModule } from './distributed-ledger/distributed-ledger.module'; import { ZKPModule } from './zkp/zkp.module'; import { FraudDetectionModule } from './fraud-detection/fraud-detection.module'; +import { SecurityModule } from './security/security.module'; import { MetricsService } from './common/metrics/metrics.service'; import { TypeOrmMetricsLogger } from './common/metrics/typeorm-metrics.logger'; import { DbPoolMetricsService } from './common/metrics/db-pool-metrics.service'; @@ -51,6 +52,7 @@ import { DbPoolMetricsService } from './common/metrics/db-pool-metrics.service'; DistributedLedgerModule, ZKPModule, FraudDetectionModule, + SecurityModule, PaymentModule, ApiGatewayModule, BlockchainListenerModule, diff --git a/backend/src/security/controllers/security.controller.ts b/backend/src/security/controllers/security.controller.ts new file mode 100644 index 0000000..6860b2e --- /dev/null +++ b/backend/src/security/controllers/security.controller.ts @@ -0,0 +1,67 @@ +import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common'; +import { VulnerabilityManagementService } from '../services/vulnerability-management.service'; +import { IngestScanDto } from '../dto/ingest-scan.dto'; +import { AssignVulnerabilityDto } from '../dto/assign-vulnerability.dto'; +import { ResolveVulnerabilityDto, IgnoreVulnerabilityDto } from '../dto/resolve-vulnerability.dto'; +import { ScanIngestGuard } from '../guards/scan-ingest.guard'; +import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../../auth/guards/roles.guard'; +import { Roles } from '../../auth/decorators/roles.decorator'; +import { Role } from '../../auth/enums/role.enum'; + +@Controller('security') +export class SecurityController { + constructor(private readonly vulnerabilityService: VulnerabilityManagementService) {} + + @Post('scans/ingest') + @UseGuards(ScanIngestGuard) + async ingestScan(@Body() dto: IngestScanDto) { + return this.vulnerabilityService.ingestScan(dto); + } + + @Get('dashboard') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(Role.ADMIN) + async getDashboard() { + return this.vulnerabilityService.getDashboard(); + } + + @Get('vulnerabilities') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(Role.ADMIN) + async getVulnerabilities( + @Query('severity') severity?: string, + @Query('status') status?: string, + @Query('type') type?: string, + ) { + return this.vulnerabilityService.findAll({ severity, status, type }); + } + + @Get('vulnerabilities/:id') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(Role.ADMIN) + async getVulnerability(@Param('id') id: string) { + return this.vulnerabilityService.findOne(id); + } + + @Post('vulnerabilities/:id/assign') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(Role.ADMIN) + async assignVulnerability(@Param('id') id: string, @Body() dto: AssignVulnerabilityDto) { + return this.vulnerabilityService.assign(id, dto.assignee); + } + + @Post('vulnerabilities/:id/resolve') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(Role.ADMIN) + async resolveVulnerability(@Param('id') id: string, @Body() dto: ResolveVulnerabilityDto) { + return this.vulnerabilityService.resolve(id, dto.resolution); + } + + @Post('vulnerabilities/:id/ignore') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(Role.ADMIN) + async ignoreVulnerability(@Param('id') id: string, @Body() dto: IgnoreVulnerabilityDto) { + return this.vulnerabilityService.ignore(id, dto.reason); + } +} diff --git a/backend/src/security/dto/assign-vulnerability.dto.ts b/backend/src/security/dto/assign-vulnerability.dto.ts new file mode 100644 index 0000000..0ac1ff3 --- /dev/null +++ b/backend/src/security/dto/assign-vulnerability.dto.ts @@ -0,0 +1,6 @@ +import { IsString } from 'class-validator'; + +export class AssignVulnerabilityDto { + @IsString() + assignee!: string; +} diff --git a/backend/src/security/dto/ingest-scan.dto.ts b/backend/src/security/dto/ingest-scan.dto.ts new file mode 100644 index 0000000..b3bf5e4 --- /dev/null +++ b/backend/src/security/dto/ingest-scan.dto.ts @@ -0,0 +1,22 @@ +import { IsEnum, IsObject, IsOptional } from 'class-validator'; +import { VulnerabilityType } from '../entities/vulnerability.entity'; + +export enum ScanFormat { + SARIF = 'SARIF', + NPM_AUDIT = 'NPM_AUDIT', + GITLEAKS = 'GITLEAKS', +} + +export class IngestScanDto { + @IsEnum(ScanFormat) + format!: ScanFormat; + + // Only used for SARIF payloads, where the tool doesn't imply a single vulnerability type + // (e.g. Trivy emits both dependency and container findings via SARIF). + @IsOptional() + @IsEnum(VulnerabilityType) + type?: VulnerabilityType; + + @IsObject() + payload!: any; +} diff --git a/backend/src/security/dto/resolve-vulnerability.dto.ts b/backend/src/security/dto/resolve-vulnerability.dto.ts new file mode 100644 index 0000000..e5dfa4e --- /dev/null +++ b/backend/src/security/dto/resolve-vulnerability.dto.ts @@ -0,0 +1,11 @@ +import { IsString } from 'class-validator'; + +export class ResolveVulnerabilityDto { + @IsString() + resolution!: string; +} + +export class IgnoreVulnerabilityDto { + @IsString() + reason!: string; +} diff --git a/backend/src/security/guards/scan-ingest.guard.ts b/backend/src/security/guards/scan-ingest.guard.ts new file mode 100644 index 0000000..935a991 --- /dev/null +++ b/backend/src/security/guards/scan-ingest.guard.ts @@ -0,0 +1,27 @@ +import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common'; +import { Request } from 'express'; + +const SCAN_TOKEN_HEADER = 'x-scan-token'; + +/** + * CI pipelines ingest scan results, not logged-in users, so this checks a shared + * secret (SECURITY_SCAN_TOKEN) instead of the JWT/session guards used elsewhere. + */ +@Injectable() +export class ScanIngestGuard implements CanActivate { + canActivate(context: ExecutionContext): boolean { + const expectedToken = process.env.SECURITY_SCAN_TOKEN; + if (!expectedToken) { + throw new UnauthorizedException('Security scan ingestion is not configured'); + } + + const request = context.switchToHttp().getRequest(); + const providedToken = request.headers[SCAN_TOKEN_HEADER]; + + if (providedToken !== expectedToken) { + throw new UnauthorizedException('Invalid scan ingestion token'); + } + + return true; + } +} diff --git a/backend/src/security/security.module.ts b/backend/src/security/security.module.ts new file mode 100644 index 0000000..570d1d9 --- /dev/null +++ b/backend/src/security/security.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Vulnerability } from './entities/vulnerability.entity'; +import { SecurityEvent } from './entities/security-event.entity'; +import { ScanResultParser } from './parsers/scan-result.parser'; +import { SecurityAlertService } from './services/security-alert.service'; +import { VulnerabilityManagementService } from './services/vulnerability-management.service'; +import { SecurityController } from './controllers/security.controller'; + +@Module({ + imports: [TypeOrmModule.forFeature([Vulnerability, SecurityEvent])], + controllers: [SecurityController], + providers: [ScanResultParser, SecurityAlertService, VulnerabilityManagementService], + exports: [VulnerabilityManagementService], +}) +export class SecurityModule {} From 0740d85bc930de27a362ec5cd75292b8f5d5db06 Mon Sep 17 00:00:00 2001 From: Samuel1505 Date: Wed, 19 Aug 2026 09:21:53 +0100 Subject: [PATCH 4/8] test(security): cover scan parsing and vulnerability lifecycle Parser tests cover SARIF severity mapping (security-severity score vs. level fallback), CVE extraction, fingerprint stability across re-scans, npm-audit and Gitleaks mapping, and empty-payload handling. Service tests cover create-vs-update-on-ingest, critical-finding alerting, status preservation on re-scan, assign/resolve, and dashboard grouping. Part of #70. --- .../parsers/scan-result.parser.spec.ts | 158 ++++++++++++++++ .../vulnerability-management.service.spec.ts | 168 ++++++++++++++++++ 2 files changed, 326 insertions(+) create mode 100644 backend/src/security/parsers/scan-result.parser.spec.ts create mode 100644 backend/src/security/services/vulnerability-management.service.spec.ts diff --git a/backend/src/security/parsers/scan-result.parser.spec.ts b/backend/src/security/parsers/scan-result.parser.spec.ts new file mode 100644 index 0000000..4159cdb --- /dev/null +++ b/backend/src/security/parsers/scan-result.parser.spec.ts @@ -0,0 +1,158 @@ +import { ScanResultParser } from './scan-result.parser'; +import { VulnerabilitySeverity, VulnerabilitySource, VulnerabilityType } from '../entities/vulnerability.entity'; + +describe('ScanResultParser', () => { + let parser: ScanResultParser; + + beforeEach(() => { + parser = new ScanResultParser(); + }); + + describe('parseSarif', () => { + it('maps SARIF results into vulnerabilities using security-severity when present', () => { + const sarif = { + runs: [ + { + tool: { driver: { name: 'Semgrep' } }, + results: [ + { + ruleId: 'no-hardcoded-secrets', + level: 'error', + message: { text: 'Hardcoded secret detected' }, + properties: { 'security-severity': '9.5' }, + locations: [ + { + physicalLocation: { + artifactLocation: { uri: 'src/payment/payment.service.ts' }, + region: { startLine: 42 }, + }, + }, + ], + }, + ], + }, + ], + }; + + const [finding] = parser.parseSarif(sarif, VulnerabilitySource.SEMGREP, VulnerabilityType.CODE); + + expect(finding.severity).toBe(VulnerabilitySeverity.CRITICAL); + expect(finding.affected_component).toBe('src/payment/payment.service.ts'); + expect(finding.location).toBe('src/payment/payment.service.ts:42'); + expect(finding.description).toBe('Hardcoded secret detected'); + }); + + it('falls back to SARIF level when no security-severity score is provided', () => { + const sarif = { + runs: [ + { + tool: { driver: { name: 'Semgrep' } }, + results: [ + { ruleId: 'weak-crypto', level: 'warning', message: { text: 'Weak cryptographic algorithm' } }, + ], + }, + ], + }; + + const [finding] = parser.parseSarif(sarif, VulnerabilitySource.SEMGREP, VulnerabilityType.CODE); + + expect(finding.severity).toBe(VulnerabilitySeverity.MEDIUM); + expect(finding.cvss_score).toBeUndefined(); + }); + + it('extracts a CVE id from the rule id or message when present', () => { + const sarif = { + runs: [ + { + tool: { driver: { name: 'Trivy' } }, + results: [ + { + ruleId: 'CVE-2023-12345', + level: 'error', + message: { text: 'Vulnerable base image layer' }, + locations: [{ physicalLocation: { artifactLocation: { uri: 'Dockerfile' } } }], + }, + ], + }, + ], + }; + + const [finding] = parser.parseSarif(sarif, VulnerabilitySource.TRIVY, VulnerabilityType.CONTAINER); + + expect(finding.cve_id).toBe('CVE-2023-12345'); + }); + + it('produces the same fingerprint for identical findings so re-scans dedupe', () => { + const sarif = { + runs: [ + { + tool: { driver: { name: 'Semgrep' } }, + results: [ + { + ruleId: 'sql-injection', + level: 'error', + message: { text: 'Potential SQL injection' }, + locations: [{ physicalLocation: { artifactLocation: { uri: 'src/db.ts' }, region: { startLine: 10 } } }], + }, + ], + }, + ], + }; + + const first = parser.parseSarif(sarif, VulnerabilitySource.SEMGREP, VulnerabilityType.CODE); + const second = parser.parseSarif(sarif, VulnerabilitySource.SEMGREP, VulnerabilityType.CODE); + + expect(first[0].fingerprint).toEqual(second[0].fingerprint); + }); + + it('returns an empty array when there are no runs', () => { + expect(parser.parseSarif({}, VulnerabilitySource.SEMGREP, VulnerabilityType.CODE)).toEqual([]); + }); + }); + + describe('parseNpmAudit', () => { + it('maps npm audit v2 vulnerabilities into findings', () => { + const report = { + vulnerabilities: { + lodash: { + name: 'lodash', + severity: 'high', + range: '<4.17.21', + via: [{ title: 'Prototype Pollution in lodash', cve: ['CVE-2021-23337'], url: 'https://example.com' }], + fixAvailable: { name: 'lodash', version: '4.17.21' }, + }, + }, + }; + + const [finding] = parser.parseNpmAudit(report); + + expect(finding.severity).toBe(VulnerabilitySeverity.HIGH); + expect(finding.type).toBe(VulnerabilityType.DEPENDENCY); + expect(finding.affected_component).toBe('lodash@<4.17.21'); + expect(finding.cve_id).toBe('CVE-2021-23337'); + expect(finding.remediation).toContain('npm audit fix'); + }); + + it('returns an empty array when there are no vulnerabilities', () => { + expect(parser.parseNpmAudit({ vulnerabilities: {} })).toEqual([]); + }); + }); + + describe('parseGitleaks', () => { + it('treats every leaked secret as a critical finding', () => { + const report = [ + { RuleID: 'stripe-api-key', File: '.env', StartLine: 3, Description: 'Stripe API key' }, + ]; + + const [finding] = parser.parseGitleaks(report); + + expect(finding.severity).toBe(VulnerabilitySeverity.CRITICAL); + expect(finding.type).toBe(VulnerabilityType.SECRET); + expect(finding.location).toBe('.env:3'); + }); + + it('returns an empty array for a non-array payload', () => { + expect(parser.parseGitleaks(null as any)).toEqual([]); + }); + }); +}); diff --git a/backend/src/security/services/vulnerability-management.service.spec.ts b/backend/src/security/services/vulnerability-management.service.spec.ts new file mode 100644 index 0000000..f827c43 --- /dev/null +++ b/backend/src/security/services/vulnerability-management.service.spec.ts @@ -0,0 +1,168 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { NotFoundException } from '@nestjs/common'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { VulnerabilityManagementService } from './vulnerability-management.service'; +import { ScanResultParser } from '../parsers/scan-result.parser'; +import { SecurityAlertService } from './security-alert.service'; +import { + Vulnerability, + VulnerabilitySeverity, + VulnerabilitySource, + VulnerabilityStatus, + VulnerabilityType, +} from '../entities/vulnerability.entity'; +import { SecurityEvent } from '../entities/security-event.entity'; +import { ScanFormat } from '../dto/ingest-scan.dto'; + +describe('VulnerabilityManagementService', () => { + let service: VulnerabilityManagementService; + let vulnerabilityRepository: any; + let securityEventRepository: any; + let alertService: SecurityAlertService; + + const buildVuln = (overrides: Partial = {}): Vulnerability => + ({ + id: 'vuln-1', + severity: VulnerabilitySeverity.HIGH, + type: VulnerabilityType.CODE, + source: VulnerabilitySource.SEMGREP, + description: 'desc', + affected_component: 'src/foo.ts', + status: VulnerabilityStatus.OPEN, + fingerprint: 'fp-1', + created_at: new Date(), + updated_at: new Date(), + ...overrides, + }) as Vulnerability; + + beforeEach(async () => { + vulnerabilityRepository = { + create: jest.fn((data) => data), + save: jest.fn(async (entity) => ({ id: 'vuln-1', ...entity })), + update: jest.fn(), + find: jest.fn().mockResolvedValue([]), + findOne: jest.fn(), + createQueryBuilder: jest.fn().mockReturnValue({ + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + groupBy: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + getRawMany: jest.fn().mockResolvedValue([]), + }), + }; + securityEventRepository = { + create: jest.fn((data) => data), + save: jest.fn(async (entity) => ({ id: 'event-1', ...entity })), + }; + alertService = { notifyCriticalVulnerability: jest.fn() } as unknown as SecurityAlertService; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + VulnerabilityManagementService, + ScanResultParser, + { provide: getRepositoryToken(Vulnerability), useValue: vulnerabilityRepository }, + { provide: getRepositoryToken(SecurityEvent), useValue: securityEventRepository }, + { provide: SecurityAlertService, useValue: alertService }, + ], + }).compile(); + + service = module.get(VulnerabilityManagementService); + }); + + describe('ingestScan', () => { + it('creates a new vulnerability and alerts when a critical finding is new', async () => { + vulnerabilityRepository.findOne.mockResolvedValue(null); + + const sarif = { + runs: [ + { + tool: { driver: { name: 'Semgrep' } }, + results: [ + { + ruleId: 'no-hardcoded-secrets', + level: 'error', + message: { text: 'Hardcoded secret' }, + properties: { 'security-severity': '9.8' }, + locations: [{ physicalLocation: { artifactLocation: { uri: 'src/x.ts' } } }], + }, + ], + }, + ], + }; + + const summary = await service.ingestScan({ format: ScanFormat.SARIF, payload: sarif } as any); + + expect(summary).toEqual({ received: 1, created: 1, updated: 0 }); + expect(alertService.notifyCriticalVulnerability).toHaveBeenCalledTimes(1); + }); + + it('updates an existing finding without touching status or assignment', async () => { + vulnerabilityRepository.findOne.mockResolvedValue( + buildVuln({ status: VulnerabilityStatus.IN_PROGRESS, assigned_to: 'alice' }), + ); + + const sarif = { + runs: [ + { + tool: { driver: { name: 'Semgrep' } }, + results: [ + { ruleId: 'weak-crypto', level: 'warning', message: { text: 'Weak crypto' } }, + ], + }, + ], + }; + + const summary = await service.ingestScan({ format: ScanFormat.SARIF, payload: sarif } as any); + + expect(summary).toEqual({ received: 1, created: 0, updated: 1 }); + const updatePayload = vulnerabilityRepository.update.mock.calls[0][1]; + expect(updatePayload.status).toBeUndefined(); + expect(updatePayload.assigned_to).toBeUndefined(); + expect(alertService.notifyCriticalVulnerability).not.toHaveBeenCalled(); + }); + }); + + describe('assign / resolve / ignore', () => { + it('throws NotFoundException when the vulnerability does not exist', async () => { + vulnerabilityRepository.findOne.mockResolvedValue(null); + await expect(service.assign('missing', 'bob')).rejects.toThrow(NotFoundException); + }); + + it('assigns a vulnerability and moves it to IN_PROGRESS', async () => { + vulnerabilityRepository.findOne.mockResolvedValue(buildVuln()); + await service.assign('vuln-1', 'bob'); + expect(vulnerabilityRepository.update).toHaveBeenCalledWith('vuln-1', { + assigned_to: 'bob', + status: VulnerabilityStatus.IN_PROGRESS, + }); + }); + + it('resolves a vulnerability with a resolution note and timestamp', async () => { + vulnerabilityRepository.findOne.mockResolvedValue(buildVuln()); + await service.resolve('vuln-1', 'patched dependency'); + const updatePayload = vulnerabilityRepository.update.mock.calls[0][1]; + expect(updatePayload.status).toBe(VulnerabilityStatus.RESOLVED); + expect(updatePayload.resolution).toBe('patched dependency'); + expect(updatePayload.resolved_at).toBeInstanceOf(Date); + }); + }); + + describe('getDashboard', () => { + it('groups open vulnerabilities by severity and type', async () => { + vulnerabilityRepository.find.mockResolvedValue([ + buildVuln({ severity: VulnerabilitySeverity.CRITICAL, type: VulnerabilityType.SECRET }), + buildVuln({ severity: VulnerabilitySeverity.HIGH, type: VulnerabilityType.DEPENDENCY }), + buildVuln({ severity: VulnerabilitySeverity.HIGH, type: VulnerabilityType.DEPENDENCY }), + ]); + + const dashboard = await service.getDashboard(); + + expect(dashboard.totalOpen).toBe(3); + expect(dashboard.criticalCount).toBe(1); + expect(dashboard.highCount).toBe(2); + expect(dashboard.bySeverity[VulnerabilitySeverity.HIGH]).toBe(2); + expect(dashboard.byType[VulnerabilityType.DEPENDENCY]).toBe(2); + }); + }); +}); From 84641dec0777ce962e37c17330cf2f4b01f3f13e Mon Sep 17 00:00:00 2001 From: Samuel1505 Date: Wed, 19 Aug 2026 09:22:13 +0100 Subject: [PATCH 5/8] ci(security): add automated security scanning workflow Runs SAST (Semgrep), dependency scanning (npm audit on backend and frontend), secret scanning (Gitleaks), container scanning (Trivy on the backend image), IaC scanning (Checkov), and OWASP ZAP baseline DAST against the backend on push/PR/daily schedule. SARIF results upload to the GitHub Security tab; each job optionally forwards results to POST /security/scans/ingest when SECURITY_API_URL/SECURITY_SCAN_TOKEN secrets are configured. Dependabot opens weekly PRs for backend/frontend npm deps, the backend Docker base image, and GitHub Actions versions. Closes #70. --- .github/dependabot.yml | 41 +++++ .github/workflows/security-scan.yml | 223 ++++++++++++++++++++++++++++ 2 files changed, 264 insertions(+) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/security-scan.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..a0ff539 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,41 @@ +version: 2 +updates: + - package-ecosystem: 'npm' + directory: '/backend' + schedule: + interval: 'weekly' + open-pull-requests-limit: 10 + labels: + - 'dependencies' + - 'security' + groups: + minor-and-patch: + update-types: ['minor', 'patch'] + + - package-ecosystem: 'npm' + directory: '/frontend' + schedule: + interval: 'weekly' + open-pull-requests-limit: 10 + labels: + - 'dependencies' + - 'security' + groups: + minor-and-patch: + update-types: ['minor', 'patch'] + + - package-ecosystem: 'github-actions' + directory: '/' + schedule: + interval: 'weekly' + labels: + - 'dependencies' + - 'security' + + - package-ecosystem: 'docker' + directory: '/backend' + schedule: + interval: 'weekly' + labels: + - 'dependencies' + - 'security' diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml new file mode 100644 index 0000000..d5f3439 --- /dev/null +++ b/.github/workflows/security-scan.yml @@ -0,0 +1,223 @@ +name: Security Scanning + +on: + push: + branches: [main, develop] + pull_request: + branches: [main] + schedule: + - cron: '0 0 * * *' # Daily scan + workflow_dispatch: {} + +permissions: + contents: read + security-events: write + +env: + # When set (as repo/org secrets), scan results are also pushed into the + # in-app vulnerability dashboard at $SECURITY_API_URL/security/scans/ingest. + # Both must be present or ingestion is skipped - see docs/security-scanning.md. + SECURITY_API_URL: ${{ secrets.SECURITY_API_URL }} + SECURITY_SCAN_TOKEN: ${{ secrets.SECURITY_SCAN_TOKEN }} + +jobs: + sast: + name: SAST (Semgrep) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Run Semgrep + uses: semgrep/semgrep-action@v1 + with: + config: >- + p/security-audit + p/secrets + p/typescript + p/nodejsscan + .semgrep/security-rules.yaml + generateSarif: '1' + + - name: Upload SARIF + if: always() + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: semgrep.sarif + category: semgrep + + - name: Report to security dashboard + if: always() && env.SECURITY_API_URL != '' && env.SECURITY_SCAN_TOKEN != '' + run: | + if [ -f semgrep.sarif ]; then + jq -n --slurpfile payload semgrep.sarif '{format: "SARIF", type: "CODE", payload: $payload[0]}' | \ + curl -sf -X POST "$SECURITY_API_URL/security/scans/ingest" \ + -H "Content-Type: application/json" \ + -H "x-scan-token: $SECURITY_SCAN_TOKEN" \ + -d @- + fi + + dependency-scan: + name: Dependency Scan (npm audit) + runs-on: ubuntu-latest + strategy: + matrix: + workspace: [backend, frontend] + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '18' + + - name: Run npm audit + working-directory: ${{ matrix.workspace }} + run: npm audit --json > npm-audit.json || true + + - name: Fail on high/critical vulnerabilities + working-directory: ${{ matrix.workspace }} + run: | + npm audit --audit-level=high + + - name: Report to security dashboard + if: always() && env.SECURITY_API_URL != '' && env.SECURITY_SCAN_TOKEN != '' + working-directory: ${{ matrix.workspace }} + run: | + if [ -s npm-audit.json ]; then + jq -n --slurpfile payload npm-audit.json '{format: "NPM_AUDIT", payload: $payload[0]}' | \ + curl -sf -X POST "$SECURITY_API_URL/security/scans/ingest" \ + -H "Content-Type: application/json" \ + -H "x-scan-token: $SECURITY_SCAN_TOKEN" \ + -d @- + fi + + # Dependabot itself runs from .github/dependabot.yml, not as a CI job - + # it opens PRs on its own schedule, see that file for config. + + secret-scan: + name: Secret Scan (Gitleaks) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Run Gitleaks + run: | + docker run --rm -v "$PWD:/repo" zricethezav/gitleaks:latest \ + detect --source /repo --report-format json --report-path /repo/gitleaks-report.json --exit-code 1 || \ + (echo "GITLEAKS_FAILED=1" >> "$GITHUB_ENV") + + - name: Report to security dashboard + if: always() && env.SECURITY_API_URL != '' && env.SECURITY_SCAN_TOKEN != '' + run: | + if [ -s gitleaks-report.json ]; then + jq -n --slurpfile payload gitleaks-report.json '{format: "GITLEAKS", payload: $payload[0]}' | \ + curl -sf -X POST "$SECURITY_API_URL/security/scans/ingest" \ + -H "Content-Type: application/json" \ + -H "x-scan-token: $SECURITY_SCAN_TOKEN" \ + -d @- + fi + + - name: Fail if secrets were found + if: env.GITLEAKS_FAILED == '1' + run: exit 1 + + container-scan: + name: Container Scan (Trivy) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Build backend image + run: docker build -t lumina/payment-service:${{ github.sha }} ./backend + + - name: Run Trivy + uses: aquasecurity/trivy-action@master + with: + image-ref: lumina/payment-service:${{ github.sha }} + format: 'sarif' + output: 'trivy-results.sarif' + severity: 'CRITICAL,HIGH' + + - name: Upload Trivy results + if: always() + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: 'trivy-results.sarif' + category: trivy + + - name: Report to security dashboard + if: always() && env.SECURITY_API_URL != '' && env.SECURITY_SCAN_TOKEN != '' + run: | + if [ -f trivy-results.sarif ]; then + jq -n --slurpfile payload trivy-results.sarif '{format: "SARIF", type: "CONTAINER", payload: $payload[0]}' | \ + curl -sf -X POST "$SECURITY_API_URL/security/scans/ingest" \ + -H "Content-Type: application/json" \ + -H "x-scan-token: $SECURITY_SCAN_TOKEN" \ + -d @- + fi + + infrastructure-scan: + name: Infrastructure Scan (Checkov) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Run Checkov + uses: bridgecrewio/checkov-action@master + with: + directory: . + framework: dockerfile,docker_compose + output_format: sarif + output_file_path: checkov-results.sarif + soft_fail: true + + - name: Upload Checkov results + if: always() + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: checkov-results.sarif + category: checkov + + dast: + name: DAST (OWASP ZAP baseline) + runs-on: ubuntu-latest + needs: [sast, dependency-scan, container-scan, secret-scan] + if: github.event_name != 'pull_request' + steps: + - uses: actions/checkout@v4 + + - name: Start backend stack + run: docker compose up -d --build postgres redis backend + + - name: Wait for backend to be healthy + run: | + for i in $(seq 1 30); do + if curl -sf http://localhost:4000/health >/dev/null 2>&1; then exit 0; fi + sleep 5 + done + echo "Backend did not become healthy in time" + docker compose logs backend + exit 1 + + - name: Run OWASP ZAP baseline scan + uses: zaproxy/action-baseline@v0.12.0 + with: + target: 'http://localhost:4000' + rules_file_name: '.zap/rules.tsv' + cmd_options: '-a' + + - name: Report to security dashboard + if: always() && env.SECURITY_API_URL != '' && env.SECURITY_SCAN_TOKEN != '' + run: | + if [ -f report_json.json ]; then + jq -n --slurpfile payload report_json.json '{format: "SARIF", type: "DAST", payload: $payload[0]}' | \ + curl -sf -X POST "$SECURITY_API_URL/security/scans/ingest" \ + -H "Content-Type: application/json" \ + -H "x-scan-token: $SECURITY_SCAN_TOKEN" \ + -d @- || true + fi + + - name: Tear down backend stack + if: always() + run: docker compose down -v From e8d0f7e49106f96df8018cca7ce34ba4940f1d86 Mon Sep 17 00:00:00 2001 From: Samuel1505 Date: Wed, 19 Aug 2026 09:22:22 +0100 Subject: [PATCH 6/8] chore(security): add scanner tuning configs Custom Semgrep rules for hardcoded secrets, string-concatenated SQL, weak hashes, and disabled TLS verification (layered on top of the p/security-audit and p/secrets community rulesets in CI). Gitleaks allowlist excludes test fixtures/.env.example from secret matches. ZAP baseline rules.tsv suppresses two alerts that are expected noise for a cookieless JSON API. Part of #70. --- .gitleaks.toml | 12 +++++++ .semgrep/security-rules.yaml | 66 ++++++++++++++++++++++++++++++++++++ .zap/rules.tsv | 8 +++++ 3 files changed, 86 insertions(+) create mode 100644 .gitleaks.toml create mode 100644 .semgrep/security-rules.yaml create mode 100644 .zap/rules.tsv diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000..411f211 --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,12 @@ +title = "Lumina gitleaks config" + +[extend] +useDefault = true + +[allowlist] +description = "Test fixtures and examples, not real credentials" +paths = [ + '''\.env\.example$''', + '''.*\.spec\.ts$''', + '''.*\.test\.ts$''', +] diff --git a/.semgrep/security-rules.yaml b/.semgrep/security-rules.yaml new file mode 100644 index 0000000..0f4d37b --- /dev/null +++ b/.semgrep/security-rules.yaml @@ -0,0 +1,66 @@ +rules: + - id: no-hardcoded-secrets + languages: [typescript, javascript] + severity: ERROR + message: >- + Hardcoded credential-like literal detected. Load secrets from + process.env / a secrets manager instead of committing them. + metadata: + category: security + cwe: 'CWE-798: Use of Hard-coded Credentials' + patterns: + - pattern-either: + - pattern: const $VAR = "..." + - pattern: let $VAR = "..." + - metavariable-regex: + metavariable: $VAR + regex: (?i)^(api[_-]?key|secret|password|passwd|token|private[_-]?key)$ + - pattern-not: const $VAR = process.env.$ENV + + - id: sql-injection-string-concat + languages: [typescript, javascript] + severity: ERROR + message: >- + Building a SQL query with string concatenation/interpolation of a + variable is a SQL injection risk. Use parameterized queries + (TypeORM query builder / prepared statement params) instead. + metadata: + category: security + cwe: 'CWE-89: SQL Injection' + patterns: + - pattern-either: + - pattern: $QUERY = $BASE + $INPUT + - pattern: $QUERY = `...${$INPUT}...` + - pattern-inside: | + $QUERY = ... + ... + $DB.query($QUERY, ...) + + - id: weak-crypto-hash + languages: [typescript, javascript] + severity: WARNING + message: >- + MD5/SHA1 are cryptographically broken for security-sensitive use + (password hashing, signatures, integrity checks). Use SHA-256/SHA-512 + or a purpose-built KDF (bcrypt/argon2) instead. + metadata: + category: security + cwe: 'CWE-327: Use of a Broken or Risky Cryptographic Algorithm' + patterns: + - pattern-either: + - pattern: crypto.createHash("md5") + - pattern: crypto.createHash("sha1") + + - id: disabled-tls-verification + languages: [typescript, javascript] + severity: ERROR + message: >- + TLS certificate verification is disabled. This allows + man-in-the-middle attacks against payment/webhook traffic. + metadata: + category: security + cwe: 'CWE-295: Improper Certificate Validation' + patterns: + - pattern-either: + - pattern: '$AXIOS.create({..., rejectUnauthorized: false, ...})' + - pattern: process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0" diff --git a/.zap/rules.tsv b/.zap/rules.tsv new file mode 100644 index 0000000..693eeb0 --- /dev/null +++ b/.zap/rules.tsv @@ -0,0 +1,8 @@ +# OWASP ZAP baseline scan rule overrides. +# Format: +# Full rule list: https://www.zaproxy.org/docs/alerts/ +# +# The backend is a JSON API with no session cookies issued at these routes, +# so cookie-hardening alerts are noise here - revisit if that changes. +10096 IGNORE # Timestamp disclosure - many API responses legitimately include timestamps +10021 IGNORE # X-Content-Type-Options header missing on non-HTML JSON responses From ed5a8ee42893eabe73c2fbaf097d4a62d1f170fa Mon Sep 17 00:00:00 2001 From: Samuel1505 Date: Wed, 19 Aug 2026 09:22:32 +0100 Subject: [PATCH 7/8] docs(security): document security scanning setup Explains what each CI job checks, how to enable CI-to-dashboard reporting and critical-finding webhook alerts, local commands to run each scanner, and known gaps (no SonarQube/Snyk/Burp Suite - free equivalents used instead; no Terraform directory yet for the IaC scan to target). Adds the new SECURITY_SCAN_TOKEN/SECURITY_ALERT_WEBHOOK_URL env vars to .env.example. Part of #70. --- .env.example | 8 ++++ docs/SECURITY_SCANNING.md | 86 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+) create mode 100644 docs/SECURITY_SCANNING.md diff --git a/.env.example b/.env.example index 9d4d6eb..7a3d51a 100644 --- a/.env.example +++ b/.env.example @@ -83,3 +83,11 @@ BANXA_WEBHOOK_SECRET= # alertmanager/secrets/*, not from this file; see alertmanager/README.md. GRAFANA_ADMIN_USER=admin GRAFANA_ADMIN_PASSWORD=admin + +# Security scanning - see docs/SECURITY_SCANNING.md +# Shared secret the CI security-scan workflow presents via the x-scan-token +# header when POSTing scan results to POST /security/scans/ingest. +SECURITY_SCAN_TOKEN= +# Optional: webhook (e.g. a Slack incoming webhook URL) notified on every +# CRITICAL vulnerability ingested. +SECURITY_ALERT_WEBHOOK_URL= diff --git a/docs/SECURITY_SCANNING.md b/docs/SECURITY_SCANNING.md new file mode 100644 index 0000000..bf4cb00 --- /dev/null +++ b/docs/SECURITY_SCANNING.md @@ -0,0 +1,86 @@ +# Automated Security Scanning + +Implements issue #70. Continuous scanning runs on every push to `main`/`develop`, +every PR into `main`, and once a day on a schedule, via +[`.github/workflows/security-scan.yml`](../.github/workflows/security-scan.yml). + +## What runs + +| Check | Tool | Job | Scope | +|---|---|---|---| +| SAST | Semgrep (`p/security-audit`, `p/secrets`, `p/typescript`, `p/nodejsscan` + [`.semgrep/security-rules.yaml`](../.semgrep/security-rules.yaml)) | `sast` | whole repo | +| Dependency scan | `npm audit` | `dependency-scan` | `backend/`, `frontend/` (matrix) | +| Secret scan | Gitleaks ([`.gitleaks.toml`](../.gitleaks.toml)) | `secret-scan` | full git history | +| Container scan | Trivy | `container-scan` | `backend` image | +| IaC scan | Checkov | `infrastructure-scan` | `Dockerfile`s, `docker-compose.yml` | +| DAST | OWASP ZAP baseline ([`.zap/rules.tsv`](../.zap/rules.tsv)) | `dast` | `backend` running via docker compose, `push`/`schedule` only (not PRs, to keep PR CI fast) | + +All jobs are free/OSS and need no external account. SonarQube, Snyk, and Burp Suite +(mentioned in the original issue) were intentionally left out because they require +paid accounts/API tokens this repo doesn't have configured; Semgrep + npm audit + +ZAP cover the same categories (SAST / dependency / DAST) without that dependency. +If the team later gets a Snyk or SonarQube account, add the corresponding action +as a new job following the same pattern and gate it on the relevant `secrets.*` +being present, the way `dast` and the `Report to security dashboard` steps do. + +SARIF results (Semgrep, Trivy, Checkov) are uploaded to the **Security** tab of +the GitHub repo automatically via `github/codeql-action/upload-sarif`. + +## Vulnerability management + +Findings can also be pushed into the in-app dashboard, implemented in +[`backend/src/security/`](../backend/src/security/): + +- `POST /security/scans/ingest` β€” accepts a SARIF, npm-audit, or Gitleaks report + (see `IngestScanDto`) and upserts `Vulnerability` rows, deduplicated by a + fingerprint of (source, rule, component, location). Re-ingesting a known + finding updates its details but never touches `status`/`assigned_to`, so + triage state survives repeat scans. Guarded by a shared-secret header + (`x-scan-token`), not a user session β€” see `ScanIngestGuard`. +- `GET /security/dashboard` β€” counts by severity/type and a 30-day trend. +- `GET /security/vulnerabilities`, `GET /security/vulnerabilities/:id` β€” listing/detail. +- `POST /security/vulnerabilities/:id/assign|resolve|ignore` β€” triage actions, + each recorded as a `SecurityEvent` for audit history. + +Admin-role JWT required for everything except `/scans/ingest`. + +### Enabling CI β†’ dashboard reporting + +Unset by default. To wire it up, add these repo/org secrets: + +- `SECURITY_API_URL` β€” base URL of a deployed backend (e.g. `https://api.lumina.example`) +- `SECURITY_SCAN_TOKEN` β€” must match the backend's `SECURITY_SCAN_TOKEN` env var + +Every scan job's "Report to security dashboard" step is a no-op until both are set. + +### Alerting + +`SecurityAlertService` logs every CRITICAL finding and, if `SECURITY_ALERT_WEBHOOK_URL` +is set (e.g. a Slack incoming webhook), POSTs a summary there too. + +## Local usage + +```bash +# SAST +docker run --rm -v "$PWD:/src" semgrep/semgrep semgrep scan --config auto --config .semgrep/security-rules.yaml + +# Dependency scan +cd backend && npm audit +cd frontend && npm audit + +# Secret scan +docker run --rm -v "$PWD:/repo" zricethezav/gitleaks:latest detect --source /repo + +# Container scan +docker build -t lumina/payment-service ./backend +docker run --rm aquasec/trivy image lumina/payment-service +``` + +## Known gaps / follow-ups + +- PCI DSS / SOC 2 compliance scanning and formal security training materials + from the original issue are process/org work, not something a CI job can + cover, and are out of scope here. +- No Terraform/IaC directory exists yet in this repo, so `infrastructure-scan` + currently only checks the Dockerfiles and `docker-compose.yml`. Point Checkov + at a `framework: terraform` directory once one exists. From c64e7a125dd3f0c7dd5a647001e313779338d7d3 Mon Sep 17 00:00:00 2001 From: Samuel1505 Date: Wed, 19 Aug 2026 09:23:32 +0100 Subject: [PATCH 8/8] done --- pr.md | 139 +++++++++++++++++++++++++++++++++++----------------------- 1 file changed, 83 insertions(+), 56 deletions(-) diff --git a/pr.md b/pr.md index 04acef1..100ff4f 100644 --- a/pr.md +++ b/pr.md @@ -1,63 +1,90 @@ -## [Infrastructure] Implement Monitoring with Prometheus and Grafana +## [Feature] Automated Security Scanning and Vulnerability Management -Closes #17 +Closes #70 ### Overview -Adds a full monitoring and observability stack β€” metrics collection in the -backend, Prometheus scraping/alerting, and provisioned Grafana dashboards β€” -where none existed before. +Adds continuous, automated security scanning (SAST, dependency, secret, +container, IaC, and DAST) plus an in-app vulnerability management system, +where security checks were previously manual and inconsistent. + +SonarQube, Snyk, and Burp Suite from the original issue were intentionally +left out β€” they need paid accounts/tokens this repo doesn't have. Semgrep, +`npm audit`, and OWASP ZAP cover the same categories (SAST / dependency / +DAST) for free, with no external account required to run in CI. See +"Known gaps" in the docs below for the rest of what's out of scope here. ### What's included -**Application metrics** (`backend/src/common/metrics/`) -- New `MetricsService` wrapping a `prom-client` registry, exposed at `GET /metrics`. -- `HttpMetricsInterceptor` β€” request rate/latency/error metrics for every route (`http_requests_total`, `http_request_duration_seconds`). -- `TypeOrmMetricsLogger` β€” database query duration/error metrics (`db_query_duration_seconds`, `db_query_errors_total`), wired via `TypeOrmModule.forRootAsync` with `maxQueryExecutionTime: 1` so the duration hook fires for effectively every query. -- `DbPoolMetricsService` β€” polls the underlying `pg` pool every 10s for `db_pool_total_connections` / `db_pool_idle_connections` / `db_pool_waiting_requests`. -- `trackExternalCall()` helper wraps outbound calls with latency + success/error counters (`external_service_call_duration_seconds`, `external_service_calls_total`), applied to: CoinGecko, Binance, and Chainlink price providers; Stellar RPC calls in the blockchain listener; Stripe calls in the ramp service; and webhook delivery. -- Queue metrics (`queue_depth`, `queue_job_processing_duration_seconds`, `queue_jobs_total`) applied to the webhook delivery retry queue β€” the one queue-like system in the codebase today. -- Business metrics: `payments_total` / `payment_volume_total` (payment service) and `ramp_operations_total` / `ramp_operation_volume_total` (on/off-ramp), both labeled by currency and status for success-rate dashboards. - -**Prometheus** (`prometheus/`) -- `prometheus.yml` β€” scrape config for the backend, Prometheus/Alertmanager self-monitoring, `node-exporter` (host metrics), and `postgres-exporter`. -- 15-day retention and TSDB storage path configured via CLI flags on the `prometheus` service in `docker-compose.yml`. -- `alerts/rules.yml` β€” the five required alerts: `HighErrorRate` (>5% 5xx for 5m), `HighLatency` (p95 > 2s for 5m), `DatabaseConnectionPoolExhausted`, `QueueDepthThresholdExceeded`, `ServiceDown` (via Prometheus's built-in `up` metric). - -**Alertmanager** (`alertmanager/`) -- Routes `severity: critical` to Slack + PagerDuty, `severity: warning` to Slack only; groups by `alertname` + `service`; inhibits latency/error-rate noise when `ServiceDown` is already firing for the same service. -- Secrets (Slack webhook, SMTP, PagerDuty routing key) are read from files under `alertmanager/secrets/` (git-ignored, `.example` templates committed) since Alertmanager doesn't expand env vars in its config. -- On-call/escalation policy documented in `alertmanager/README.md` (owned by PagerDuty's escalation policy, not this repo). - -**Grafana** (`grafana/`) -- Auto-provisioned Prometheus datasource + five dashboards, no manual import needed: System Overview, API Performance, Database Performance, Queue Metrics, Business Metrics. - -**docker-compose.yml** -- New services: `prometheus`, `alertmanager`, `grafana`, `node-exporter`, `postgres-exporter`, wired to the existing `backend`/`postgres` services. - -### Not included / follow-ups - -- **Centralized, searchable logging** (the "Logging centralized and searchable" acceptance item) is a separate concern from metrics/dashboards/alerting β€” the backend already emits structured JSON logs via Winston with correlation IDs, but shipping them to a searchable store (Loki, ELK) is a large enough addition that it deserves its own PR/issue rather than being bundled into "Prometheus and Grafana." -- `backend/package-lock.json` was not regenerated (no network access in this environment to run `npm install`) β€” run `npm install` in `backend/` after merging to lock `prom-client`. -- Pre-existing, unrelated bugs noticed in `ramp-service.service.ts` (undefined `crypto_amount`/`exchangeRateValue`/`exchangeRate` references in `initiateOnRamp`/`initiateOffRamp`) were left untouched β€” out of scope for this monitoring PR. - -### How to try it - -```bash -cp .env.example .env -cd alertmanager/secrets && for f in *.example; do cp "$f" "${f%.example}"; done && cd - -docker-compose up -d -``` - -- Backend metrics: http://localhost:4000/metrics -- Prometheus: http://localhost:9090 -- Alertmanager: http://localhost:9093 -- Grafana: http://localhost:3001 (`admin` / `admin` by default β€” see `.env.example`) - -### Test plan - -- [ ] `cd backend && npm install && npm test` β€” updated unit tests for `CoinGeckoProvider`, `BinanceProvider`, `ChainlinkProvider`, `BlockchainListenerService`, `WebhookService`, `PaymentService` pass with the new `MetricsService` dependency injected/stubbed. -- [ ] `docker-compose up -d` and confirm `backend:4000/metrics` returns Prometheus text format. -- [ ] Confirm Prometheus targets page shows all scrape jobs as `UP`. -- [ ] Confirm Grafana loads the five dashboards under the "Lumina" folder with data flowing. -- [ ] Trigger a synthetic 5xx burst and confirm `HighErrorRate` fires in Prometheus β†’ Alertmanager β†’ Slack. +**CI security scanning** (`.github/workflows/security-scan.yml`) β€” runs on +push to `main`/`develop`, PRs into `main`, and daily on a schedule: +- **SAST** β€” Semgrep, using the community `p/security-audit`, `p/secrets`, + `p/typescript`, `p/nodejsscan` rulesets plus custom rules + (`.semgrep/security-rules.yaml`) for hardcoded secrets, string-concatenated + SQL, weak hashes (MD5/SHA1), and disabled TLS verification. +- **Dependency scan** β€” `npm audit --audit-level=high` across `backend/` and + `frontend/` (matrix job), fails the build on high/critical findings. +- **Secret scan** β€” Gitleaks over full git history, with an allowlist + (`.gitleaks.toml`) for test fixtures and `.env.example`. +- **Container scan** β€” Trivy against the built backend image. +- **IaC scan** β€” Checkov over the Dockerfiles and `docker-compose.yml` + (no Terraform directory exists yet to point it at). +- **DAST** β€” OWASP ZAP baseline scan against the backend, brought up via + `docker compose` (Postgres + Redis + backend) and health-checked before + scanning. Runs on push/schedule only, not PRs, to keep PR CI fast. +- SARIF output from Semgrep, Trivy, and Checkov uploads to the repo's + **Security** tab via `github/codeql-action/upload-sarif`. +- Every job optionally forwards its results into the vulnerability dashboard + (`POST /security/scans/ingest`) when `SECURITY_API_URL` / + `SECURITY_SCAN_TOKEN` secrets are configured β€” a no-op otherwise. +- `.github/dependabot.yml` β€” weekly PRs for `backend`/`frontend` npm deps, + the backend's Docker base image, and GitHub Actions versions. + +**Vulnerability management** (`backend/src/security/`) +- `Vulnerability` / `SecurityEvent` entities (`entities/`). +- `ScanResultParser` normalizes SARIF (Semgrep/Trivy/ZAP), `npm audit`, and + Gitleaks reports into a common shape, deduplicated by a fingerprint of + (source, rule, affected component, location) β€” re-ingesting a known + finding updates its details but never touches `status`/`assigned_to`, so + triage state survives repeat scans. +- `VulnerabilityManagementService` β€” ingest, list/get, assign, resolve, + ignore, and a dashboard (open counts by severity/type, 30-day trend). + Every state transition is recorded as a `SecurityEvent` for audit history. +- `SecurityAlertService` β€” logs every CRITICAL finding and optionally POSTs + a summary to `SECURITY_ALERT_WEBHOOK_URL` (e.g. a Slack incoming webhook). +- `SecurityController`: + - `POST /security/scans/ingest` β€” guarded by a shared-secret `x-scan-token` + header (`ScanIngestGuard`), since CI posts here, not a logged-in user. + - `GET /security/dashboard`, `GET /security/vulnerabilities[/:id]`, + `POST /security/vulnerabilities/:id/{assign,resolve,ignore}` β€” behind + the existing `JwtAuthGuard` + `RolesGuard(Role.ADMIN)`. +- Wired into `AppModule`. + +**Docs** β€” `docs/SECURITY_SCANNING.md` covers what each job checks, how to +enable CIβ†’dashboard reporting and webhook alerts, local commands to run each +scanner, and known gaps. `SECURITY_SCAN_TOKEN` / `SECURITY_ALERT_WEBHOOK_URL` +added to `.env.example`. + +### Testing + +- 15 new unit tests (`backend/src/security/**/*.spec.ts`): SARIF severity + mapping (CVSS score vs. level fallback), CVE extraction, fingerprint + stability across re-scans, npm-audit/Gitleaks mapping, create-vs-update- + on-ingest, critical-finding alerting, status preservation on re-scan, + assign/resolve, and dashboard grouping β€” all passing. +- `tsc --noEmit` clean for the new module. +- Full existing backend test suite re-run: no regressions from this change + (pre-existing failures in `crypto`/`distributed-ledger` specs are + unrelated β€” ESM import issues and a private-property access, both present + before this branch). +- All new YAML (`security-scan.yml`, `dependabot.yml`, Semgrep rules) + validated with a YAML parser. + +### Manual verification still needed + +- The CI workflow itself (Semgrep/Trivy/Checkov/ZAP/Gitleaks marketplace + actions, docker-compose health-check timing) hasn't run in GitHub Actions + yet β€” needs a live run on this PR to confirm each job passes end-to-end. +- CIβ†’dashboard reporting and the Slack alert webhook are exercised by unit + tests only; wiring real secrets and confirming an ingested finding shows + up in `GET /security/dashboard` against a deployed backend is a follow-up.