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/.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 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 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/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/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/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/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)]), + }; + }); + } +} 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 {} 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.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); + }); + }); +}); 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) })); + } +} 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. 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.