diff --git a/.env.example b/.env.example index 7a3d51a..9d4d6eb 100644 --- a/.env.example +++ b/.env.example @@ -83,11 +83,3 @@ 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 deleted file mode 100644 index a0ff539..0000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,41 +0,0 @@ -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 deleted file mode 100644 index d5f3439..0000000 --- a/.github/workflows/security-scan.yml +++ /dev/null @@ -1,223 +0,0 @@ -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 deleted file mode 100644 index 411f211..0000000 --- a/.gitleaks.toml +++ /dev/null @@ -1,12 +0,0 @@ -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 deleted file mode 100644 index 0f4d37b..0000000 --- a/.semgrep/security-rules.yaml +++ /dev/null @@ -1,66 +0,0 @@ -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 deleted file mode 100644 index 693eeb0..0000000 --- a/.zap/rules.tsv +++ /dev/null @@ -1,8 +0,0 @@ -# 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 d2ebe7f..df17b51 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -15,7 +15,6 @@ 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'; @@ -52,7 +51,6 @@ 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 deleted file mode 100644 index 6860b2e..0000000 --- a/backend/src/security/controllers/security.controller.ts +++ /dev/null @@ -1,67 +0,0 @@ -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 deleted file mode 100644 index 0ac1ff3..0000000 --- a/backend/src/security/dto/assign-vulnerability.dto.ts +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index b3bf5e4..0000000 --- a/backend/src/security/dto/ingest-scan.dto.ts +++ /dev/null @@ -1,22 +0,0 @@ -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 deleted file mode 100644 index e5dfa4e..0000000 --- a/backend/src/security/dto/resolve-vulnerability.dto.ts +++ /dev/null @@ -1,11 +0,0 @@ -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 deleted file mode 100644 index 421a3bd..0000000 --- a/backend/src/security/entities/security-event.entity.ts +++ /dev/null @@ -1,32 +0,0 @@ -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 deleted file mode 100644 index cea97b7..0000000 --- a/backend/src/security/entities/vulnerability.entity.ts +++ /dev/null @@ -1,93 +0,0 @@ -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 deleted file mode 100644 index 935a991..0000000 --- a/backend/src/security/guards/scan-ingest.guard.ts +++ /dev/null @@ -1,27 +0,0 @@ -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 deleted file mode 100644 index 4159cdb..0000000 --- a/backend/src/security/parsers/scan-result.parser.spec.ts +++ /dev/null @@ -1,158 +0,0 @@ -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 deleted file mode 100644 index 656d01b..0000000 --- a/backend/src/security/parsers/scan-result.parser.ts +++ /dev/null @@ -1,172 +0,0 @@ -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 deleted file mode 100644 index 570d1d9..0000000 --- a/backend/src/security/security.module.ts +++ /dev/null @@ -1,16 +0,0 @@ -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 deleted file mode 100644 index 3836094..0000000 --- a/backend/src/security/services/security-alert.service.ts +++ /dev/null @@ -1,39 +0,0 @@ -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 deleted file mode 100644 index f827c43..0000000 --- a/backend/src/security/services/vulnerability-management.service.spec.ts +++ /dev/null @@ -1,168 +0,0 @@ -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 deleted file mode 100644 index e0b2cf6..0000000 --- a/backend/src/security/services/vulnerability-management.service.ts +++ /dev/null @@ -1,246 +0,0 @@ -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 deleted file mode 100644 index bf4cb00..0000000 --- a/docs/SECURITY_SCANNING.md +++ /dev/null @@ -1,86 +0,0 @@ -# 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 100ff4f..04acef1 100644 --- a/pr.md +++ b/pr.md @@ -1,90 +1,63 @@ -## [Feature] Automated Security Scanning and Vulnerability Management +## [Infrastructure] Implement Monitoring with Prometheus and Grafana -Closes #70 +Closes #17 ### Overview -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. +Adds a full monitoring and observability stack β€” metrics collection in the +backend, Prometheus scraping/alerting, and provisioned Grafana dashboards β€” +where none existed before. ### What's included -**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. +**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.