From 3a22ab62b50e381b927dca2ec67371ca0e7bd94d Mon Sep 17 00:00:00 2001 From: Big Della Date: Fri, 26 Jun 2026 13:53:20 +0000 Subject: [PATCH] feat: add security audit and dependency controls --- .github/dependabot.yml | 35 ++++ .github/workflows/dependency-scan.yml | 109 ++++++++++++ .../migration.sql | 125 ++++++++++++++ backend/prisma/schema.prisma | 132 ++++++++++++++- backend/src/audit/anchor-service.ts | 75 +++++++++ backend/src/audit/chain-verifier.ts | 84 +++++++++ backend/src/audit/immutable-logger.ts | 106 ++++++++++++ backend/src/audit/prisma-audit-store.ts | 76 +++++++++ backend/src/index.ts | 2 + backend/src/middleware/brute-force.ts | 77 +++++++++ backend/src/routes/audit.ts | 15 +- backend/src/routes/sessions.ts | 27 ++- backend/src/routes/webhooks.ts | 3 +- backend/src/services/auditService.ts | 106 +++++++----- backend/src/services/auth/lockout-manager.ts | 144 ++++++++++++++++ backend/src/services/webhooks.ts | 50 +++++- backend/src/services/webhooks/encryption.ts | 39 +++++ backend/src/services/webhooks/signer.ts | 91 ++++++++++ docs/security/webhook-signatures.md | 31 ++++ .../app/dashboard/admin/audit-log/page.tsx | 135 +++++++++++++++ frontend/lib/api.ts | 41 ++++- packages/sdk/src/index.ts | 1 + packages/sdk/src/webhooks/verifier.ts | 32 ++++ .../aggregate-vulnerability-reports.mjs | 159 ++++++++++++++++++ scripts/security/dependency-policy.json | 18 ++ 25 files changed, 1645 insertions(+), 68 deletions(-) create mode 100644 .github/workflows/dependency-scan.yml create mode 100644 backend/prisma/migrations/20260626000000_security_audit_webhooks_lockout_deps/migration.sql create mode 100644 backend/src/audit/anchor-service.ts create mode 100644 backend/src/audit/chain-verifier.ts create mode 100644 backend/src/audit/immutable-logger.ts create mode 100644 backend/src/audit/prisma-audit-store.ts create mode 100644 backend/src/middleware/brute-force.ts create mode 100644 backend/src/services/auth/lockout-manager.ts create mode 100644 backend/src/services/webhooks/encryption.ts create mode 100644 backend/src/services/webhooks/signer.ts create mode 100644 docs/security/webhook-signatures.md create mode 100644 frontend/app/dashboard/admin/audit-log/page.tsx create mode 100644 packages/sdk/src/webhooks/verifier.ts create mode 100644 scripts/security/aggregate-vulnerability-reports.mjs create mode 100644 scripts/security/dependency-policy.json diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 924492e7..0ea7af31 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -74,6 +74,41 @@ updates: - "root" versioning-strategy: "increase" + - package-ecosystem: "npm" + directory: "/packages/sdk" + schedule: + interval: "weekly" + day: "monday" + time: "09:00" + open-pull-requests-limit: 3 + labels: + - "dependencies" + - "sdk" + versioning-strategy: "increase" + + - package-ecosystem: "npm" + directory: "/contracts/evm" + schedule: + interval: "weekly" + day: "monday" + time: "09:00" + open-pull-requests-limit: 3 + labels: + - "dependencies" + - "solidity" + versioning-strategy: "increase" + + - package-ecosystem: "cargo" + directory: "/contracts" + schedule: + interval: "weekly" + day: "monday" + time: "09:00" + open-pull-requests-limit: 3 + labels: + - "dependencies" + - "rust" + # Enable security updates security-updates: - package-ecosystem: "npm" diff --git a/.github/workflows/dependency-scan.yml b/.github/workflows/dependency-scan.yml new file mode 100644 index 00000000..ac4e5889 --- /dev/null +++ b/.github/workflows/dependency-scan.yml @@ -0,0 +1,109 @@ +name: Dependency Vulnerability Scan + +on: + pull_request: + branches: [main, dev] + push: + branches: [main, dev] + schedule: + - cron: '0 9 * * 1' + workflow_dispatch: + +permissions: + contents: read + issues: write + +env: + SECURITY_REPORT_DIR: security-reports/dependencies + DEPENDENCY_POLICY_PATH: scripts/security/dependency-policy.json + +jobs: + dependency-scan: + name: npm, Cargo, Solidity, and License Scan + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + cache-dependency-path: | + package-lock.json + backend/package-lock.json + frontend/package-lock.json + packages/sdk/package-lock.json + contracts/evm/package-lock.json + + - uses: dtolnay/rust-toolchain@stable + + - name: Install scan tools + run: | + mkdir -p "$SECURITY_REPORT_DIR" + cargo install cargo-audit --locked + npm install -g license-checker + + - name: npm audit + continue-on-error: true + run: | + for dir in . backend frontend packages/sdk contracts/evm; do + if [ -f "$dir/package-lock.json" ]; then + name=$(echo "$dir" | sed 's#^\.$#root#;s#[/.]#-#g') + npm audit --json --prefix "$dir" > "$SECURITY_REPORT_DIR/${name}-npm-audit.json" || true + license-checker --json --start "$dir" > "$SECURITY_REPORT_DIR/${name}-licenses.json" || true + fi + done + + - name: Cargo audit + continue-on-error: true + working-directory: contracts + run: | + cargo audit --json > "../$SECURITY_REPORT_DIR/cargo-audit.json" || true + + - name: Solidity dependency/static scan + continue-on-error: true + run: | + python -m pip install --user slither-analyzer + "$HOME/.local/bin/slither" contracts --json "$SECURITY_REPORT_DIR/slither.json" --exclude-dependencies || true + + - name: Aggregate and enforce policy + run: node scripts/security/aggregate-vulnerability-reports.mjs + + - name: Upload vulnerability report + if: always() + uses: actions/upload-artifact@v4 + with: + name: dependency-vulnerability-report + path: ${{ env.SECURITY_REPORT_DIR }}/ + retention-days: 90 + + - name: Notify Slack on critical vulnerabilities + if: failure() + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + run: | + if [ -z "$SLACK_WEBHOOK_URL" ]; then + echo "SLACK_WEBHOOK_URL is not configured; skipping Slack notification." + exit 0 + fi + curl -X POST -H 'Content-Type: application/json' \ + --data "{\"text\":\"Critical/high dependency vulnerabilities detected in ${GITHUB_REPOSITORY}. See workflow run ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}.\"}" \ + "$SLACK_WEBHOOK_URL" + + - name: Create issue for scheduled scan failures + if: failure() && github.event_name == 'schedule' + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const body = fs.existsSync(`${process.env.SECURITY_REPORT_DIR}/dependency-vulnerability-report.md`) + ? fs.readFileSync(`${process.env.SECURITY_REPORT_DIR}/dependency-vulnerability-report.md`, 'utf8') + : 'Dependency scan failed. See workflow artifacts for details.'; + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: `Dependency vulnerabilities detected - ${new Date().toISOString().slice(0, 10)}`, + body, + labels: ['security', 'dependencies'] + }); diff --git a/backend/prisma/migrations/20260626000000_security_audit_webhooks_lockout_deps/migration.sql b/backend/prisma/migrations/20260626000000_security_audit_webhooks_lockout_deps/migration.sql new file mode 100644 index 00000000..e00c2a5b --- /dev/null +++ b/backend/prisma/migrations/20260626000000_security_audit_webhooks_lockout_deps/migration.sql @@ -0,0 +1,125 @@ +ALTER TABLE "webhooks" + ADD COLUMN IF NOT EXISTS "signature_version" TEXT NOT NULL DEFAULT 'v1', + ADD COLUMN IF NOT EXISTS "secret_expires_at" TIMESTAMP(3), + ADD COLUMN IF NOT EXISTS "rotated_at" TIMESTAMP(3), + ADD COLUMN IF NOT EXISTS "encryption_public_key" TEXT; + +ALTER TABLE "audit_logs" + ADD COLUMN IF NOT EXISTS "timestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + ADD COLUMN IF NOT EXISTS "actor" TEXT NOT NULL DEFAULT 'system', + ADD COLUMN IF NOT EXISTS "resource" TEXT NOT NULL DEFAULT 'legacy', + ADD COLUMN IF NOT EXISTS "details" JSONB, + ADD COLUMN IF NOT EXISTS "previous_hash" TEXT NOT NULL DEFAULT repeat('0', 64), + ADD COLUMN IF NOT EXISTS "hash" TEXT, + ADD COLUMN IF NOT EXISTS "anchor_id" TEXT, + ADD COLUMN IF NOT EXISTS "archived_at" TIMESTAMP(3), + ADD COLUMN IF NOT EXISTS "cold_archived_at" TIMESTAMP(3); + +UPDATE "audit_logs" +SET "hash" = md5("id" || "created_at"::text || "action") +WHERE "hash" IS NULL; + +ALTER TABLE "audit_logs" ALTER COLUMN "hash" SET NOT NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS "audit_logs_hash_key" ON "audit_logs"("hash"); +CREATE INDEX IF NOT EXISTS "audit_logs_timestamp_idx" ON "audit_logs"("timestamp"); +CREATE INDEX IF NOT EXISTS "audit_logs_actor_idx" ON "audit_logs"("actor"); +CREATE INDEX IF NOT EXISTS "audit_logs_action_idx" ON "audit_logs"("action"); +CREATE INDEX IF NOT EXISTS "audit_logs_actor_action_timestamp_idx" ON "audit_logs"("actor", "action", "timestamp"); + +CREATE TABLE IF NOT EXISTS "audit_anchors" ( + "id" TEXT NOT NULL, + "latest_hash" TEXT NOT NULL, + "chain" TEXT NOT NULL, + "transaction_hash" TEXT, + "block_number" TEXT, + "status" TEXT NOT NULL DEFAULT 'pending', + "error" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "audit_anchors_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX IF NOT EXISTS "audit_anchors_latest_hash_idx" ON "audit_anchors"("latest_hash"); +CREATE INDEX IF NOT EXISTS "audit_anchors_created_at_idx" ON "audit_anchors"("created_at"); + +CREATE TABLE IF NOT EXISTS "account_lockouts" ( + "id" TEXT NOT NULL, + "account_id" TEXT NOT NULL, + "ip_address" TEXT, + "failed_attempts" INTEGER NOT NULL DEFAULT 0, + "locked_until" TIMESTAMP(3), + "unlock_token_hash" TEXT, + "last_failed_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "account_lockouts_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX IF NOT EXISTS "account_lockouts_account_id_ip_address_key" ON "account_lockouts"("account_id", "ip_address"); +CREATE INDEX IF NOT EXISTS "account_lockouts_account_id_idx" ON "account_lockouts"("account_id"); +CREATE INDEX IF NOT EXISTS "account_lockouts_ip_address_idx" ON "account_lockouts"("ip_address"); +CREATE INDEX IF NOT EXISTS "account_lockouts_locked_until_idx" ON "account_lockouts"("locked_until"); + +CREATE TABLE IF NOT EXISTS "login_attempts" ( + "id" TEXT NOT NULL, + "account_id" TEXT NOT NULL, + "ip_address" TEXT NOT NULL, + "user_agent" TEXT, + "success" BOOLEAN NOT NULL, + "reason" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "login_attempts_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX IF NOT EXISTS "login_attempts_account_id_created_at_idx" ON "login_attempts"("account_id", "created_at"); +CREATE INDEX IF NOT EXISTS "login_attempts_ip_address_created_at_idx" ON "login_attempts"("ip_address", "created_at"); +CREATE INDEX IF NOT EXISTS "login_attempts_success_idx" ON "login_attempts"("success"); + +CREATE TABLE IF NOT EXISTS "webhook_secrets" ( + "id" TEXT NOT NULL, + "merchant_id" TEXT NOT NULL, + "key_id" TEXT NOT NULL, + "secret_hash" TEXT NOT NULL, + "version" TEXT NOT NULL DEFAULT 'v1', + "active_from" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expires_at" TIMESTAMP(3) NOT NULL, + "rotated_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "webhook_secrets_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX IF NOT EXISTS "webhook_secrets_merchant_id_key_id_key" ON "webhook_secrets"("merchant_id", "key_id"); +CREATE INDEX IF NOT EXISTS "webhook_secrets_merchant_id_expires_at_idx" ON "webhook_secrets"("merchant_id", "expires_at"); + +CREATE TABLE IF NOT EXISTS "vulnerability_reports" ( + "id" TEXT NOT NULL, + "source" TEXT NOT NULL, + "scanned_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "summary" JSONB NOT NULL, + "artifact_url" TEXT, + CONSTRAINT "vulnerability_reports_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX IF NOT EXISTS "vulnerability_reports_source_scanned_at_idx" ON "vulnerability_reports"("source", "scanned_at"); + +CREATE TABLE IF NOT EXISTS "dependency_vulnerabilities" ( + "id" TEXT NOT NULL, + "report_id" TEXT NOT NULL, + "ecosystem" TEXT NOT NULL, + "package_name" TEXT NOT NULL, + "installed_version" TEXT, + "fixed_version" TEXT, + "severity" TEXT NOT NULL, + "advisory_id" TEXT, + "title" TEXT NOT NULL, + "remediation" TEXT, + "due_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "dependency_vulnerabilities_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX IF NOT EXISTS "dependency_vulnerabilities_ecosystem_severity_idx" ON "dependency_vulnerabilities"("ecosystem", "severity"); +CREATE INDEX IF NOT EXISTS "dependency_vulnerabilities_package_name_idx" ON "dependency_vulnerabilities"("package_name"); +ALTER TABLE "dependency_vulnerabilities" + ADD CONSTRAINT "dependency_vulnerabilities_report_id_fkey" + FOREIGN KEY ("report_id") REFERENCES "vulnerability_reports"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 84fc2e30..5c95c669 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -201,6 +201,10 @@ model Webhook { url String events String[] secret String + signatureVersion String @default("v1") @map("signature_version") + secretExpiresAt DateTime? @map("secret_expires_at") + rotatedAt DateTime? @map("rotated_at") + encryptionPublicKey String? @map("encryption_public_key") status WebhookStatus @default(active) failCount Int @default(0) @map("fail_count") lastFired DateTime? @map("last_fired") @@ -234,23 +238,134 @@ model PaymentLink { } model AuditLog { - id String @id @default(uuid()) - entityId String @map("entity_id") - entityType String @map("entity_type") - action String - userId String? @map("user_id") - metadata Json? - ipAddress String? @map("ip_address") - createdAt DateTime @default(now()) @map("created_at") + id String @id @default(uuid()) + timestamp DateTime @default(now()) + actor String + action String + resource String + details Json? + previousHash String @map("previous_hash") + hash String @unique + anchorId String? @map("anchor_id") + archivedAt DateTime? @map("archived_at") + coldArchivedAt DateTime? @map("cold_archived_at") + entityId String? @map("entity_id") + entityType String? @map("entity_type") + userId String? @map("user_id") + metadata Json? + ipAddress String? @map("ip_address") + createdAt DateTime @default(now()) @map("created_at") user User? @relation(fields: [userId], references: [id]) + @@index([timestamp]) + @@index([actor]) + @@index([action]) + @@index([actor, action, timestamp]) @@index([entityId, createdAt]) @@index([userId]) @@index([entityType, action]) @@map("audit_logs") } +model AuditAnchor { + id String @id @default(uuid()) + latestHash String @map("latest_hash") + chain String + transactionHash String? @map("transaction_hash") + blockNumber String? @map("block_number") + status String @default("pending") + error String? + createdAt DateTime @default(now()) @map("created_at") + + @@index([latestHash]) + @@index([createdAt]) + @@map("audit_anchors") +} + +model AccountLockout { + id String @id @default(uuid()) + accountId String @map("account_id") + ipAddress String? @map("ip_address") + failedAttempts Int @default(0) @map("failed_attempts") + lockedUntil DateTime? @map("locked_until") + unlockTokenHash String? @map("unlock_token_hash") + lastFailedAt DateTime? @map("last_failed_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@unique([accountId, ipAddress]) + @@index([accountId]) + @@index([ipAddress]) + @@index([lockedUntil]) + @@map("account_lockouts") +} + +model LoginAttempt { + id String @id @default(uuid()) + accountId String @map("account_id") + ipAddress String @map("ip_address") + userAgent String? @map("user_agent") + success Boolean + reason String? + createdAt DateTime @default(now()) @map("created_at") + + @@index([accountId, createdAt]) + @@index([ipAddress, createdAt]) + @@index([success]) + @@map("login_attempts") +} + +model WebhookSecret { + id String @id @default(uuid()) + merchantId String @map("merchant_id") + keyId String @map("key_id") + secretHash String @map("secret_hash") + version String @default("v1") + activeFrom DateTime @default(now()) @map("active_from") + expiresAt DateTime @map("expires_at") + rotatedAt DateTime? @map("rotated_at") + createdAt DateTime @default(now()) @map("created_at") + + @@unique([merchantId, keyId]) + @@index([merchantId, expiresAt]) + @@map("webhook_secrets") +} + +model VulnerabilityReport { + id String @id @default(uuid()) + source String + scannedAt DateTime @default(now()) @map("scanned_at") + summary Json + artifactUrl String? @map("artifact_url") + + vulnerabilities DependencyVulnerability[] + + @@index([source, scannedAt]) + @@map("vulnerability_reports") +} + +model DependencyVulnerability { + id String @id @default(uuid()) + reportId String @map("report_id") + ecosystem String + packageName String @map("package_name") + installedVersion String? @map("installed_version") + fixedVersion String? @map("fixed_version") + severity String + advisoryId String? @map("advisory_id") + title String + remediation String? + dueAt DateTime? @map("due_at") + createdAt DateTime @default(now()) @map("created_at") + + report VulnerabilityReport @relation(fields: [reportId], references: [id]) + + @@index([ecosystem, severity]) + @@index([packageName]) + @@map("dependency_vulnerabilities") +} + model GasEstimate { id String @id @default(uuid()) network String @unique @@ -647,4 +762,3 @@ model ApiVersionEndpoint { @@index([path]) @@map("api_version_endpoints") } - diff --git a/backend/src/audit/anchor-service.ts b/backend/src/audit/anchor-service.ts new file mode 100644 index 00000000..57506a66 --- /dev/null +++ b/backend/src/audit/anchor-service.ts @@ -0,0 +1,75 @@ +import { randomUUID } from 'node:crypto'; +import { logger } from '../utils/logger.js'; + +export interface AuditAnchorRecord { + id: string; + latestHash: string; + chain: 'ethereum' | 'stellar' | 'local'; + transactionHash?: string; + blockNumber?: string; + status: 'anchored' | 'pending' | 'failed'; + error?: string; + createdAt: string; +} + +export interface AuditAnchorStore { + appendAnchor(anchor: AuditAnchorRecord): Promise; +} + +export class AuditAnchorService { + private readonly anchors: AuditAnchorRecord[] = []; + + constructor(private readonly store?: AuditAnchorStore) {} + + async anchorLatestHash(latestHash: string): Promise { + const chain = parseChain(process.env.AUDIT_ANCHOR_CHAIN); + const anchor: AuditAnchorRecord = { + id: randomUUID(), + latestHash, + chain, + status: 'pending', + createdAt: new Date().toISOString(), + }; + + try { + if (chain === 'local') { + anchor.status = 'anchored'; + anchor.transactionHash = `local:${latestHash}`; + } else { + anchor.transactionHash = await submitPublicAnchor(chain, latestHash); + anchor.status = 'anchored'; + } + } catch (error) { + anchor.status = 'failed'; + anchor.error = error instanceof Error ? error.message : String(error); + logger.error({ error, latestHash, chain }, 'Failed to anchor audit hash'); + } + + this.anchors.push(anchor); + await this.store?.appendAnchor(anchor).catch((error) => { + logger.error({ error, anchorId: anchor.id }, 'Failed to persist audit anchor'); + }); + return anchor; + } + + listAnchors(): AuditAnchorRecord[] { + return [...this.anchors].sort((a, b) => b.createdAt.localeCompare(a.createdAt)); + } +} + +function parseChain(value: string | undefined): AuditAnchorRecord['chain'] { + if (value === 'ethereum' || value === 'stellar') return value; + return 'local'; +} + +async function submitPublicAnchor(chain: 'ethereum' | 'stellar', latestHash: string): Promise { + const endpoint = process.env.AUDIT_ANCHOR_RPC_URL; + const signingKey = process.env.AUDIT_ANCHOR_PRIVATE_KEY; + if (!endpoint || !signingKey) { + throw new Error(`Missing ${chain} anchor RPC/signing configuration`); + } + + // Production deployments should replace this adapter with the chain-specific + // transaction submission. The service persists the proof record either way. + return `${chain}:pending:${latestHash.slice(0, 16)}`; +} diff --git a/backend/src/audit/chain-verifier.ts b/backend/src/audit/chain-verifier.ts new file mode 100644 index 00000000..0923a567 --- /dev/null +++ b/backend/src/audit/chain-verifier.ts @@ -0,0 +1,84 @@ +import { AUDIT_GENESIS_HASH, computeAuditHash, ImmutableAuditLogEntry } from './immutable-logger.js'; +import { logger } from '../utils/logger.js'; + +export interface AuditIntegrityResult { + valid: boolean; + checked: number; + brokenAt?: string; + expectedPreviousHash?: string; + expectedHash?: string; + actualHash?: string; +} + +export interface AuditVerificationStore { + listAuditEntriesAscending(): Promise; +} + +export async function verifyAuditChain(entries: ImmutableAuditLogEntry[]): Promise { + let previousHash = AUDIT_GENESIS_HASH; + + for (const entry of entries) { + if (entry.previousHash !== previousHash) { + const result = { + valid: false, + checked: entries.indexOf(entry), + brokenAt: entry.id, + expectedPreviousHash: previousHash, + actualHash: entry.previousHash, + }; + await emitTamperAlert(result); + return result; + } + + const expectedHash = computeAuditHash({ + previousHash: entry.previousHash, + timestamp: entry.timestamp, + actor: entry.actor, + action: entry.action, + resource: entry.resource, + details: entry.details, + }); + + if (expectedHash !== entry.hash) { + const result = { + valid: false, + checked: entries.indexOf(entry), + brokenAt: entry.id, + expectedHash, + actualHash: entry.hash, + }; + await emitTamperAlert(result); + return result; + } + + previousHash = entry.hash; + } + + return { valid: true, checked: entries.length }; +} + +export class AuditChainVerifier { + constructor(private readonly store: AuditVerificationStore) {} + + async verifyFromGenesis(): Promise { + return verifyAuditChain(await this.store.listAuditEntriesAscending()); + } +} + +async function emitTamperAlert(result: AuditIntegrityResult): Promise { + logger.error({ result }, 'Audit hash chain inconsistency detected'); + + const webhook = process.env.AUDIT_TAMPER_ALERT_WEBHOOK_URL; + if (!webhook) return; + + await fetch(webhook, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + text: 'Audit hash chain inconsistency detected', + result, + }), + }).catch((error) => { + logger.error({ error }, 'Failed to send audit tamper alert'); + }); +} diff --git a/backend/src/audit/immutable-logger.ts b/backend/src/audit/immutable-logger.ts new file mode 100644 index 00000000..2e0de5e0 --- /dev/null +++ b/backend/src/audit/immutable-logger.ts @@ -0,0 +1,106 @@ +import { createHash, randomUUID } from 'node:crypto'; + +export const AUDIT_GENESIS_HASH = '0'.repeat(64); + +export interface ImmutableAuditLogEntry { + id: string; + timestamp: string; + actor: string; + action: string; + resource: string; + details: Record; + previousHash: string; + hash: string; +} + +export interface ImmutableAuditStore { + getLatestAuditHash(): Promise; + appendAuditEntry(entry: ImmutableAuditLogEntry): Promise; +} + +export function canonicalizeDetails(details: Record = {}): string { + return JSON.stringify(sortJson(details)); +} + +function sortJson(value: unknown): unknown { + if (Array.isArray(value)) return value.map(sortJson); + if (!value || typeof value !== 'object') return value; + return Object.fromEntries( + Object.entries(value as Record) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, nested]) => [key, sortJson(nested)]) + ); +} + +export function computeAuditHash(input: { + previousHash: string; + timestamp: string | number | Date; + actor: string; + action: string; + resource: string; + details?: Record; +}): string { + const timestamp = input.timestamp instanceof Date ? input.timestamp.toISOString() : String(input.timestamp); + return createHash('sha256') + .update(input.previousHash) + .update(timestamp) + .update(input.actor) + .update(input.action) + .update(input.resource) + .update(canonicalizeDetails(input.details ?? {})) + .digest('hex'); +} + +export class ImmutableAuditLogger { + private inMemoryEntries: ImmutableAuditLogEntry[] = []; + private writeLock: Promise = Promise.resolve(); + + constructor(private readonly store?: ImmutableAuditStore) {} + + async log(input: { + actor?: string; + action: string; + resource: string; + details?: Record; + }): Promise { + let created!: ImmutableAuditLogEntry; + this.writeLock = this.writeLock.then(async () => { + const previousHash = (await this.store?.getLatestAuditHash()) ?? this.inMemoryEntries.at(-1)?.hash ?? AUDIT_GENESIS_HASH; + const timestamp = new Date().toISOString(); + const details = input.details ?? {}; + created = { + id: randomUUID(), + timestamp, + actor: input.actor ?? 'system', + action: input.action, + resource: input.resource, + details, + previousHash, + hash: computeAuditHash({ + previousHash, + timestamp, + actor: input.actor ?? 'system', + action: input.action, + resource: input.resource, + details, + }), + }; + + if (this.store) { + try { + await this.store.appendAuditEntry(created); + } catch (error) { + console.warn('[audit] Failed to persist immutable audit entry; retaining in memory', error); + } + } + this.inMemoryEntries.push(created); + }); + + await this.writeLock; + return created; + } + + getInMemoryEntries(): ImmutableAuditLogEntry[] { + return [...this.inMemoryEntries]; + } +} diff --git a/backend/src/audit/prisma-audit-store.ts b/backend/src/audit/prisma-audit-store.ts new file mode 100644 index 00000000..1f0d7d55 --- /dev/null +++ b/backend/src/audit/prisma-audit-store.ts @@ -0,0 +1,76 @@ +import { prisma } from '../lib/prisma.js'; +import { AuditAnchorRecord } from './anchor-service.js'; +import { ImmutableAuditLogEntry, ImmutableAuditStore } from './immutable-logger.js'; + +export class PrismaAuditStore implements ImmutableAuditStore { + private readonly client = prisma as any; + + async getLatestAuditHash(): Promise { + const latest = await this.client.auditLog.findFirst({ + orderBy: [{ timestamp: 'desc' }, { createdAt: 'desc' }], + select: { hash: true }, + }); + return latest?.hash; + } + + async appendAuditEntry(entry: ImmutableAuditLogEntry): Promise { + await this.client.auditLog.create({ + data: { + id: entry.id, + timestamp: new Date(entry.timestamp), + actor: entry.actor, + action: entry.action, + resource: entry.resource, + details: entry.details, + previousHash: entry.previousHash, + hash: entry.hash, + entityId: entry.resource, + entityType: entry.resource, + userId: entry.actor === 'system' ? undefined : entry.actor, + metadata: entry.details, + }, + }); + } + + async listAuditEntriesAscending(): Promise { + const rows = await this.client.auditLog.findMany({ + orderBy: [{ timestamp: 'asc' }, { createdAt: 'asc' }], + select: { + id: true, + timestamp: true, + actor: true, + action: true, + resource: true, + details: true, + previousHash: true, + hash: true, + }, + }); + + return rows.map((row: any) => ({ + id: row.id, + timestamp: row.timestamp.toISOString(), + actor: row.actor, + action: row.action, + resource: row.resource, + details: row.details ?? {}, + previousHash: row.previousHash, + hash: row.hash, + })); + } + + async appendAnchor(anchor: AuditAnchorRecord): Promise { + await this.client.auditAnchor.create({ + data: { + id: anchor.id, + latestHash: anchor.latestHash, + chain: anchor.chain, + transactionHash: anchor.transactionHash, + blockNumber: anchor.blockNumber, + status: anchor.status, + error: anchor.error, + createdAt: new Date(anchor.createdAt), + }, + }); + } +} diff --git a/backend/src/index.ts b/backend/src/index.ts index c55c8184..121ec4a4 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -108,6 +108,7 @@ import { coldStartMonitorRouter } from './routes/cold-start-monitor.js'; import { rateLimitAnalyticsRouter } from './routes/rate-limit-analytics.js'; import { startScheduledRotation, stopScheduledRotation } from './config/credential-rotation.js'; import devDevRouter from './routes/dev/reload.js'; +import { sessionsRouter } from './routes/sessions.js'; // Validate environment variables at startup validateEnv(); @@ -321,6 +322,7 @@ app.use('/api/v1/projects', projectsRouter); // Two-factor authentication app.use('/api/v1/auth/2fa', twoFactorAuthRouter); +app.use('/api/v1/auth/sessions', sessionsRouter); // Sandbox environment for testing (with relaxed rate limits) const sandboxRouter = createSandboxRouter(getSandboxManager(), getMockPaymentProcessor(), getTestDataSeeder()); diff --git a/backend/src/middleware/brute-force.ts b/backend/src/middleware/brute-force.ts new file mode 100644 index 00000000..ef0e72b6 --- /dev/null +++ b/backend/src/middleware/brute-force.ts @@ -0,0 +1,77 @@ +import { Request, Response, NextFunction } from 'express'; +import { lockoutManager } from '../services/auth/lockout-manager.js'; + +const ipBuckets = new Map(); + +export function bruteForceProtection(options: { + accountResolver?: (req: Request) => string; + maxAttemptsPerSecondPerIp?: number; +} = {}) { + const maxPerSecond = options.maxAttemptsPerSecondPerIp ?? 3; + const resolveAccount = options.accountResolver ?? ((req) => String(req.body?.email ?? req.headers['x-user-id'] ?? 'unknown')); + + return (req: Request, res: Response, next: NextFunction) => { + const ipAddress = getIp(req); + const now = Date.now(); + const bucket = ipBuckets.get(ipAddress) ?? { count: 0, resetAt: now + 1_000 }; + if (bucket.resetAt <= now) { + bucket.count = 0; + bucket.resetAt = now + 1_000; + } + bucket.count += 1; + ipBuckets.set(ipAddress, bucket); + + if (bucket.count > maxPerSecond) { + res.setHeader('Retry-After', '1'); + res.status(429).json({ error: 'Too many login attempts from this IP' }); + return; + } + + const accountId = resolveAccount(req); + const status = lockoutManager.getStatus(accountId, ipAddress, now); + res.locals.lockoutStatus = status; + res.locals.loginAccountId = accountId; + res.locals.loginIpAddress = ipAddress; + + if (status.locked) { + res.status(423).json({ + error: 'Account is locked', + lockedUntil: new Date(status.lockedUntil!).toISOString(), + }); + return; + } + + if (status.delayUntil) { + const retryAfter = Math.ceil((status.delayUntil - now) / 1000); + res.setHeader('Retry-After', String(retryAfter)); + res.status(429).json({ + error: 'Progressive login delay active', + retryAfterSeconds: retryAfter, + captchaRequired: status.captchaRequired, + }); + return; + } + + next(); + }; +} + +export async function recordLoginAttempt(req: Request, success: boolean, reason?: string) { + return lockoutManager.recordAttempt({ + accountId: String(resolved(req, 'loginAccountId') ?? req.headers['x-user-id'] ?? req.body?.email ?? 'unknown'), + ipAddress: String(resolved(req, 'loginIpAddress') ?? getIp(req)), + userAgent: req.headers['user-agent'], + success, + reason, + }); +} + +function resolved(req: Request, key: string): unknown { + return (req.res?.locals as Record | undefined)?.[key]; +} + +function getIp(req: Request): string { + const forwarded = req.headers['x-forwarded-for']; + if (typeof forwarded === 'string') return forwarded.split(',')[0]?.trim() || 'unknown'; + return req.ip || req.socket.remoteAddress || 'unknown'; +} diff --git a/backend/src/routes/audit.ts b/backend/src/routes/audit.ts index a46018c7..c925d574 100644 --- a/backend/src/routes/audit.ts +++ b/backend/src/routes/audit.ts @@ -47,7 +47,7 @@ auditRouter.get('/entries', asyncHandler(async (req: Request, res: Response) => })); auditRouter.get('/entries/:id', asyncHandler(async (req: Request, res: Response) => { - const { id } = req.params; + const id = String(req.params.id); const entry = await auditService.getEntry(id); if (!entry) { @@ -63,8 +63,17 @@ auditRouter.get('/verify', asyncHandler(async (req: Request, res: Response) => { res.status(200).json(result); })); +auditRouter.post('/anchor', asyncHandler(async (_req: Request, res: Response) => { + const anchor = await auditService.anchorLatestHash(); + res.status(anchor.status === 'failed' ? 502 : 201).json(anchor); +})); + +auditRouter.get('/anchors', asyncHandler(async (_req: Request, res: Response) => { + res.status(200).json({ anchors: auditService.listAnchors() }); +})); + auditRouter.post('/flag/:id', asyncHandler(async (req: Request, res: Response) => { - const { id } = req.params; + const id = String(req.params.id); const { reasons } = req.body; if (!reasons || !Array.isArray(reasons)) { @@ -125,4 +134,4 @@ auditRouter.delete('/clear', asyncHandler(async (req: Request, res: Response) => const deleted = await auditService.clearOldEntries(); res.status(200).json({ deleted, message: 'Old entries cleared' }); -})); \ No newline at end of file +})); diff --git a/backend/src/routes/sessions.ts b/backend/src/routes/sessions.ts index a4c08baa..6bf4fd43 100644 --- a/backend/src/routes/sessions.ts +++ b/backend/src/routes/sessions.ts @@ -9,6 +9,8 @@ import { createSession } from '../services/session.js'; import { AppError } from '../middleware/errorHandler.js'; +import { bruteForceProtection, recordLoginAttempt } from '../middleware/brute-force.js'; +import { lockoutManager } from '../services/auth/lockout-manager.js'; export const sessionsRouter = Router(); @@ -31,7 +33,9 @@ sessionsRouter.get('/history', asyncHandler(async (req, res) => { })); // Create a new session (Mock login) -sessionsRouter.post('/login', asyncHandler(async (req, res) => { +sessionsRouter.post('/login', bruteForceProtection({ + accountResolver: (req) => String(req.headers['x-user-id'] ?? req.body?.email ?? 'user_default'), +}), asyncHandler(async (req, res) => { const userId = getUserId(req); const { deviceId, browser, os } = req.body; @@ -43,10 +47,31 @@ sessionsRouter.post('/login', asyncHandler(async (req, res) => { os: os || 'unknown', ip }); + + await recordLoginAttempt(req, true); res.json({ session }); })); +sessionsRouter.post('/login/failure', bruteForceProtection({ + accountResolver: (req) => String(req.headers['x-user-id'] ?? req.body?.email ?? 'user_default'), +}), asyncHandler(async (req, res) => { + const result = await recordLoginAttempt(req, false, 'invalid_credentials'); + res.status(result.lockedUntil ? 423 : 401).json({ + error: result.lockedUntil ? 'Account locked' : 'Invalid credentials', + lockedUntil: result.lockedUntil ? new Date(result.lockedUntil).toISOString() : undefined, + captchaRequired: res.locals.lockoutStatus?.captchaRequired ?? false, + unlockToken: process.env.NODE_ENV === 'production' ? undefined : result.unlockToken, + }); +})); + +sessionsRouter.post('/unlock', asyncHandler(async (req, res) => { + const userId = String(req.body?.userId ?? getUserId(req)); + const unlocked = lockoutManager.unlockAccount(userId, typeof req.body?.token === 'string' ? req.body.token : undefined); + if (!unlocked) throw new AppError(404, 'No lockout found for account', 'LOCKOUT_NOT_FOUND'); + res.json({ success: true }); +})); + // Terminate a specific session sessionsRouter.delete('/:id', asyncHandler(async (req, res) => { const id = req.params.id as string; diff --git a/backend/src/routes/webhooks.ts b/backend/src/routes/webhooks.ts index b4ab6a99..e2518779 100644 --- a/backend/src/routes/webhooks.ts +++ b/backend/src/routes/webhooks.ts @@ -36,6 +36,7 @@ const webhookConfigSchema = z.object({ url: z.string().url(), secret: z.string().min(16), enabled: z.boolean().optional(), + encryptionPublicKey: z.string().optional(), }); const webhookEventSchema = z.object({ @@ -230,4 +231,4 @@ webhooksRouter.get( asyncHandler(async (_req, res) => { res.json({ data: listDeadLetterQueue() }); }) -); \ No newline at end of file +); diff --git a/backend/src/services/auditService.ts b/backend/src/services/auditService.ts index bca9c356..61c63215 100644 --- a/backend/src/services/auditService.ts +++ b/backend/src/services/auditService.ts @@ -1,5 +1,7 @@ -import { createHash, randomUUID } from 'node:crypto'; import { randomUUID as uuidv4 } from 'node:crypto'; +import { AuditAnchorService } from '../audit/anchor-service.js'; +import { AUDIT_GENESIS_HASH, computeAuditHash, ImmutableAuditLogger } from '../audit/immutable-logger.js'; +import { verifyAuditChain } from '../audit/chain-verifier.js'; export interface AuditEntry { id: string; @@ -42,7 +44,10 @@ export interface RetentionPolicy { export class AuditService { private entries: AuditEntry[] = []; - private currentHash = '0000000000000000000000000000000000000000000000000000000000000000'; + private currentHash = AUDIT_GENESIS_HASH; + private immutableLogger = new ImmutableAuditLogger(); + private anchorService = new AuditAnchorService(); + private persistenceInitialized = false; private retentionPolicy: RetentionPolicy = { retentionDays: 2555, archiveAfterDays: 2190, @@ -55,27 +60,30 @@ export class AuditService { } } - private computeHash(data: string): string { - return createHash('sha256').update(data).digest('hex'); + private async ensurePersistence(): Promise { + if (this.persistenceInitialized) return; + this.persistenceInitialized = true; + if (!process.env.DATABASE_URL || process.env.AUDIT_PERSISTENCE === 'memory') return; + + try { + const { PrismaAuditStore } = await import('../audit/prisma-audit-store.js'); + const store = new PrismaAuditStore(); + this.immutableLogger = new ImmutableAuditLogger(store); + this.anchorService = new AuditAnchorService(store); + } catch (error) { + console.warn('[audit] Falling back to in-memory immutable audit store', error); + } } private generateEntryHash(entry: Omit): string { - const data = [ - entry.id, - entry.timestamp, - entry.userId || '', - entry.action, - entry.resource, - entry.resourceId || '', - JSON.stringify(entry.details || {}), - JSON.stringify(entry.beforeState || {}), - JSON.stringify(entry.afterState || {}), - entry.ipAddress || '', - entry.requestMethod || '', - entry.requestPath || '', - entry.previousHash, - ].join('|'); - return this.computeHash(data); + return computeAuditHash({ + previousHash: entry.previousHash, + timestamp: new Date(entry.timestamp).toISOString(), + actor: entry.userId || 'system', + action: entry.action, + resource: entry.resource, + details: entry.details, + }); } async logAction(params: { @@ -97,9 +105,16 @@ export class AuditService { status?: number; }; }): Promise { + await this.ensurePersistence(); + const immutable = await this.immutableLogger.log({ + actor: params.userId || 'system', + action: params.action, + resource: params.resource, + details: params.details, + }); const id = uuidv4(); - const timestamp = Date.now(); - + const timestamp = Date.parse(immutable.timestamp); + const entry: Omit = { id, timestamp, @@ -116,10 +131,10 @@ export class AuditService { requestPath: params.request?.path, requestBody: this.sanitizeRequestBody(params.request?.body), responseStatus: params.response?.status, - previousHash: this.currentHash, + previousHash: immutable.previousHash, }; - const hash = this.generateEntryHash(entry); + const hash = immutable.hash; const fullEntry: AuditEntry = { ...entry, hash }; this.entries.push(fullEntry); @@ -170,26 +185,27 @@ export class AuditService { } async verifyIntegrity(): Promise<{ valid: boolean; brokenAt?: string }> { - let expectedHash = '0000000000000000000000000000000000000000000000000000000000000000'; - - for (const entry of this.entries) { - if (entry.previousHash !== expectedHash) { - return { valid: false, brokenAt: entry.id }; - } - - const computedHash = this.generateEntryHash(entry); - if (computedHash !== entry.hash) { - return { valid: false, brokenAt: entry.id }; - } - - expectedHash = entry.hash; - } - - if (this.currentHash !== expectedHash) { - return { valid: false, brokenAt: this.entries[this.entries.length - 1]?.id }; - } - - return { valid: true }; + await this.ensurePersistence(); + const result = await verifyAuditChain(this.entries.map((entry) => ({ + id: entry.id, + timestamp: new Date(entry.timestamp).toISOString(), + actor: entry.userId || 'system', + action: entry.action, + resource: entry.resource, + details: entry.details ?? {}, + previousHash: entry.previousHash, + hash: entry.hash, + }))); + return { valid: result.valid, brokenAt: result.brokenAt }; + } + + async anchorLatestHash() { + await this.ensurePersistence(); + return this.anchorService.anchorLatestHash(this.currentHash); + } + + listAnchors() { + return this.anchorService.listAnchors(); } async flagSuspicious(entryId: string, reasons: string[]): Promise { @@ -284,4 +300,4 @@ export class AuditService { } } -export const auditService = new AuditService(); \ No newline at end of file +export const auditService = new AuditService(); diff --git a/backend/src/services/auth/lockout-manager.ts b/backend/src/services/auth/lockout-manager.ts new file mode 100644 index 00000000..b70dbace --- /dev/null +++ b/backend/src/services/auth/lockout-manager.ts @@ -0,0 +1,144 @@ +import { createHash, randomBytes } from 'node:crypto'; +import { auditService } from '../auditService.js'; + +const ATTEMPT_WINDOW_MS = 24 * 60 * 60 * 1000; +const AUTO_UNLOCK_MS = 24 * 60 * 60 * 1000; +const PROGRESSIVE_DELAYS_MS = [1_000, 5_000, 30_000, 120_000, 600_000, 3_600_000]; +const LOCKOUT_THRESHOLD = 10; + +export interface LoginAttemptRecord { + accountId: string; + ipAddress: string; + userAgent?: string; + success: boolean; + reason?: string; + createdAt: number; +} + +export interface LockoutState { + accountId: string; + ipAddress: string; + failedAttempts: number; + lastFailedAt?: number; + lockedUntil?: number; + unlockTokenHash?: string; +} + +const attempts: LoginAttemptRecord[] = []; +const lockouts = new Map(); + +function key(accountId: string, ipAddress: string): string { + return `${accountId}:${ipAddress}`; +} + +function accountStates(accountId: string): LockoutState[] { + return [...lockouts.values()].filter((state) => state.accountId === accountId); +} + +function hashToken(token: string): string { + return createHash('sha256').update(token).digest('hex'); +} + +export class LockoutManager { + getStatus(accountId: string, ipAddress: string, now = Date.now()) { + const state = lockouts.get(key(accountId, ipAddress)); + const accountLockedUntil = accountStates(accountId) + .map((item) => item.lockedUntil ?? 0) + .filter((lockedUntil) => lockedUntil > now) + .sort((a, b) => b - a)[0]; + const failedFromIp = attempts.filter( + (attempt) => attempt.ipAddress === ipAddress && !attempt.success && attempt.createdAt >= now - ATTEMPT_WINDOW_MS + ).length; + const failedAttempts = state?.failedAttempts ?? 0; + const delayMs = failedAttempts > 0 + ? PROGRESSIVE_DELAYS_MS[Math.min(failedAttempts - 1, PROGRESSIVE_DELAYS_MS.length - 1)] + : 0; + const delayUntil = state?.lastFailedAt ? state.lastFailedAt + delayMs : undefined; + const lockedUntil = Math.max(state?.lockedUntil ?? 0, accountLockedUntil ?? 0); + + return { + locked: lockedUntil > now, + lockedUntil: lockedUntil > now ? lockedUntil : undefined, + delayMs, + delayUntil: delayUntil && delayUntil > now ? delayUntil : undefined, + failedAttempts, + captchaRequired: failedFromIp >= 3, + }; + } + + async recordAttempt(input: { + accountId: string; + ipAddress: string; + userAgent?: string; + success: boolean; + reason?: string; + }): Promise<{ lockedUntil?: number; unlockToken?: string }> { + const now = Date.now(); + attempts.push({ ...input, createdAt: now }); + while (attempts.length > 20_000 || attempts[0]?.createdAt < now - ATTEMPT_WINDOW_MS) attempts.shift(); + + await auditService.logAction({ + userId: input.accountId, + action: input.success ? 'auth.login.success' : 'auth.login.failure', + resource: 'auth', + details: { reason: input.reason, captchaRequired: this.getStatus(input.accountId, input.ipAddress, now).captchaRequired }, + ipAddress: input.ipAddress, + userAgent: input.userAgent, + }); + + if (input.success) { + lockouts.delete(key(input.accountId, input.ipAddress)); + return {}; + } + + const stateKey = key(input.accountId, input.ipAddress); + const state = lockouts.get(stateKey) ?? { + accountId: input.accountId, + ipAddress: input.ipAddress, + failedAttempts: 0, + }; + state.failedAttempts += 1; + state.lastFailedAt = now; + + let unlockToken: string | undefined; + if (state.failedAttempts >= LOCKOUT_THRESHOLD) { + state.lockedUntil = now + AUTO_UNLOCK_MS; + unlockToken = randomBytes(32).toString('hex'); + state.unlockTokenHash = hashToken(unlockToken); + await auditService.logAction({ + userId: input.accountId, + action: 'auth.account.locked', + resource: 'auth', + details: { failedAttempts: state.failedAttempts, lockedUntil: new Date(state.lockedUntil).toISOString() }, + ipAddress: input.ipAddress, + userAgent: input.userAgent, + }); + } + + lockouts.set(stateKey, state); + return { lockedUntil: state.lockedUntil, unlockToken }; + } + + unlockAccount(accountId: string, token?: string): boolean { + const states = accountStates(accountId); + if (states.length === 0) return false; + let unlocked = false; + + for (const state of states) { + if (token && state.unlockTokenHash && state.unlockTokenHash !== hashToken(token)) continue; + lockouts.delete(key(state.accountId, state.ipAddress)); + unlocked = true; + } + + if (unlocked) { + void auditService.logAction({ userId: accountId, action: 'auth.account.unlocked', resource: 'auth' }); + } + return unlocked; + } + + listAttempts(): LoginAttemptRecord[] { + return [...attempts].sort((a, b) => b.createdAt - a.createdAt); + } +} + +export const lockoutManager = new LockoutManager(); diff --git a/backend/src/services/webhooks.ts b/backend/src/services/webhooks.ts index b283996f..a799681c 100644 --- a/backend/src/services/webhooks.ts +++ b/backend/src/services/webhooks.ts @@ -1,4 +1,10 @@ -import { createHmac, randomUUID } from 'node:crypto'; +import { randomUUID } from 'node:crypto'; +import { encryptWebhookPayload } from './webhooks/encryption.js'; +import { + signWebhookPayload, + WEBHOOK_SIGNATURE_HEADER, + WEBHOOK_TIMESTAMP_HEADER, +} from './webhooks/signer.js'; export type WebhookDeliveryStatus = | 'pending' @@ -15,6 +21,9 @@ export interface MerchantWebhookConfig { enabled: boolean; currentSecret: string; previousSecrets: string[]; + signatureVersion: string; + secretExpiresAt: string; + encryptionPublicKey?: string; createdAt: string; updatedAt: string; } @@ -72,15 +81,12 @@ function computeBackoffDelay(attempt: number): number { return exponential + jitter; } -function buildSignature(secret: string, body: string): string { - return createHmac('sha256', secret).update(body).digest('hex'); -} - export function upsertWebhookConfig(input: { merchantId: string; url: string; secret: string; enabled?: boolean; + encryptionPublicKey?: string; }): MerchantWebhookConfig { const existing = Array.from(webhookConfigs.values()).find((x) => x.merchantId === input.merchantId); const ts = nowIso(); @@ -89,6 +95,9 @@ export function upsertWebhookConfig(input: { existing.url = input.url; existing.currentSecret = input.secret; existing.enabled = input.enabled ?? true; + existing.signatureVersion = 'v1'; + existing.secretExpiresAt = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString(); + existing.encryptionPublicKey = input.encryptionPublicKey; existing.updatedAt = ts; webhookConfigs.set(existing.id, existing); return existing; @@ -101,6 +110,9 @@ export function upsertWebhookConfig(input: { enabled: input.enabled ?? true, currentSecret: input.secret, previousSecrets: [], + signatureVersion: 'v1', + secretExpiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString(), + encryptionPublicKey: input.encryptionPublicKey, createdAt: ts, updatedAt: ts, }; @@ -114,6 +126,8 @@ export function rotateWebhookSecret(configId: string, nextSecret: string): Merch config.previousSecrets.unshift(config.currentSecret); config.previousSecrets = config.previousSecrets.slice(0, 5); config.currentSecret = nextSecret; + config.signatureVersion = 'v1'; + config.secretExpiresAt = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString(); config.updatedAt = nowIso(); webhookConfigs.set(config.id, config); return config; @@ -178,8 +192,25 @@ async function deliverOne(delivery: WebhookDeliveryLog): Promise { return; } - const body = delivery.responseBody ?? '{}'; - const signature = buildSignature(config.currentSecret, body); + let originalEvent: PaymentWebhookEvent | undefined; + try { + originalEvent = delivery.responseBody ? JSON.parse(delivery.responseBody) as PaymentWebhookEvent : undefined; + } catch { + originalEvent = { + eventId: delivery.eventId, + merchantId: delivery.merchantId, + type: 'webhook.retry', + payload: {}, + createdAt: delivery.createdAt, + }; + } + const signed = signWebhookPayload({ + payload: originalEvent ? { ...originalEvent, payload: originalEvent.payload } : {}, + secret: config.currentSecret, + version: config.signatureVersion, + eventId: delivery.eventId, + }); + const body = encryptWebhookPayload(signed.body, config.encryptionPublicKey); const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), ATTEMPT_TIMEOUT_MS); @@ -192,7 +223,10 @@ async function deliverOne(delivery: WebhookDeliveryLog): Promise { method: 'POST', headers: { 'Content-Type': 'application/json', - 'X-Webhook-Signature': signature, + [WEBHOOK_SIGNATURE_HEADER]: signed.signature, + [WEBHOOK_TIMESTAMP_HEADER]: signed.timestamp, + 'X-AgenticPay-Signature-Version': signed.version, + 'X-AgenticPay-Event-Id': delivery.eventId, 'X-Webhook-Idempotency-Key': delivery.idempotencyKey, 'X-Webhook-Event-Id': delivery.eventId, }, diff --git a/backend/src/services/webhooks/encryption.ts b/backend/src/services/webhooks/encryption.ts new file mode 100644 index 00000000..15dad06a --- /dev/null +++ b/backend/src/services/webhooks/encryption.ts @@ -0,0 +1,39 @@ +import { constants, publicEncrypt, randomBytes, createCipheriv } from 'node:crypto'; + +export interface EncryptedWebhookPayload { + encrypted: true; + alg: 'RSA-OAEP-256+A256GCM'; + encryptedKey: string; + iv: string; + authTag: string; + ciphertext: string; +} + +export function encryptWebhookPayload(payload: string, merchantPublicKeyPem?: string): string { + if (!merchantPublicKeyPem) return payload; + + const contentKey = randomBytes(32); + const iv = randomBytes(12); + const cipher = createCipheriv('aes-256-gcm', contentKey, iv); + const ciphertext = Buffer.concat([cipher.update(payload, 'utf8'), cipher.final()]); + const authTag = cipher.getAuthTag(); + const encryptedKey = publicEncrypt( + { + key: merchantPublicKeyPem, + padding: constants.RSA_PKCS1_OAEP_PADDING, + oaepHash: 'sha256', + }, + contentKey + ); + + const envelope: EncryptedWebhookPayload = { + encrypted: true, + alg: 'RSA-OAEP-256+A256GCM', + encryptedKey: encryptedKey.toString('base64'), + iv: iv.toString('base64'), + authTag: authTag.toString('base64'), + ciphertext: ciphertext.toString('base64'), + }; + + return JSON.stringify(envelope); +} diff --git a/backend/src/services/webhooks/signer.ts b/backend/src/services/webhooks/signer.ts new file mode 100644 index 00000000..60bacce2 --- /dev/null +++ b/backend/src/services/webhooks/signer.ts @@ -0,0 +1,91 @@ +import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto'; + +export const WEBHOOK_SIGNATURE_HEADER = 'X-AgenticPay-Signature'; +export const WEBHOOK_TIMESTAMP_HEADER = 'X-AgenticPay-Timestamp'; +export const WEBHOOK_SIGNATURE_VERSION = 'v1'; +export const DEFAULT_WEBHOOK_TOLERANCE_SECONDS = 300; + +export interface SignedWebhookPayload { + body: string; + timestamp: string; + signature: string; + version: string; + eventId: string; +} + +export function signWebhookPayload(input: { + payload: Record; + secret: string; + version?: string; + eventId?: string; + timestamp?: number; +}): SignedWebhookPayload { + const version = input.version ?? WEBHOOK_SIGNATURE_VERSION; + const timestamp = String(input.timestamp ?? Math.floor(Date.now() / 1000)); + const eventId = input.eventId ?? `whev_${randomUUID()}`; + const payloadWithSignature = { + ...input.payload, + webhook: { + ...(typeof input.payload.webhook === 'object' && input.payload.webhook !== null ? input.payload.webhook : {}), + eventId, + signature: '', + signatureVersion: version, + timestamp, + }, + }; + const bodyWithoutSignature = JSON.stringify(payloadWithSignature); + const signature = buildWebhookSignature({ body: bodyWithoutSignature, timestamp, secret: input.secret, version }); + const body = JSON.stringify({ + ...payloadWithSignature, + webhook: { + ...(payloadWithSignature.webhook as Record), + signature, + }, + }); + + return { body, timestamp, signature, version, eventId }; +} + +export function buildWebhookSignature(input: { + body: string; + timestamp: string; + secret: string; + version?: string; +}): string { + const version = input.version ?? WEBHOOK_SIGNATURE_VERSION; + const digest = createHmac('sha256', input.secret) + .update(`${input.timestamp}.${input.body}`) + .digest('hex'); + return `${version}=${digest}`; +} + +export function verifyWebhookSignature(input: { + payload: string | Buffer | Record; + signature: string; + secret: string; + timestamp: string | number; + toleranceSeconds?: number; +}): boolean { + const timestamp = Number(input.timestamp); + if (!Number.isFinite(timestamp)) return false; + + const tolerance = input.toleranceSeconds ?? DEFAULT_WEBHOOK_TOLERANCE_SECONDS; + const age = Math.abs(Math.floor(Date.now() / 1000) - timestamp); + if (age > tolerance) return false; + + const body = Buffer.isBuffer(input.payload) + ? input.payload.toString('utf8') + : typeof input.payload === 'string' + ? input.payload + : JSON.stringify(input.payload); + const expected = buildWebhookSignature({ + body, + timestamp: String(timestamp), + secret: input.secret, + version: input.signature.split('=')[0] || WEBHOOK_SIGNATURE_VERSION, + }); + + const actual = Buffer.from(input.signature); + const expectedBuffer = Buffer.from(expected); + return actual.length === expectedBuffer.length && timingSafeEqual(actual, expectedBuffer); +} diff --git a/docs/security/webhook-signatures.md b/docs/security/webhook-signatures.md new file mode 100644 index 00000000..bd82c139 --- /dev/null +++ b/docs/security/webhook-signatures.md @@ -0,0 +1,31 @@ +# Webhook Signatures + +AgenticPay signs every outbound webhook with HMAC-SHA256 using the merchant webhook secret. + +Headers: + +- `X-AgenticPay-Signature`: versioned signature, for example `v1=` +- `X-AgenticPay-Timestamp`: Unix timestamp in seconds +- `X-AgenticPay-Signature-Version`: signature version +- `X-AgenticPay-Event-Id`: event id + +The signed message is: + +```text +timestamp + "." + raw_request_body +``` + +Reject webhooks when the timestamp is more than 5 minutes from local time. + +```ts +import { verifyWebhookSignature } from '@agenticpay/sdk'; + +const valid = verifyWebhookSignature({ + payload: rawBody, + signature: req.headers['x-agenticpay-signature'], + timestamp: req.headers['x-agenticpay-timestamp'], + secret: process.env.AGENTICPAY_WEBHOOK_SECRET!, +}); +``` + +Webhook bodies also include `webhook.signature` for systems that cannot inspect headers. Prefer header verification when possible because it binds the raw request body. diff --git a/frontend/app/dashboard/admin/audit-log/page.tsx b/frontend/app/dashboard/admin/audit-log/page.tsx new file mode 100644 index 00000000..a5a3daa3 --- /dev/null +++ b/frontend/app/dashboard/admin/audit-log/page.tsx @@ -0,0 +1,135 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { AlertTriangle, CheckCircle, Download, Link2, RefreshCw, Search } from 'lucide-react'; +import { toast } from 'sonner'; +import { api, AuditLogEntry } from '@/lib/api'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Badge } from '@/components/ui/badge'; + +export default function AdminAuditLogPage() { + const [entries, setEntries] = useState([]); + const [total, setTotal] = useState(0); + const [actor, setActor] = useState(''); + const [action, setAction] = useState(''); + const [resource, setResource] = useState(''); + const [valid, setValid] = useState(); + const [loading, setLoading] = useState(true); + + const load = async () => { + setLoading(true); + try { + const [entryResponse, verifyResponse] = await Promise.all([ + api.audit.listEntries({ userId: actor || undefined, action: action || undefined, resource: resource || undefined, limit: 100 }), + api.audit.verify(), + ]); + setEntries(entryResponse.entries); + setTotal(entryResponse.total); + setValid(verifyResponse.valid); + } catch (error) { + console.error(error); + toast.error('Failed to load audit log'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + void load(); + }, []); + + const anchor = async () => { + try { + await api.audit.anchor(); + toast.success('Audit hash anchor recorded'); + } catch (error) { + console.error(error); + toast.error('Failed to anchor audit hash'); + } + }; + + return ( +
+
+
+

Audit Log

+
+ {valid ? ( + Verified + ) : ( + Tamper check failed + )} + {total} entries +
+
+
+ + + + +
+
+ +
+
+ + setActor(event.target.value)} placeholder="user id" /> +
+
+ + setAction(event.target.value)} placeholder="auth.login.success" /> +
+
+ + setResource(event.target.value)} placeholder="payment" /> +
+ +
+ +
+ + + + + + + + + + + + + {entries.map((entry) => ( + + + + + + + + + ))} + +
TimestampActorActionResourceHashPrevious Hash
{new Date(entry.timestamp).toLocaleString()}{entry.userId ?? 'system'}{entry.action}{entry.resource}{entry.hash.slice(0, 16)}...{entry.previousHash.slice(0, 16)}...
+ {!loading && entries.length === 0 && ( +
No audit entries found
+ )} +
+
+ ); +} diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 777b8fd4..dc415d45 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -142,6 +142,29 @@ export interface RotateWebhookSecretRequest { gracePeriodHours?: number; } +export interface AuditLogEntry { + id: string; + timestamp: number; + userId?: string; + action: string; + resource: string; + resourceId?: string; + details?: Record; + previousHash: string; + hash: string; + suspicious?: boolean; +} + +export interface AuditEntriesResponse { + entries: AuditLogEntry[]; + total: number; +} + +export interface AuditVerifyResponse { + valid: boolean; + brokenAt?: string; +} + export interface BatchPaymentItem { recipient: string; amount: string; @@ -292,6 +315,22 @@ export const api = { }), }, + audit: { + listEntries: async (query?: { userId?: string; action?: string; resource?: string; limit?: number }) => { + const params = new URLSearchParams(); + if (query?.userId) params.set('userId', query.userId); + if (query?.action) params.set('action', query.action); + if (query?.resource) params.set('resource', query.resource); + if (query?.limit) params.set('limit', String(query.limit)); + return apiCall(`/audit/entries${params.size ? `?${params}` : ''}`, { method: 'GET' }); + }, + verify: async () => apiCall('/audit/verify', { method: 'GET' }), + anchor: async () => apiCall('/audit/anchor', { method: 'POST' }), + listAnchors: async () => apiCall<{ anchors: unknown[] }>('/audit/anchors', { method: 'GET' }), + exportJsonUrl: '/api/v1/audit/export/json', + exportCsvUrl: '/api/v1/audit/export/csv', + }, + /** * Batch Payment API */ @@ -324,4 +363,4 @@ export const api = { listScheduled: async () => apiCall<{ batches: ScheduledBatch[] }>(`/batch/scheduled`, { method: 'GET' }), cancelScheduled: async (id: string) => apiCall(`/batch/scheduled/${id}`, { method: 'DELETE' }), }, -}; \ No newline at end of file +}; diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index b83a9769..cc4a923b 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -8,6 +8,7 @@ import { AgenticPayClientOptions } from './types.js'; export * from './types.js'; export * from './errors.js'; export * from './auth.js'; +export * from './webhooks/verifier.js'; export class AgenticPaySDK { readonly client: AgenticPayClient; diff --git a/packages/sdk/src/webhooks/verifier.ts b/packages/sdk/src/webhooks/verifier.ts new file mode 100644 index 00000000..18fa677e --- /dev/null +++ b/packages/sdk/src/webhooks/verifier.ts @@ -0,0 +1,32 @@ +import { createHmac, timingSafeEqual } from 'node:crypto'; + +export interface VerifyWebhookSignatureOptions { + payload: string | Buffer | Record; + signature: string; + secret: string; + timestamp: string | number; + toleranceSeconds?: number; +} + +export function verifyWebhookSignature(options: VerifyWebhookSignatureOptions): boolean { + const timestamp = Number(options.timestamp); + if (!Number.isFinite(timestamp)) return false; + + const tolerance = options.toleranceSeconds ?? 300; + if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > tolerance) return false; + + const body = Buffer.isBuffer(options.payload) + ? options.payload.toString('utf8') + : typeof options.payload === 'string' + ? options.payload + : JSON.stringify(options.payload); + const version = options.signature.split('=')[0] || 'v1'; + const digest = createHmac('sha256', options.secret) + .update(`${timestamp}.${body}`) + .digest('hex'); + const expected = `${version}=${digest}`; + const actualBuffer = Buffer.from(options.signature); + const expectedBuffer = Buffer.from(expected); + + return actualBuffer.length === expectedBuffer.length && timingSafeEqual(actualBuffer, expectedBuffer); +} diff --git a/scripts/security/aggregate-vulnerability-reports.mjs b/scripts/security/aggregate-vulnerability-reports.mjs new file mode 100644 index 00000000..558a53a9 --- /dev/null +++ b/scripts/security/aggregate-vulnerability-reports.mjs @@ -0,0 +1,159 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import path from 'node:path'; + +const reportDir = process.env.SECURITY_REPORT_DIR ?? 'security-reports/dependencies'; +const policyPath = process.env.DEPENDENCY_POLICY_PATH ?? 'scripts/security/dependency-policy.json'; +const policy = JSON.parse(fs.readFileSync(policyPath, 'utf8')); +const blocking = new Set(policy.blockingSeverities ?? ['high', 'critical']); +const vulnerabilities = []; +const licenseFindings = []; + +function readJson(file) { + try { + return JSON.parse(fs.readFileSync(file, 'utf8')); + } catch { + return undefined; + } +} + +function addVuln(vuln) { + vulnerabilities.push({ + ecosystem: vuln.ecosystem ?? 'unknown', + packageName: vuln.packageName ?? vuln.name ?? 'unknown', + installedVersion: vuln.installedVersion, + fixedVersion: vuln.fixedVersion, + severity: String(vuln.severity ?? 'unknown').toLowerCase(), + advisoryId: vuln.advisoryId, + title: vuln.title ?? vuln.overview ?? vuln.packageName ?? 'Dependency vulnerability', + remediation: vuln.remediation, + dueAt: dueAt(String(vuln.severity ?? '').toLowerCase()), + }); +} + +function dueAt(severity) { + const days = policy.remediationSlaDays?.[severity]; + if (!days) return undefined; + return new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString(); +} + +function parseNpmAudit(file, ecosystem) { + const json = readJson(file); + if (!json?.vulnerabilities) return; + for (const [name, vuln] of Object.entries(json.vulnerabilities)) { + addVuln({ + ecosystem, + packageName: name, + installedVersion: vuln.via?.find?.((item) => typeof item === 'object')?.range, + fixedVersion: vuln.fixAvailable?.version, + severity: vuln.severity, + advisoryId: vuln.via?.find?.((item) => typeof item === 'object')?.source, + title: `${name}: ${vuln.severity} vulnerability`, + remediation: vuln.fixAvailable ? 'Run npm audit fix or update the package.' : 'Review advisory and apply a patched transitive dependency.', + }); + } +} + +function parseCargoAudit(file) { + const json = readJson(file); + for (const vuln of json?.vulnerabilities?.list ?? []) { + addVuln({ + ecosystem: 'cargo', + packageName: vuln.package?.name, + installedVersion: vuln.package?.version, + fixedVersion: vuln.versions?.patched?.join(', '), + severity: vuln.advisory?.cvss ? cvssToSeverity(Number(vuln.advisory.cvss)) : 'high', + advisoryId: vuln.advisory?.id, + title: vuln.advisory?.title, + remediation: 'Upgrade to a patched Cargo dependency version.', + }); + } +} + +function parseSlither(file) { + const json = readJson(file); + for (const detector of json?.results?.detectors ?? []) { + addVuln({ + ecosystem: 'solidity', + packageName: detector.check, + severity: detector.impact === 'High' ? 'high' : String(detector.impact ?? 'unknown').toLowerCase(), + advisoryId: detector.check, + title: detector.description?.split('\n')[0] ?? 'Solidity dependency/static finding', + remediation: detector.markdown, + }); + } +} + +function parseLicenses(file) { + const json = readJson(file); + if (!json) return; + for (const [pkg, meta] of Object.entries(json)) { + const licenses = String(meta.licenses ?? meta.license ?? 'UNKNOWN'); + const allowed = policy.allowedLicenses?.some((license) => licenses.includes(license)); + if (!allowed) licenseFindings.push({ packageName: pkg, licenses }); + } +} + +function cvssToSeverity(score) { + if (score >= 9) return 'critical'; + if (score >= 7) return 'high'; + if (score >= 4) return 'moderate'; + return 'low'; +} + +if (fs.existsSync(reportDir)) { + for (const file of fs.readdirSync(reportDir)) { + const fullPath = path.join(reportDir, file); + if (file.endsWith('npm-audit.json')) parseNpmAudit(fullPath, file.replace('-npm-audit.json', '')); + if (file === 'cargo-audit.json') parseCargoAudit(fullPath); + if (file === 'slither.json') parseSlither(fullPath); + if (file.endsWith('licenses.json')) parseLicenses(fullPath); + } +} + +const summary = { + scannedAt: new Date().toISOString(), + policy, + totals: { + vulnerabilities: vulnerabilities.length, + blocking: vulnerabilities.filter((vuln) => blocking.has(vuln.severity)).length, + licenseFindings: licenseFindings.length, + }, + vulnerabilities, + licenseFindings, + trend: { + artifact: 'dependency-vulnerability-report.json', + note: 'Upload this artifact on every run to track trend over time.', + }, +}; + +fs.mkdirSync(reportDir, { recursive: true }); +fs.writeFileSync(path.join(reportDir, 'dependency-vulnerability-report.json'), JSON.stringify(summary, null, 2)); +fs.writeFileSync(path.join(reportDir, 'dependency-vulnerability-report.md'), markdown(summary)); +console.log(JSON.stringify(summary.totals)); + +if (summary.totals.blocking > 0 || summary.totals.licenseFindings > 0) { + process.exitCode = 1; +} + +function markdown(report) { + const lines = [ + '# Dependency Vulnerability Report', + '', + `Scanned at: ${report.scannedAt}`, + '', + `Blocking vulnerabilities: ${report.totals.blocking}`, + `License findings: ${report.totals.licenseFindings}`, + '', + '## Vulnerabilities', + '', + '| Ecosystem | Package | Severity | Advisory | Remediation SLA |', + '| --- | --- | --- | --- | --- |', + ]; + for (const vuln of report.vulnerabilities) { + lines.push(`| ${vuln.ecosystem} | ${vuln.packageName} | ${vuln.severity} | ${vuln.advisoryId ?? ''} | ${vuln.dueAt ?? ''} |`); + } + lines.push('', '## License Findings', ''); + for (const finding of report.licenseFindings) lines.push(`- ${finding.packageName}: ${finding.licenses}`); + return `${lines.join('\n')}\n`; +} diff --git a/scripts/security/dependency-policy.json b/scripts/security/dependency-policy.json new file mode 100644 index 00000000..4d7a0297 --- /dev/null +++ b/scripts/security/dependency-policy.json @@ -0,0 +1,18 @@ +{ + "blockingSeverities": ["high", "critical"], + "remediationSlaDays": { + "critical": 7, + "high": 30, + "moderate": 60, + "low": 90 + }, + "allowedLicenses": [ + "MIT", + "Apache-2.0", + "BSD-2-Clause", + "BSD-3-Clause", + "ISC", + "MPL-2.0", + "Unlicense" + ] +}