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
24 changes: 6 additions & 18 deletions src/modules/safe-shield/entities/analysis-responses.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import type {
import {
ContractStatusGroupSchema,
RecipientStatusGroupSchema,
ThreatStatusGroupSchema,
} from './status-group.entity';
import type { AnalysisResult, CommonStatus } from './analysis-result.entity';
import {
Expand Down Expand Up @@ -103,31 +102,20 @@ export const CounterpartyAnalysisResponseSchema = z.object({
contract: ContractAnalysisResponseSchema,
});

/**
* Dynamically builds the shape object for all threat status groups.
* Maps THREAT to an array of threat results and BALANCE_CHANGE to balance changes schema.
*/
const threatGroupsShape = ThreatStatusGroupSchema.options.reduce(
(acc, key) => {
if (key === 'THREAT') {
acc[key] = z.array(ThreatAnalysisResultSchema).optional();
} else if (key === 'BALANCE_CHANGE') {
acc[key] = BalanceChangesSchema.optional();
}
return acc;
},
{} as Record<string, z.ZodTypeAny>,
);

/**
* Response structure for threat analysis endpoint.
*
* Returns threat analysis results grouped by category along with balance changes.
* Unlike recipient/contract analysis, threat analysis operates at the
* transaction level rather than per-address.
* Includes request_id from Blockaid's x-request-id header for reporting.
*/
export const ThreatAnalysisResponseSchema = z
.object(threatGroupsShape)
.object({
THREAT: z.array(ThreatAnalysisResultSchema).optional(),
BALANCE_CHANGE: BalanceChangesSchema.optional(),
request_id: z.string().optional(),
})
.strict();

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { ApiProperty } from '@nestjs/swagger';
import { z } from 'zod';

/**
* Event types for reporting false Blockaid scan results.
*
* - FALSE_POSITIVE: Transaction was flagged as malicious but is actually safe
* - FALSE_NEGATIVE: Transaction was not flagged but should have been
*/
export const ReportEvent = ['FALSE_POSITIVE', 'FALSE_NEGATIVE'] as const;

/**
* Zod schema for validating ReportEvent enum values.
*/
export const ReportEventSchema = z.enum(ReportEvent);

export type ReportEvent = z.infer<typeof ReportEventSchema>;

/**
* Zod schema for validating report false result requests.
*
* The 1000 character limit on details is a CGW-imposed safeguard
* to prevent abuse, as Blockaid does not enforce a specific limit.
*/
export const ReportFalseResultRequestSchema = z.object({
event: ReportEventSchema,
request_id: z.string().uuid(),
details: z.string().min(1).max(1000),
});

export type ReportFalseResultRequest = z.infer<
typeof ReportFalseResultRequestSchema
>;

/**
* DTO for reporting false Blockaid scan results.
* Used to submit feedback when a transaction was incorrectly classified.
*/
export class ReportFalseResultRequestDto implements ReportFalseResultRequest {
@ApiProperty({
enum: [...ReportEvent],
description:
'Type of report: FALSE_POSITIVE if flagged incorrectly, FALSE_NEGATIVE if should have been flagged',
})
public readonly event!: ReportEvent;

@ApiProperty({
description: 'The request_id from the original Blockaid scan response',
example: '11111111-1111-1111-1111-111111111111',
})
public readonly request_id!: string;

@ApiProperty({
description: 'Details about why this is a false result',
maxLength: 1000,
example: 'This transaction was incorrectly flagged as malicious',
})
public readonly details!: string;
}

/**
* DTO for report false result response.
* Indicates whether the report was successfully submitted to Blockaid.
*/
export class ReportFalseResultResponseDto {
@ApiProperty({
description: 'Whether the report was submitted successfully',
example: true,
})
public readonly success!: boolean;
}
Original file line number Diff line number Diff line change
Expand Up @@ -259,4 +259,12 @@ export class ThreatAnalysisResponseDto implements ThreatAnalysisResponse {
],
})
BALANCE_CHANGE?: Array<BalanceChange>;

@ApiPropertyOptional({
description:
'Blockaid request ID from x-request-id header. ' +
'Used for reporting false positives/negatives via the report endpoint.',
example: '11111111-1111-1111-1111-111111111111',
})
request_id?: string;
}
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ describe('SafeShieldController (Integration)', () => {
.build();
const safeAddress = getAddress(faker.finance.ethereumAddress());
const requestBody = threatAnalysisRequestBuilder().build();
const requestId = faker.string.uuid();

const blockaidResponse = {
validation: {
Expand All @@ -136,7 +137,10 @@ describe('SafeShieldController (Integration)', () => {
return Promise.reject(new Error(`No matching rule for url: ${url}`));
});

blockaidApi.scanTransaction.mockResolvedValue(blockaidResponse);
blockaidApi.scanTransaction.mockResolvedValue({
...blockaidResponse,
request_id: requestId,
});

const response = await request(app.getHttpServer())
.post(
Expand Down
49 changes: 49 additions & 0 deletions src/modules/safe-shield/safe-shield.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ import { CounterpartyAnalysisDto } from '@/modules/safe-shield/entities/dtos/cou
import { ThreatAnalysisResponseDto } from '@/modules/safe-shield/entities/dtos/threat-analysis.dto';
import { ThreatAnalysisRequestDto } from '@/modules/safe-shield/entities/dtos/threat-analysis-request.dto';
import { NumericStringSchema } from '@/validation/entities/schemas/numeric-string.schema';
import {
ReportFalseResultRequestDto,
ReportFalseResultRequestSchema,
ReportFalseResultResponseDto,
} from '@/modules/safe-shield/entities/dtos/report-false-result.dto';

/**
* Controller for Safe Shield security analysis endpoints.
Expand Down Expand Up @@ -169,4 +174,48 @@ export class SafeShieldController {
request,
});
}

@ApiOperation({
summary: 'Report false Blockaid scan result',
description:
'Reports a FALSE_POSITIVE or FALSE_NEGATIVE Blockaid transaction scan result for review. ' +
'Use the request_id from the original scan response to identify the transaction.',
})
@ApiParam({
name: 'chainId',
type: 'string',
description: 'Chain ID where the Safe is deployed',
example: '1',
})
@ApiParam({
name: 'safeAddress',
type: 'string',
description: 'Safe contract address',
})
@ApiBody({
type: ReportFalseResultRequestDto,
required: true,
description:
'Report details including event type, request_id from scan response, and details.',
})
@ApiOkResponse({
type: ReportFalseResultResponseDto,
description: 'Report submitted successfully.',
})
@HttpCode(HttpStatus.OK)
@Post('chains/:chainId/security/:safeAddress/report-false-result')
public async reportFalseResult(
@Param('chainId', new ValidationPipe(NumericStringSchema))
chainId: string,
@Param('safeAddress', new ValidationPipe(AddressSchema))
safeAddress: Address,
@Body(new ValidationPipe(ReportFalseResultRequestSchema))
request: ReportFalseResultRequestDto,
): Promise<ReportFalseResultResponseDto> {
// chainId and safeAddress are validated for URL consistency
// but not used in the report as Blockaid uses request_id
return this.safeShieldService.reportFalseResult({
request,
});
}
}
61 changes: 61 additions & 0 deletions src/modules/safe-shield/safe-shield.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { RecipientAnalysisService } from './recipient-analysis/recipient-an
import type { ContractAnalysisService } from './contract-analysis/contract-analysis.service';
import type { ThreatAnalysisService } from './threat-analysis/threat-analysis.service';
import type { ILoggingService } from '@/logging/logging.interface';
import type { ReportFalseResultRequest } from './entities/dtos/report-false-result.dto';
import type { DataDecoded } from '@/modules/data-decoder/routes/entities/data-decoded.entity';
import type { DecodedTransactionData } from '@/modules/safe-shield/entities/transaction-data.entity';
import type {
Expand Down Expand Up @@ -125,6 +126,7 @@ describe('SafeShieldService', () => {
const mockThreatAnalysisService = {
analyze: jest.fn(),
failedAnalysisResponse: jest.fn(),
reportTransaction: jest.fn(),
} as jest.MockedObjectDeep<ThreatAnalysisService>;
const mockTransactionsService = {
previewTransaction: jest.fn(),
Expand Down Expand Up @@ -1188,6 +1190,7 @@ describe('SafeShieldService', () => {
out: [],
},
],
request_id: faker.string.uuid(),
};
const mockChain = chainBuilder()
.with('chainId', mockChainId)
Expand Down Expand Up @@ -1290,4 +1293,62 @@ describe('SafeShieldService', () => {
expect(mockThreatAnalysisService.analyze).not.toHaveBeenCalled();
});
});

describe('reportFalseResult', () => {
const mockReportRequest: ReportFalseResultRequest = {
event: 'FALSE_POSITIVE',
request_id: faker.string.uuid(),
details: 'This transaction was incorrectly flagged as malicious',
};

it('should successfully report a false positive using request_id', async () => {
mockThreatAnalysisService.reportTransaction.mockResolvedValue(undefined);

const result = await service.reportFalseResult({
request: mockReportRequest,
});

expect(result).toEqual({ success: true });
expect(mockThreatAnalysisService.reportTransaction).toHaveBeenCalledWith({
event: mockReportRequest.event,
details: mockReportRequest.details,
requestId: mockReportRequest.request_id,
});
});

it('should successfully report a false negative using request_id', async () => {
const falseNegativeRequest: ReportFalseResultRequest = {
...mockReportRequest,
event: 'FALSE_NEGATIVE',
};

mockThreatAnalysisService.reportTransaction.mockResolvedValue(undefined);

const result = await service.reportFalseResult({
request: falseNegativeRequest,
});

expect(result).toEqual({ success: true });
expect(mockThreatAnalysisService.reportTransaction).toHaveBeenCalledWith({
event: 'FALSE_NEGATIVE',
details: falseNegativeRequest.details,
requestId: falseNegativeRequest.request_id,
});
});

it('should return success: false and log warning when report fails', async () => {
mockThreatAnalysisService.reportTransaction.mockRejectedValue(
new Error('Blockaid API error'),
);

const result = await service.reportFalseResult({
request: mockReportRequest,
});

expect(result).toEqual({ success: false });
expect(mockLoggingService.warn).toHaveBeenCalledWith(
`Failed to submit report for request_id ${mockReportRequest.request_id}: Blockaid API error`,
);
});
});
});
29 changes: 29 additions & 0 deletions src/modules/safe-shield/safe-shield.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
COMMON_DESCRIPTION_MAPPING,
COMMON_SEVERITY_MAPPING,
} from '@/modules/safe-shield/entities/common-status.constants';
import type { ReportFalseResultRequest } from '@/modules/safe-shield/entities/dtos/report-false-result.dto';

/**
* Main orchestration service for Safe Shield transaction analysis.
Expand Down Expand Up @@ -211,6 +212,34 @@ export class SafeShieldService {
}
}

/**
* Reports a false positive or false negative Blockaid scan result.
* Uses the request_id from the original scan response.
*
* @param {ReportFalseResultRequest} args.request - The report request containing request_id
* @returns {Promise<{ success: boolean }>} Success response
*/
public async reportFalseResult({
request,
}: {
request: ReportFalseResultRequest;
}): Promise<{ success: boolean }> {
try {
await this.threatAnalysisService.reportTransaction({
event: request.event,
details: request.details,
requestId: request.request_id,
});

return { success: true };
} catch (error) {
this.loggingService.warn(
`Failed to submit report for request_id ${request.request_id}: ${asError(error).message}`,
);
return { success: false };
}
}

private async isBlockaidEnabled(chainId: string): Promise<boolean> {
const chain = await this.configApi
.getChain(chainId)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Module } from '@nestjs/common';

const blockaidApi = {
scanTransaction: jest.fn(),
reportTransaction: jest.fn(),
};

@Module({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { Severity } from '@/modules/safe-shield/entities/severity.entity';
export const GUARD_STORAGE_POSITION =
'0x4a204f620c8c5ccdca3fd54d003badd85ba500436a431f0cbda4f558c93c34c8';
export const FF_RISK_MITIGATION = 'RISK_MITIGATION';
export const BLOCKAID_REQUEST_ID_HEADER = 'x-request-id';

export const BLOCKAID_SEVERITY_MAP: Record<string, keyof typeof Severity> = {
Malicious: 'CRITICAL',
Expand Down
Loading
Loading