2
0

Introducing Radar, fraud detection

This commit is contained in:
Baptiste Arnaud
2023-12-08 18:16:07 +00:00
parent 0b93c2b239
commit 4fdc1bfe5c
18 changed files with 221 additions and 7 deletions

View File

@ -101,6 +101,7 @@
"@typebot.io/prisma": "workspace:*",
"@typebot.io/schemas": "workspace:*",
"@typebot.io/tsconfig": "workspace:*",
"@typebot.io/radar": "workspace:*",
"@types/canvas-confetti": "1.6.0",
"@types/jsonwebtoken": "9.0.2",
"@types/micro-cors": "0.1.3",

View File

@ -41,6 +41,7 @@ type UpdateTypebotPayload = Partial<
| 'resultsTablePreferences'
| 'isClosed'
| 'whatsAppCredentialsId'
| 'riskLevel'
>
>

View File

@ -26,6 +26,7 @@ export const convertPublicTypebotToTypebot = (
resultsTablePreferences: existingTypebot.resultsTablePreferences,
selectedThemeTemplateId: existingTypebot.selectedThemeTemplateId,
whatsAppCredentialsId: existingTypebot.whatsAppCredentialsId,
riskLevel: existingTypebot.riskLevel,
events: typebot.events,
}
}

View File

@ -19,6 +19,7 @@ import { migrateTypebot } from '@typebot.io/lib/migrations/migrateTypebot'
const omittedProps = {
id: true,
whatsAppCredentialsId: true,
riskLevel: true,
isClosed: true,
isArchived: true,
createdAt: true,
@ -64,6 +65,7 @@ const migrateImportingTypebot = (
whatsAppCredentialsId: null,
publicId: null,
folderId: null,
riskLevel: null,
} satisfies Typebot
return migrateTypebot(fullTypebot)
}

View File

@ -14,6 +14,8 @@ import { isWriteTypebotForbidden } from '../helpers/isWriteTypebotForbidden'
import { sendTelemetryEvents } from '@typebot.io/lib/telemetry/sendTelemetryEvent'
import { Plan } from '@typebot.io/prisma'
import { InputBlockType } from '@typebot.io/schemas/features/blocks/inputs/constants'
import { computeRiskLevel } from '@typebot.io/radar'
import { env } from '@typebot.io/env'
export const publishTypebot = authenticatedProcedure
.meta({
@ -78,6 +80,52 @@ export const publishTypebot = authenticatedProcedure
})
}
if (existingTypebot.riskLevel && existingTypebot.riskLevel > 80)
throw new TRPCError({
code: 'FORBIDDEN',
message:
'Radar detected a potential malicious typebot. This bot is being manually reviewed by Fraud Prevention team.',
})
const riskLevel = computeRiskLevel({
name: existingTypebot.name,
groups: parseGroups(existingTypebot.groups, {
typebotVersion: existingTypebot.version,
}),
})
if (riskLevel > 0) {
if (env.MESSAGE_WEBHOOK_URL)
await fetch(env.MESSAGE_WEBHOOK_URL, {
method: 'POST',
body: `🚨 *Radar detected a potential malicious typebot* 🚨\n\n*Typebot:* ${existingTypebot.name}\n*Risk level:* ${riskLevel}/100\n*Typebot ID:* ${existingTypebot.id}\n*Workspace ID:* ${existingTypebot.workspaceId}\n*User ID:* ${user.id}`,
}).catch((err) => {
console.error('Failed to send message', err)
})
await prisma.typebot.updateMany({
where: {
id: existingTypebot.id,
},
data: {
riskLevel,
},
})
if (riskLevel > 80) {
if (existingTypebot.publishedTypebot)
await prisma.publicTypebot.deleteMany({
where: {
id: existingTypebot.publishedTypebot.id,
},
})
throw new TRPCError({
code: 'FORBIDDEN',
message:
'Radar detected a potential malicious typebot. This bot is being manually reviewed by Fraud Prevention team.',
})
}
}
if (existingTypebot.publishedTypebot)
await prisma.publicTypebot.updateMany({
where: {

View File

@ -35,6 +35,7 @@ const typebotUpdateSchemaPick = {
customDomain: true,
isClosed: true,
whatsAppCredentialsId: true,
riskLevel: true,
events: true,
} as const

View File

@ -4,10 +4,10 @@ import prisma from '@typebot.io/lib/prisma'
import { googleSheetsScopes } from './consent-url'
import { stringify } from 'querystring'
import { badRequest, notAuthenticated } from '@typebot.io/lib/api'
import { oauth2Client } from '@/lib/googleSheets'
import { getAuthenticatedUser } from '@/features/auth/helpers/getAuthenticatedUser'
import { env } from '@typebot.io/env'
import { encrypt } from '@typebot.io/lib/api/encryption/encrypt'
import { OAuth2Client } from 'google-auth-library'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const user = await getAuthenticatedUser(req, res)
@ -22,6 +22,11 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
if (!workspaceId) return badRequest(res)
if (!code)
return res.status(400).send({ message: "Bad request, couldn't get code" })
const oauth2Client = new OAuth2Client(
env.GOOGLE_CLIENT_ID,
env.GOOGLE_CLIENT_SECRET,
`${env.NEXTAUTH_URL}/api/credentials/google-sheets/callback`
)
const { tokens } = await oauth2Client.getToken(code)
if (!tokens?.access_token) {
console.error('Error getting oAuth tokens:')

View File

@ -1,4 +1,5 @@
import { oauth2Client } from '@/lib/googleSheets'
import { env } from '@typebot.io/env'
import { OAuth2Client } from 'google-auth-library'
import { NextApiRequest, NextApiResponse } from 'next'
export const googleSheetsScopes = [
@ -9,6 +10,11 @@ export const googleSheetsScopes = [
const handler = (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === 'GET') {
const oauth2Client = new OAuth2Client(
env.GOOGLE_CLIENT_ID,
env.GOOGLE_CLIENT_SECRET,
`${env.NEXTAUTH_URL}/api/credentials/google-sheets/callback`
)
const url = oauth2Client.generateAuthUrl({
access_type: 'offline',
scope: googleSheetsScopes,

View File

@ -15003,6 +15003,10 @@
"whatsAppCredentialsId": {
"type": "string",
"nullable": true
},
"riskLevel": {
"type": "number",
"nullable": true
}
},
"required": [
@ -15026,7 +15030,8 @@
"resultsTablePreferences",
"isArchived",
"isClosed",
"whatsAppCredentialsId"
"whatsAppCredentialsId",
"riskLevel"
],
"additionalProperties": false
}
@ -18922,6 +18927,10 @@
"type": "string",
"nullable": true
},
"riskLevel": {
"type": "number",
"nullable": true
},
"events": {
"enum": [
"null"
@ -22591,6 +22600,10 @@
"type": "string",
"nullable": true
},
"riskLevel": {
"type": "number",
"nullable": true
},
"events": {
"type": "array",
"minItems": 1,
@ -26396,6 +26409,10 @@
"whatsAppCredentialsId": {
"type": "string",
"nullable": true
},
"riskLevel": {
"type": "number",
"nullable": true
}
},
"required": [
@ -26419,7 +26436,8 @@
"resultsTablePreferences",
"isArchived",
"isClosed",
"whatsAppCredentialsId"
"whatsAppCredentialsId",
"riskLevel"
],
"additionalProperties": false
}
@ -30276,6 +30294,10 @@
"whatsAppCredentialsId": {
"type": "string",
"nullable": true
},
"riskLevel": {
"type": "number",
"nullable": true
}
},
"required": [
@ -30298,7 +30320,8 @@
"resultsTablePreferences",
"isArchived",
"isClosed",
"whatsAppCredentialsId"
"whatsAppCredentialsId",
"riskLevel"
],
"additionalProperties": false
},
@ -34024,6 +34047,10 @@
"whatsAppCredentialsId": {
"type": "string",
"nullable": true
},
"riskLevel": {
"type": "number",
"nullable": true
}
},
"required": [
@ -34047,7 +34074,8 @@
"resultsTablePreferences",
"isArchived",
"isClosed",
"whatsAppCredentialsId"
"whatsAppCredentialsId",
"riskLevel"
],
"additionalProperties": false
}
@ -52980,6 +53008,10 @@
"whatsAppCredentialsId": {
"type": "string",
"nullable": true
},
"riskLevel": {
"type": "number",
"nullable": true
}
},
"required": [
@ -53003,7 +53035,8 @@
"resultsTablePreferences",
"isArchived",
"isClosed",
"whatsAppCredentialsId"
"whatsAppCredentialsId",
"riskLevel"
],
"additionalProperties": false
}

11
packages/env/env.ts vendored
View File

@ -66,6 +66,16 @@ const baseEnv = {
.default('FREE'),
DEBUG: boolean.optional().default('false'),
CHAT_API_TIMEOUT: z.coerce.number().optional(),
RADAR_HIGH_RISK_KEYWORDS: z
.string()
.min(1)
.transform((val) => val.split(','))
.optional(),
RADAR_INTERMEDIATE_RISK_KEYWORDS: z
.string()
.min(1)
.transform((val) => val.split(','))
.optional(),
},
client: {
NEXT_PUBLIC_E2E_TEST: boolean.optional(),
@ -294,6 +304,7 @@ const telemetryEnv = {
server: {
TELEMETRY_WEBHOOK_URL: z.string().url().optional(),
TELEMETRY_WEBHOOK_BEARER_TOKEN: z.string().min(1).optional(),
MESSAGE_WEBHOOK_URL: z.string().url().optional(),
USER_CREATED_WEBHOOK_URL: z.string().url().optional(),
},
}

View File

@ -33,6 +33,7 @@ export const parseTestTypebot = (partialTypebot: Partial<Typebot>): Typebot => {
isClosed: false,
resultsTablePreferences: null,
whatsAppCredentialsId: null,
riskLevel: null,
events:
version === '6'
? [

View File

@ -201,6 +201,7 @@ model Typebot {
isArchived Boolean @default(false)
isClosed Boolean @default(false)
whatsAppCredentialsId String?
riskLevel Int?
@@index([workspaceId])
@@index([folderId])

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Typebot" ADD COLUMN "riskLevel" INTEGER;

View File

@ -185,6 +185,7 @@ model Typebot {
isArchived Boolean @default(false)
isClosed Boolean @default(false)
whatsAppCredentialsId String?
riskLevel Int?
@@index([workspaceId])
@@index([isArchived, createdAt(sort: Desc)])

56
packages/radar/index.ts Normal file
View File

@ -0,0 +1,56 @@
import { Group } from '@typebot.io/schemas'
import { env } from '@typebot.io/env'
import { BubbleBlockType } from '@typebot.io/schemas/features/blocks/bubbles/constants'
import { TDescendant, TElement, TText } from '@udecode/plate-common'
export const computeRiskLevel = ({
name,
groups,
}: {
name: string
groups: Group[]
}) => {
if (!env.RADAR_HIGH_RISK_KEYWORDS) return 0
if (
env.RADAR_HIGH_RISK_KEYWORDS.some((keyword) =>
name.toLowerCase().includes(keyword)
)
)
return 100
let hasSuspiciousKeywords = false
for (const group of groups) {
for (const block of group.blocks) {
if (block.type !== BubbleBlockType.TEXT) continue
for (const descendant of block.content?.richText as TDescendant[]) {
if (
env.RADAR_HIGH_RISK_KEYWORDS &&
richTextElementContainsKeywords(env.RADAR_HIGH_RISK_KEYWORDS)(
descendant
)
)
return 100
if (
env.RADAR_INTERMEDIATE_RISK_KEYWORDS &&
richTextElementContainsKeywords(env.RADAR_INTERMEDIATE_RISK_KEYWORDS)(
descendant
)
)
hasSuspiciousKeywords = true
}
}
}
return hasSuspiciousKeywords ? 50 : 0
}
const richTextElementContainsKeywords =
(keywords: string[]) => (element: TElement | TText) => {
if (element.text)
return keywords.some((keyword) =>
(element.text as string).toLowerCase().includes(keyword)
)
if (element.children)
return (element.children as TDescendant[]).some(
richTextElementContainsKeywords(keywords)
)
return false
}

View File

@ -0,0 +1,18 @@
{
"name": "@typebot.io/radar",
"version": "1.0.0",
"main": "./index.ts",
"types": "./index.ts",
"license": "AGPL-3.0-or-later",
"private": true,
"dependencies": {
"@udecode/plate-common": "21.1.5"
},
"devDependencies": {
"@typebot.io/schemas": "workspace:*",
"@typebot.io/prisma": "workspace:*",
"@typebot.io/tsconfig": "workspace:*",
"@typebot.io/env": "workspace:*",
"typescript": "5.3.2"
}
}

View File

@ -55,6 +55,7 @@ export const typebotV5Schema = z.preprocess(
isArchived: z.boolean(),
isClosed: z.boolean(),
whatsAppCredentialsId: z.string().nullable(),
riskLevel: z.number().nullable(),
}) satisfies z.ZodType<TypebotPrisma, z.ZodTypeDef, unknown>
)
export type TypebotV5 = z.infer<typeof typebotV5Schema>

25
pnpm-lock.yaml generated
View File

@ -288,6 +288,9 @@ importers:
'@typebot.io/prisma':
specifier: workspace:*
version: link:../../packages/prisma
'@typebot.io/radar':
specifier: workspace:*
version: link:../../packages/radar
'@typebot.io/schemas':
specifier: workspace:*
version: link:../../packages/schemas
@ -1269,6 +1272,28 @@ importers:
specifier: 5.3.2
version: 5.3.2
packages/radar:
dependencies:
'@udecode/plate-common':
specifier: 21.1.5
version: 21.1.5(@babel/core@7.22.9)(@types/react@18.2.15)(react-dom@18.2.0)(react@18.2.0)(scheduler@0.23.0)(slate-history@0.93.0)(slate-react@0.94.2)(slate@0.94.1)
devDependencies:
'@typebot.io/env':
specifier: workspace:*
version: link:../env
'@typebot.io/prisma':
specifier: workspace:*
version: link:../prisma
'@typebot.io/schemas':
specifier: workspace:*
version: link:../schemas
'@typebot.io/tsconfig':
specifier: workspace:*
version: link:../tsconfig
typescript:
specifier: 5.3.2
version: 5.3.2
packages/schemas:
dependencies:
'@udecode/plate-common':