diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 6b2dd88d..d6d71ab4 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -6,6 +6,7 @@ import { AppController } from "./app.controller"; import { ExampleController } from "./example/example.controller"; import { FailedTransactionController } from "./controllers/failed-transaction.controller"; import { CrossChainGasController } from "./controllers/cross-chain-gas.controller"; +import { FindingsNestController } from "./findings/findings.nest-controller"; import { FailedTransactionService } from "./services/failed-transaction.service"; import { MitigationService } from "./services/mitigation.service"; import { TransactionAnalysisService } from "./services/transaction-analysis.service"; @@ -48,6 +49,7 @@ import { ScheduleModule } from '@nestjs/schedule'; ExampleController, FailedTransactionController, CrossChainGasController, + FindingsNestController, // Add your controllers here - remember to add @Version('1') decorator ], providers: [ diff --git a/apps/api/src/findings/findings.nest-controller.ts b/apps/api/src/findings/findings.nest-controller.ts new file mode 100644 index 00000000..5e0e6650 --- /dev/null +++ b/apps/api/src/findings/findings.nest-controller.ts @@ -0,0 +1,29 @@ +/** + * NestJS adapter for the findings API (Issue #1241 — cursor pagination). + * + * findings.controller.ts / findings.service.ts / cursor.ts (#992) already + * implement full keyset/cursor pagination with tenant isolation (#996); that + * logic was only reachable through apps/api/src/server.ts, an Express + * entrypoint the deployed container does not run (main.ts boots the + * NestJS AppModule — see apps/api/src/main.ts and the Dockerfile CMD). + * This controller registers the existing handlers on a real, guarded Nest + * route instead of duplicating their logic, so `GET /v1/findings` and + * `GET /v1/findings/:id` go through the same global guards (rate limiting, + * JWT auth, RBAC) as every other endpoint in this app. + */ +import { Controller, Get, Req, Res } from "@nestjs/common"; +import { Request, Response } from "express"; +import { findingsController } from "./findings.controller"; + +@Controller({ path: "findings", version: "1" }) +export class FindingsNestController { + @Get() + list(@Req() req: Request, @Res() res: Response): void { + findingsController.list(req, res); + } + + @Get(":id") + getOne(@Req() req: Request, @Res() res: Response): void { + findingsController.getOne(req, res); + } +} diff --git a/apps/api/src/rate-limiting/controllers/admin.controller.ts b/apps/api/src/rate-limiting/controllers/admin.controller.ts index 6db0c317..87238432 100644 --- a/apps/api/src/rate-limiting/controllers/admin.controller.ts +++ b/apps/api/src/rate-limiting/controllers/admin.controller.ts @@ -25,6 +25,15 @@ import { UsageStats, MAX_TRANSACTION_LIMITS, } from "../schemas/rate-limit.schema"; +import { Roles, Role } from "../../auth/decorators"; + +/** + * Per-API-key quota/usage/reset management (Issue #1240). These operate on + * another caller's token, not the current caller's own — without the admin + * role restriction below, any authenticated user could inflate their own + * limits or degrade another tenant's by targeting its API key here, which + * would defeat the per-token isolation this module exists to enforce. + */ interface UpdateQuotaDto { requestsPerMinute?: number; @@ -47,6 +56,7 @@ interface ResetResponse { } @Controller("admin/api-keys") +@Roles(Role.ADMIN) export class RateLimitAdminController { private readonly logger = new Logger(RateLimitAdminController.name); diff --git a/src/integrations/github/README.md b/src/integrations/github/README.md index d337f552..8da9f013 100644 --- a/src/integrations/github/README.md +++ b/src/integrations/github/README.md @@ -6,6 +6,7 @@ This module provides comprehensive GitHub repository integration features for Ga - **Private Repository Access Diagnostics** (#1048) - Comprehensive diagnostics for accessing private GitHub repositories - **Branch Protection Awareness** (#1044) - Understand and work with GitHub branch protection rules - **Repository Language Detection** (#1046) - Detect and analyze programming languages used in repositories +- **GitHub App Permission Minimization Checks** (#1244) - Flag GitHub App installations granted more access than GasGuard actually uses ## Installation @@ -480,6 +481,29 @@ if (!result.valid) { } ``` +### 8. GitHub App Permission Minimization Checks (#1244) + +`InstallationValidator` (above) checks that an installation has *at least* +the permissions GasGuard needs. `PermissionMinimizationChecker` checks the +opposite — that it doesn't have *more* than GasGuard needs — against a +fixed baseline (`GASGUARD_BASELINE_PERMISSIONS`) kept in sync with what +`GitHubClient` actually calls. An installation can pass one check and fail +the other; they're independent. + +```typescript +import { PermissionMinimizationChecker, GitHubClient } from '@/integrations/github'; + +const checker = new PermissionMinimizationChecker(client); +const result = await checker.checkRepositoryInstallation('MDTechLabs', 'GasGuard'); + +if (!result.isMinimized) { + console.warn('Over-privileged installation:', result.recommendations); + // result.excessPermissions: granted above the level GasGuard uses + // result.unusedPermissions: granted but never used at all + // result.broadRepositorySelection: installed on 'all' repos, not 'selected' +} +``` + ## GitHub Enterprise Server Set `baseUrl` on the auth config to target a GHES instance. Both the host URL and diff --git a/src/integrations/github/index.ts b/src/integrations/github/index.ts index af677f0d..6f4673da 100644 --- a/src/integrations/github/index.ts +++ b/src/integrations/github/index.ts @@ -9,6 +9,7 @@ * - Pull request changed-file discovery (#1043) * - GitHub organization allowlists (#1042) * - GitHub repository installation validation (#1041) + * - GitHub App permission minimization checks (#1244) */ export * from './types'; @@ -20,6 +21,7 @@ export * from './language-detection'; export * from './pull-request-discovery'; export * from './organization-allowlist'; export * from './installation-validator'; +export * from './permission-minimization'; export * from './enterprise-config'; export * from './token-expiration'; @@ -31,6 +33,7 @@ import { LanguageDetector } from './language-detection'; import { PullRequestFileDiscovery } from './pull-request-discovery'; import { OrganizationAllowlist } from './organization-allowlist'; import { InstallationValidator } from './installation-validator'; +import { PermissionMinimizationChecker } from './permission-minimization'; import { GitHubRepository, GitHubIntegrationConfig, @@ -54,6 +57,7 @@ export class GitHubIntegration { private prDiscovery: PullRequestFileDiscovery; private allowlist?: OrganizationAllowlist; private installationValidator: InstallationValidator; + private permissionMinimizationChecker: PermissionMinimizationChecker; constructor(config: ExtendedGitHubIntegrationConfig) { this.repository = config.repository; @@ -64,6 +68,7 @@ export class GitHubIntegration { this.languageDetector = new LanguageDetector(this.client, config.repository); this.prDiscovery = new PullRequestFileDiscovery(this.client, config.repository); this.installationValidator = new InstallationValidator(this.client, config.repository); + this.permissionMinimizationChecker = new PermissionMinimizationChecker(this.client); if (config.organizationAllowlist) { this.allowlist = new OrganizationAllowlist(config.organizationAllowlist, this.client); @@ -126,6 +131,13 @@ export class GitHubIntegration { return this.installationValidator; } + /** + * Get the GitHub App permission minimization checker (#1244) + */ + getPermissionMinimizationChecker(): PermissionMinimizationChecker { + return this.permissionMinimizationChecker; + } + /** * Run a comprehensive repository analysis */ @@ -135,6 +147,7 @@ export class GitHubIntegration { branchProtection: any; access: any; installation: any; + permissionMinimization: any; }> { const results = { monorepo: false, @@ -142,6 +155,7 @@ export class GitHubIntegration { branchProtection: null, access: null, installation: null, + permissionMinimization: null, }; try { @@ -172,6 +186,15 @@ export class GitHubIntegration { if (this.installationValidator) { results.installation = await this.installationValidator.validateRepositoryInstallation(); } + + // Check installation permission minimization + if (this.permissionMinimizationChecker) { + results.permissionMinimization = + await this.permissionMinimizationChecker.checkRepositoryInstallation( + this.repository.owner, + this.repository.repo, + ); + } } catch (error) { console.error('Repository analysis failed:', error); } diff --git a/src/integrations/github/permission-minimization.ts b/src/integrations/github/permission-minimization.ts new file mode 100644 index 00000000..ea39d552 --- /dev/null +++ b/src/integrations/github/permission-minimization.ts @@ -0,0 +1,167 @@ +/** + * GitHub App Permission Minimization Checks (#1244) + * + * InstallationValidator (#1041) checks that an installation has *at least* + * the permissions GasGuard requires. This checker looks the other + * direction: it flags an installation that has *more* access than + * GasGuard's integration actually uses — the over-privileged case that + * InstallationValidator cannot see, since "has enough" and "has too much" + * are independent conditions an installation can satisfy at the same time. + * + * Checks performed against GASGUARD_BASELINE_PERMISSIONS: + * - a granted permission above the level GasGuard's client code + * actually calls (e.g. `contents: write` when every call GasGuard + * makes only reads content) + * - a granted permission GasGuard does not use at all + * - `repository_selection: 'all'` (every repo in the account/org) instead + * of an explicit, selected subset + */ + +import { GitHubClient } from './github-client'; +import { + ExcessPermission, + PermissionMinimizationOptions, + PermissionMinimizationResult, +} from './types'; + +const PERMISSION_HIERARCHY: Record = { + none: 0, + read: 1, + write: 2, + admin: 3, +}; + +/** + * The permissions GasGuard's GitHub client (github-client.ts) actually + * exercises, and the minimum level each is used at. Keep this in sync with + * github-client.ts: a permission this client never calls for should not + * appear here, and a permission it needs must be listed at its true + * minimum level (not rounded up "to be safe" — that defeats the point of a + * minimization check). + */ +export const GASGUARD_BASELINE_PERMISSIONS: Record = { + // Base repository metadata — required for any GitHub App installation. + metadata: 'read', + // getContents / getTree / getLanguages — source reading for analysis. + contents: 'read', + // getPullRequestFiles — PR changed-file discovery (#1043). + pull_requests: 'read', + // getBranchProtection — branch protection awareness (#1044). + administration: 'read', +}; + +export class PermissionMinimizationChecker { + constructor(private readonly client: GitHubClient) {} + + /** + * Check the installation on a specific repository. + */ + async checkRepositoryInstallation( + owner: string, + repo: string, + options: PermissionMinimizationOptions = {}, + ): Promise { + const data = await this.client.getRepositoryInstallation(owner, repo); + return this.evaluate(data, options); + } + + /** + * Check an installation directly by its installation ID. + */ + async checkAppInstallation( + installationId: number | string, + options: PermissionMinimizationOptions = {}, + ): Promise { + const data = await this.client.getAppInstallation(installationId); + return this.evaluate(data, options); + } + + /** + * Pure evaluation over an already-fetched installation payload — + * exposed so callers (and tests) can check minimization without an + * extra API round trip when they already have the installation data + * (e.g. from InstallationValidator's own fetch). + */ + evaluate( + data: { id?: number; permissions?: Record; repository_selection?: 'all' | 'selected' }, + options: PermissionMinimizationOptions = {}, + ): PermissionMinimizationResult { + const baseline = options.baselinePermissions ?? GASGUARD_BASELINE_PERMISSIONS; + const granted: Record = data.permissions ?? {}; + + const excessPermissions: ExcessPermission[] = []; + const unusedPermissions: string[] = []; + + for (const [permission, grantedLevel] of Object.entries(granted)) { + if (grantedLevel === 'none') continue; + + const requiredLevel = baseline[permission]; + if (requiredLevel === undefined) { + unusedPermissions.push(permission); + continue; + } + + const grantedRank = PERMISSION_HIERARCHY[grantedLevel] ?? 0; + const requiredRank = PERMISSION_HIERARCHY[requiredLevel] ?? 0; + if (grantedRank > requiredRank) { + excessPermissions.push({ permission, grantedLevel, requiredLevel }); + } + } + + const repositorySelection = data.repository_selection ?? 'selected'; + const broadRepositorySelection = repositorySelection === 'all'; + + const isMinimized = + excessPermissions.length === 0 && + unusedPermissions.length === 0 && + !broadRepositorySelection; + + const recommendations = this.buildRecommendations( + excessPermissions, + unusedPermissions, + broadRepositorySelection, + ); + + return { + installationId: data.id, + isMinimized, + excessPermissions, + unusedPermissions, + broadRepositorySelection, + repositorySelection, + grantedPermissions: granted, + baselinePermissions: baseline, + recommendations, + }; + } + + private buildRecommendations( + excessPermissions: ExcessPermission[], + unusedPermissions: string[], + broadRepositorySelection: boolean, + ): string[] { + const recommendations: string[] = []; + + for (const excess of excessPermissions) { + recommendations.push( + `Reduce '${excess.permission}' from '${excess.grantedLevel}' to '${excess.requiredLevel}' — ` + + `GasGuard does not use the elevated access.`, + ); + } + + for (const unused of unusedPermissions) { + recommendations.push( + `Remove permission '${unused}' — GasGuard's integration does not use it at all.`, + ); + } + + if (broadRepositorySelection) { + recommendations.push( + `Change repository selection from 'all' to 'selected' and explicitly list the ` + + `repositories GasGuard needs, instead of granting access to every repository in the account/org.`, + ); + } + + return recommendations; + } +} diff --git a/src/integrations/github/types.ts b/src/integrations/github/types.ts index ba7942ee..a252a33d 100644 --- a/src/integrations/github/types.ts +++ b/src/integrations/github/types.ts @@ -147,3 +147,35 @@ export interface InstallationValidationOptions { requiredPermissions?: Record; checkAccess?: boolean; } + +// --- GitHub App Permission Minimization (#1244) --- +export interface ExcessPermission { + permission: string; + grantedLevel: string; + requiredLevel: string; +} + +export interface PermissionMinimizationOptions { + /** + * Override GasGuard's default baseline (GASGUARD_BASELINE_PERMISSIONS) — + * e.g. a stricter environment-specific policy, or for testing. + */ + baselinePermissions?: Record; +} + +export interface PermissionMinimizationResult { + installationId?: number; + /** True only when there is no excess permission, no unused permission, and repository selection is not 'all'. */ + isMinimized: boolean; + /** Permissions granted at a higher level than GasGuard's baseline requires. */ + excessPermissions: ExcessPermission[]; + /** Permissions granted that do not appear in GasGuard's baseline at all. */ + unusedPermissions: string[]; + /** True when the installation is scoped to 'all' repositories rather than a selected subset. */ + broadRepositorySelection: boolean; + repositorySelection: 'all' | 'selected'; + grantedPermissions: Record; + baselinePermissions: Record; + /** Human-readable remediation steps, one per finding. */ + recommendations: string[]; +} diff --git a/src/modules/gas-spike-alert/alerts.controller.ts b/src/modules/gas-spike-alert/alerts.controller.ts index ef2db355..eb01b3dc 100644 --- a/src/modules/gas-spike-alert/alerts.controller.ts +++ b/src/modules/gas-spike-alert/alerts.controller.ts @@ -9,11 +9,12 @@ import { Patch, Post, Put, + Query, } from '@nestjs/common'; -import { RegisterWebhookDto, UpdateWebhookDto, AlertConfigDto } from '../dto/gas-spike-alert.dto'; -import { WebhookDispatcherService } from '../services/webhook-dispatcher.service'; -import { GasMonitorService } from '../services/gas-monitor.service'; -import { SpikeClassifierService } from '../services/spike-classifier.service'; +import { RegisterWebhookDto, UpdateWebhookDto, AlertConfigDto } from './gas-spike-alert.dto'; +import { WebhookDispatcherService } from './webhook-dispatcher.service'; +import { GasMonitorService } from './gas-monitor.service'; +import { SpikeClassifierService } from './spike-classifier.service'; @Controller('alerts') export class AlertsController { @@ -53,6 +54,64 @@ export class AlertsController { return { subscriptions }; } + // ─── Dead-Letter Endpoints (#1243) ──────────────────────────────── + // Registered before the generic `webhook/:id` routes below: Nest/Express + // match routes in registration order, and `webhook/dead-letter` would + // otherwise be captured by `webhook/:id` with id="dead-letter". + + /** + * GET /alerts/webhook/dead-letter + * List deliveries that exhausted all retries, optionally filtered by + * subscription (?subscriptionId=...). + */ + @Get('webhook/dead-letter') + listDeadLetters(@Query('subscriptionId') subscriptionId?: string) { + return { entries: this.dispatcher.listDeadLetters(subscriptionId) }; + } + + /** + * GET /alerts/webhook/dead-letter/:id + */ + @Get('webhook/dead-letter/:id') + getDeadLetter(@Param('id') id: string) { + return this.dispatcher.getDeadLetter(id); + } + + /** + * POST /alerts/webhook/dead-letter/:id/replay + * Re-attempt one dead-lettered delivery against the subscription's + * current URL/secret. Removed from the queue on success. + */ + @Post('webhook/dead-letter/:id/replay') + async replayDeadLetter(@Param('id') id: string) { + return this.dispatcher.replayDeadLetter(id); + } + + /** + * POST /alerts/webhook/dead-letter/replay + * Replay every dead-lettered delivery, optionally scoped to one + * subscription via { "subscriptionId": "..." } in the body. + */ + @Post('webhook/dead-letter/replay') + async replayAllDeadLetters(@Body() body: { subscriptionId?: string }) { + const results = await this.dispatcher.replayAllDeadLetters(body?.subscriptionId); + return { + replayed: results.length, + succeeded: results.filter((r) => r.succeeded).length, + results, + }; + } + + /** + * DELETE /alerts/webhook/dead-letter/:id + * Permanently discard a dead-lettered delivery without replaying it. + */ + @Delete('webhook/dead-letter/:id') + @HttpCode(HttpStatus.NO_CONTENT) + discardDeadLetter(@Param('id') id: string): void { + this.dispatcher.discardDeadLetter(id); + } + /** * GET /alerts/webhook/:id * Get a single subscription (secret redacted). diff --git a/src/modules/gas-spike-alert/gas-monitor.service.ts b/src/modules/gas-spike-alert/gas-monitor.service.ts index ceaa9d1b..e65d1873 100644 --- a/src/modules/gas-spike-alert/gas-monitor.service.ts +++ b/src/modules/gas-spike-alert/gas-monitor.service.ts @@ -7,9 +7,9 @@ import { import { AlertPayload, GasSnapshot, -} from '../interfaces/gas-spike.interface'; -import { Severity } from '../enums/severity.enum'; -import { RECOMMENDATIONS, MONITOR_INTERVAL_MS } from '../constants/thresholds'; +} from './gas-spike.interface'; +import { Severity } from './severity.enum'; +import { RECOMMENDATIONS, MONITOR_INTERVAL_MS } from './thresholds'; import { SpikeClassifierService } from './spike-classifier.service'; import { ThresholdEngineService } from './threshold-engine.service'; import { WebhookDispatcherService } from './webhook-dispatcher.service'; diff --git a/src/modules/gas-spike-alert/gas-spike-alert.dto.ts b/src/modules/gas-spike-alert/gas-spike-alert.dto.ts index 5060160e..27706151 100644 --- a/src/modules/gas-spike-alert/gas-spike-alert.dto.ts +++ b/src/modules/gas-spike-alert/gas-spike-alert.dto.ts @@ -12,7 +12,7 @@ import { Max, } from 'class-validator'; import { Type } from 'class-transformer'; -import { Severity } from '../enums/severity.enum'; +import { Severity } from './severity.enum'; export class RegisterWebhookDto { @IsUrl({ require_tld: false, require_protocol: true }) diff --git a/src/modules/gas-spike-alert/gas-spike-alert.module.ts b/src/modules/gas-spike-alert/gas-spike-alert.module.ts index 1af7193c..23984f0e 100644 --- a/src/modules/gas-spike-alert/gas-spike-alert.module.ts +++ b/src/modules/gas-spike-alert/gas-spike-alert.module.ts @@ -1,9 +1,10 @@ import { Module } from '@nestjs/common'; -import { AlertsController } from './controllers/alerts.controller'; -import { GasMonitorService } from './services/gas-monitor.service'; -import { SpikeClassifierService } from './services/spike-classifier.service'; -import { ThresholdEngineService } from './services/threshold-engine.service'; -import { WebhookDispatcherService } from './services/webhook-dispatcher.service'; +import { AlertsController } from './alerts.controller'; +import { GasMonitorService } from './gas-monitor.service'; +import { SpikeClassifierService } from './spike-classifier.service'; +import { ThresholdEngineService } from './threshold-engine.service'; +import { WebhookDispatcherService } from './webhook-dispatcher.service'; +import { WebhookDeadLetterService } from './webhook-dead-letter.service'; /** * GasSpikeAlertModule @@ -14,6 +15,7 @@ import { WebhookDispatcherService } from './services/webhook-dispatcher.service' * • Configurable threshold evaluation (%, absolute, volatility, priority fee) * • Severity-based spike classification (Info / Warning / Critical) * • Webhook fan-out with HMAC-signed payloads and retry back-off + * • Dead-letter queue + replay for deliveries that exhaust retries (#1243) * • REST endpoints for webhook management and threshold configuration * * Usage in AppModule: @@ -28,12 +30,14 @@ import { WebhookDispatcherService } from './services/webhook-dispatcher.service' SpikeClassifierService, ThresholdEngineService, WebhookDispatcherService, + WebhookDeadLetterService, ], exports: [ GasMonitorService, SpikeClassifierService, ThresholdEngineService, WebhookDispatcherService, + WebhookDeadLetterService, ], }) export class GasSpikeAlertModule {} diff --git a/src/modules/gas-spike-alert/gas-spike.interface.ts b/src/modules/gas-spike-alert/gas-spike.interface.ts index 94d1c79f..222bc26e 100644 --- a/src/modules/gas-spike-alert/gas-spike.interface.ts +++ b/src/modules/gas-spike-alert/gas-spike.interface.ts @@ -1,4 +1,4 @@ -import { Severity, SpikeType } from '../enums/severity.enum'; +import { Severity, SpikeType } from './severity.enum'; /** Raw gas price snapshot fetched per chain */ export interface GasSnapshot { diff --git a/src/modules/gas-spike-alert/spike-classifier.service.ts b/src/modules/gas-spike-alert/spike-classifier.service.ts index 3a4ea2e5..be994b5d 100644 --- a/src/modules/gas-spike-alert/spike-classifier.service.ts +++ b/src/modules/gas-spike-alert/spike-classifier.service.ts @@ -1,15 +1,15 @@ import { Injectable } from '@nestjs/common'; -import { Severity, SpikeType } from '../enums/severity.enum'; +import { Severity, SpikeType } from './severity.enum'; import { DEFAULT_THRESHOLDS, MOVING_AVERAGE_WINDOW, MAX_HISTORY_POINTS, -} from '../constants/thresholds'; +} from './thresholds'; import { GasSnapshot, EnrichedSnapshot, CustomThresholdConfig, -} from '../interfaces/gas-spike.interface'; +} from './gas-spike.interface'; /** * SpikeClassifierService diff --git a/src/modules/gas-spike-alert/threshold-engine.service.ts b/src/modules/gas-spike-alert/threshold-engine.service.ts index 74ff1095..82e698a1 100644 --- a/src/modules/gas-spike-alert/threshold-engine.service.ts +++ b/src/modules/gas-spike-alert/threshold-engine.service.ts @@ -1,10 +1,10 @@ import { Injectable, Logger } from '@nestjs/common'; -import { Severity, SpikeType } from '../enums/severity.enum'; -import { DEFAULT_THRESHOLDS } from '../constants/thresholds'; +import { Severity, SpikeType } from './severity.enum'; +import { DEFAULT_THRESHOLDS } from './thresholds'; import { EnrichedSnapshot, ThresholdResult, -} from '../interfaces/gas-spike.interface'; +} from './gas-spike.interface'; import { SpikeClassifierService } from './spike-classifier.service'; /** diff --git a/src/modules/gas-spike-alert/thresholds.ts b/src/modules/gas-spike-alert/thresholds.ts index c4ac4a82..454f32b2 100644 --- a/src/modules/gas-spike-alert/thresholds.ts +++ b/src/modules/gas-spike-alert/thresholds.ts @@ -1,4 +1,4 @@ -import { Severity, SpikeType } from '../enums/severity.enum'; +import { Severity, SpikeType } from './severity.enum'; export interface SeverityBand { min: number; diff --git a/src/modules/gas-spike-alert/webhook-dead-letter.service.ts b/src/modules/gas-spike-alert/webhook-dead-letter.service.ts new file mode 100644 index 00000000..94576d12 --- /dev/null +++ b/src/modules/gas-spike-alert/webhook-dead-letter.service.ts @@ -0,0 +1,136 @@ +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { randomBytes } from 'crypto'; +import { AlertPayload, WebhookSubscription } from './gas-spike.interface'; + +/** + * A webhook delivery that exhausted all retries in WebhookDispatcherService + * and was parked instead of dropped, so it can be inspected and replayed + * once the receiving endpoint is fixed (Issue #1243). + */ +export interface DeadLetterEntry { + id: string; + subscriptionId: string; + /** URL at the time of original failure — the live subscription may have changed since. */ + url: string; + payload: AlertPayload; + attempts: number; + lastError: string; + firstFailedAt: number; + lastFailedAt: number; + replayCount: number; + lastReplayAt?: number; + lastReplayError?: string; +} + +export interface ReplayResult { + id: string; + succeeded: boolean; + error?: string; +} + +/** + * Performs exactly one delivery attempt (sign + send) against the current + * subscription record. Supplied by WebhookDispatcherService so this service + * doesn't need to depend back on it — the dispatcher owns HTTP/signing, this + * service only owns the dead-letter queue and replay bookkeeping. + */ +export type WebhookDeliveryAttempt = ( + subscription: WebhookSubscription, + payload: AlertPayload, +) => Promise; + +/** In-memory dead-letter queue (replace with a durable store/table in production). */ +@Injectable() +export class WebhookDeadLetterService { + private readonly logger = new Logger(WebhookDeadLetterService.name); + private readonly entries = new Map(); + + /** + * Record a delivery that exhausted all retries. Called by + * WebhookDispatcherService.dispatchOne once WEBHOOK_MAX_RETRIES is reached. + */ + record(params: { + subscription: WebhookSubscription; + payload: AlertPayload; + attempts: number; + lastError: string; + }): DeadLetterEntry { + const id = randomBytes(12).toString('hex'); + const now = Date.now(); + + const entry: DeadLetterEntry = { + id, + subscriptionId: params.subscription.id, + url: params.subscription.url, + payload: params.payload, + attempts: params.attempts, + lastError: params.lastError, + firstFailedAt: now, + lastFailedAt: now, + replayCount: 0, + }; + + this.entries.set(id, entry); + this.logger.error( + `Webhook ${params.subscription.id} dead-lettered as ${id} after ` + + `${params.attempts} attempts: ${params.lastError}`, + ); + return entry; + } + + /** List dead-lettered deliveries, optionally scoped to one subscription. */ + list(subscriptionId?: string): DeadLetterEntry[] { + const all = Array.from(this.entries.values()); + return subscriptionId + ? all.filter((entry) => entry.subscriptionId === subscriptionId) + : all; + } + + get(id: string): DeadLetterEntry { + const entry = this.entries.get(id); + if (!entry) { + throw new NotFoundException(`Dead-letter entry ${id} not found`); + } + return entry; + } + + /** Permanently discard a dead-lettered delivery without replaying it. */ + discard(id: string): void { + if (!this.entries.has(id)) { + throw new NotFoundException(`Dead-letter entry ${id} not found`); + } + this.entries.delete(id); + this.logger.log(`Dead-letter entry ${id} discarded`); + } + + /** + * Replay one entry against the *current* subscription record (url/secret + * may have been updated since the original failure — only the payload is + * replayed from the stored snapshot). Removes the entry on success; on + * failure, updates its replay bookkeeping and leaves it queued. + */ + async replay( + id: string, + subscription: WebhookSubscription, + attempt: WebhookDeliveryAttempt, + ): Promise { + const entry = this.get(id); + entry.replayCount += 1; + entry.lastReplayAt = Date.now(); + + try { + await attempt(subscription, entry.payload); + this.entries.delete(id); + this.logger.log( + `Dead-letter entry ${id} replayed successfully; removed from queue`, + ); + return { id, succeeded: true }; + } catch (err) { + entry.lastReplayError = (err as Error).message; + this.logger.warn( + `Dead-letter entry ${id} replay failed: ${entry.lastReplayError}`, + ); + return { id, succeeded: false, error: entry.lastReplayError }; + } + } +} diff --git a/src/modules/gas-spike-alert/webhook-dispatcher.service.ts b/src/modules/gas-spike-alert/webhook-dispatcher.service.ts index 372f302f..1808e31a 100644 --- a/src/modules/gas-spike-alert/webhook-dispatcher.service.ts +++ b/src/modules/gas-spike-alert/webhook-dispatcher.service.ts @@ -8,14 +8,19 @@ import { createHmac, randomBytes } from 'crypto'; import { AlertPayload, WebhookSubscription, -} from '../interfaces/gas-spike.interface'; -import { Severity } from '../enums/severity.enum'; +} from './gas-spike.interface'; +import { Severity } from './severity.enum'; import { WEBHOOK_HMAC_ALGO, WEBHOOK_MAX_RETRIES, WEBHOOK_BACKOFF_BASE_MS, -} from '../constants/thresholds'; -import { RegisterWebhookDto, UpdateWebhookDto } from '../dto/gas-spike-alert.dto'; +} from './thresholds'; +import { RegisterWebhookDto, UpdateWebhookDto } from './gas-spike-alert.dto'; +import { + DeadLetterEntry, + ReplayResult, + WebhookDeadLetterService, +} from './webhook-dead-letter.service'; /** * WebhookDispatcherService @@ -46,6 +51,8 @@ export class WebhookDispatcherService { /** In-memory subscription store (replace with DB entity in production) */ private readonly subscriptions = new Map(); + constructor(private readonly deadLetterService: WebhookDeadLetterService) {} + // ─── Subscription management ───────────────────────────────────── register(dto: RegisterWebhookDto): WebhookSubscription { @@ -124,47 +131,28 @@ export class WebhookDispatcherService { } /** - * Dispatch to a single subscription with retry + back-off. + * Dispatch to a single subscription with retry + back-off. Once + * WEBHOOK_MAX_RETRIES is exhausted, the delivery is handed to the + * dead-letter queue instead of being dropped (#1243) so it can be + * inspected and replayed later via replayDeadLetter(). */ async dispatchOne( sub: WebhookSubscription, payload: AlertPayload, ): Promise { - const body = JSON.stringify(payload); - const signature = this.sign(body, sub.secret); - - const { createSafeAgent } = require('../../security/dns'); - const axios = require('axios'); - const httpAgent = createSafeAgent('http'); - const httpsAgent = createSafeAgent('https'); + let lastError = 'Unknown error'; for (let attempt = 1; attempt <= WEBHOOK_MAX_RETRIES; attempt++) { try { - const response = await axios.post(sub.url, payload, { - httpAgent, - httpsAgent, - validateStatus: () => true, - headers: { - 'Content-Type': 'application/json', - 'X-GasGuard-Signature': `${WEBHOOK_HMAC_ALGO}=${signature}`, - 'X-GasGuard-Subscription-ID': sub.id, - 'X-GasGuard-Timestamp': String(payload.timestamp), - }, - }); - - if (response.status >= 200 && response.status < 300) { - this.logger.log( - `Webhook ${sub.id} dispatched successfully (attempt ${attempt})`, - ); - return; - } - - this.logger.warn( - `Webhook ${sub.id} attempt ${attempt} returned HTTP ${response.status}`, + await this.attemptDelivery(sub, payload); + this.logger.log( + `Webhook ${sub.id} dispatched successfully (attempt ${attempt})`, ); + return; } catch (err) { + lastError = (err as Error).message; this.logger.warn( - `Webhook ${sub.id} attempt ${attempt} network error: ${(err as Error).message}`, + `Webhook ${sub.id} attempt ${attempt} failed: ${lastError}`, ); } @@ -176,6 +164,94 @@ export class WebhookDispatcherService { this.logger.error( `Webhook ${sub.id} failed after ${WEBHOOK_MAX_RETRIES} attempts`, ); + this.deadLetterService.record({ + subscription: sub, + payload, + attempts: WEBHOOK_MAX_RETRIES, + lastError, + }); + } + + /** + * A single delivery attempt: sign + POST, throwing on a network error or + * a non-2xx response. Shared by dispatchOne's retry loop and by + * replayDeadLetter, so both paths sign and send identically. + */ + private async attemptDelivery( + sub: WebhookSubscription, + payload: AlertPayload, + ): Promise { + const body = JSON.stringify(payload); + const signature = this.sign(body, sub.secret); + + const { createSafeAgent } = require('../../security/dns'); + const axios = require('axios'); + const httpAgent = createSafeAgent('http'); + const httpsAgent = createSafeAgent('https'); + + const response = await axios.post(sub.url, payload, { + httpAgent, + httpsAgent, + validateStatus: () => true, + headers: { + 'Content-Type': 'application/json', + 'X-GasGuard-Signature': `${WEBHOOK_HMAC_ALGO}=${signature}`, + 'X-GasGuard-Subscription-ID': sub.id, + 'X-GasGuard-Timestamp': String(payload.timestamp), + }, + }); + + if (response.status < 200 || response.status >= 300) { + throw new Error(`HTTP ${response.status}`); + } + } + + // ─── Dead-letter replay (#1243) ─────────────────────────────────── + + /** List dead-lettered deliveries, optionally scoped to one subscription. */ + listDeadLetters(subscriptionId?: string): DeadLetterEntry[] { + return this.deadLetterService.list(subscriptionId); + } + + getDeadLetter(id: string): DeadLetterEntry { + return this.deadLetterService.get(id); + } + + discardDeadLetter(id: string): void { + this.deadLetterService.discard(id); + } + + /** + * Replay a single dead-lettered delivery against the subscription's + * *current* record (so a URL/secret fix since the original failure takes + * effect); the payload itself is replayed unchanged from the original + * failure. Throws NotFoundException if the subscription was deleted — + * discard the entry instead in that case, there is nothing to deliver to. + */ + async replayDeadLetter(id: string): Promise { + const entry = this.deadLetterService.get(id); + const subscription = this.findById(entry.subscriptionId); + return this.deadLetterService.replay(id, subscription, (sub, payload) => + this.attemptDelivery(sub, payload), + ); + } + + /** + * Replay every dead-lettered delivery, optionally scoped to one + * subscription. Runs replays concurrently; one failure doesn't block the + * others — inspect the returned results to see which succeeded. + */ + async replayAllDeadLetters(subscriptionId?: string): Promise { + const entries = this.deadLetterService.list(subscriptionId); + const results = await Promise.allSettled( + entries.map((entry) => this.replayDeadLetter(entry.id)), + ); + + return results.map((result, i) => + result.status === 'fulfilled' + ? result.value + : { id: entries[i].id, succeeded: false, error: (result.reason as Error).message }, + ); } // ─── Signature utilities ─────────────────────────────────────────