glassframe-protocol 1.2.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 +197 -0
- package/GETTING_STARTED.md +133 -0
- package/LICENSE +56 -0
- package/README.md +145 -0
- package/dist/config.js +138 -0
- package/dist/index.js +13 -0
- package/dist/src/GlassFrame.js +195 -0
- package/dist/src/ai/GroqClient.js +161 -0
- package/dist/src/commands/PrefixRouter.js +177 -0
- package/dist/src/core/Cache.js +98 -0
- package/dist/src/core/EventQueue.js +62 -0
- package/dist/src/core/Layer.js +68 -0
- package/dist/src/core/PerformanceMonitor.js +52 -0
- package/dist/src/core/StateStore.js +69 -0
- package/dist/src/core/ThreatEngine.js +86 -0
- package/dist/src/core/VersionInfo.js +32 -0
- package/dist/src/layers/AIModerationLayer.js +77 -0
- package/dist/src/layers/AntiNukeLayer.js +278 -0
- package/dist/src/layers/AntiRaidLayer.js +144 -0
- package/dist/src/layers/BasicSecurityLayer.js +111 -0
- package/dist/src/logging/ComponentsV2.js +44 -0
- package/dist/src/logging/SmartLogger.js +84 -0
- package/dist/src/moderation/PunishmentEngine.js +175 -0
- package/dist/src/moderation/RoleAnalyzer.js +82 -0
- package/dist/src/security/PhishingDatabase.js +71 -0
- package/dist/src/ui/ControlPanel.js +58 -0
- package/dist/src/utils/nlpEngine.js +149 -0
- package/docs/CACHE_ARCHITECTURE.md +78 -0
- package/docs/COMMANDS.md +38 -0
- package/docs/PERFORMANCE.md +59 -0
- package/docs/PROTOCOL_LAYERS.md +89 -0
- package/docs/PUBLISHING.md +93 -0
- package/examples/basic-usage.js +44 -0
- package/package.json +44 -0
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { AuditLogEvent, PermissionsBitField } = require("discord.js");
|
|
4
|
+
const Layer = require("../core/Layer");
|
|
5
|
+
const ProtocolCache = require("../core/Cache");
|
|
6
|
+
|
|
7
|
+
const EVENT_MAP = {
|
|
8
|
+
[AuditLogEvent.ChannelDelete]: "channelDelete",
|
|
9
|
+
[AuditLogEvent.ChannelCreate]: "channelCreate",
|
|
10
|
+
[AuditLogEvent.RoleDelete]: "roleDelete",
|
|
11
|
+
[AuditLogEvent.RoleCreate]: "roleCreate",
|
|
12
|
+
[AuditLogEvent.MemberBanAdd]: "memberBanAdd",
|
|
13
|
+
[AuditLogEvent.MemberKick]: "memberKick",
|
|
14
|
+
[AuditLogEvent.WebhookCreate]: "webhookCreate",
|
|
15
|
+
[AuditLogEvent.BotAdd]: "botAdd",
|
|
16
|
+
[AuditLogEvent.GuildUpdate]: "guildUpdate",
|
|
17
|
+
[AuditLogEvent.RoleUpdate]: "roleUpdate",
|
|
18
|
+
[AuditLogEvent.InviteCreate]: "inviteCreate",
|
|
19
|
+
[AuditLogEvent.EmojiCreate]: "emojiCreate",
|
|
20
|
+
[AuditLogEvent.EmojiDelete]: "emojiDelete",
|
|
21
|
+
[AuditLogEvent.StickerCreate]: "stickerCreate",
|
|
22
|
+
[AuditLogEvent.StickerDelete]: "stickerDelete"
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
class AntiNukeLayer extends Layer {
|
|
26
|
+
constructor(frame) {
|
|
27
|
+
super("antiNuke", frame);
|
|
28
|
+
this.actionLog = new Map(); // guildId -> executorId -> { type: timestamps[] }
|
|
29
|
+
this.snapshots = new ProtocolCache("nuke-snapshots", { ttlMs: 0 });
|
|
30
|
+
// webhookId -> { executorId, guildId } for webhooks created recently
|
|
31
|
+
// enough to still be worth watching for a message-flood pattern.
|
|
32
|
+
this.recentWebhooks = new ProtocolCache("recent-webhooks", { ttlMs: 0 });
|
|
33
|
+
this.webhookMessageCounts = new Map(); // webhookId -> timestamps[]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
onEnable() {
|
|
37
|
+
this._listen(this.client, "guildAuditLogEntryCreate", (entry, guild) =>
|
|
38
|
+
this._handleEntry(entry, guild).catch(() => {})
|
|
39
|
+
);
|
|
40
|
+
if (this.config.webhookAbuse.enabled) {
|
|
41
|
+
this._listen(this.client, "messageCreate", (message) => this._handleWebhookMessage(message).catch(() => {}));
|
|
42
|
+
}
|
|
43
|
+
// Give every guild an immediate role audit the moment AntiNuke is armed.
|
|
44
|
+
for (const guild of this.client.guilds.cache.values()) {
|
|
45
|
+
this._auditRoles(guild).catch(() => {});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
onDisable() {
|
|
50
|
+
this.actionLog.clear();
|
|
51
|
+
this.webhookMessageCounts.clear();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
_record(guildId, executorId, type) {
|
|
55
|
+
const now = Date.now();
|
|
56
|
+
if (!this.actionLog.has(guildId)) this.actionLog.set(guildId, new Map());
|
|
57
|
+
const guildMap = this.actionLog.get(guildId);
|
|
58
|
+
if (!guildMap.has(executorId)) guildMap.set(executorId, {});
|
|
59
|
+
const byType = guildMap.get(executorId);
|
|
60
|
+
const list = (byType[type] || []).filter((t) => now - t < this.config.windowMs);
|
|
61
|
+
list.push(now);
|
|
62
|
+
byType[type] = list;
|
|
63
|
+
return list.length;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async _handleEntry(entry, guild) {
|
|
67
|
+
const type = EVENT_MAP[entry.action];
|
|
68
|
+
if (!type) return;
|
|
69
|
+
this.frame.performance.recordEvent(`antiNuke.auditEntry.${type}`);
|
|
70
|
+
|
|
71
|
+
const executor = entry.executor;
|
|
72
|
+
if (!executor || executor.id === this.client.user.id) return;
|
|
73
|
+
if (executor.id === guild.ownerId) return;
|
|
74
|
+
if (this.frame.whitelist.has(executor.id)) return;
|
|
75
|
+
|
|
76
|
+
if (type === "roleUpdate" && this.config.watchDangerousGrants) {
|
|
77
|
+
await this._checkDangerousGrant(entry, guild, executor);
|
|
78
|
+
}
|
|
79
|
+
if (type === "guildUpdate" && this.config.watchVanityChanges) {
|
|
80
|
+
await this._checkVanityChange(entry, guild, executor);
|
|
81
|
+
}
|
|
82
|
+
if (type === "inviteCreate" && this.config.watchInviteAbuse) {
|
|
83
|
+
await this._checkInviteAbuse(entry, guild, executor);
|
|
84
|
+
}
|
|
85
|
+
if (type === "webhookCreate" && this.config.webhookAbuse.enabled) {
|
|
86
|
+
this.recentWebhooks.set(
|
|
87
|
+
entry.targetId,
|
|
88
|
+
{ executorId: executor.id, guildId: guild.id },
|
|
89
|
+
this.config.webhookAbuse.watchPeriodMs
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Invite creation doesn't count toward the generic burst threshold below -
|
|
94
|
+
// a legitimate server creates invites routinely, so it's special-cased
|
|
95
|
+
// above (and only escalated for non-staff + unrestricted configs)
|
|
96
|
+
// instead of contributing to a raw count.
|
|
97
|
+
if (type === "inviteCreate") return;
|
|
98
|
+
|
|
99
|
+
const count = this._record(guild.id, executor.id, type);
|
|
100
|
+
const threshold = this.config.thresholds[type];
|
|
101
|
+
if (!threshold || count < threshold) return;
|
|
102
|
+
|
|
103
|
+
this.snapshot(guild); // forensic context captured at the moment of the incident
|
|
104
|
+
|
|
105
|
+
let member = null;
|
|
106
|
+
try {
|
|
107
|
+
member = await guild.members.fetch(executor.id);
|
|
108
|
+
} catch {
|
|
109
|
+
/* executor already left the server */
|
|
110
|
+
}
|
|
111
|
+
if (!member) return;
|
|
112
|
+
|
|
113
|
+
await this.frame.punishmentEngine.report({
|
|
114
|
+
guild,
|
|
115
|
+
member,
|
|
116
|
+
layer: "antiNuke",
|
|
117
|
+
weight: 60,
|
|
118
|
+
reason: `${count}x ${type} inside ${this.config.windowMs / 1000}s`
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** A role gaining Administrator/ManageGuild/etc is worth flagging the moment it happens. */
|
|
123
|
+
async _checkDangerousGrant(entry, guild, executor) {
|
|
124
|
+
const dangerous = this.frame.config.roleAnalysis.dangerousPermissions;
|
|
125
|
+
const change = entry.changes?.find((c) => c.key === "permissions");
|
|
126
|
+
if (!change) return;
|
|
127
|
+
|
|
128
|
+
const newPerms = new PermissionsBitField(BigInt(change.new ?? 0));
|
|
129
|
+
const oldPerms = new PermissionsBitField(BigInt(change.old ?? 0));
|
|
130
|
+
const gained = dangerous.filter(
|
|
131
|
+
(p) => newPerms.has(PermissionsBitField.Flags[p]) && !oldPerms.has(PermissionsBitField.Flags[p])
|
|
132
|
+
);
|
|
133
|
+
if (!gained.length) return;
|
|
134
|
+
|
|
135
|
+
const role = guild.roles.cache.get(entry.targetId);
|
|
136
|
+
|
|
137
|
+
await this.frame.logger.log(guild, {
|
|
138
|
+
level: "alert",
|
|
139
|
+
title: "Dangerous Permission Grant Detected",
|
|
140
|
+
description: `${executor.tag} granted **${gained.join(", ")}** to role ${role ? role.name : entry.targetId}.`,
|
|
141
|
+
fields: [
|
|
142
|
+
{ name: "Auto-revert", value: this.config.autoRevertDangerousGrants ? "enabled" : "disabled - review manually" }
|
|
143
|
+
],
|
|
144
|
+
dedupeKey: `antinuke:grant:${entry.targetId}`
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
if (this.config.autoRevertDangerousGrants && role) {
|
|
148
|
+
try {
|
|
149
|
+
await role.setPermissions(oldPerms, "GlassFrame Protocol: reverting unauthorized dangerous permission grant");
|
|
150
|
+
} catch (err) {
|
|
151
|
+
this.frame.emit("warning", { layer: "antiNuke", message: `auto-revert failed: ${err.message}` });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async _checkVanityChange(entry, guild, executor) {
|
|
157
|
+
const watched = new Set(["name", "icon", "vanityURLCode"]);
|
|
158
|
+
const changed = (entry.changes || []).filter((c) => watched.has(c.key));
|
|
159
|
+
if (!changed.length) return;
|
|
160
|
+
|
|
161
|
+
await this.frame.logger.log(guild, {
|
|
162
|
+
level: "warn",
|
|
163
|
+
title: "Server Identity Changed",
|
|
164
|
+
description: `${executor.tag} changed: ${changed.map((c) => c.key).join(", ")}.`,
|
|
165
|
+
dedupeKey: `antinuke:identity:${executor.id}`
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* An unrestricted invite (no expiry, no use limit) is routine when a real
|
|
171
|
+
* staff member sets up the server's permanent "join us" link - flagging
|
|
172
|
+
* every one of those would just be noise. It's only worth a look when
|
|
173
|
+
* whoever created it isn't recognized as trusted staff.
|
|
174
|
+
*/
|
|
175
|
+
async _checkInviteAbuse(entry, guild, executor) {
|
|
176
|
+
const changes = entry.changes || [];
|
|
177
|
+
const maxAge = changes.find((c) => c.key === "max_age");
|
|
178
|
+
const maxUses = changes.find((c) => c.key === "max_uses");
|
|
179
|
+
const isUnlimitedAge = !maxAge || Number(maxAge.new) === 0;
|
|
180
|
+
const isUnlimitedUses = !maxUses || Number(maxUses.new) === 0;
|
|
181
|
+
if (!isUnlimitedAge || !isUnlimitedUses) return;
|
|
182
|
+
|
|
183
|
+
let member = null;
|
|
184
|
+
try {
|
|
185
|
+
member = await guild.members.fetch(executor.id);
|
|
186
|
+
} catch {
|
|
187
|
+
/* left already */
|
|
188
|
+
}
|
|
189
|
+
const trust = member ? this.frame.roleAnalyzer.trustLevel(member) : "LOW";
|
|
190
|
+
if (trust === "HIGH" || trust === "PROTECTED") return;
|
|
191
|
+
|
|
192
|
+
await this.frame.logger.log(guild, {
|
|
193
|
+
level: "warn",
|
|
194
|
+
title: "Unrestricted Invite Created By Non-Staff Member",
|
|
195
|
+
description: `${executor.tag} (trust: ${trust}) created an invite with no expiry and no use limit.`,
|
|
196
|
+
dedupeKey: `antinuke:invite:${executor.id}`
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Webhook messages never carry a guild member, so BasicSecurityLayer's
|
|
202
|
+
* per-author spam buffer can't see them - a "create webhook, then flood
|
|
203
|
+
* messages through it" nuke would otherwise slip past every other layer.
|
|
204
|
+
* This traces a flood back to whoever created the webhook (recorded in
|
|
205
|
+
* `recentWebhooks` above) and contains it by deleting the webhook itself.
|
|
206
|
+
*/
|
|
207
|
+
async _handleWebhookMessage(message) {
|
|
208
|
+
if (!message.webhookId || !message.guild) return;
|
|
209
|
+
const info = this.recentWebhooks.get(message.webhookId);
|
|
210
|
+
if (!info) return;
|
|
211
|
+
|
|
212
|
+
const cfg = this.config.webhookAbuse;
|
|
213
|
+
const now = Date.now();
|
|
214
|
+
const list = (this.webhookMessageCounts.get(message.webhookId) || []).filter((t) => now - t < cfg.windowMs);
|
|
215
|
+
list.push(now);
|
|
216
|
+
this.webhookMessageCounts.set(message.webhookId, list);
|
|
217
|
+
if (list.length < cfg.messageThreshold) return;
|
|
218
|
+
|
|
219
|
+
this.webhookMessageCounts.delete(message.webhookId); // one alert per burst, not one per message past threshold
|
|
220
|
+
const guild = message.guild;
|
|
221
|
+
|
|
222
|
+
try {
|
|
223
|
+
const webhooks = await guild.fetchWebhooks();
|
|
224
|
+
const hook = webhooks.get(message.webhookId);
|
|
225
|
+
if (hook) await hook.delete("GlassFrame Protocol: webhook message-flood containment");
|
|
226
|
+
} catch (err) {
|
|
227
|
+
this.frame.emit("warning", { layer: "antiNuke", message: `failed to delete abusive webhook: ${err.message}` });
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
await this.frame.logger.log(guild, {
|
|
231
|
+
level: "alert",
|
|
232
|
+
title: "Webhook Message Flood - Webhook Removed",
|
|
233
|
+
description: `A recently created webhook sent ${list.length}+ messages inside ${cfg.windowMs / 1000}s and has been deleted.`,
|
|
234
|
+
dedupeKey: `antinuke:webhook-flood:${message.webhookId}`
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
let member = null;
|
|
238
|
+
try {
|
|
239
|
+
member = await guild.members.fetch(info.executorId);
|
|
240
|
+
} catch {
|
|
241
|
+
/* executor already left */
|
|
242
|
+
}
|
|
243
|
+
if (member) {
|
|
244
|
+
await this.frame.punishmentEngine.report({
|
|
245
|
+
guild,
|
|
246
|
+
member,
|
|
247
|
+
layer: "antiNuke",
|
|
248
|
+
weight: 70,
|
|
249
|
+
reason: `created a webhook that then flooded ${list.length}+ messages inside ${cfg.windowMs / 1000}s`
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Cheap forensic snapshot kept only in memory, purely as context for future alerts. */
|
|
255
|
+
snapshot(guild) {
|
|
256
|
+
const data = {
|
|
257
|
+
takenAt: Date.now(),
|
|
258
|
+
roles: guild.roles.cache.map((r) => ({ id: r.id, name: r.name, position: r.position })),
|
|
259
|
+
channels: guild.channels.cache.map((c) => ({ id: c.id, name: c.name, type: c.type }))
|
|
260
|
+
};
|
|
261
|
+
this.snapshots.set(guild.id, data, this.config.snapshotTtlMs);
|
|
262
|
+
return data;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async _auditRoles(guild) {
|
|
266
|
+
const flags = this.frame.roleAnalyzer.scanRoles(guild);
|
|
267
|
+
if (!flags.length) return;
|
|
268
|
+
|
|
269
|
+
await this.frame.logger.log(guild, {
|
|
270
|
+
level: "warn",
|
|
271
|
+
title: "Role Audit - Possible Impersonation/Permission Mismatch",
|
|
272
|
+
description: flags.map((f) => `**${f.name}** (${f.roleId}) - ${f.reason}`).join("\n"),
|
|
273
|
+
dedupeKey: "antinuke:role-audit"
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
module.exports = AntiNukeLayer;
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const Layer = require("../core/Layer");
|
|
4
|
+
const { suspiciousUsernameEntropy } = require("../utils/nlpEngine");
|
|
5
|
+
|
|
6
|
+
class AntiRaidLayer extends Layer {
|
|
7
|
+
constructor(frame) {
|
|
8
|
+
super("antiRaid", frame);
|
|
9
|
+
this.joinTimestamps = new Map(); // guildId -> timestamps[]
|
|
10
|
+
this.lockedDown = new Map(); // guildId -> unlock timestamp
|
|
11
|
+
this.previousVerification = new Map(); // guildId -> original verification level
|
|
12
|
+
this.joinProfiles = new Map(); // guildId -> [{ at, created }] for cluster correlation
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
onEnable() {
|
|
16
|
+
this._listen(this.client, "guildMemberAdd", (member) => this._handleJoin(member).catch(() => {}));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
onDisable() {
|
|
20
|
+
this.joinTimestamps.clear();
|
|
21
|
+
this.joinProfiles.clear();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
isLockedDown(guildId) {
|
|
25
|
+
const until = this.lockedDown.get(guildId);
|
|
26
|
+
return typeof until === "number" && Date.now() < until;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
_recordJoin(guildId) {
|
|
30
|
+
const now = Date.now();
|
|
31
|
+
const cfg = this.config;
|
|
32
|
+
const list = (this.joinTimestamps.get(guildId) || []).filter((t) => now - t < cfg.joinWindowMs);
|
|
33
|
+
list.push(now);
|
|
34
|
+
this.joinTimestamps.set(guildId, list);
|
|
35
|
+
return list.length;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 0-100 composite: newer account + suspicious entropy + no avatar all stack. */
|
|
39
|
+
_altScore(user) {
|
|
40
|
+
const cfg = this.config;
|
|
41
|
+
let score = 0;
|
|
42
|
+
const age = Date.now() - user.createdTimestamp;
|
|
43
|
+
if (age < cfg.minAccountAgeMs) score += 40 * (1 - age / cfg.minAccountAgeMs);
|
|
44
|
+
|
|
45
|
+
const entropyCheck = suspiciousUsernameEntropy(user.username, cfg.usernameEntropyFloor, cfg.usernameEntropyCeiling);
|
|
46
|
+
if (entropyCheck.suspicious) score += 35;
|
|
47
|
+
if (!user.avatar) score += 15;
|
|
48
|
+
|
|
49
|
+
return { score: Math.round(score), entropyCheck };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Evasion-resistant raid signal: a bulk-registered account farm often
|
|
54
|
+
* trickles members in slowly enough that the raw join-rate counter above
|
|
55
|
+
* never trips, but the accounts themselves were still all created within
|
|
56
|
+
* the same narrow window months (or minutes) ago. Catching that pattern
|
|
57
|
+
* doesn't require a fast join rate at all - just enough joiners, over a
|
|
58
|
+
* longer window, who share a suspiciously similar creation time.
|
|
59
|
+
*/
|
|
60
|
+
_clusterScore(guildId, user) {
|
|
61
|
+
const cfg = this.config.cluster;
|
|
62
|
+
const now = Date.now();
|
|
63
|
+
const list = (this.joinProfiles.get(guildId) || []).filter((p) => now - p.at < cfg.windowMs);
|
|
64
|
+
list.push({ at: now, created: user.createdTimestamp });
|
|
65
|
+
this.joinProfiles.set(guildId, list);
|
|
66
|
+
|
|
67
|
+
if (list.length < cfg.minClusterSize) return { flagged: false, clusterSize: 0 };
|
|
68
|
+
|
|
69
|
+
const closeCreations = list.filter((p) => Math.abs(p.created - user.createdTimestamp) < cfg.creationToleranceMs);
|
|
70
|
+
if (closeCreations.length >= cfg.minClusterSize) {
|
|
71
|
+
return { flagged: true, clusterSize: closeCreations.length };
|
|
72
|
+
}
|
|
73
|
+
return { flagged: false, clusterSize: closeCreations.length };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async _handleJoin(member) {
|
|
77
|
+
const { guild, user } = member;
|
|
78
|
+
this.frame.performance.recordEvent("antiRaid.guildMemberAdd");
|
|
79
|
+
const joinCount = this._recordJoin(guild.id);
|
|
80
|
+
const { score: altScore, entropyCheck } = this._altScore(user);
|
|
81
|
+
const cluster = this._clusterScore(guild.id, user);
|
|
82
|
+
|
|
83
|
+
const reasons = [];
|
|
84
|
+
if (altScore >= this.config.altScoreThreshold) reasons.push(`alt-account score ${altScore}/100`);
|
|
85
|
+
if (entropyCheck.suspicious) reasons.push(`username pattern: ${entropyCheck.reason}`);
|
|
86
|
+
if (cluster.flagged) reasons.push(`part of a ${cluster.clusterSize}-account creation-time cluster`);
|
|
87
|
+
if (this.isLockedDown(guild.id)) reasons.push("joined during active raid lockdown");
|
|
88
|
+
|
|
89
|
+
if ((this.isLockedDown(guild.id) || cluster.flagged) && reasons.length) {
|
|
90
|
+
const weight = reasons.length * 25;
|
|
91
|
+
await this.frame.punishmentEngine.report({ guild, member, layer: "antiRaid", weight, reason: reasons.join("; ") });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (joinCount >= this.config.joinThreshold && !this.isLockedDown(guild.id)) {
|
|
95
|
+
await this._triggerLockdown(guild, joinCount);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async _triggerLockdown(guild, joinCount) {
|
|
100
|
+
const unlockAt = Date.now() + this.config.lockdownDurationMs;
|
|
101
|
+
this.lockedDown.set(guild.id, unlockAt);
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
if (!this.previousVerification.has(guild.id)) {
|
|
105
|
+
this.previousVerification.set(guild.id, guild.verificationLevel);
|
|
106
|
+
}
|
|
107
|
+
await guild.setVerificationLevel(4, "GlassFrame Protocol: anti-raid lockdown");
|
|
108
|
+
} catch (err) {
|
|
109
|
+
this.frame.emit("warning", { layer: "antiRaid", message: `failed to raise verification level: ${err.message}` });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
await this.frame.logger.log(guild, {
|
|
113
|
+
level: "alert",
|
|
114
|
+
title: "Raid Detected - Lockdown Engaged",
|
|
115
|
+
description: `${joinCount} joins inside ${this.config.joinWindowMs / 1000}s. Verification level temporarily raised.`,
|
|
116
|
+
fields: [{ name: "Duration", value: `${this.config.lockdownDurationMs / 60000} min` }],
|
|
117
|
+
dedupeKey: "antiraid:lockdown"
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const timer = setTimeout(() => this._endLockdown(guild), this.config.lockdownDurationMs);
|
|
121
|
+
timer.unref?.();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async _endLockdown(guild) {
|
|
125
|
+
this.lockedDown.delete(guild.id);
|
|
126
|
+
const original = this.previousVerification.get(guild.id);
|
|
127
|
+
try {
|
|
128
|
+
if (original !== undefined) await guild.setVerificationLevel(original, "GlassFrame Protocol: lockdown ended");
|
|
129
|
+
} catch (err) {
|
|
130
|
+
this.frame.emit("warning", { layer: "antiRaid", message: `failed to restore verification level: ${err.message}` });
|
|
131
|
+
} finally {
|
|
132
|
+
this.previousVerification.delete(guild.id);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
await this.frame.logger.log(guild, {
|
|
136
|
+
level: "ok",
|
|
137
|
+
title: "Lockdown Lifted",
|
|
138
|
+
description: "Join activity normalized. Verification level restored.",
|
|
139
|
+
dedupeKey: "antiraid:lockdown-end"
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
module.exports = AntiRaidLayer;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const Layer = require("../core/Layer");
|
|
4
|
+
const { classifyMessage } = require("../utils/nlpEngine");
|
|
5
|
+
|
|
6
|
+
class BasicSecurityLayer extends Layer {
|
|
7
|
+
constructor(frame) {
|
|
8
|
+
super("basicSecurity", frame);
|
|
9
|
+
this.buffers = new Map(); // userId -> { timestamps, lastContent, dupCount }
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
onEnable() {
|
|
13
|
+
this._listen(this.client, "messageCreate", (message) => this._handle(message).catch(() => {}));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
onDisable() {
|
|
17
|
+
this.buffers.clear();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
_bufferFor(userId) {
|
|
21
|
+
if (!this.buffers.has(userId)) {
|
|
22
|
+
this.buffers.set(userId, { timestamps: [], lastContent: "", dupCount: 0 });
|
|
23
|
+
}
|
|
24
|
+
return this.buffers.get(userId);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async _handle(message) {
|
|
28
|
+
if (!message.guild || message.author.bot) return;
|
|
29
|
+
const done = this.frame.performance.time("basicSecurity.messageCreate");
|
|
30
|
+
this.frame.performance.recordEvent("basicSecurity.messageCreate");
|
|
31
|
+
|
|
32
|
+
const cfg = this.config;
|
|
33
|
+
const buf = this._bufferFor(message.author.id);
|
|
34
|
+
const now = Date.now();
|
|
35
|
+
|
|
36
|
+
buf.timestamps = buf.timestamps.filter((t) => now - t < cfg.spam.windowMs);
|
|
37
|
+
buf.timestamps.push(now);
|
|
38
|
+
|
|
39
|
+
if (message.content && message.content === buf.lastContent) buf.dupCount++;
|
|
40
|
+
else {
|
|
41
|
+
buf.dupCount = 1;
|
|
42
|
+
buf.lastContent = message.content;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const mentionCount = message.mentions.users.size + message.mentions.roles.size;
|
|
46
|
+
const classification = classifyMessage(message.content || "");
|
|
47
|
+
const linkFlag = this._checkLinks(message.content || "");
|
|
48
|
+
|
|
49
|
+
const reasons = [];
|
|
50
|
+
let weight = 0;
|
|
51
|
+
|
|
52
|
+
if (buf.timestamps.length >= cfg.spam.messageThreshold) {
|
|
53
|
+
reasons.push(`message rate ${buf.timestamps.length}/${cfg.spam.windowMs / 1000}s`);
|
|
54
|
+
weight += 20;
|
|
55
|
+
}
|
|
56
|
+
if (buf.dupCount >= cfg.spam.duplicateThreshold) {
|
|
57
|
+
reasons.push(`duplicate flood x${buf.dupCount}`);
|
|
58
|
+
weight += 20;
|
|
59
|
+
}
|
|
60
|
+
if (mentionCount >= cfg.mentionSpam.maxMentionsPerMessage) {
|
|
61
|
+
reasons.push(`mention spam (${mentionCount})`);
|
|
62
|
+
weight += 25;
|
|
63
|
+
}
|
|
64
|
+
if (classification.scam >= cfg.nlp.scamScoreThreshold) {
|
|
65
|
+
reasons.push(`scam language (${classification.scam.toFixed(2)})`);
|
|
66
|
+
weight += 35;
|
|
67
|
+
}
|
|
68
|
+
if (classification.phishing >= cfg.nlp.phishingScoreThreshold) {
|
|
69
|
+
reasons.push(`phishing language (${classification.phishing.toFixed(2)})`);
|
|
70
|
+
weight += 35;
|
|
71
|
+
}
|
|
72
|
+
if (linkFlag) {
|
|
73
|
+
reasons.push(linkFlag);
|
|
74
|
+
weight += 30;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (!reasons.length) {
|
|
78
|
+
done();
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
if (message.deletable) await message.delete();
|
|
84
|
+
} catch {
|
|
85
|
+
/* best effort - still report the signal even if delete fails */
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
await this.frame.punishmentEngine.report({
|
|
89
|
+
guild: message.guild,
|
|
90
|
+
member: message.member,
|
|
91
|
+
layer: "basicSecurity",
|
|
92
|
+
weight,
|
|
93
|
+
reason: reasons.join("; ")
|
|
94
|
+
});
|
|
95
|
+
done();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Delegates to the shared PhishingDatabase (server blocklist + fast heuristics). */
|
|
99
|
+
_checkLinks(text) {
|
|
100
|
+
const urls = text.match(/https?:\/\/[^\s]+/gi);
|
|
101
|
+
if (!urls) return null;
|
|
102
|
+
|
|
103
|
+
for (const url of urls) {
|
|
104
|
+
const reason = this.frame.phishingDatabase.check(url);
|
|
105
|
+
if (reason) return reason;
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
module.exports = BasicSecurityLayer;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const {
|
|
4
|
+
ContainerBuilder,
|
|
5
|
+
TextDisplayBuilder,
|
|
6
|
+
SeparatorBuilder,
|
|
7
|
+
SeparatorSpacingSize,
|
|
8
|
+
MessageFlags
|
|
9
|
+
} = require("discord.js");
|
|
10
|
+
|
|
11
|
+
const COLORS = { info: 0x5865f2, warn: 0xf2a65a, alert: 0xed4245, ok: 0x57f287 };
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Builds a Components V2 payload (no legacy EmbedBuilder anywhere in this
|
|
15
|
+
* library). Pass the return value directly to channel.send() / interaction
|
|
16
|
+
* .reply()/.update(). `count` renders a "(xN)" badge next to the title when
|
|
17
|
+
* SmartLogger has merged repeated events together.
|
|
18
|
+
*/
|
|
19
|
+
function buildLogMessage({ level = "info", title, description, fields = [], footer, count }) {
|
|
20
|
+
const container = new ContainerBuilder().setAccentColor(COLORS[level] ?? COLORS.info);
|
|
21
|
+
|
|
22
|
+
const heading = count && count > 1 ? `**${title}** (x${count})` : `**${title}**`;
|
|
23
|
+
container.addTextDisplayComponents(new TextDisplayBuilder().setContent(heading));
|
|
24
|
+
|
|
25
|
+
if (description) {
|
|
26
|
+
container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
|
|
27
|
+
container.addTextDisplayComponents(new TextDisplayBuilder().setContent(description));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (fields.length) {
|
|
31
|
+
container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
|
|
32
|
+
const fieldText = fields.map((f) => `**${f.name}**\n${f.value}`).join("\n\n");
|
|
33
|
+
container.addTextDisplayComponents(new TextDisplayBuilder().setContent(fieldText));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
|
|
37
|
+
container.addTextDisplayComponents(
|
|
38
|
+
new TextDisplayBuilder().setContent(`-# GlassFrame Protocol${footer ? " - " + footer : ""}`)
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
return { flags: MessageFlags.IsComponentsV2, components: [container] };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
module.exports = { buildLogMessage, COLORS };
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { buildLogMessage } = require("./ComponentsV2");
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Every GlassFrame alert goes through one gate that:
|
|
7
|
+
* (a) merges near-duplicate events inside a short window into a single,
|
|
8
|
+
* updated message instead of one message per event, and
|
|
9
|
+
* (b) never sends to a guild's log channel faster than a configured floor,
|
|
10
|
+
* so a burst never turns into a wall of messages or a Discord 429.
|
|
11
|
+
*/
|
|
12
|
+
class SmartLogger {
|
|
13
|
+
constructor(getLogChannel, config, { debug = false } = {}) {
|
|
14
|
+
this.getLogChannel = getLogChannel;
|
|
15
|
+
this.config = config.logging;
|
|
16
|
+
this.debug = debug;
|
|
17
|
+
this.pending = new Map(); // dedupeKey -> { count, payload, sent }
|
|
18
|
+
this.lastSend = new Map(); // guildId -> timestamp
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async log(guild, payload) {
|
|
22
|
+
const key = payload.dedupeKey ? `${guild.id}:${payload.dedupeKey}` : null;
|
|
23
|
+
|
|
24
|
+
if (key && this.pending.has(key)) {
|
|
25
|
+
const entry = this.pending.get(key);
|
|
26
|
+
entry.count += 1;
|
|
27
|
+
entry.payload = payload;
|
|
28
|
+
this._debug(`merge ${key} -> x${entry.count}`);
|
|
29
|
+
return entry.sent;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const entry = { count: 1, payload, sent: null };
|
|
33
|
+
if (key) this.pending.set(key, entry);
|
|
34
|
+
|
|
35
|
+
entry.sent = await this._deliver(guild, payload, key ? 1 : undefined);
|
|
36
|
+
|
|
37
|
+
if (key) {
|
|
38
|
+
const timer = setTimeout(async () => {
|
|
39
|
+
const final = this.pending.get(key);
|
|
40
|
+
this.pending.delete(key);
|
|
41
|
+
if (final && final.count > 1 && final.sent) {
|
|
42
|
+
try {
|
|
43
|
+
const updated = buildLogMessage({ ...final.payload, count: final.count });
|
|
44
|
+
await final.sent.edit(updated);
|
|
45
|
+
this._debug(`final edit ${key} -> x${final.count}`);
|
|
46
|
+
} catch (err) {
|
|
47
|
+
this._debug(`edit failed for ${key}: ${err.message}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}, this.config.aggregationWindowMs);
|
|
51
|
+
timer.unref?.();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return entry.sent;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async _deliver(guild, payload, count) {
|
|
58
|
+
await this._respectRateFloor(guild.id);
|
|
59
|
+
const channel = await this.getLogChannel(guild);
|
|
60
|
+
if (!channel) return null;
|
|
61
|
+
|
|
62
|
+
const message = buildLogMessage({ ...payload, count });
|
|
63
|
+
try {
|
|
64
|
+
const sent = await channel.send(message);
|
|
65
|
+
this.lastSend.set(guild.id, Date.now());
|
|
66
|
+
return sent;
|
|
67
|
+
} catch (err) {
|
|
68
|
+
console.error("[GlassFrame:SmartLogger] failed to send log:", err.message);
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async _respectRateFloor(guildId) {
|
|
74
|
+
const last = this.lastSend.get(guildId) || 0;
|
|
75
|
+
const wait = this.config.minSendIntervalMs - (Date.now() - last);
|
|
76
|
+
if (wait > 0) await new Promise((resolve) => setTimeout(resolve, wait));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
_debug(msg) {
|
|
80
|
+
if (this.debug) console.log(`[GlassFrame:SmartLogger] ${msg}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
module.exports = SmartLogger;
|