trusted reporters (#183)

* Trusted Reporters protection

* redact/ban reasons

* some documentation
This commit is contained in:
Jess Porter 2022-02-08 13:07:42 +00:00 committed by GitHub
parent ff9a7db159
commit f74cf8a6e5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 138 additions and 9 deletions

View File

@ -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<RoomUpdateError[]> {
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);
}
}
}

View File

@ -28,12 +28,26 @@ export interface IProtection {
readonly description: string;
enabled: boolean;
settings: { [setting: string]: AbstractProtectionSetting<any, any> };
/*
* 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<any>;
/*
* 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<any>;
}
export abstract class Protection implements IProtection {
abstract readonly name: string
abstract readonly description: string;
enabled = false;
abstract settings: { [setting: string]: AbstractProtectionSetting<any, any> };
abstract handleEvent(mjolnir: Mjolnir, roomId: string, event: any): Promise<any>;
handleEvent(mjolnir: Mjolnir, roomId: string, event: any): Promise<any> {
return Promise.resolve(null);
}
handleReport(mjolnir: Mjolnir, roomId: string, reporterId: string, event: any, reason?: string): Promise<any> {
return Promise.resolve(null);
}
}

View File

@ -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<TChange, TValue> {
// the current value of this setting
value: TValue
@ -91,6 +95,12 @@ export class StringListProtectionSetting extends AbstractProtectionListSetting<s
}
}
// A list of strings that match the glob pattern @*:*
export class MXIDListProtectionSetting extends StringListProtectionSetting {
// validate an individual piece of data for this setting - namely a single mxid
validate = (data: string) => /^@\S+:\S+$/.test(data);
}
export class NumberProtectionSetting extends AbstractProtectionSetting<number, number> {
min: number|undefined;
max: number|undefined;

View File

@ -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<string /* eventId */, Set<string /* reporterId */>>();
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<any> {
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<string>();
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(', ')}`
});
}
}
}

View File

@ -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()
];

View File

@ -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 });
}

View File

@ -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({});