diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ef6107..c9e5500 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Changed + +- The _Redaction Synchronisation Protection_ has been improved in a few ways: + - Invitations in protected rooms will be rejected as part of the redaction + process when they are sent from users being redacted (e.g. as a brigading + tactic). + - User redaction will now be triggered on bans and the reason will be scanned + for `automaticallyRedactForReasons` from Draupnir's config. + ## [v2.3.0-beta.0] In this update we want feedback on new diff --git a/package.json b/package.json index d5525bc..87afce9 100644 --- a/package.json +++ b/package.json @@ -63,8 +63,8 @@ "jsdom": "^24.0.0", "matrix-appservice-bridge": "^10.3.1", "matrix-bot-sdk": "npm:@vector-im/matrix-bot-sdk@^0.7.1-element.6", - "matrix-protection-suite": "npm:@gnuxie/matrix-protection-suite@3.0.0", - "matrix-protection-suite-for-matrix-bot-sdk": "npm:@gnuxie/matrix-protection-suite-for-matrix-bot-sdk@3.0.0", + "matrix-protection-suite": "npm:@gnuxie/matrix-protection-suite@3.1.0", + "matrix-protection-suite-for-matrix-bot-sdk": "npm:@gnuxie/matrix-protection-suite-for-matrix-bot-sdk@3.1.0", "pg": "^8.8.0", "yaml": "^2.3.2" }, diff --git a/src/protections/DraupnirProtectionsIndex.ts b/src/protections/DraupnirProtectionsIndex.ts index 074939d..8d8a7ae 100644 --- a/src/protections/DraupnirProtectionsIndex.ts +++ b/src/protections/DraupnirProtectionsIndex.ts @@ -9,7 +9,6 @@ */ // keep alphabetical please. -import "./RoomTakedown/RoomTakedownProtection"; import "./BanPropagation"; import "./BasicFlooding"; import "./FirstMessageIsImage"; diff --git a/src/protections/RedactionSynchronisation.ts b/src/protections/RedactionSynchronisation.ts index b85e140..496f6c8 100644 --- a/src/protections/RedactionSynchronisation.ts +++ b/src/protections/RedactionSynchronisation.ts @@ -1,16 +1,15 @@ -// SPDX-FileCopyrightText: 2024 Gnuxie +// SPDX-FileCopyrightText: 2024 - 2025 Gnuxie // // SPDX-License-Identifier: AFL-3.0 -// README: This protection really exists as a stop gap to bring over redaction -// functionality over from Draupnir, while we figure out how to add redaction -// policies that operate on a timeline cache, which removes the painfull process -// that is currently used to repeatedly fetch `/messages`. - import { AbstractProtection, + ActionException, + ActionExceptionKind, ActionResult, - CapabilitySet, + Capability, + CapabilityMethodSchema, + Membership, MembershipChange, MembershipChangeType, Ok, @@ -26,161 +25,353 @@ import { ProtectionDescription, Recommendation, RoomMembershipRevision, + SetMembershipPolicyRevisionIssuer, + SetRoomMembership, Task, + UnknownConfig, + WatchedPolicyRooms, + describeCapabilityInterface, + describeCapabilityProvider, + describeCapabilityRenderer, describeProtection, } from "matrix-protection-suite"; import { Draupnir } from "../Draupnir"; -import { redactUserMessagesIn } from "../utils"; import { MatrixGlob, StringRoomID, StringUserID, } from "@the-draupnir-project/matrix-basic-types"; +import { Result } from "@gnuxie/typescript-result"; +import { Type } from "@sinclair/typebox"; +import { revisionRulesMatchingUser } from "../commands/unban/UnbanUsers"; +import { redactUserMessagesIn } from "../utils"; -type RedactionSynchronisationProtectionDescription = - ProtectionDescription; +export type RedactionSynchronisationProtectionCapabilitiesSet = { + consequences: RedactionSynchronisationConsequences; +}; + +type RedactionSynchronisationProtectionDescription = ProtectionDescription< + Draupnir, + UnknownConfig, + RedactionSynchronisationProtectionCapabilitiesSet +>; + +// FIXME: We really need to design a capability interface for this protection... +// TBH doing so, would probably just be the UserConsequences capability... +// There shouldn't be much difference between this protection and MemberBanSynchronisation +// except this one applies when redaction reasons are given on policies and ban events. + +// FIXME: Need to decide whether to use two consequences... +// the reason why we might need two is if calling redaction when there are invited +// users will cause issues,, we could just add a second method though. + +// FIXME: Just add the invited users thing, we need that code here or there +// so may aswell do it and test it. + +// FIXME: We should consider updating both SetMembership and SetMembershipPolicies +// to understand parted members... + +export interface RedactionSynchronisationConsequences extends Capability { + redactMessagesIn( + userIDOrGlob: StringUserID, + reason: string | undefined, + roomIDs: StringRoomID[] + ): Promise>; + rejectInvite( + roomID: StringRoomID, + sender: StringUserID, + reciever: StringUserID, + reason: string | undefined + ): Promise>; +} + +describeCapabilityInterface({ + name: "RedactionSynchronisationConsequences", + description: "Consequences for the RedactionSynchronisationProtection", + schema: Type.Object({ + redactMessagesIn: CapabilityMethodSchema, + }), +}); + +describeCapabilityProvider({ + name: "StandardRedactionSynchronisationConsequences", + interface: "RedactionSynchronisationConsequences", + description: "redacts events and rejects invitations send by the target", + factory(description, draupnir: Draupnir) { + return Object.freeze({ + requiredPermissions: [ + PowerLevelPermission.Redact, + PowerLevelPermission.Kick, + ], + requiredStatePermissions: [], + requiredEventPermissions: [], + async redactMessagesIn(userIDOrGlob, reason, roomIDs) { + const redactionResult = await redactUserMessagesIn( + draupnir.client, + draupnir.managementRoomOutput, + userIDOrGlob, + roomIDs + ).then( + (_) => Ok(undefined), + (error) => + ActionException.Result( + `Error redacting messages for ${userIDOrGlob}`, + { + exception: error, + exceptionKind: ActionExceptionKind.Unknown, + } + ) + ); + return redactionResult; + }, + async rejectInvite(roomID, _sender, target, reason) { + return await draupnir.clientPlatform + .toRoomKicker() + .kickUser(roomID, target, reason); + }, + } satisfies RedactionSynchronisationConsequences); + }, +}); + +// FIXME: We really need capability rendering to be configurable. +describeCapabilityProvider({ + name: "SimulatedRedactionSynchronisationConsequences", + interface: "RedactionSynchronisationConsequences", + description: "Simulated redaction consequences", + factory(_description, _context) { + return Object.freeze({ + requiredPermissions: [], + requiredStatePermissions: [], + requiredEventPermissions: [], + isSimulated: true, + async redactMessagesIn() { + return Ok(undefined); + }, + async rejectInvite() { + return Ok(undefined); + }, + } satisfies RedactionSynchronisationConsequences); + }, +}); + +describeCapabilityRenderer({ + name: "StandardRedactionSynchronisationConsequencesRenderer", + interface: "RedactionSynchronisationConsequences", + description: "Doesn't render anything tbh, because it would be too annoying", + isDefaultForInterface: true, + factory( + _protectionDescription, + _context, + provider: RedactionSynchronisationConsequences + ) { + return Object.freeze({ + ...(provider.isSimulated ? { isSimulated: true } : {}), + requiredPermissions: provider.requiredPermissions, + requiredStatePermissions: provider.requiredStatePermissions, + requiredEventPermissions: provider.requiredEventPermissions, + async redactMessagesIn(userIDOrGlob, reason, roomIDs) { + return await provider.redactMessagesIn(userIDOrGlob, reason, roomIDs); + }, + async rejectInvite(roomID, sender, reciever, reason) { + return await provider.rejectInvite(roomID, sender, reciever, reason); + }, + } satisfies RedactionSynchronisationConsequences); + }, +}); + +interface RedactionSynchronisation { + // Used to check matching policies at startup. + // Not used for applying redactions on match, since the new policy + // hook is used for that. + handlePermissionRequirementsMet(roomID: StringRoomID): void; + // Used to check for when someone is trying to trigger draupnir to cleanup + // or new policy was issued + handlePolicyChange(policyChange: PolicyRuleChange): void; + // Used to handle redactions/reject invitations as a user is banned + handleMembershipChange(change: MembershipChange): void; +} + +export class StandardRedactionSynchronisation + implements RedactionSynchronisation +{ + public constructor( + private readonly automaticRedactionReasons: MatrixGlob[], + private readonly consequences: RedactionSynchronisationConsequences, + private readonly watchedPolicyRooms: WatchedPolicyRooms, + private readonly setRoomMembership: SetRoomMembership, + private readonly setPoliciesMatchingMembership: SetMembershipPolicyRevisionIssuer + ) { + // nothing to do. + } + handlePermissionRequirementsMet(roomID: StringRoomID): void { + const membershipRevision = this.setRoomMembership.getRevision(roomID); + if (membershipRevision !== undefined) { + this.checkRoomInvitations(membershipRevision); + } + for (const match of this.setPoliciesMatchingMembership.currentRevision.allMembersWithRules()) { + if (membershipRevision?.membershipForUser(match.userID)) { + const policyRequiringRedaction = match.policies.find((policy) => + this.isPolicyRequiringRedaction(policy) + ); + if (policyRequiringRedaction !== undefined) { + void Task( + this.consequences.redactMessagesIn( + match.userID, + policyRequiringRedaction.reason, + [roomID] + ) + ); + } + } + } + } + handlePolicyChange(change: PolicyRuleChange): void { + const policy = change.rule; + if (change.changeType === PolicyRuleChangeType.Removed) { + return; + } + if (policy.kind !== PolicyRuleType.User) { + return; + } + if (policy.matchType === PolicyRuleMatchType.HashedLiteral) { + return; + } + if (!this.isPolicyRequiringRedaction(policy)) { + return; + } + const roomsRequiringRedaction = + policy.matchType === PolicyRuleMatchType.Literal + ? this.setRoomMembership.allRooms.filter((revision) => + revision.membershipForUser(StringUserID(policy.entity)) + ) + : this.setRoomMembership.allRooms; + void Task( + this.consequences.redactMessagesIn( + StringUserID(policy.entity), + policy.reason, + roomsRequiringRedaction.map((revision) => + revision.room.toRoomIDOrAlias() + ) + ) + ); + for (const revision of roomsRequiringRedaction) { + this.checkRoomInvitations(revision); + } + } + handleMembershipChange(change: MembershipChange): void { + if ( + change.membershipChangeType === MembershipChangeType.Banned && + this.automaticRedactionReasons.some((reason) => + reason.test(change.content.reason ?? "") + ) + ) { + void Task( + this.consequences.redactMessagesIn(change.userID, undefined, [ + change.roomID, + ]) + ); + const membershipRevision = this.setRoomMembership.getRevision( + change.roomID + ); + if (membershipRevision) { + this.checkRoomInvitations(membershipRevision); + } + } + } + + private isPolicyRequiringRedaction(policy: PolicyRule): boolean { + return this.automaticRedactionReasons.some((reason) => + reason.test(policy.reason ?? "") + ); + } + + private checkRoomInvitations( + membershipRevision: RoomMembershipRevision + ): void { + const invites = membershipRevision.membersOfMembership(Membership.Invite); + for (const invite of invites) { + const relevantRules = revisionRulesMatchingUser( + invite.sender, + [Recommendation.Takedown, Recommendation.Ban], + this.watchedPolicyRooms.currentRevision + ).filter((policy) => this.isPolicyRequiringRedaction(policy)); + if (relevantRules.length > 0) { + void Task( + this.consequences.rejectInvite( + invite.roomID, + invite.sender, + invite.userID, + relevantRules.find((policy) => policy.reason !== undefined)?.reason + ) + ); + } + } + } +} export class RedactionSynchronisationProtection extends AbstractProtection implements Protection { - private automaticRedactionReasons: MatrixGlob[] = []; + private readonly redactionSynchronisation: RedactionSynchronisation; public constructor( description: RedactionSynchronisationProtectionDescription, - capabilities: CapabilitySet, + capabilities: RedactionSynchronisationProtectionCapabilitiesSet, protectedRoomsSet: ProtectedRoomsSet, - private readonly draupnir: Draupnir + automaticallyRedactForReasons: string[] ) { - super(description, capabilities, protectedRoomsSet, { - requiredPermissions: [PowerLevelPermission.Redact], - }); - for (const reason of draupnir.config.automaticallyRedactForReasons) { - this.automaticRedactionReasons.push(new MatrixGlob(reason.toLowerCase())); - } - } - public redactForNewUserPolicy(policy: PolicyRule): void { - const rooms: StringRoomID[] = []; - if (policy.matchType === PolicyRuleMatchType.HashedLiteral) { - return; // wait for them to be reversed and placed into the model as clear text. - } - if (policy.matchType === PolicyRuleMatchType.Glob) { - this.protectedRoomsSet.allProtectedRooms.forEach((room) => - rooms.push(room.toRoomIDOrAlias()) - ); - } else { - for (const roomMembership of this.protectedRoomsSet.setRoomMembership - .allRooms) { - const membership = roomMembership.membershipForUser( - policy.entity as StringUserID - ); - if (membership !== undefined) { - rooms.push(roomMembership.room.toRoomIDOrAlias()); - } - } - } - void Task( - redactUserMessagesIn( - this.draupnir.client, - this.draupnir.managementRoomOutput, - policy.entity, - rooms - ) + super(description, capabilities, protectedRoomsSet, {}); + this.redactionSynchronisation = new StandardRedactionSynchronisation( + automaticallyRedactForReasons.map((reason) => new MatrixGlob(reason)), + capabilities.consequences, + protectedRoomsSet.watchedPolicyRooms, + protectedRoomsSet.setRoomMembership, + protectedRoomsSet.setPoliciesMatchingMembership ); } + public async handlePolicyChange( - revision: PolicyListRevision, + _revision: PolicyListRevision, changes: PolicyRuleChange[] ): Promise> { - const relevantChanges = changes.filter( - (change) => - change.changeType !== PolicyRuleChangeType.Removed && - change.rule.kind === PolicyRuleType.User && - this.automaticRedactionReasons.some((reason) => - reason.test(change.rule.reason ?? "") - ) - ); - // Can't see this fucking up at all when watching a new list :skull:. - // So instead, we employ a genius big brain move. - // Basically, this stops us from overwhelming draupnir with redaction - // requests if the user watches a new list. Very unideal. - // however, please see the comment at the top of the file which explains - // how this protection **should** work, if it wasn't a stop gap. - if (relevantChanges.length > 5) { - return Ok(undefined); - } else if (relevantChanges.length === 0) { - return Ok(undefined); - } else { - relevantChanges.forEach((change) => { - this.redactForNewUserPolicy(change.rule); - }); - return Ok(undefined); - } + changes.forEach((change) => { + this.redactionSynchronisation.handlePolicyChange(change); + }); + return Ok(undefined); } + + // Scan again on ban to make sure we mopped everything up. public async handleMembershipChange( - revision: RoomMembershipRevision, + _revision: RoomMembershipRevision, changes: MembershipChange[] ): Promise> { - const isUserJoiningWithPolicyRequiringRedaction = ( - change: MembershipChange - ) => { - if ( - change.membershipChangeType === MembershipChangeType.Joined || - change.membershipChangeType === MembershipChangeType.Rejoined - ) { - const policyRevision = - this.protectedRoomsSet.watchedPolicyRooms.currentRevision; - const matchingPolicy = - policyRevision.findRuleMatchingEntity(change.userID, { - type: PolicyRuleType.User, - recommendation: Recommendation.Ban, - searchHashedRules: false, - }) ?? - policyRevision.findRuleMatchingEntity(change.userID, { - type: PolicyRuleType.User, - recommendation: Recommendation.Takedown, - searchHashedRules: false, - }); - return ( - matchingPolicy !== undefined && - this.automaticRedactionReasons.some((reason) => - reason.test(matchingPolicy.reason ?? "") - ) - ); - } else { - return false; - } - }; - const relevantChanges = changes.filter( - isUserJoiningWithPolicyRequiringRedaction - ); - for (const change of relevantChanges) { - void Task( - redactUserMessagesIn( - this.draupnir.client, - this.draupnir.managementRoomOutput, - change.userID, - [revision.room.toRoomIDOrAlias()] - ) - ); - } + changes.forEach((change) => { + this.redactionSynchronisation.handleMembershipChange(change); + }); return Ok(undefined); } } -describeProtection, Draupnir>({ - name: RedactionSynchronisationProtection.name, - description: - "Redacts messages when a new ban policy has been issued that matches config.automaticallyRedactForReasons. Work in progress.", - capabilityInterfaces: {}, - defaultCapabilities: {}, - factory(description, protectedRoomsSet, draupnir, capabilities) { - return Ok( - new RedactionSynchronisationProtection( - description, - capabilities, - protectedRoomsSet, - draupnir - ) - ); - }, -}); +describeProtection( + { + name: RedactionSynchronisationProtection.name, + description: + "Redacts messages when a new ban policy has been issued that matches config.automaticallyRedactForReasons. Work in progress.", + capabilityInterfaces: { + consequences: "RedactionSynchronisationConsequences", + }, + defaultCapabilities: { + consequences: "StandardRedactionSynchronisationConsequences", + }, + factory(description, protectedRoomsSet, draupnir, capabilities) { + return Ok( + new RedactionSynchronisationProtection( + description, + capabilities, + protectedRoomsSet, + draupnir.config.automaticallyRedactForReasons + ) + ); + }, + } +); diff --git a/test/unit/protections/RedactionSynchronisationTest.ts b/test/unit/protections/RedactionSynchronisationTest.ts new file mode 100644 index 0000000..ba01391 --- /dev/null +++ b/test/unit/protections/RedactionSynchronisationTest.ts @@ -0,0 +1,82 @@ +// SPDX-FileCopyrightText: 2025 Gnuxie +// +// SPDX-License-Identifier: AFL-3.0 + +import { + describeProtectedRoomsSet, + Membership, + Ok, + PolicyRuleType, + randomRoomID, + randomUserID, + Recommendation, +} from "matrix-protection-suite"; +import { + RedactionSynchronisationConsequences, + StandardRedactionSynchronisation, +} from "../../../src/protections/RedactionSynchronisation"; +import { MatrixGlob } from "@the-draupnir-project/matrix-basic-types"; +import { createMock } from "ts-auto-mock"; +import expect from "expect"; + +describe("RedactionSynchronisation", function () { + it("Attempts to retract invitations on permission requirements met", async function () { + const room = randomRoomID([]); + const targetUser = randomUserID(); + const { protectedRoomsSet } = await describeProtectedRoomsSet({ + rooms: [ + { + room, + membershipDescriptions: [ + { + sender: targetUser, + membership: Membership.Leave, + }, + { + sender: targetUser, + membership: Membership.Invite, + target: randomUserID(), + }, + ], + }, + ], + lists: [ + { + policyDescriptions: [ + { + recommendation: Recommendation.Ban, + entity: targetUser, + reason: "spam", + type: PolicyRuleType.User, + }, + ], + }, + ], + }); + let mockMethodCalls = 0; + const mockConsequences = createMock({ + async redactMessagesIn(userIDOrGlob, _reason, _roomIDs) { + expect(userIDOrGlob).toBe(targetUser); + mockMethodCalls += 1; + return Ok(undefined); + }, + async rejectInvite(roomID, sender, _reciever, _reason) { + expect(roomID).toBe(room.toRoomIDOrAlias()); + expect(sender).toBe(targetUser); + mockMethodCalls += 1; + return Ok(undefined); + }, + }); + const redactionSynronisationService = new StandardRedactionSynchronisation( + [new MatrixGlob("spam")], + mockConsequences, + protectedRoomsSet.watchedPolicyRooms, + protectedRoomsSet.setRoomMembership, + protectedRoomsSet.setPoliciesMatchingMembership + ); + redactionSynronisationService.handlePermissionRequirementsMet( + room.toRoomIDOrAlias() + ); + expect(mockMethodCalls).toBe(1); + }); +}); diff --git a/yarn.lock b/yarn.lock index ca89100..6d7d71c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2593,18 +2593,18 @@ matrix-appservice@^2.0.0: request-promise "^4.2.6" sanitize-html "^2.11.0" -"matrix-protection-suite-for-matrix-bot-sdk@npm:@gnuxie/matrix-protection-suite-for-matrix-bot-sdk@3.0.0": - version "3.0.0" - resolved "https://registry.yarnpkg.com/@gnuxie/matrix-protection-suite-for-matrix-bot-sdk/-/matrix-protection-suite-for-matrix-bot-sdk-3.0.0.tgz#eb4eaf07b040d223d18e949dfbe8d6712563974e" - integrity sha512-Rv7Ouxc11z08RvJ6d+W1v8pTxWUsQoJztMR53YQaRV1Q2YBkRAPJxIm/xIAfAf15j32LYJR+2TKrs1HxwFMybQ== +"matrix-protection-suite-for-matrix-bot-sdk@npm:@gnuxie/matrix-protection-suite-for-matrix-bot-sdk@3.1.0": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@gnuxie/matrix-protection-suite-for-matrix-bot-sdk/-/matrix-protection-suite-for-matrix-bot-sdk-3.1.0.tgz#36ed640f6425f568e85c880b6e3da87019e06db5" + integrity sha512-dgagHqAupTj7L/MAdXxwXFxNNceGALuMUr05WfnfBUfkbeI0xiC2rPtwKzh3xO2crjSYx52UE+59V73koHzDpQ== dependencies: "@gnuxie/typescript-result" "^1.0.0" await-lock "^2.2.2" -"matrix-protection-suite@npm:@gnuxie/matrix-protection-suite@3.0.0": - version "3.0.0" - resolved "https://registry.yarnpkg.com/@gnuxie/matrix-protection-suite/-/matrix-protection-suite-3.0.0.tgz#6e679f1f0e3b9a16f046c095d1ed1416a078e376" - integrity sha512-X06uzOKN/fwOW2Vm3AKL2HKLbjU1PrqhUsYTzZGGQnEQYkAvoFnuK/TyKF8QaVoMJ/PF4Ybkkj6t5GbZGsGajg== +"matrix-protection-suite@npm:@gnuxie/matrix-protection-suite@3.1.0": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@gnuxie/matrix-protection-suite/-/matrix-protection-suite-3.1.0.tgz#5969adb791e28b5ed985cb82cf6d5bd0b2d2740c" + integrity sha512-8w1uLsH1Q18qcl/vyGru+ckywEicg7JqPMgi9VGaHPhz9+JB5JmT+GoYXgyI4lEpqpiPv0uI+2bs2RkplWyseQ== dependencies: "@gnuxie/typescript-result" "^1.0.0" await-lock "^2.2.2"