Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { DependencyTrackerModule } from './modules/contracts/dependencies/depend
import { GovernanceModule } from './modules/governance/governance.module';
import { SiemModule } from './integrations/siem/siem.module';
import { ChainsModule } from './modules/chains/chains.module';
import { RiskAnalyzerModule } from './modules/soroban/risk/risk-analyzer.module';

@Module({
imports: [
Expand All @@ -19,6 +20,7 @@ import { ChainsModule } from './modules/chains/chains.module';
GovernanceModule,
SiemModule,
ChainsModule,
RiskAnalyzerModule,
],
controllers: [AppController],
})
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
export type RiskSeverity = 'medium' | 'high' | 'critical';

export interface RiskFinding {
/** Signature ID from soroban.json (e.g. "SOROBAN-001") */
id: string;
/** Human-readable name of the detected pattern */
name: string;
/** Matched function signature */
signature: string;
severity: RiskSeverity;
description: string;
}

export interface RiskAnalysisResult {
contractId: string;
analyzedAt: string;
/** Signatures/invocations observed in the contract's transaction history */
observedSignatures: string[];
findings: RiskFinding[];
/** Highest severity found, or 'none' if clean */
overallRisk: RiskSeverity | 'none';
riskScore: number;
}

export interface AnalyzeContractDto {
/** Soroban contract ID or address */
contractId: string;
/** Function signatures observed in the contract (e.g. from transaction history) */
observedSignatures: string[];
}
18 changes: 18 additions & 0 deletions apps/backend/src/modules/soroban/risk/risk-analyzer.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { Body, Controller, Post } from '@nestjs/common';
import { RiskAnalyzerService } from './risk-analyzer.service';
import { AnalyzeContractDto, RiskAnalysisResult } from './interfaces/risk-analyzer.interface';

/**
* REST API for the Soroban Contract Risk Analyzer.
*
* POST /soroban/risk/analyze — analyze a contract against known risk patterns
*/
@Controller('soroban/risk')
export class RiskAnalyzerController {
constructor(private readonly riskAnalyzerService: RiskAnalyzerService) {}

@Post('analyze')
analyze(@Body() dto: AnalyzeContractDto): RiskAnalysisResult {
return this.riskAnalyzerService.analyzeContract(dto);
}
}
10 changes: 10 additions & 0 deletions apps/backend/src/modules/soroban/risk/risk-analyzer.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { RiskAnalyzerController } from './risk-analyzer.controller';
import { RiskAnalyzerService } from './risk-analyzer.service';

@Module({
controllers: [RiskAnalyzerController],
providers: [RiskAnalyzerService],
exports: [RiskAnalyzerService],
})
export class RiskAnalyzerModule {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import 'reflect-metadata';
import { Test, TestingModule } from '@nestjs/testing';
import { RiskAnalyzerService } from './risk-analyzer.service';

describe('RiskAnalyzerService', () => {
let service: RiskAnalyzerService;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [RiskAnalyzerService],
}).compile();

service = module.get<RiskAnalyzerService>(RiskAnalyzerService);
});

it('should be defined', () => {
expect(service).toBeDefined();
});

describe('analyzeContract', () => {
it('returns no findings and riskScore 0 for unknown signatures', () => {
const result = service.analyzeContract({
contractId: 'CTEST001',
observedSignatures: ['unknown_fn'],
});
expect(result.findings).toHaveLength(0);
expect(result.overallRisk).toBe('none');
expect(result.riskScore).toBe(0);
});

it('detects a critical finding for set_admin signature', () => {
const result = service.analyzeContract({
contractId: 'CTEST002',
observedSignatures: ['set_admin'],
});
const finding = result.findings.find(f => f.signature === 'set_admin');
expect(finding).toBeDefined();
expect(finding!.severity).toBe('critical');
expect(result.overallRisk).toBe('critical');
expect(result.riskScore).toBeGreaterThan(0);
});

it('detects a high finding for transfer signature', () => {
const result = service.analyzeContract({
contractId: 'CTEST003',
observedSignatures: ['transfer'],
});
const finding = result.findings.find(f => f.signature === 'transfer');
expect(finding).toBeDefined();
expect(finding!.severity).toBe('high');
});

it('overallRisk is the highest severity among findings', () => {
const result = service.analyzeContract({
contractId: 'CTEST004',
observedSignatures: ['pause', 'upgrade'],
});
expect(result.overallRisk).toBe('critical');
});

it('riskScore is capped at 100', () => {
const result = service.analyzeContract({
contractId: 'CTEST005',
observedSignatures: ['set_admin', 'upgrade', 'transfer', 'mint', 'pause'],
});
expect(result.riskScore).toBeLessThanOrEqual(100);
});

it('echoes back contractId and observedSignatures', () => {
const dto = { contractId: 'CTEST006', observedSignatures: ['mint'] };
const result = service.analyzeContract(dto);
expect(result.contractId).toBe(dto.contractId);
expect(result.observedSignatures).toEqual(dto.observedSignatures);
});

it('analyzedAt is a valid ISO timestamp', () => {
const result = service.analyzeContract({ contractId: 'CTEST007', observedSignatures: [] });
expect(new Date(result.analyzedAt).toISOString()).toBe(result.analyzedAt);
});
});
});
71 changes: 71 additions & 0 deletions apps/backend/src/modules/soroban/risk/risk-analyzer.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { Injectable } from '@nestjs/common';
import {
AnalyzeContractDto,
RiskAnalysisResult,
RiskFinding,
RiskSeverity,
} from './interfaces/risk-analyzer.interface';
import sorobanSignatures from '../../../../../../signatures/soroban.json';

interface SorobanPattern {
id: string;
name: string;
signature: string;
severity: string;
description: string;
}

const SEVERITY_SCORE: Record<RiskSeverity, number> = {
medium: 30,
high: 60,
critical: 100,
};

const SEVERITY_ORDER: Array<RiskSeverity | 'none'> = ['none', 'medium', 'high', 'critical'];

function maxSeverity(a: RiskSeverity | 'none', b: RiskSeverity | 'none'): RiskSeverity | 'none' {
return SEVERITY_ORDER.indexOf(a) >= SEVERITY_ORDER.indexOf(b) ? a : b;
}

@Injectable()
export class RiskAnalyzerService {
private readonly patterns: SorobanPattern[] = sorobanSignatures;

analyzeContract(dto: AnalyzeContractDto): RiskAnalysisResult {
const findings: RiskFinding[] = [];

for (const pattern of this.patterns) {
if (dto.observedSignatures.includes(pattern.signature)) {
findings.push({
id: pattern.id,
name: pattern.name,
signature: pattern.signature,
severity: pattern.severity as RiskSeverity,
description: pattern.description,
});
}
}

const overallRisk = findings.reduce<RiskSeverity | 'none'>(
(acc, f) => maxSeverity(acc, f.severity),
'none',
);

const riskScore =
findings.length === 0
? 0
: Math.min(
100,
findings.reduce((sum, f) => sum + SEVERITY_SCORE[f.severity], 0),
);

return {
contractId: dto.contractId,
analyzedAt: new Date().toISOString(),
observedSignatures: dto.observedSignatures,
findings,
overallRisk,
riskScore,
};
}
}
Loading