support compound consequences, switch WordList to consequences (#351)

This commit is contained in:
Jess Porter 2022-09-26 16:57:21 +01:00 committed by GitHub
parent 89b7ec1a18
commit f108935d07
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 84 additions and 68 deletions

View File

@ -35,7 +35,7 @@ import { applyUserBans } from "./actions/ApplyBan";
import ErrorCache, { ERROR_KIND_FATAL, ERROR_KIND_PERMISSION } from "./ErrorCache"; import ErrorCache, { ERROR_KIND_FATAL, ERROR_KIND_PERMISSION } from "./ErrorCache";
import { Protection } from "./protections/IProtection"; import { Protection } from "./protections/IProtection";
import { PROTECTIONS } from "./protections/protections"; import { PROTECTIONS } from "./protections/protections";
import { ConsequenceType, Consequence } from "./protections/consequence"; import { Consequence } from "./protections/consequence";
import { ProtectionSettingValidationError } from "./protections/ProtectionSettings"; import { ProtectionSettingValidationError } from "./protections/ProtectionSettings";
import { UnlistedUserRedactionQueue } from "./queues/UnlistedUserRedactionQueue"; import { UnlistedUserRedactionQueue } from "./queues/UnlistedUserRedactionQueue";
import { EventRedactionQueue, RedactUserInRoom } from "./queues/EventRedactionQueue"; import { EventRedactionQueue, RedactUserInRoom } from "./queues/EventRedactionQueue";
@ -959,36 +959,37 @@ export class Mjolnir {
await this.printBanlistChanges(changes, policyList, true); await this.printBanlistChanges(changes, policyList, true);
} }
private async handleConsequence(protection: Protection, roomId: string, eventId: string, sender: string, consequence: Consequence) { private async handleConsequences(protection: Protection, roomId: string, eventId: string, sender: string, consequences: Consequence[]) {
switch (consequence.type) { for (const consequence of consequences) {
case ConsequenceType.alert: try {
break; if (consequence.name === "alert") {
case ConsequenceType.redact: /* take no additional action, just print the below message to management room */
await this.client.redactEvent(roomId, eventId, "abuse detected"); } else if (consequence.name === "ban") {
break;
case ConsequenceType.ban:
await this.client.banUser(sender, roomId, "abuse detected"); await this.client.banUser(sender, roomId, "abuse detected");
break; } else if (consequence.name === "redact") {
await this.client.redactEvent(roomId, eventId, "abuse detected");
} else {
throw new Error(`unknown consequence ${consequence.name}`);
} }
let message = `protection ${protection.name} enacting ${ConsequenceType[consequence.type]}` let message = `protection ${protection.name} enacting`
+ ` ${consequence.name}`
+ ` against ${htmlEscape(sender)}` + ` against ${htmlEscape(sender)}`
+ ` in ${htmlEscape(roomId)}`; + ` in ${htmlEscape(roomId)}`
if (consequence.reason !== undefined) { + ` (reason: ${htmlEscape(consequence.reason)})`;
// even though internally-sourced, there's no promise that `consequence.reason`
// will never have user-supplied information, so escape it
message += ` (reason: ${htmlEscape(consequence.reason)})`;
}
await this.client.sendMessage(this.managementRoomId, { await this.client.sendMessage(this.managementRoomId, {
msgtype: "m.notice", msgtype: "m.notice",
body: message, body: message,
[CONSEQUENCE_EVENT_DATA]: { [CONSEQUENCE_EVENT_DATA]: {
who: sender, who: sender,
room: roomId, room: roomId,
type: ConsequenceType[consequence.type] types: [consequence.name],
} }
}); });
} catch (e) {
await this.logMessage(LogLevel.ERROR, "handleConsequences", `Failed to enact ${consequence.name} consequence: ${e}`);
}
}
} }
private async handleEvent(roomId: string, event: any) { private async handleEvent(roomId: string, event: any) {
@ -1018,9 +1019,9 @@ export class Mjolnir {
// Iterate all the enabled protections // Iterate all the enabled protections
for (const protection of this.enabledProtections) { for (const protection of this.enabledProtections) {
let consequence: Consequence | undefined = undefined; let consequences: Consequence[] | undefined = undefined;
try { try {
consequence = await protection.handleEvent(this, roomId, event); consequences = await protection.handleEvent(this, roomId, event);
} catch (e) { } catch (e) {
const eventPermalink = Permalinks.forEvent(roomId, event['event_id']); const eventPermalink = Permalinks.forEvent(roomId, event['event_id']);
LogService.error("Mjolnir", "Error handling protection: " + protection.name); LogService.error("Mjolnir", "Error handling protection: " + protection.name);
@ -1030,8 +1031,8 @@ export class Mjolnir {
continue; continue;
} }
if (consequence !== undefined) { if (consequences !== undefined) {
await this.handleConsequence(protection, roomId, event["event_id"], event["sender"], consequence); await this.handleConsequences(protection, roomId, event["event_id"], event["sender"], consequences);
} }
} }

View File

@ -35,7 +35,7 @@ export abstract class Protection {
* Handle a single event from a protected room, to decide if we need to * Handle a single event from a protected room, to decide if we need to
* respond to it * respond to it
*/ */
async handleEvent(mjolnir: Mjolnir, roomId: string, event: any): Promise<Consequence | any> { async handleEvent(mjolnir: Mjolnir, roomId: string, event: any): Promise<Consequence[] | any> {
} }
/* /*

View File

@ -15,6 +15,7 @@ limitations under the License.
*/ */
import { Protection } from "./IProtection"; import { Protection } from "./IProtection";
import { ConsequenceBan, ConsequenceRedact } from "./consequence";
import { Mjolnir } from "../Mjolnir"; import { Mjolnir } from "../Mjolnir";
import { LogLevel, LogService } from "matrix-bot-sdk"; import { LogLevel, LogService } from "matrix-bot-sdk";
import { isTrueJoinEvent } from "../utils"; import { isTrueJoinEvent } from "../utils";
@ -95,21 +96,14 @@ export class WordList extends Protection {
} }
} }
// Perform the test if (!message) {
if (message && this.badWords!.test(message)) { return;
await mjolnir.logMessage(LogLevel.WARN, "WordList", `Banning ${event['sender']} for word list violation in ${roomId}.`);
if (!mjolnir.config.noop) {
await mjolnir.client.banUser(event['sender'], roomId, "Word list violation");
} else {
await mjolnir.logMessage(LogLevel.WARN, "WordList", `Tried to ban ${event['sender']} in ${roomId} but Mjolnir is running in no-op mode`, roomId);
} }
// Redact the event const matches = message.match(this.badWords!);
if (!mjolnir.config.noop) { if (matches) {
await mjolnir.client.redactEvent(roomId, event['event_id'], "spam"); const reason = `bad word: ${matches[0]}`;
} else { return [new ConsequenceBan(reason), new ConsequenceRedact(reason)];
await mjolnir.logMessage(LogLevel.WARN, "WordList", `Tried to redact ${event['event_id']} in ${roomId} but Mjolnir is running in no-op mode`, roomId);
}
} }
} }
} }

View File

@ -1,23 +1,44 @@
/*
* Distinct individual actions that can be caused as a result of detected abuse
*/
export enum ConsequenceType {
// effectively a no-op. just tell the management room
alert,
// redact the event that triggered this consequence
redact,
// ban the user that sent the event that triggered this consequence
ban
}
export class Consequence { export class Consequence {
/* /*
* Action to take upon detection of abuse and an optional explanation of the detection * A requested action to take against a user after detected abuse
* *
* @param type Action to take * @param name The name of the consequence being requested
* @param reason Brief explanation of why we're taking an action, printed to management room. * @param reason Brief explanation of why we're taking an action, printed to management room.
* this will be HTML escaped before printing, just in case it has user-provided data * this will be HTML escaped before printing, just in case it has user-provided data
*/ */
constructor(public readonly type: ConsequenceType, public readonly reason?: string) {} constructor(public name: string, public reason: string) { }
}
export class ConsequenceAlert extends Consequence {
/*
* Request an alert to be created after detected abuse
*
* @param reason Brief explanation of why we're taking an action, printed to management room.
* this will be HTML escaped before printing, just in case it has user-provided data
*/
constructor(reason: string) {
super("alert", reason);
}
}
export class ConsequenceRedact extends Consequence {
/*
* Request a message redaction after detected abuse
*
* @param reason Brief explanation of why we're taking an action, printed to management room.
* this will be HTML escaped before printing, just in case it has user-provided data
*/
constructor(reason: string) {
super("redact", reason);
}
}
export class ConsequenceBan extends Consequence {
/*
* Request a ban after detected abuse
*
* @param reason Brief explanation of why we're taking an action, printed to management room.
* this will be HTML escaped before printing, just in case it has user-provided data
*/
constructor(reason: string) {
super("ban", reason);
}
} }

View File

@ -4,7 +4,7 @@ import { Mjolnir } from "../../src/Mjolnir";
import { IProtection } from "../../src/protections/IProtection"; import { IProtection } from "../../src/protections/IProtection";
import { newTestUser, noticeListener } from "./clientHelper"; import { newTestUser, noticeListener } from "./clientHelper";
import { matrixClient, mjolnir } from "./mjolnirSetupUtils"; import { matrixClient, mjolnir } from "./mjolnirSetupUtils";
import { ConsequenceType, Consequence } from "../../src/protections/consequence"; import { ConsequenceBan, ConsequenceRedact } from "../../src/protections/consequence";
describe("Test: standard consequences", function() { describe("Test: standard consequences", function() {
let badUser; let badUser;
@ -33,7 +33,7 @@ describe("Test: standard consequences", function() {
settings = { }; settings = { };
handleEvent = async (mjolnir: Mjolnir, roomId: string, event: any) => { handleEvent = async (mjolnir: Mjolnir, roomId: string, event: any) => {
if (event.content.body === "ngmWkF") { if (event.content.body === "ngmWkF") {
return new Consequence(ConsequenceType.redact, "asd"); return [new ConsequenceRedact("asd")];
} }
}; };
}); });
@ -77,7 +77,7 @@ describe("Test: standard consequences", function() {
settings = { }; settings = { };
handleEvent = async (mjolnir: Mjolnir, roomId: string, event: any) => { handleEvent = async (mjolnir: Mjolnir, roomId: string, event: any) => {
if (event.content.body === "7Uga3d") { if (event.content.body === "7Uga3d") {
return new Consequence(ConsequenceType.ban, "asd"); return [new ConsequenceBan("asd")];
} }
}; };
}); });
@ -124,7 +124,7 @@ describe("Test: standard consequences", function() {
settings = { }; settings = { };
handleEvent = async (mjolnir: Mjolnir, roomId: string, event: any) => { handleEvent = async (mjolnir: Mjolnir, roomId: string, event: any) => {
if (event.content.body === "8HUnwb") { if (event.content.body === "8HUnwb") {
return new Consequence(ConsequenceType.ban, "asd"); return [new ConsequenceBan("asd")];
} }
}; };
}); });