glassframe-protocol 2.0.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +100 -0
- package/GETTING_STARTED.md +17 -0
- package/README.md +36 -10
- package/dist/config.js +20 -1
- package/dist/src/GlassFrame.js +166 -5
- package/dist/src/commands/PrefixRouter.js +120 -26
- package/dist/src/core/PerformanceMonitor.js +16 -4
- package/dist/src/core/VersionInfo.js +1 -1
- package/dist/src/layers/AIModerationLayer.js +1 -0
- package/dist/src/layers/AntiNukeLayer.js +100 -7
- package/dist/src/layers/AntiRaidLayer.js +3 -1
- package/dist/src/layers/BasicSecurityLayer.js +30 -1
- package/dist/src/logging/SmartLogger.js +33 -3
- package/dist/src/moderation/PunishmentEngine.js +14 -3
- package/dist/src/ui/ControlPanel.js +141 -11
- package/dist/src/utils/nlpEngine.js +2 -2
- package/docs/CACHE_ARCHITECTURE.md +5 -0
- package/docs/COMMANDS.md +13 -1
- package/docs/ENGINE.md +83 -0
- package/docs/PROTOCOL_LAYERS.md +49 -10
- package/package.json +1 -1
|
@@ -1,16 +1,25 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
|
-
const { buildPanel, buildMetricsMessage } = require("../ui/ControlPanel");
|
|
3
|
+
const { buildPanel, buildMetricsMessage, buildEnginePage } = require("../ui/ControlPanel");
|
|
4
4
|
const { buildLogMessage } = require("../logging/ComponentsV2");
|
|
5
5
|
|
|
6
|
-
const
|
|
7
|
-
const
|
|
6
|
+
const COMMANDS = ["panel", "status", "help", "whitelist", "scan", "metrics", "phishing", "prefix", "engine"];
|
|
7
|
+
const OWNER_ONLY_COMMANDS = new Set(["engine"]);
|
|
8
|
+
|
|
9
|
+
const LAYER_REASONING = {
|
|
10
|
+
basicSecurity: "watches every message for spam, scam/phishing language, raid-recruitment language, and bad links.",
|
|
11
|
+
antiRaid: "watches joins for rate spikes, alt-account patterns, and account-creation clusters.",
|
|
12
|
+
antiNuke: "watches the audit log for destructive bursts, dangerous permission grants, and channel lockouts.",
|
|
13
|
+
aiModeration: "sends only ambiguous messages (not clean, not clearly bad) to Groq for a second opinion."
|
|
14
|
+
};
|
|
8
15
|
|
|
9
16
|
/**
|
|
10
17
|
* GlassFrame Protocol registers no slash commands, by design (see
|
|
11
18
|
* docs/COMMANDS.md). Every command here is a plain prefix command gated on
|
|
12
19
|
* Manage Server permission, with a short per-user cooldown so a doubled
|
|
13
|
-
* keypress can't fire the same command twice in a row.
|
|
20
|
+
* keypress can't fire the same command twice in a row. Each server can set
|
|
21
|
+
* its own prefix (`!gf prefix set`); the default from config.js always
|
|
22
|
+
* still works too, as a fallback.
|
|
14
23
|
*/
|
|
15
24
|
class PrefixRouter {
|
|
16
25
|
constructor(frame) {
|
|
@@ -32,16 +41,25 @@ class PrefixRouter {
|
|
|
32
41
|
return false;
|
|
33
42
|
}
|
|
34
43
|
|
|
44
|
+
/** Tries this server's custom prefix (if set) and the default, longer one first, so an overlapping pair resolves sensibly. */
|
|
45
|
+
_matchPrefix(message) {
|
|
46
|
+
const custom = this.frame.guildPrefixes.get(message.guild.id);
|
|
47
|
+
const candidates = [custom, this.frame.config.prefix].filter(Boolean).sort((a, b) => b.length - a.length);
|
|
48
|
+
return candidates.find((p) => message.content.toLowerCase().startsWith(p.toLowerCase())) || null;
|
|
49
|
+
}
|
|
50
|
+
|
|
35
51
|
async _handle(message) {
|
|
36
52
|
if (!message.guild || message.author.bot) return;
|
|
37
|
-
const prefix = this.
|
|
38
|
-
if (!
|
|
53
|
+
const prefix = this._matchPrefix(message);
|
|
54
|
+
if (!prefix) return;
|
|
39
55
|
|
|
40
56
|
const args = message.content.slice(prefix.length).trim().split(/\s+/);
|
|
41
57
|
const command = (args.shift() || "").toLowerCase();
|
|
42
58
|
if (!COMMANDS.includes(command)) return;
|
|
43
59
|
|
|
44
|
-
if (
|
|
60
|
+
if (OWNER_ONLY_COMMANDS.has(command)) {
|
|
61
|
+
if (!this.frame.isOwner(message.author.id)) return; // silent - this command doesn't exist as far as non-owners can tell
|
|
62
|
+
} else if (!this.frame.isAuthorized(message.member)) {
|
|
45
63
|
await message.reply({ content: "You need Manage Server permission to use GlassFrame Protocol commands." });
|
|
46
64
|
return;
|
|
47
65
|
}
|
|
@@ -55,6 +73,8 @@ class PrefixRouter {
|
|
|
55
73
|
if (command === "scan") return this._scan(message);
|
|
56
74
|
if (command === "metrics") return this._metrics(message);
|
|
57
75
|
if (command === "phishing") return this._phishing(message, args);
|
|
76
|
+
if (command === "prefix") return this._prefix(message, args);
|
|
77
|
+
if (command === "engine") return this._engine(message, args);
|
|
58
78
|
}
|
|
59
79
|
|
|
60
80
|
async _panel(message) {
|
|
@@ -63,36 +83,56 @@ class PrefixRouter {
|
|
|
63
83
|
this.frame.panelMessages.set(sent.id, message.guild.id);
|
|
64
84
|
}
|
|
65
85
|
|
|
86
|
+
/** Each layer's line explains what it actually watches for, not just on/off - the same reasoning-first approach as metrics. */
|
|
66
87
|
async _status(message) {
|
|
67
88
|
const status = this.frame.getStatus(message.guild.id);
|
|
68
|
-
const fields =
|
|
89
|
+
const fields = Object.keys(this.frame.layers).map((name) => ({
|
|
90
|
+
name: `${status[name] ? "ACTIVE" : "INACTIVE"} - ${name}`,
|
|
91
|
+
value: LAYER_REASONING[name] || "a custom layer registered via frame.registerLayer()."
|
|
92
|
+
}));
|
|
69
93
|
const payload = buildLogMessage({ level: "info", title: "GlassFrame Protocol - Status", fields });
|
|
70
94
|
await message.channel.send(payload);
|
|
71
95
|
}
|
|
72
96
|
|
|
73
97
|
async _help(message) {
|
|
74
|
-
const prefix = this.frame.
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
98
|
+
const prefix = this.frame.getPrefix(message.guild.id);
|
|
99
|
+
const sections = [
|
|
100
|
+
{
|
|
101
|
+
name: "Getting started",
|
|
102
|
+
value: [
|
|
103
|
+
`${prefix}panel - the 5-button control panel. Nothing runs until a layer is armed here (or via autoStart in code).`,
|
|
104
|
+
`${prefix}status - which layers are on right now, and what each one actually watches for.`
|
|
105
|
+
].join("\n")
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
name: "Security tools",
|
|
109
|
+
value: [
|
|
110
|
+
`${prefix}scan - checks current roles for name/permission mismatches (impersonation bait, quiet privilege grants).`,
|
|
111
|
+
`${prefix}phishing add|remove|list <domain> - manage the link blocklist Basic Security checks messages against.`,
|
|
112
|
+
`${prefix}whitelist add|remove <userId> - exempt someone from punitive action, in this server only.`
|
|
113
|
+
].join("\n")
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
name: "Diagnostics",
|
|
117
|
+
value: `${prefix}metrics - this server's numbers (open cases, flagged members, whitelist size); one button on the message switches to bot-wide totals across every server.`
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
name: "Customization",
|
|
121
|
+
value: [
|
|
122
|
+
`${prefix}prefix set <newPrefix> - change this server's command prefix.`,
|
|
123
|
+
`${prefix}prefix reset - go back to the default (\`${this.frame.config.prefix}\`).`
|
|
124
|
+
].join("\n")
|
|
125
|
+
}
|
|
126
|
+
];
|
|
127
|
+
const payload = buildLogMessage({ level: "info", title: "GlassFrame Protocol - Help", fields: sections });
|
|
89
128
|
await message.channel.send(payload);
|
|
90
129
|
}
|
|
91
130
|
|
|
92
131
|
async _whitelist(message, args) {
|
|
132
|
+
const prefix = this.frame.getPrefix(message.guild.id);
|
|
93
133
|
const [sub, id] = args;
|
|
94
134
|
if (!sub || !id) {
|
|
95
|
-
await message.reply({ content: `Usage: ${
|
|
135
|
+
await message.reply({ content: `Usage: ${prefix}whitelist <add|remove> <userId>` });
|
|
96
136
|
return;
|
|
97
137
|
}
|
|
98
138
|
|
|
@@ -102,7 +142,7 @@ class PrefixRouter {
|
|
|
102
142
|
else return;
|
|
103
143
|
|
|
104
144
|
await message.reply({
|
|
105
|
-
content: `Whitelist updated: ${id} ${action === "add" ? "added to" : "removed from"} the exemption list.`
|
|
145
|
+
content: `Whitelist updated: ${id} ${action === "add" ? "added to" : "removed from"} the exemption list for this server.`
|
|
106
146
|
});
|
|
107
147
|
}
|
|
108
148
|
|
|
@@ -126,8 +166,8 @@ class PrefixRouter {
|
|
|
126
166
|
}
|
|
127
167
|
|
|
128
168
|
async _phishing(message, args) {
|
|
169
|
+
const prefix = this.frame.getPrefix(message.guild.id);
|
|
129
170
|
const [sub, domain] = args;
|
|
130
|
-
const prefix = this.frame.config.prefix;
|
|
131
171
|
|
|
132
172
|
if (!sub || sub.toLowerCase() === "list") {
|
|
133
173
|
const domains = this.frame.phishingDatabase.list();
|
|
@@ -153,6 +193,60 @@ class PrefixRouter {
|
|
|
153
193
|
await message.reply({ content: `Usage: ${prefix}phishing <add|remove|list> <domain>` });
|
|
154
194
|
}
|
|
155
195
|
}
|
|
196
|
+
|
|
197
|
+
async _prefix(message, args) {
|
|
198
|
+
const [sub, newPrefix] = args;
|
|
199
|
+
const current = this.frame.getPrefix(message.guild.id);
|
|
200
|
+
|
|
201
|
+
if (!sub) {
|
|
202
|
+
await message.reply({ content: `This server's prefix is currently \`${current}\`. Usage: ${current}prefix set <newPrefix> | reset` });
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const action = sub.toLowerCase();
|
|
207
|
+
if (action === "reset") {
|
|
208
|
+
this.frame.guildPrefixes.delete(message.guild.id);
|
|
209
|
+
this.frame._persistGuildState(message.guild.id);
|
|
210
|
+
await message.reply({ content: `Prefix reset to the default: \`${this.frame.config.prefix}\`` });
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (action === "set") {
|
|
215
|
+
if (!newPrefix) {
|
|
216
|
+
await message.reply({ content: `Usage: ${current}prefix set <newPrefix>` });
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
this.frame.setPrefix(message.guild.id, newPrefix);
|
|
220
|
+
await message.reply({
|
|
221
|
+
content: `This server's prefix is now \`${newPrefix}\`. The default \`${this.frame.config.prefix}\` still works too, as a fallback.`
|
|
222
|
+
});
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
await message.reply({ content: `Usage: ${current}prefix set <newPrefix> | reset` });
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Owner-only, bot-wide dashboard - see frame.getEngineReport(). Not
|
|
231
|
+
* listed in !gf help on purpose. If config.engine.password is set, it's
|
|
232
|
+
* required as `!gf engine <password>`; the command message is deleted
|
|
233
|
+
* either way (right or wrong) so the password never sits visible in
|
|
234
|
+
* channel history. Wrong-password attempts count toward a lockout - see
|
|
235
|
+
* GlassFrame.verifyEnginePassword().
|
|
236
|
+
*/
|
|
237
|
+
async _engine(message, args) {
|
|
238
|
+
const passwordOk = this.frame.verifyEnginePassword(message.author.id, args.join(" "));
|
|
239
|
+
try {
|
|
240
|
+
if (message.deletable) await message.delete();
|
|
241
|
+
} catch {
|
|
242
|
+
/* best effort - if the bot can't delete here, that's a permissions gap to fix, not a reason to fail open */
|
|
243
|
+
}
|
|
244
|
+
if (!passwordOk) return;
|
|
245
|
+
|
|
246
|
+
const report = this.frame.getEngineReport();
|
|
247
|
+
const payload = buildEnginePage(report, "overview");
|
|
248
|
+
await message.channel.send(payload);
|
|
249
|
+
}
|
|
156
250
|
}
|
|
157
251
|
|
|
158
252
|
module.exports = PrefixRouter;
|
|
@@ -3,19 +3,31 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* Lightweight internal metrics collector - no external dependency, no
|
|
5
5
|
* network call, just counters and a small rolling latency sample per event
|
|
6
|
-
* type
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* type, plus a lightweight per-guild activity tally. Exposed via
|
|
7
|
+
* `frame.getMetrics()` / `!gf metrics` (per-server and bot-wide) and the
|
|
8
|
+
* owner-only `!gf engine` dashboard, so you can see whether the protocol is
|
|
9
|
+
* keeping up - and which server is generating the most work - without
|
|
10
|
+
* wiring in a separate APM tool.
|
|
9
11
|
*/
|
|
10
12
|
class PerformanceMonitor {
|
|
11
13
|
constructor() {
|
|
12
14
|
this.startedAt = Date.now();
|
|
13
15
|
this.eventCounts = {};
|
|
14
16
|
this.latencies = {}; // name -> last 50 durations in ms
|
|
17
|
+
this.guildActivity = new Map(); // guildId -> event count since startup
|
|
15
18
|
}
|
|
16
19
|
|
|
17
|
-
|
|
20
|
+
/** `guildId` is optional - pass it from any per-guild handler to also tally that guild's activity. */
|
|
21
|
+
recordEvent(name, guildId) {
|
|
18
22
|
this.eventCounts[name] = (this.eventCounts[name] || 0) + 1;
|
|
23
|
+
if (guildId) {
|
|
24
|
+
this.guildActivity.set(guildId, (this.guildActivity.get(guildId) || 0) + 1);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Guild IDs ranked by recorded activity, busiest first. */
|
|
29
|
+
topGuildsByActivity(limit = 5) {
|
|
30
|
+
return [...this.guildActivity.entries()].sort((a, b) => b[1] - a[1]).slice(0, limit);
|
|
19
31
|
}
|
|
20
32
|
|
|
21
33
|
/** Call at the start of a unit of work; call the returned function when it finishes. */
|
|
@@ -43,6 +43,7 @@ class AIModerationLayer extends Layer {
|
|
|
43
43
|
if (!message.guild || message.author.bot || !message.content) return;
|
|
44
44
|
if (!this.isEnabled(message.guild.id)) return;
|
|
45
45
|
if (!this.groq?.available) return;
|
|
46
|
+
this.frame.performance.recordEvent("aiModeration.messageCreate", message.guild.id);
|
|
46
47
|
|
|
47
48
|
const cfg = this.config;
|
|
48
49
|
const local = classifyMessage(message.content);
|
|
@@ -19,7 +19,10 @@ const EVENT_MAP = {
|
|
|
19
19
|
[AuditLogEvent.EmojiCreate]: "emojiCreate",
|
|
20
20
|
[AuditLogEvent.EmojiDelete]: "emojiDelete",
|
|
21
21
|
[AuditLogEvent.StickerCreate]: "stickerCreate",
|
|
22
|
-
[AuditLogEvent.StickerDelete]: "stickerDelete"
|
|
22
|
+
[AuditLogEvent.StickerDelete]: "stickerDelete",
|
|
23
|
+
[AuditLogEvent.MemberRoleUpdate]: "memberRoleUpdate",
|
|
24
|
+
[AuditLogEvent.ChannelOverwriteCreate]: "channelOverwriteCreate",
|
|
25
|
+
[AuditLogEvent.ChannelOverwriteUpdate]: "channelOverwriteUpdate"
|
|
23
26
|
};
|
|
24
27
|
|
|
25
28
|
class AntiNukeLayer extends Layer {
|
|
@@ -69,7 +72,7 @@ class AntiNukeLayer extends Layer {
|
|
|
69
72
|
const type = EVENT_MAP[entry.action];
|
|
70
73
|
if (!type) return;
|
|
71
74
|
if (!this.isEnabled(guild.id)) return;
|
|
72
|
-
this.frame.performance.recordEvent(`antiNuke.auditEntry.${type}
|
|
75
|
+
this.frame.performance.recordEvent(`antiNuke.auditEntry.${type}`, guild.id);
|
|
73
76
|
|
|
74
77
|
const executor = entry.executor;
|
|
75
78
|
if (!executor || executor.id === this.client.user.id) return;
|
|
@@ -85,6 +88,12 @@ class AntiNukeLayer extends Layer {
|
|
|
85
88
|
if (type === "inviteCreate" && this.config.watchInviteAbuse) {
|
|
86
89
|
await this._checkInviteAbuse(entry, guild, executor);
|
|
87
90
|
}
|
|
91
|
+
if (type === "memberRoleUpdate" && this.config.watchDangerousGrants) {
|
|
92
|
+
await this._checkMemberRoleGrant(entry, guild, executor);
|
|
93
|
+
}
|
|
94
|
+
if ((type === "channelOverwriteCreate" || type === "channelOverwriteUpdate") && this.config.watchOverwriteAbuse) {
|
|
95
|
+
await this._checkChannelOverwriteAbuse(entry, guild, executor);
|
|
96
|
+
}
|
|
88
97
|
if (type === "webhookCreate" && this.config.webhookAbuse.enabled) {
|
|
89
98
|
this.recentWebhooks.set(
|
|
90
99
|
entry.targetId,
|
|
@@ -93,11 +102,10 @@ class AntiNukeLayer extends Layer {
|
|
|
93
102
|
);
|
|
94
103
|
}
|
|
95
104
|
|
|
96
|
-
//
|
|
97
|
-
// a
|
|
98
|
-
//
|
|
99
|
-
|
|
100
|
-
if (type === "inviteCreate") return;
|
|
105
|
+
// These are special-cased above instead of contributing to a raw burst
|
|
106
|
+
// count - a single grant/overwrite change is worth flagging immediately
|
|
107
|
+
// on its own merits, not after N repeats.
|
|
108
|
+
if (type === "inviteCreate" || type === "memberRoleUpdate" || type === "channelOverwriteCreate" || type === "channelOverwriteUpdate") return;
|
|
101
109
|
|
|
102
110
|
const count = this._record(guild.id, executor.id, type);
|
|
103
111
|
const threshold = this.config.thresholds[type];
|
|
@@ -138,6 +146,7 @@ class AntiNukeLayer extends Layer {
|
|
|
138
146
|
const role = guild.roles.cache.get(entry.targetId);
|
|
139
147
|
|
|
140
148
|
await this.frame.logger.log(guild, {
|
|
149
|
+
layer: this.name,
|
|
141
150
|
level: "alert",
|
|
142
151
|
title: "Dangerous Permission Grant Detected",
|
|
143
152
|
description: `${executor.tag} granted **${gained.join(", ")}** to role ${role ? role.name : entry.targetId}.`,
|
|
@@ -162,6 +171,7 @@ class AntiNukeLayer extends Layer {
|
|
|
162
171
|
if (!changed.length) return;
|
|
163
172
|
|
|
164
173
|
await this.frame.logger.log(guild, {
|
|
174
|
+
layer: this.name,
|
|
165
175
|
level: "warn",
|
|
166
176
|
title: "Server Identity Changed",
|
|
167
177
|
description: `${executor.tag} changed: ${changed.map((c) => c.key).join(", ")}.`,
|
|
@@ -193,6 +203,7 @@ class AntiNukeLayer extends Layer {
|
|
|
193
203
|
if (trust === "HIGH" || trust === "PROTECTED") return;
|
|
194
204
|
|
|
195
205
|
await this.frame.logger.log(guild, {
|
|
206
|
+
layer: this.name,
|
|
196
207
|
level: "warn",
|
|
197
208
|
title: "Unrestricted Invite Created By Non-Staff Member",
|
|
198
209
|
description: `${executor.tag} (trust: ${trust}) created an invite with no expiry and no use limit.`,
|
|
@@ -200,6 +211,86 @@ class AntiNukeLayer extends Layer {
|
|
|
200
211
|
});
|
|
201
212
|
}
|
|
202
213
|
|
|
214
|
+
/**
|
|
215
|
+
* RoleUpdate (above) catches a role's own permissions changing - this
|
|
216
|
+
* catches the other half: a specific MEMBER being handed a role that
|
|
217
|
+
* already carries dangerous permissions. Missing this meant someone
|
|
218
|
+
* with ManageRoles could quietly promote themselves (or an accomplice)
|
|
219
|
+
* without ever touching a role's definition.
|
|
220
|
+
*/
|
|
221
|
+
async _checkMemberRoleGrant(entry, guild, executor) {
|
|
222
|
+
const added = (entry.changes || []).find((c) => c.key === "$add");
|
|
223
|
+
if (!added || !Array.isArray(added.new) || !added.new.length) return;
|
|
224
|
+
|
|
225
|
+
const dangerous = this.frame.config.roleAnalysis.dangerousPermissions;
|
|
226
|
+
const dangerousRoles = added.new
|
|
227
|
+
.map((r) => guild.roles.cache.get(r.id))
|
|
228
|
+
.filter((role) => role && dangerous.some((p) => role.permissions.has(PermissionsBitField.Flags[p])));
|
|
229
|
+
if (!dangerousRoles.length) return;
|
|
230
|
+
|
|
231
|
+
let executorMember = null;
|
|
232
|
+
try {
|
|
233
|
+
executorMember = await guild.members.fetch(executor.id);
|
|
234
|
+
} catch {
|
|
235
|
+
/* left already */
|
|
236
|
+
}
|
|
237
|
+
const trust = executorMember ? this.frame.roleAnalyzer.trustLevel(executorMember) : "LOW";
|
|
238
|
+
if (trust === "HIGH" || trust === "PROTECTED") return;
|
|
239
|
+
|
|
240
|
+
await this.frame.logger.log(guild, {
|
|
241
|
+
layer: this.name,
|
|
242
|
+
level: "alert",
|
|
243
|
+
title: "Dangerous Role Granted To A Member",
|
|
244
|
+
description: `${executor.tag} (trust: ${trust}) gave <@${entry.targetId}> the role(s): ${dangerousRoles
|
|
245
|
+
.map((r) => r.name)
|
|
246
|
+
.join(", ")}.`,
|
|
247
|
+
dedupeKey: `antinuke:member-grant:${entry.targetId}`
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* A nuke doesn't have to delete anything - locking @everyone out of a
|
|
253
|
+
* public channel, or opening a private one up to @everyone, does the
|
|
254
|
+
* same damage through permission overwrites instead. Only @everyone's
|
|
255
|
+
* own overwrite is watched here; a role- or member-specific overwrite
|
|
256
|
+
* change is routine channel administration and not worth flagging.
|
|
257
|
+
*/
|
|
258
|
+
async _checkChannelOverwriteAbuse(entry, guild, executor) {
|
|
259
|
+
if (!entry.extra || String(entry.extra.id) !== guild.id) return;
|
|
260
|
+
|
|
261
|
+
const allowChange = (entry.changes || []).find((c) => c.key === "allow");
|
|
262
|
+
const denyChange = (entry.changes || []).find((c) => c.key === "deny");
|
|
263
|
+
if (!allowChange && !denyChange) return;
|
|
264
|
+
|
|
265
|
+
const oldAllow = new PermissionsBitField(BigInt(allowChange?.old ?? 0));
|
|
266
|
+
const newAllow = new PermissionsBitField(BigInt(allowChange?.new ?? 0));
|
|
267
|
+
const oldDeny = new PermissionsBitField(BigInt(denyChange?.old ?? 0));
|
|
268
|
+
const newDeny = new PermissionsBitField(BigInt(denyChange?.new ?? 0));
|
|
269
|
+
|
|
270
|
+
const view = PermissionsBitField.Flags.ViewChannel;
|
|
271
|
+
const nowGrantsView = !oldAllow.has(view) && newAllow.has(view);
|
|
272
|
+
const nowBlocksView = !oldDeny.has(view) && newDeny.has(view);
|
|
273
|
+
if (!nowGrantsView && !nowBlocksView) return;
|
|
274
|
+
|
|
275
|
+
let executorMember = null;
|
|
276
|
+
try {
|
|
277
|
+
executorMember = await guild.members.fetch(executor.id);
|
|
278
|
+
} catch {
|
|
279
|
+
/* left already */
|
|
280
|
+
}
|
|
281
|
+
const trust = executorMember ? this.frame.roleAnalyzer.trustLevel(executorMember) : "LOW";
|
|
282
|
+
if (trust === "HIGH" || trust === "PROTECTED") return;
|
|
283
|
+
|
|
284
|
+
const channel = guild.channels.cache.get(entry.targetId);
|
|
285
|
+
await this.frame.logger.log(guild, {
|
|
286
|
+
layer: this.name,
|
|
287
|
+
level: "alert",
|
|
288
|
+
title: nowBlocksView ? "Channel Locked Out For Everyone" : "Private Channel Opened To Everyone",
|
|
289
|
+
description: `${executor.tag} (trust: ${trust}) changed @everyone's access to ${channel ? channel.name : entry.targetId}.`,
|
|
290
|
+
dedupeKey: `antinuke:overwrite:${entry.targetId}`
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
203
294
|
/**
|
|
204
295
|
* Webhook messages never carry a guild member, so BasicSecurityLayer's
|
|
205
296
|
* per-author spam buffer can't see them - a "create webhook, then flood
|
|
@@ -232,6 +323,7 @@ class AntiNukeLayer extends Layer {
|
|
|
232
323
|
}
|
|
233
324
|
|
|
234
325
|
await this.frame.logger.log(guild, {
|
|
326
|
+
layer: this.name,
|
|
235
327
|
level: "alert",
|
|
236
328
|
title: "Webhook Message Flood - Webhook Removed",
|
|
237
329
|
description: `A recently created webhook sent ${list.length}+ messages inside ${cfg.windowMs / 1000}s and has been deleted.`,
|
|
@@ -271,6 +363,7 @@ class AntiNukeLayer extends Layer {
|
|
|
271
363
|
if (!flags.length) return;
|
|
272
364
|
|
|
273
365
|
await this.frame.logger.log(guild, {
|
|
366
|
+
layer: this.name,
|
|
274
367
|
level: "warn",
|
|
275
368
|
title: "Role Audit - Possible Impersonation/Permission Mismatch",
|
|
276
369
|
description: flags.map((f) => `**${f.name}** (${f.roleId}) - ${f.reason}`).join("\n"),
|
|
@@ -71,7 +71,7 @@ class AntiRaidLayer extends Layer {
|
|
|
71
71
|
async _handleJoin(member) {
|
|
72
72
|
const { guild, user } = member;
|
|
73
73
|
if (!this.isEnabled(guild.id)) return;
|
|
74
|
-
this.frame.performance.recordEvent("antiRaid.guildMemberAdd");
|
|
74
|
+
this.frame.performance.recordEvent("antiRaid.guildMemberAdd", guild.id);
|
|
75
75
|
const joinCount = this._recordJoin(guild.id);
|
|
76
76
|
const { score: altScore, entropyCheck } = this._altScore(user);
|
|
77
77
|
const cluster = this._clusterScore(guild.id, user);
|
|
@@ -106,6 +106,7 @@ class AntiRaidLayer extends Layer {
|
|
|
106
106
|
}
|
|
107
107
|
|
|
108
108
|
await this.frame.logger.log(guild, {
|
|
109
|
+
layer: this.name,
|
|
109
110
|
level: "alert",
|
|
110
111
|
title: "Raid Detected - Lockdown Engaged",
|
|
111
112
|
description: `${joinCount} joins inside ${this.config.joinWindowMs / 1000}s. Verification level temporarily raised.`,
|
|
@@ -129,6 +130,7 @@ class AntiRaidLayer extends Layer {
|
|
|
129
130
|
}
|
|
130
131
|
|
|
131
132
|
await this.frame.logger.log(guild, {
|
|
133
|
+
layer: this.name,
|
|
132
134
|
level: "ok",
|
|
133
135
|
title: "Lockdown Lifted",
|
|
134
136
|
description: "Join activity normalized. Verification level restored.",
|
|
@@ -25,7 +25,7 @@ class BasicSecurityLayer extends Layer {
|
|
|
25
25
|
if (!message.guild || message.author.bot) return;
|
|
26
26
|
if (!this.isEnabled(message.guild.id)) return;
|
|
27
27
|
const done = this.frame.performance.time("basicSecurity.messageCreate");
|
|
28
|
-
this.frame.performance.recordEvent("basicSecurity.messageCreate");
|
|
28
|
+
this.frame.performance.recordEvent("basicSecurity.messageCreate", message.guild.id);
|
|
29
29
|
|
|
30
30
|
const cfg = this.config;
|
|
31
31
|
const buf = this._bufferFor(message.guild.id, message.author.id);
|
|
@@ -43,6 +43,8 @@ class BasicSecurityLayer extends Layer {
|
|
|
43
43
|
const mentionCount = message.mentions.users.size + message.mentions.roles.size;
|
|
44
44
|
const classification = classifyMessage(message.content || "");
|
|
45
45
|
const linkFlag = this._checkLinks(message.content || "");
|
|
46
|
+
const hasInvite = this._checkInviteLink(message.content || "");
|
|
47
|
+
const trust = this.frame.roleAnalyzer.trustLevel(message.member);
|
|
46
48
|
|
|
47
49
|
const reasons = [];
|
|
48
50
|
let weight = 0;
|
|
@@ -59,6 +61,14 @@ class BasicSecurityLayer extends Layer {
|
|
|
59
61
|
reasons.push(`mention spam (${mentionCount})`);
|
|
60
62
|
weight += 25;
|
|
61
63
|
}
|
|
64
|
+
// A single @everyone/@here reaches every member at once - much higher
|
|
65
|
+
// impact than several individual mentions, so it's weighted heavier and
|
|
66
|
+
// checked separately. Recognized trusted staff are exempt, since a real
|
|
67
|
+
// admin's announcement ping is routine, not an attack.
|
|
68
|
+
if (message.mentions.everyone && trust !== "HIGH" && trust !== "PROTECTED") {
|
|
69
|
+
reasons.push("@everyone/@here mention from a non-trusted member");
|
|
70
|
+
weight += 50;
|
|
71
|
+
}
|
|
62
72
|
if (classification.scam >= cfg.nlp.scamScoreThreshold) {
|
|
63
73
|
reasons.push(`scam language (${classification.scam.toFixed(2)})`);
|
|
64
74
|
weight += 35;
|
|
@@ -67,6 +77,20 @@ class BasicSecurityLayer extends Layer {
|
|
|
67
77
|
reasons.push(`phishing language (${classification.phishing.toFixed(2)})`);
|
|
68
78
|
weight += 35;
|
|
69
79
|
}
|
|
80
|
+
if (classification.raidCallout >= cfg.nlp.raidCalloutThreshold) {
|
|
81
|
+
reasons.push(`raid-recruitment language (${classification.raidCallout.toFixed(2)})`);
|
|
82
|
+
weight += 35;
|
|
83
|
+
}
|
|
84
|
+
// The specific pattern in a lot of real raid-tool spam: an invite link
|
|
85
|
+
// to another server, paired with language recruiting people to raid
|
|
86
|
+
// with it. Neither signal alone is damning - plenty of normal messages
|
|
87
|
+
// share an invite, plenty of normal messages mention "raid" in passing
|
|
88
|
+
// - but together, from a non-trusted member, it's a strong, specific
|
|
89
|
+
// signal worth weighting heavily on its own.
|
|
90
|
+
if (hasInvite && classification.raidCallout >= cfg.nlp.raidCalloutThreshold && trust !== "HIGH" && trust !== "PROTECTED") {
|
|
91
|
+
reasons.push("Discord invite link paired with raid-recruitment language");
|
|
92
|
+
weight += 50;
|
|
93
|
+
}
|
|
70
94
|
if (linkFlag) {
|
|
71
95
|
reasons.push(linkFlag);
|
|
72
96
|
weight += 30;
|
|
@@ -104,6 +128,11 @@ class BasicSecurityLayer extends Layer {
|
|
|
104
128
|
}
|
|
105
129
|
return null;
|
|
106
130
|
}
|
|
131
|
+
|
|
132
|
+
/** True if the message contains a Discord server invite link, of any of the URL forms Discord accepts. */
|
|
133
|
+
_checkInviteLink(text) {
|
|
134
|
+
return /(?:discord\.gg|discord(?:app)?\.com\/invite)\/[a-z0-9-]+/i.test(text);
|
|
135
|
+
}
|
|
107
136
|
}
|
|
108
137
|
|
|
109
138
|
module.exports = BasicSecurityLayer;
|
|
@@ -2,12 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
const { buildLogMessage } = require("./ComponentsV2");
|
|
4
4
|
|
|
5
|
+
const RECENT_LOG_LIMIT = 30;
|
|
6
|
+
|
|
5
7
|
/**
|
|
6
8
|
* Every GlassFrame alert goes through one gate that:
|
|
7
9
|
* (a) merges near-duplicate events inside a short window into a single,
|
|
8
|
-
* updated message instead of one message per event,
|
|
10
|
+
* updated message instead of one message per event,
|
|
9
11
|
* (b) never sends to a guild's log channel faster than a configured floor,
|
|
10
|
-
*
|
|
12
|
+
* so a burst never turns into a wall of messages or a Discord 429, and
|
|
13
|
+
* (c) keeps a small in-memory record of the most recent events across
|
|
14
|
+
* EVERY guild, for the owner-only `!gf engine` dashboard - see
|
|
15
|
+
* getRecentLogs() below.
|
|
16
|
+
*
|
|
17
|
+
* `getLogChannel(guild, layerName)` receives the reporting layer's name as
|
|
18
|
+
* an optional second argument, so you can route different layers to
|
|
19
|
+
* different channels if you want. A `getLogChannel` that only takes one
|
|
20
|
+
* parameter (the common case) keeps working unchanged - JS simply ignores
|
|
21
|
+
* the extra argument.
|
|
11
22
|
*/
|
|
12
23
|
class SmartLogger {
|
|
13
24
|
constructor(getLogChannel, config, { debug = false } = {}) {
|
|
@@ -16,10 +27,12 @@ class SmartLogger {
|
|
|
16
27
|
this.debug = debug;
|
|
17
28
|
this.pending = new Map(); // dedupeKey -> { count, payload, sent }
|
|
18
29
|
this.lastSend = new Map(); // guildId -> timestamp
|
|
30
|
+
this.recentLogs = []; // bounded ring buffer, newest first - see getRecentLogs()
|
|
19
31
|
}
|
|
20
32
|
|
|
21
33
|
async log(guild, payload) {
|
|
22
34
|
const key = payload.dedupeKey ? `${guild.id}:${payload.dedupeKey}` : null;
|
|
35
|
+
this._recordRecent(guild, payload);
|
|
23
36
|
|
|
24
37
|
if (key && this.pending.has(key)) {
|
|
25
38
|
const entry = this.pending.get(key);
|
|
@@ -56,7 +69,7 @@ class SmartLogger {
|
|
|
56
69
|
|
|
57
70
|
async _deliver(guild, payload, count) {
|
|
58
71
|
await this._respectRateFloor(guild.id);
|
|
59
|
-
const channel = await this.getLogChannel(guild);
|
|
72
|
+
const channel = await this.getLogChannel(guild, payload.layer);
|
|
60
73
|
if (!channel) return null;
|
|
61
74
|
|
|
62
75
|
const message = buildLogMessage({ ...payload, count });
|
|
@@ -76,6 +89,23 @@ class SmartLogger {
|
|
|
76
89
|
if (wait > 0) await new Promise((resolve) => setTimeout(resolve, wait));
|
|
77
90
|
}
|
|
78
91
|
|
|
92
|
+
_recordRecent(guild, payload) {
|
|
93
|
+
this.recentLogs.unshift({
|
|
94
|
+
at: Date.now(),
|
|
95
|
+
guildId: guild.id,
|
|
96
|
+
guildName: guild.name,
|
|
97
|
+
level: payload.level || "info",
|
|
98
|
+
title: payload.title || "",
|
|
99
|
+
layer: payload.layer || null
|
|
100
|
+
});
|
|
101
|
+
if (this.recentLogs.length > RECENT_LOG_LIMIT) this.recentLogs.length = RECENT_LOG_LIMIT;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The most recent log events across every guild, newest first - powers the owner-only `!gf engine` command. */
|
|
105
|
+
getRecentLogs(limit = 10) {
|
|
106
|
+
return this.recentLogs.slice(0, limit);
|
|
107
|
+
}
|
|
108
|
+
|
|
79
109
|
_debug(msg) {
|
|
80
110
|
if (this.debug) console.log(`[GlassFrame:SmartLogger] ${msg}`);
|
|
81
111
|
}
|
|
@@ -5,8 +5,11 @@ const ProtocolCache = require("../core/Cache");
|
|
|
5
5
|
|
|
6
6
|
// Used only to decide whether continued bad behavior mid-case deserves a
|
|
7
7
|
// stronger response - never to decide the response itself (_decideAction
|
|
8
|
-
// still owns that, trust-gating included).
|
|
8
|
+
// still owns that, trust-gating included). Custom actions (registerAction)
|
|
9
|
+
// default to 2 - the same tier as quarantine/timeout - since there's no
|
|
10
|
+
// way to know their real severity automatically.
|
|
9
11
|
const ACTION_SEVERITY = { log: 0, alert: 1, quarantine: 2, timeout: 2, kick: 3, ban: 4 };
|
|
12
|
+
const severityOf = (action) => ACTION_SEVERITY[action] ?? 2;
|
|
10
13
|
|
|
11
14
|
/**
|
|
12
15
|
* The only place in GlassFrame Protocol that actually punishes a member.
|
|
@@ -18,12 +21,16 @@ const ACTION_SEVERITY = { log: 0, alert: 1, quarantine: 2, timeout: 2, kick: 3,
|
|
|
18
21
|
* ladder alone would say.
|
|
19
22
|
*/
|
|
20
23
|
class PunishmentEngine extends EventEmitter {
|
|
21
|
-
constructor({ config, threatEngine, roleAnalyzer, logger, whitelist, queue, performance, debug = false }) {
|
|
24
|
+
constructor({ config, threatEngine, roleAnalyzer, logger, whitelist, customActions, queue, performance, debug = false }) {
|
|
22
25
|
super();
|
|
23
26
|
this.config = config.punishment;
|
|
24
27
|
this.threatEngine = threatEngine;
|
|
25
28
|
this.roleAnalyzer = roleAnalyzer;
|
|
26
29
|
this.logger = logger;
|
|
30
|
+
// actionName -> async (member, record) => void, registered via
|
|
31
|
+
// frame.registerAction() so config.punishment.ladder can reference
|
|
32
|
+
// custom actions beyond ban/kick/timeout/quarantine.
|
|
33
|
+
this.customActions = customActions || new Map();
|
|
27
34
|
// Actual Discord mutations (ban/kick/timeout/quarantine) go through this
|
|
28
35
|
// bounded-concurrency queue rather than firing directly, so a burst of
|
|
29
36
|
// simultaneous cases (a raid, a nuke sweep) can't overrun Discord's rate
|
|
@@ -67,7 +74,7 @@ class PunishmentEngine extends EventEmitter {
|
|
|
67
74
|
|
|
68
75
|
const trust = this.roleAnalyzer.trustLevel(member);
|
|
69
76
|
const nextAction = this._decideAction(tier, trust);
|
|
70
|
-
if (
|
|
77
|
+
if (severityOf(nextAction) > severityOf(openCase.action)) {
|
|
71
78
|
openCase.action = nextAction;
|
|
72
79
|
openCase.trust = trust;
|
|
73
80
|
this.cases.set(key, openCase);
|
|
@@ -125,6 +132,9 @@ class PunishmentEngine extends EventEmitter {
|
|
|
125
132
|
const removable = member.roles.cache.filter((r) => r.id !== guild.id && r.editable);
|
|
126
133
|
await this.queue.push(() => member.roles.remove(removable, this._reason(record)));
|
|
127
134
|
performed = true;
|
|
135
|
+
} else if (this.customActions.has(action) && canAct) {
|
|
136
|
+
await this.queue.push(() => this.customActions.get(action)(member, record));
|
|
137
|
+
performed = true;
|
|
128
138
|
}
|
|
129
139
|
} catch (err) {
|
|
130
140
|
record.error = err.message;
|
|
@@ -141,6 +151,7 @@ class PunishmentEngine extends EventEmitter {
|
|
|
141
151
|
await this.logger.log(guild, {
|
|
142
152
|
level: action === "alert" ? "alert" : action === "log" ? "info" : "warn",
|
|
143
153
|
title: this._title(action),
|
|
154
|
+
layer: "punishmentEngine",
|
|
144
155
|
description: `${member.user.tag} (${member.id}) - tier **${record.tier}**, trust **${record.trust}**.`,
|
|
145
156
|
fields: [
|
|
146
157
|
{ name: "Reasons", value: record.reasons.slice(-5).join("\n") },
|