Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions nodyx-core/src/migrations/133_octoguard_marks_unique.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
-- OctoGuard 2, lot 10 (D25) : « Faux positif » et « C'était normal » ne se
-- comptent qu'UNE fois par entrée du journal, même sur deux clics simultanés.
-- La garantie est portée par la base (vérifier puis écrire laisse passer deux
-- requêtes concurrentes). Limité aux deux actions NOUVELLES de ce lot : aucune
-- ligne existante ne peut violer l'index, la migration ne peut pas échouer.
CREATE UNIQUE INDEX IF NOT EXISTS uq_admin_audit_log_og_marks
ON admin_audit_log (target_id, action)
WHERE action IN ('octoguard.false_positive', 'octoguard.observed_normal');
168 changes: 153 additions & 15 deletions nodyx-core/src/routes/octoguard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { getInstanceCommunityId } from '../middleware/adminOnly'
import { runSandbox, ruleStats, ruleHits } from '../services/octoguard/sandbox'
import { BASE_PRESETS, BASE_PROFILE_VERSION, startingLevel, exportProfile, previewImport, applyImport, ProfileZ, type Profile } from '../services/octoguard/profile'
import { checkRuleParams } from '../services/octoguard/params'
import { planLearning, ACTED_BY_ENGINE, type LogMeta } from '../services/octoguard/falsePositive'
import { reportContext, heldContext } from '../services/octoguard/context'
import { listPending, countPending, approveHeld, refuseHeld, listMine, forAuthor, HELD_EXPIRY_HOURS } from '../services/octoguard/held'
import { resolveReportTarget, type ReportTargetType as ResolvableTarget } from '../services/reports'
Expand Down Expand Up @@ -707,7 +708,11 @@ export async function octoguardAdminPlugin(app: FastifyInstance) {
`SELECT id, actor_id, actor_username, action, target_type, target_id, target_label,
metadata, created_at::text AS created_at,
EXISTS (SELECT 1 FROM admin_audit_log u
WHERE u.action = 'octoguard.action_undone' AND u.target_id::text = admin_audit_log.id::text) AS undone
WHERE u.action = 'octoguard.action_undone' AND u.target_id::text = admin_audit_log.id::text) AS undone,
EXISTS (SELECT 1 FROM admin_audit_log u
WHERE u.action = 'octoguard.false_positive' AND u.target_id::text = admin_audit_log.id::text) AS false_positive,
EXISTS (SELECT 1 FROM admin_audit_log u
WHERE u.action = 'octoguard.observed_normal' AND u.target_id::text = admin_audit_log.id::text) AS normal
FROM admin_audit_log
${whereClause}
ORDER BY created_at DESC
Expand All @@ -726,31 +731,36 @@ export async function octoguardAdminPlugin(app: FastifyInstance) {
})
})

app.post('/logs/:id/undo', async (request, reply) => {
const { id } = request.params as { id: string }
const adminUser = request.user!
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i

type UndoResult = { ok: true } | { ok: false; status: number; code: string; error: string }
/**
* Annule une action d'OctoGuard (avertissement, sourdine). Partagé par
* « Annuler » et « Faux positif » : une seule logique, testée une fois.
*/
async function undoLogEntry(id: string, actorId: string): Promise<UndoResult> {
const { rows } = await db.query(
`SELECT action, metadata, created_at FROM admin_audit_log WHERE id = $1`, [id]
)
const log = rows[0]
if (!log) return reply.code(404).send({ error: 'Log not found', code: 'NOT_FOUND' })
if (!log) return { ok: false, status: 404, code: 'NOT_FOUND', error: 'Log not found' }

const meta = (log.metadata ?? {}) as { undoable?: boolean; rule_id?: string; undo_op?: { type?: string; mute_id?: string; user_id?: string } }
if (!meta.undoable || !meta.undo_op) {
return reply.code(400).send({ error: 'Action not undoable', code: 'NOT_UNDOABLE' })
return { ok: false, status: 400, code: 'NOT_UNDOABLE', error: 'Action not undoable' }
}

// Une action ne s'annule qu'une fois : sinon, un double clic effaçait un
// SECOND avertissement, qui n'avait rien à voir.
const { rows: done } = await db.query(
`SELECT 1 FROM admin_audit_log WHERE action = 'octoguard.action_undone' AND target_id::text = $1 LIMIT 1`, [id])
if (done.length) return reply.code(409).send({ error: 'Already undone', code: 'ALREADY_UNDONE' })
if (done.length) return { ok: false, status: 409, code: 'ALREADY_UNDONE', error: 'Already undone' }

if (meta.undo_op.type === 'remove_mute' && meta.undo_op.mute_id) {
const ok = await removeMute(meta.undo_op.mute_id, adminUser.userId)
if (!ok) return reply.code(409).send({ error: 'Nothing left to undo (mute already lifted)', code: 'NOTHING_TO_UNDO' })
await logAdminAction(adminUser.userId, 'octoguard.action_undone', 'log', id, log.action, { undo_op: meta.undo_op, success: true })
return reply.send({ ok: true })
const ok = await removeMute(meta.undo_op.mute_id, actorId)
if (!ok) return { ok: false, status: 409, code: 'NOTHING_TO_UNDO', error: 'Nothing left to undo (mute already lifted)' }
await logAdminAction(actorId, 'octoguard.action_undone', 'log', id, log.action, { undo_op: meta.undo_op, success: true })
return { ok: true }
}
if (meta.undo_op.type === 'clear_last_warn' && meta.undo_op.user_id) {
// PostgreSQL n'accepte ni ORDER BY ni LIMIT dans un UPDATE : l'ancienne
Expand All @@ -765,11 +775,139 @@ export async function octoguardAdminPlugin(app: FastifyInstance) {
RETURNING id`,
[meta.undo_op.user_id, meta.rule_id ?? null, log.created_at]
)
if (cleared.length === 0) return reply.code(409).send({ error: 'Nothing left to undo (warning already cleared or expired)', code: 'NOTHING_TO_UNDO' })
await logAdminAction(adminUser.userId, 'octoguard.action_undone', 'log', id, log.action, { undo_op: meta.undo_op, warn_id: cleared[0].id })
return reply.send({ ok: true })
if (cleared.length === 0) return { ok: false, status: 409, code: 'NOTHING_TO_UNDO', error: 'Nothing left to undo (warning already cleared or expired)' }
await logAdminAction(actorId, 'octoguard.action_undone', 'log', id, log.action, { undo_op: meta.undo_op, warn_id: cleared[0].id })
return { ok: true }
}
return { ok: false, status: 400, code: 'UNSUPPORTED', error: 'Undo type not implemented' }
}

app.post('/logs/:id/undo', { preHandler: [rateLimit] }, async (request, reply) => {
const { id } = request.params as { id: string }
// Avant : un identifiant mal formé partait tel quel dans la requête (erreur 500)
if (!UUID_RE.test(id)) return reply.code(400).send({ error: 'Invalid id', code: 'INVALID_REQUEST' })
const r = await undoLogEntry(id, request.user!.userId)
return r.ok ? reply.send({ ok: true }) : reply.code(r.status).send({ error: r.error, code: r.code })
})

// ─── Lot 10 (D25) : « Faux positif » et « C'était normal » ───────────────

/**
* Pose la marque (« faux positif » ou « c'était normal ») AVANT toute autre
* écriture : c'est elle qui décide. L'index unique de la migration 133 garantit
* qu'un second clic, même simultané, ne passe pas. null = déjà marqué.
*/
async function claimMark(action: 'octoguard.false_positive' | 'octoguard.observed_normal', logId: string, actorId: string, label: string, meta: Record<string, unknown>): Promise<string | null> {
const { rows } = await db.query<{ id: string }>(
`INSERT INTO admin_audit_log (actor_id, actor_username, action, target_type, target_id, target_label, metadata)
SELECT $1, COALESCE((SELECT username FROM users WHERE id = $1), 'unknown'), $2, 'log', $3, $4, $5::jsonb
ON CONFLICT DO NOTHING
RETURNING id`,
[actorId, action, logId, label, JSON.stringify(meta)]
)
return rows[0]?.id ?? null
}

/** Lit une entrée du journal d'OctoGuard et ce qu'on sait déjà d'elle. */
async function readEntry(id: string) {
const { rows } = await db.query(
`SELECT l.id, l.action, l.metadata, l.target_label,
EXISTS (SELECT 1 FROM admin_audit_log u WHERE u.action = 'octoguard.action_undone' AND u.target_id::text = l.id::text) AS undone,
EXISTS (SELECT 1 FROM admin_audit_log u WHERE u.action = 'octoguard.false_positive' AND u.target_id::text = l.id::text) AS false_positive,
EXISTS (SELECT 1 FROM admin_audit_log u WHERE u.action = 'octoguard.observed_normal' AND u.target_id::text = l.id::text) AS normal
FROM admin_audit_log l WHERE l.id = $1`, [id])
return rows[0] as { id: string; action: string; metadata: LogMeta | null; target_label: string | null; undone: boolean; false_positive: boolean; normal: boolean } | undefined
}
async function ruleOf(meta: LogMeta | null) {
if (!meta?.rule_id || !UUID_RE.test(meta.rule_id)) return null
const { rows } = await db.query(`SELECT id, name, type, params FROM octoguard_automod_rules WHERE id = $1`, [meta.rule_id])
return (rows[0] ?? null) as { id: string; name: string; type: string; params: { words?: string[]; exceptions?: string[] } } | null
}

// Ce que « Faux positif » va faire, AVANT que l'admin valide (rien n'est écrit)
app.get('/logs/:id/false-positive', async (request, reply) => {
const { id } = request.params as { id: string }
if (!UUID_RE.test(id)) return reply.code(400).send({ error: 'Invalid id', code: 'INVALID_REQUEST' })
const e = await readEntry(id)
if (!e) return reply.code(404).send({ error: 'Log not found', code: 'NOT_FOUND' })
if (!ACTED_BY_ENGINE.has(e.action) || e.metadata?.actor !== 'octoguard:auto') {
return reply.code(400).send({ error: 'Only an action taken by OctoGuard can be a false positive', code: 'NOT_ENGINE_ACTION' })
}
const rule = await ruleOf(e.metadata)
return reply.send({
already: e.false_positive,
will_undo: !!e.metadata?.undoable && !e.undone,
rule: rule ? { id: rule.id, name: rule.name } : null,
plan: planLearning(e.metadata ?? {}, rule),
})
})

app.post('/logs/:id/false-positive', {
preHandler: [rateLimit, validate({ body: z.object({ learn: z.boolean().default(false) }) })],
}, async (request, reply) => {
const { id } = request.params as { id: string }
if (!UUID_RE.test(id)) return reply.code(400).send({ error: 'Invalid id', code: 'INVALID_REQUEST' })
const { learn } = request.body as { learn: boolean }
const actorId = request.user!.userId
const e = await readEntry(id)
if (!e) return reply.code(404).send({ error: 'Log not found', code: 'NOT_FOUND' })
if (!ACTED_BY_ENGINE.has(e.action) || e.metadata?.actor !== 'octoguard:auto') {
return reply.code(400).send({ error: 'Only an action taken by OctoGuard can be a false positive', code: 'NOT_ENGINE_ACTION' })
}
if (e.false_positive) return reply.code(409).send({ error: 'Already marked', code: 'ALREADY_MARKED' })
const markId = await claimMark('octoguard.false_positive', id, actorId, e.action, { rule_id: e.metadata?.rule_id ?? null })
if (!markId) return reply.code(409).send({ error: 'Already marked', code: 'ALREADY_MARKED' })

// 1. Annuler ce qui peut l'être (avertissement, sourdine). Un refus de
// l'annulation (déjà levée) n'empêche pas de compter le faux positif.
let undone = false
if (e.metadata?.undoable && !e.undone) {
const u = await undoLogEntry(id, actorId)
undone = u.ok
}

// 2. Apprendre, seulement si l'admin l'a demandé ET que le cas est sûr
// (plan recalculé ici : jamais sur la foi du navigateur).
const rule = await ruleOf(e.metadata)
const plan = planLearning(e.metadata ?? {}, rule)
let learned: string | null = null
if (learn && plan.learn && rule) {
// Ajout atomique, sans doublon, plafonné comme l'éditeur (500 exceptions)
const { rows } = await db.query(
`UPDATE octoguard_automod_rules
SET params = jsonb_set(params, '{exceptions}',
COALESCE(params->'exceptions', '[]'::jsonb) || to_jsonb($2::text)),
customized = true, updated_at = NOW()
WHERE id = $1 AND type = 'keywords'
AND NOT (COALESCE(params->'exceptions', '[]'::jsonb) ? $2::text)
AND jsonb_array_length(COALESCE(params->'exceptions', '[]'::jsonb)) < 500
RETURNING id`,
[rule.id, plan.token]
)
if (rows.length) { learned = plan.token; await reloadRules() }
}

// 3. Compléter la marque (compte pour « Justesse ») avec ce qui a été fait
await db.query(
`UPDATE admin_audit_log SET metadata = metadata || $2::jsonb WHERE id = $1`,
[markId, JSON.stringify({ undone, learned })]
)
return reply.send({ ok: true, undone, learned })
})

// « C'était normal » : une entrée d'observation était une fausse alerte
app.post('/logs/:id/normal', { preHandler: [rateLimit] }, async (request, reply) => {
const { id } = request.params as { id: string }
if (!UUID_RE.test(id)) return reply.code(400).send({ error: 'Invalid id', code: 'INVALID_REQUEST' })
const e = await readEntry(id)
if (!e) return reply.code(404).send({ error: 'Log not found', code: 'NOT_FOUND' })
if (!e.action.startsWith('octoguard.would_')) {
return reply.code(400).send({ error: 'Only an observation can be marked normal', code: 'NOT_OBSERVATION' })
}
return reply.code(400).send({ error: 'Undo type not implemented', code: 'UNSUPPORTED' })
if (e.normal) return reply.code(409).send({ error: 'Already marked', code: 'ALREADY_MARKED' })
const markId = await claimMark('octoguard.observed_normal', id, request.user!.userId, e.action, { rule_id: e.metadata?.rule_id ?? null })
if (!markId) return reply.code(409).send({ error: 'Already marked', code: 'ALREADY_MARKED' })
return reply.send({ ok: true })
})

// ─── Webhook config (Module 5) ────────────────────────────────────────────
Expand Down
57 changes: 57 additions & 0 deletions nodyx-core/src/services/octoguard/falsePositive.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/**
* OctoGuard 2, lot 10 (CDC E4h, D25) : « Faux positif » et « C'était normal ».
*
* Règle d'or : un faux positif n'affaiblit JAMAIS une règle en silence.
* On n'apprend qu'un seul cas sûr : une règle de mots interdits a attrapé un
* mot qui CONTIENT un motif partiel sans être le motif lui-même (« contenu »
* attrapé par « *con* »). Ce mot-là devient une exception, affichée à l'admin
* avant qu'il valide. Tout le reste est seulement compté.
*/

import { normalize } from './matchers2'

/** Ce que le journal a retenu d'un déclenchement (metadata d'admin_audit_log). */
export interface LogMeta {
rule_id?: string
actor?: string
would_be?: boolean
undoable?: boolean
found?: { token?: unknown; pattern?: unknown; kind?: unknown }
}

/** Les actions d'OctoGuard qu'on peut marquer « faux positif ». */
export const ACTED_BY_ENGINE = new Set([
'octoguard.delete_message', 'octoguard.warn_user', 'octoguard.mute_user', 'octoguard.ban_temp_user',
'octoguard.report_filed', 'octoguard.notify', 'octoguard.message_held',
])

export type NoLearnReason = 'not_keywords' | 'no_token' | 'exact_word' | 'already_excepted' | 'rule_gone'

export type LearnPlan =
| { learn: true; token: string; pattern: string }
| { learn: false; reason: NoLearnReason }

const clean = (s: string) => normalize(s).replace(/[^\p{L}]/gu, '')

/**
* Le mot qu'on PEUT apprendre, ou la raison pour laquelle on ne le fait pas.
* Fonction pure : testée sans base de données.
*/
export function planLearning(meta: LogMeta, rule: { type: string; params: { words?: string[]; exceptions?: string[] } } | null): LearnPlan {
if (!rule) return { learn: false, reason: 'rule_gone' }
if (rule.type !== 'keywords') return { learn: false, reason: 'not_keywords' }
const f = meta.found
if (!f || typeof f.token !== 'string' || typeof f.pattern !== 'string' || !f.token) return { learn: false, reason: 'no_token' }
const token = clean(f.token)
if (token.length < 2) return { learn: false, reason: 'no_token' }
// Le mot est-il LUI-MÊME interdit ? (mot entier listé, ou motif égal au mot)
const words = rule.params.words ?? []
const exactlyBanned = words.some(w => {
const v = clean(w.replace(/^\*|\*$/g, ''))
return v === token
})
if (f.kind === 'word' || exactlyBanned) return { learn: false, reason: 'exact_word' }
const exceptions = new Set((rule.params.exceptions ?? []).map(clean))
if (exceptions.has(token)) return { learn: false, reason: 'already_excepted' }
return { learn: true, token, pattern: f.pattern.slice(0, 64) }
}
4 changes: 4 additions & 0 deletions nodyx-core/src/services/octoguard/matchers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,10 @@ export interface MatchContext {
export interface MatchResult {
/** Extrait pertinent pour le log/UI admin (max ~120 chars). */
excerpt: string
/** Mots interdits : le mot du message qui a déclenché, et le motif qui l'a
* attrapé. Sert à « Faux positif » (D25) : on ne propose d'apprendre un mot
* que s'il a été attrapé par un motif partiel sans être le motif lui-même. */
found?: { token: string; pattern: string; kind: 'word' | 'prefix' | 'contains' }
}

export type Matcher = (
Expand Down
10 changes: 5 additions & 5 deletions nodyx-core/src/services/octoguard/matchers2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,11 @@ export function matchKeywords(ctx: MatchContext, params: AutomodRuleParams): Mat
// Jamais de recollage entre deux vrais mots (« bacon nard » ne doit pas
// donner « connard ») : seuls les mots épelés lettre à lettre sont recollés.
for (const p of patterns) {
const hit =
p.kind === 'word' ? tokens.includes(p.value) :
p.kind === 'prefix' ? tokens.some(t => t.startsWith(p.value)) :
/* contains */ tokens.some(t => t.includes(p.value))
if (hit) return { excerpt: `mot interdit : ${p.raw}` }
const token =
p.kind === 'word' ? tokens.find(t => t === p.value) :
p.kind === 'prefix' ? tokens.find(t => t.startsWith(p.value)) :
/* contains */ tokens.find(t => t.includes(p.value))
if (token !== undefined) return { excerpt: `mot interdit : ${p.raw}`, found: { token: token.slice(0, 64), pattern: p.raw, kind: p.kind } }
}
return null
}
Expand Down
6 changes: 5 additions & 1 deletion nodyx-core/src/services/octoguard/pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { randomUUID } from 'crypto'
import { db } from '../../config/database'
import { isOctoGuardEnabled } from './index'
import { getRules } from './cache'
import { matchRule } from './matchers'
import { matchRule, type MatchResult } from './matchers'
import { recordActivity } from './matchers2'
import { logOctoGuardAction } from './logger'
import { applyMute } from './mutes'
Expand Down Expand Up @@ -130,6 +130,7 @@ async function applyAction(
excerpt: string,
eventId: string,
dryRun: boolean,
found?: MatchResult['found'],
): Promise<ActionOutcome> {
const action: AutomodAction = rule.action
const meta = {
Expand All @@ -138,6 +139,8 @@ async function applyAction(
event_id: eventId,
actor: 'octoguard:auto' as const,
would_be: dryRun,
// Mots interdits : le mot trouvé et le motif (D25, « Faux positif »)
...(found ? { found } : {}),
}

// Cran « observer » (dry_run) : on note ce qui AURAIT été fait, rien ne se passe
Expand Down Expand Up @@ -326,6 +329,7 @@ async function runPipelineInner(input: PipelineInput): Promise<PipelineResult> {
match.excerpt,
eventId,
rule.dry_run,
match.found,
)

return {
Expand Down
Loading
Loading