From f74cf8a6e53d05762608af924a640f27fe2a21bc Mon Sep 17 00:00:00 2001 From: Jess Porter Date: Tue, 8 Feb 2022 13:07:42 +0000 Subject: [PATCH] trusted reporters (#183) * Trusted Reporters protection * redact/ban reasons * some documentation --- src/Mjolnir.ts | 10 ++- src/protections/IProtection.ts | 16 ++++- src/protections/ProtectionSettings.ts | 10 +++ src/protections/TrustedReporters.ts | 93 +++++++++++++++++++++++++++ src/protections/protections.ts | 4 +- src/report/ReportManager.ts | 12 ++-- src/webapis/WebAPIs.ts | 2 +- 7 files changed, 138 insertions(+), 9 deletions(-) create mode 100644 src/protections/TrustedReporters.ts diff --git a/src/Mjolnir.ts b/src/Mjolnir.ts index efd919a..3982ddc 100644 --- a/src/Mjolnir.ts +++ b/src/Mjolnir.ts @@ -225,7 +225,9 @@ export class Mjolnir { // Setup Web APIs console.log("Creating Web APIs"); - this.webapis = new WebAPIs(new ReportManager(this), this.ruleServer); + const reportManager = new ReportManager(this); + reportManager.on("report.new", this.handleReport); + this.webapis = new WebAPIs(reportManager, this.ruleServer); } public get lists(): BanList[] { @@ -977,4 +979,10 @@ export class Mjolnir { public async processRedactionQueue(roomId?: string): Promise { return await this.eventRedactionQueue.process(this.client, roomId); } + + private async handleReport(roomId: string, reporterId: string, event: any, reason?: string) { + for (const protection of this.enabledProtections) { + await protection.handleReport(this, roomId, reporterId, event, reason); + } + } } diff --git a/src/protections/IProtection.ts b/src/protections/IProtection.ts index 2a7d14c..129098f 100644 --- a/src/protections/IProtection.ts +++ b/src/protections/IProtection.ts @@ -28,12 +28,26 @@ export interface IProtection { readonly description: string; enabled: boolean; settings: { [setting: string]: AbstractProtectionSetting }; + /* + * Handle a single event from a protected room, to decide if we need to + * respond to it + */ handleEvent(mjolnir: Mjolnir, roomId: string, event: any): Promise; + /* + * Handle a single reported event from a protecte room, to decide if we + * need to respond to it + */ + handleReport(mjolnir: Mjolnir, roomId: string, reporterId: string, reason: string, event: any): Promise; } export abstract class Protection implements IProtection { abstract readonly name: string abstract readonly description: string; enabled = false; abstract settings: { [setting: string]: AbstractProtectionSetting }; - abstract handleEvent(mjolnir: Mjolnir, roomId: string, event: any): Promise; + handleEvent(mjolnir: Mjolnir, roomId: string, event: any): Promise { + return Promise.resolve(null); + } + handleReport(mjolnir: Mjolnir, roomId: string, reporterId: string, event: any, reason?: string): Promise { + return Promise.resolve(null); + } } diff --git a/src/protections/ProtectionSettings.ts b/src/protections/ProtectionSettings.ts index e1266f9..b6653ec 100644 --- a/src/protections/ProtectionSettings.ts +++ b/src/protections/ProtectionSettings.ts @@ -16,6 +16,10 @@ limitations under the License. export class ProtectionSettingValidationError extends Error {}; +/* + * @param TChange Type for individual pieces of data (e.g. `string`) + * @param TValue Type for overall value of this setting (e.g. `string[]`) + */ export class AbstractProtectionSetting { // the current value of this setting value: TValue @@ -91,6 +95,12 @@ export class StringListProtectionSetting extends AbstractProtectionListSetting /^@\S+:\S+$/.test(data); +} + export class NumberProtectionSetting extends AbstractProtectionSetting { min: number|undefined; max: number|undefined; diff --git a/src/protections/TrustedReporters.ts b/src/protections/TrustedReporters.ts new file mode 100644 index 0000000..7f831f7 --- /dev/null +++ b/src/protections/TrustedReporters.ts @@ -0,0 +1,93 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import config from "../config"; +import { Protection } from "./IProtection"; +import { MXIDListProtectionSetting, NumberProtectionSetting } from "./ProtectionSettings"; +import { Mjolnir } from "../Mjolnir"; + +const MAX_REPORTED_EVENT_BACKLOG = 20; + +/* + * Hold a list of users trusted to make reports, and enact consequences on + * events that surpass configured report count thresholds + */ +export class TrustedReporters extends Protection { + private recentReported = new Map>(); + + settings = { + mxids: new MXIDListProtectionSetting(), + alertThreshold: new NumberProtectionSetting(3), + // -1 means 'disabled' + redactThreshold: new NumberProtectionSetting(-1), + banThreshold: new NumberProtectionSetting(-1) + }; + + constructor() { + super(); + } + + public get name(): string { + return 'TrustedReporters'; + } + public get description(): string { + return "Count reports from trusted reporters and take a configured action"; + } + + public async handleReport(mjolnir: Mjolnir, roomId: string, reporterId: string, event: any, reason?: string): Promise { + if (!this.settings.mxids.value.includes(reporterId)) { + // not a trusted user, we're not interested + return + } + + let reporters = this.recentReported.get(event.id); + if (reporters === undefined) { + // first report we've seen recently for this event + reporters = new Set(); + this.recentReported.set(event.id, reporters); + if (this.recentReported.size > MAX_REPORTED_EVENT_BACKLOG) { + // queue too big. push the oldest reported event off the queue + const oldest = Array.from(this.recentReported.keys())[0]; + this.recentReported.delete(oldest); + } + } + + reporters.add(reporterId); + + let met: string[] = []; + if (reporters.size === this.settings.alertThreshold.value) { + met.push("alert"); + // do nothing. let the `sendMessage` call further down be the alert + } + if (reporters.size === this.settings.redactThreshold.value) { + met.push("redact"); + await mjolnir.client.redactEvent(roomId, event.id, "abuse detected"); + } + if (reporters.size === this.settings.banThreshold.value) { + met.push("ban"); + await mjolnir.client.banUser(event.userId, roomId, "abuse detected"); + } + + + if (met.length > 0) { + await mjolnir.client.sendMessage(config.managementRoom, { + msgtype: "m.notice", + body: `message ${event.id} reported by ${[...reporters].join(', ')}. ` + + `actions: ${met.join(', ')}` + }); + } + } +} diff --git a/src/protections/protections.ts b/src/protections/protections.ts index b212a24..624c6c3 100644 --- a/src/protections/protections.ts +++ b/src/protections/protections.ts @@ -20,11 +20,13 @@ import { BasicFlooding } from "./BasicFlooding"; import { WordList } from "./WordList"; import { MessageIsVoice } from "./MessageIsVoice"; import { MessageIsMedia } from "./MessageIsMedia"; +import { TrustedReporters } from "./TrustedReporters"; export const PROTECTIONS: IProtection[] = [ new FirstMessageIsImage(), new BasicFlooding(), new WordList(), new MessageIsVoice(), - new MessageIsMedia() + new MessageIsMedia(), + new TrustedReporters() ]; diff --git a/src/report/ReportManager.ts b/src/report/ReportManager.ts index cfd6be4..af64043 100644 --- a/src/report/ReportManager.ts +++ b/src/report/ReportManager.ts @@ -19,6 +19,7 @@ import { LogService, UserID } from "matrix-bot-sdk"; import { htmlToText } from "html-to-text"; import { htmlEscape } from "../utils"; import { JSDOM } from 'jsdom'; +import { EventEmitter } from 'events'; import { Mjolnir } from "../Mjolnir"; @@ -74,9 +75,10 @@ enum Kind { /** * A class designed to respond to abuse reports. */ -export class ReportManager { +export class ReportManager extends EventEmitter { private displayManager: DisplayManager; constructor(public mjolnir: Mjolnir) { + super(); // Configure bot interactions. mjolnir.client.on("room.event", async (roomId, event) => { try { @@ -101,17 +103,17 @@ export class ReportManager { * The following MUST hold true: * - the reporter's id is `reporterId`; * - the reporter is a member of `roomId`; - * - `eventId` did take place in room `roomId`; - * - the reporter could witness event `eventId` in room `roomId`; + * - `event` did take place in room `roomId`; + * - the reporter could witness event `event` in room `roomId`; * - the event being reported is `event`; * * @param roomId The room in which the abuse took place. - * @param eventId The ID of the event reported as abuse. * @param reporterId The user who reported the event. * @param event The event being reported. * @param reason A reason provided by the reporter. */ - public async handleServerAbuseReport({ reporterId, event, reason }: { roomId: string, eventId: string, reporterId: string, event: any, reason?: string }) { + public async handleServerAbuseReport({ roomId, reporterId, event, reason }: { roomId: string, reporterId: string, event: any, reason?: string }) { + this.emit("report.new", { roomId: roomId, reporterId: reporterId, event: event, reason: reason }); return this.displayManager.displayReportAndUI({ kind: Kind.SERVER_ABUSE_REPORT, event, reporterId, reason, moderationRoomId: this.mjolnir.managementRoomId }); } diff --git a/src/webapis/WebAPIs.ts b/src/webapis/WebAPIs.ts index 6e866e1..5afdf50 100644 --- a/src/webapis/WebAPIs.ts +++ b/src/webapis/WebAPIs.ts @@ -175,7 +175,7 @@ export class WebAPIs { } let reason = request.body["reason"]; - await this.reportManager.handleServerAbuseReport({ roomId, eventId, reporterId, event, reason }); + await this.reportManager.handleServerAbuseReport({ roomId, reporterId, event, reason }); // Match the spec behavior of `/report`: return 200 and an empty JSON. response.status(200).json({});