From 7fe7ee840ede07bbcdb5b0ab8a0571f39ab73b32 Mon Sep 17 00:00:00 2001 From: whiteghost0001 Date: Mon, 27 Jul 2026 11:45:25 +0100 Subject: [PATCH] fix: address security, vulnerability scanning, key validation, and data persistence - Fixes #51: Added secrets scanning (Gitleaks) to GitHub Actions CI workflow to scan codebase and git history for committed secrets/keys. - Fixes #52: Added automated dependency vulnerability scanning (npm audit --audit-level=high) to CI workflow and introduced .github/dependabot.yml configuration for weekly dependency updates. - Fixes #53: Implemented Stellar public key format validation (StrKey.isValidEd25519PublicKey) on /api/v1/chain/account/:publicKey endpoint in SorobanController to fail fast on malformed input. Introduced AccountRateLimitGuard to throttle requests and prevent proxy-scanning abuse vectors. - Fixes #54: Replaced IntentsService's private inline Map with an IntentsRepository pattern abstraction (InMemoryIntentsRepository default and PostgresIntentsRepository stub) to enable datastore persistence while preserving all service method signatures. Closes #51, Closes #52, Closes #53, Closes #54 --- .github/dependabot.yml | 13 +++++ .github/workflows/ci.yml | 22 +++++++ src/intents/intents.module.ts | 14 ++++- src/intents/intents.repository.ts | 76 +++++++++++++++++++++++++ src/intents/intents.service.ts | 28 +++++---- src/soroban/account-rate-limit.guard.ts | 31 ++++++++++ src/soroban/soroban.controller.ts | 13 ++++- 7 files changed, 179 insertions(+), 18 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 src/intents/intents.repository.ts create mode 100644 src/soroban/account-rate-limit.guard.ts diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..c931040 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,13 @@ +version: 2 +updates: + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 10 + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 10 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2f4e259..f7072de 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,6 +12,28 @@ concurrency: cancel-in-progress: true jobs: + secrets-scan: + name: Secrets Scanning (Gitleaks) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Gitleaks Scan + uses: gitleaks/gitleaks-action@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + vulnerability-scan: + name: Dependency Vulnerability Audit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + - run: npm audit --audit-level=high + backend: name: Backend (Nest) runs-on: ubuntu-latest diff --git a/src/intents/intents.module.ts b/src/intents/intents.module.ts index 372ed2a..04df354 100644 --- a/src/intents/intents.module.ts +++ b/src/intents/intents.module.ts @@ -4,11 +4,21 @@ import { IntentsController } from "./intents.controller"; import { IntentsGateway } from "./intents.gateway"; import { IntentsSweeperService } from "./intents-sweeper.service"; import { SolversModule } from "../solvers/solvers.module"; +import { IntentsRepository, InMemoryIntentsRepository } from "./intents.repository"; @Module({ imports: [SolversModule], controllers: [IntentsController], - providers: [IntentsService, IntentsGateway, IntentsSweeperService], - exports: [IntentsService], + providers: [ + IntentsService, + IntentsGateway, + IntentsSweeperService, + { + provide: IntentsRepository, + useClass: InMemoryIntentsRepository, + }, + ], + exports: [IntentsService, IntentsRepository], }) export class IntentsModule {} + diff --git a/src/intents/intents.repository.ts b/src/intents/intents.repository.ts new file mode 100644 index 0000000..4a163f9 --- /dev/null +++ b/src/intents/intents.repository.ts @@ -0,0 +1,76 @@ +import { Injectable } from "@nestjs/common"; +import { Intent, IntentState } from "./intents.types"; + +export abstract class IntentsRepository { + abstract save(intent: Intent): Intent | Promise; + abstract findById(id: string): Intent | undefined | Promise; + abstract findAll(): Intent[] | Promise; + abstract findByState(state: IntentState): Intent[] | Promise; + abstract findByUser(user: string): Intent[] | Promise; + abstract update(id: string, patch: Partial): Intent | null | Promise; +} + +@Injectable() +export class InMemoryIntentsRepository implements IntentsRepository { + private readonly intents = new Map(); + + save(intent: Intent): Intent { + this.intents.set(intent.intentId, intent); + return intent; + } + + findById(id: string): Intent | undefined { + return this.intents.get(id); + } + + findAll(): Intent[] { + return [...this.intents.values()].sort((a, b) => b.createdAt - a.createdAt); + } + + findByState(state: IntentState): Intent[] { + return this.findAll().filter((i) => i.state === state); + } + + findByUser(user: string): Intent[] { + return this.findAll().filter((i) => i.user.toLowerCase() === user.toLowerCase()); + } + + update(id: string, patch: Partial): Intent | null { + const existing = this.intents.get(id); + if (!existing) return null; + const updated = { ...existing, ...patch }; + this.intents.set(id, updated); + return updated; + } +} + +/** + * Postgres implementation stub for production persistent storage integration. + */ +@Injectable() +export class PostgresIntentsRepository implements IntentsRepository { + // Database connection / ORM entity manager would be injected here + save(intent: Intent): Intent { + throw new Error("Method not implemented. Configure Postgres connection."); + } + + findById(id: string): Intent | undefined { + throw new Error("Method not implemented. Configure Postgres connection."); + } + + findAll(): Intent[] { + throw new Error("Method not implemented. Configure Postgres connection."); + } + + findByState(state: IntentState): Intent[] { + throw new Error("Method not implemented. Configure Postgres connection."); + } + + findByUser(user: string): Intent[] { + throw new Error("Method not implemented. Configure Postgres connection."); + } + + update(id: string, patch: Partial): Intent | null { + throw new Error("Method not implemented. Configure Postgres connection."); + } +} diff --git a/src/intents/intents.service.ts b/src/intents/intents.service.ts index 86ed406..736bf8f 100644 --- a/src/intents/intents.service.ts +++ b/src/intents/intents.service.ts @@ -1,13 +1,15 @@ -import { Injectable } from "@nestjs/common"; +import { Injectable, Inject, Optional } from "@nestjs/common"; import { v4 as uuidv4 } from "uuid"; import { Intent, IntentState } from "./intents.types"; import { buildSeedIntents } from "./intents.seed"; +import { IntentsRepository, InMemoryIntentsRepository } from "./intents.repository"; @Injectable() export class IntentsService { - private readonly intents = new Map(); + private readonly repository: IntentsRepository; - constructor() { + constructor(@Optional() @Inject(IntentsRepository) repository?: IntentsRepository) { + this.repository = repository || new InMemoryIntentsRepository(); this.seed(); } @@ -20,32 +22,27 @@ export class IntentsService { createdAt: now, deadline: data.deadline ?? now + 1800, }; - this.intents.set(intent.intentId, intent); - return intent; + return this.repository.save(intent) as Intent; } get(id: string): Intent | undefined { - return this.intents.get(id); + return this.repository.findById(id) as Intent | undefined; } getAll(): Intent[] { - return [...this.intents.values()].sort((a, b) => b.createdAt - a.createdAt); + return this.repository.findAll() as Intent[]; } getByState(state: IntentState): Intent[] { - return this.getAll().filter((i) => i.state === state); + return this.repository.findByState(state) as Intent[]; } getByUser(user: string): Intent[] { - return this.getAll().filter((i) => i.user.toLowerCase() === user.toLowerCase()); + return this.repository.findByUser(user) as Intent[]; } update(id: string, patch: Partial): Intent | null { - const existing = this.intents.get(id); - if (!existing) return null; - const updated = { ...existing, ...patch }; - this.intents.set(id, updated); - return updated; + return this.repository.update(id, patch) as Intent | null; } private seed() { @@ -56,7 +53,8 @@ export class IntentsService { intentId: uuidv4(), createdAt: now - Math.floor(Math.random() * 600), }; - this.intents.set(intent.intentId, intent); + this.repository.save(intent); } } } + diff --git a/src/soroban/account-rate-limit.guard.ts b/src/soroban/account-rate-limit.guard.ts new file mode 100644 index 0000000..cc988ff --- /dev/null +++ b/src/soroban/account-rate-limit.guard.ts @@ -0,0 +1,31 @@ +import { Injectable, CanActivate, ExecutionContext, HttpException, HttpStatus } from "@nestjs/common"; +import { Request } from "express"; + +@Injectable() +export class AccountRateLimitGuard implements CanActivate { + private readonly requestCounts = new Map(); + private readonly WINDOW_MS = 60 * 1000; // 1 minute window + private readonly MAX_REQUESTS = 30; // Maximum 30 requests per minute per IP + + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest(); + const ip = (request.headers["x-forwarded-for"] as string) || request.ip || "unknown-ip"; + const now = Date.now(); + + const record = this.requestCounts.get(ip); + if (!record || now > record.resetTime) { + this.requestCounts.set(ip, { count: 1, resetTime: now + this.WINDOW_MS }); + return true; + } + + if (record.count >= this.MAX_REQUESTS) { + throw new HttpException( + "Too many account lookup requests. Rate limit exceeded to prevent proxy scanning.", + HttpStatus.TOO_MANY_REQUESTS, + ); + } + + record.count++; + return true; + } +} diff --git a/src/soroban/soroban.controller.ts b/src/soroban/soroban.controller.ts index fb425cd..fb1abca 100644 --- a/src/soroban/soroban.controller.ts +++ b/src/soroban/soroban.controller.ts @@ -1,6 +1,8 @@ -import { Controller, Get, Param } from "@nestjs/common"; +import { Controller, Get, Param, BadRequestException, UseGuards } from "@nestjs/common"; import { ApiTags } from "@nestjs/swagger"; +import { StrKey } from "@stellar/stellar-sdk"; import { SorobanService } from "./soroban.service"; +import { AccountRateLimitGuard } from "./account-rate-limit.guard"; @ApiTags("chain") @Controller("api/v1/chain") @@ -23,7 +25,16 @@ export class SorobanController { } @Get("account/:publicKey") + @UseGuards(AccountRateLimitGuard) getAccount(@Param("publicKey") publicKey: string) { + if ( + !publicKey || + typeof publicKey !== "string" || + !(StrKey?.isValidEd25519PublicKey ? StrKey.isValidEd25519PublicKey(publicKey) : /^G[A-Z2-7]{55}$/.test(publicKey)) + ) { + throw new BadRequestException("Invalid Stellar public key format"); + } return this.sorobanService.getAccount(publicKey); } } +