Skip to content

Commit 88e7602

Browse files
VortexUKclaude
andcommitted
v0.1.15: tamper detection + audit channel + assembly metadata
Rename detection (E1001) — heuristic check that title matches an enemy combatant; trips on ACT's right-click → Rename Encounter. Blocks the leaderboard upload and POSTs the suspect payload to a new /api/parses/tamper-report endpoint with the X-Lexicon-Tamper-Reason: title_enemy_mismatch header so the admin has an audit trail. User-visible code stays opaque (E1001) so the heuristic isn't trivially game-able. Import detection (E1002) — two-tier signal anchored in a side-by-side dump-and-compare investigation (live capture vs imported encounter): * stale_encounter — (Now - EndTime) > 1h. Live captures end seconds ago; imports end hours-to-days ago. Four orders of magnitude apart, gate is wide-margin-safe. * recent_import_activity — ImportActivityWatcher polls ActGlobals.oFormActMain.ActiveControl every tick; any control whose type-name or Name contains "Import" stamps a timestamp. Encids first-seen within 30s of that timestamp ⇒ tamper. Direct evidence of intent — user is in ACT's import UI. Both signals block + tamper-report via the same channel as rename detection. Pure ImportControlClassifier predicate lives in Core so the rule is unit-testable; the WinForms-coupled watcher is a thin delegating wrapper. folder_hint_mismatch — soft client_warnings entry on normal uploads when HistoryRecord.FolderHint diverges from the detected logger_server. Rides along with the parse rather than blocking. Status text refresh — "✦ EQ2 Lexicon · watching" for ready, "◇ EQ2 Lexicon · {reason}" for paused (no config / no token / disabled / blacklisted character). Assembly metadata — Company=VortexUK, Copyright surfaced in the Win32 version info block. Both csproj files mirror in lockstep so the standalone Core.dll carries the same attribution. 287/287 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ffd05d9 commit 88e7602

14 files changed

Lines changed: 1227 additions & 61 deletions

CLAUDE.md

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,72 @@ When absent, the plugin uses a built-in default of `["Varsoon", "Wuoshi"]` (the
155155

156156
Future enforcement: the server should *also* validate `logger_server` on incoming uploads against the same list and reject mismatched uploads with a 403. The plugin's display is courtesy/transparency; the real gate is the server.
157157

158+
### `client_warnings` on `/api/parses/ingest`
159+
160+
Accept an optional `client_warnings: [string, ...]` field on incoming upload payloads. The plugin attaches it when it spots something that *looks* suspect but isn't worth blocking on its own — currently:
161+
162+
- `"folder_hint_mismatch"``EncounterData.HistoryRecord.FolderHint` (ACT's per-encounter folder-of-origin tag) doesn't match the EQ2 server name derived from the active log path. Typical cause: user manually moved a downloaded log into `<install>/logs/<server>/` and pointed ACT at it. The log path now looks legitimate but ACT remembers the original folder. Not by itself proof of tampering (a user could be archiving + restoring their own logs) — but worth surfacing in admin tooling so a clustered pattern across uploads is visible.
163+
164+
Server behaviour: persist the warnings alongside the parse, expose them in admin views, don't reject. Empty / absent field means no warnings — old plugin versions never emit this key.
165+
166+
Note: rename detection (title doesn't match any enemy combatant) is intentionally NOT a `client_warnings` flag — it's a separate hard block + audit POST (see [Rename-detection](#rename-detection-v0115) below). `client_warnings` only carries soft warnings that ride along with successful leaderboard uploads.
167+
168+
### `/api/parses/tamper-report` (POST)
169+
170+
Audit endpoint for parses the plugin refused to send to the leaderboard. Accepts the same body shape as `/api/parses/ingest` (so server-side admin tooling reuses the existing parse-row reader) + two extra headers:
171+
172+
- `Authorization: Bearer <token>` — same auth as ingest
173+
- `X-Lexicon-Tamper-Reason: <code>` — single reason code identifying which heuristic tripped (see codes below)
174+
- `X-Lexicon-Signature: <hmac>` — same HMAC scheme as ingest (HMAC-SHA256 of body keyed by API token)
175+
176+
**Reason codes** emitted by the plugin:
177+
178+
| Reason | What tripped | User-visible code |
179+
|---|---|---|
180+
| `title_enemy_mismatch` | Encounter title doesn't match any enemy combatant — heuristic for ACT's right-click → Rename Encounter | `E1001` |
181+
| `stale_encounter` | `(DateTime.Now - EndTime).TotalHours > 1` — fight ended hours-to-days ago, almost certainly an imported log file | `E1002` |
182+
| `recent_import_activity` | `ActGlobals.oFormActMain.ActiveControl` was an import-related control (name/type contains "Import") within the last 30 seconds | `E1002` |
183+
184+
The auto path's pre-startup skip (encounter started >5min before plugin enabled) silently fires `stale_encounter` / `recent_import_activity` tamper reports alongside the existing skip, when applicable. A pre-startup encounter that's NOT stale and not flagged by the import watcher is treated as benign mid-session content — no tamper, just a quiet skip.
185+
186+
Server behaviour: persist the payload to a separate audit table — **must not** appear on public leaderboards. Surface in admin views so the maintainer can see attempted uploads. Until the endpoint is deployed, the plugin treats `404` / `405` / `501` as success (opportunistic rollout — same pattern as `is_admin` and the HMAC header in v0.1.8).
187+
188+
## Rename-detection (v0.1.15+)
189+
190+
`EncounterTitle.MatchesAnEnemy(title, enemyNames)` — heuristic check fired by both the polling path (`EncounterCapture.ProcessEncounter`) and the manual right-click path (`Plugin.OnManualUploadRequested`). ACT's "Rename Encounter" mutates `EncounterData.Title` with **no audit trail** (no `OldTitle`, no `Tags` marker, no `HistoryRecord` original — confirmed empirically via reflection dump). The only signal we have that a title was hand-edited is that an auto-derived title always matches an actual enemy combatant; a rename usually doesn't.
191+
192+
When the title doesn't match any enemy, the plugin does TWO things:
193+
194+
1. **Block the leaderboard upload.** `OnSkipped` fires with the opaque code `E1001` (rather than the literal "title doesn't match any enemy") so a user who knows the heuristic can't trivially game it by renaming to one of the combatants' names. The maintainer recognises the code; users generally don't and will ask, which is the desired flow.
195+
2. **Fire the tamper-report POST.** The same payload that *would* have been uploaded to `/ingest` is POSTed to `/api/parses/tamper-report` instead — with the `X-Lexicon-Tamper-Reason: title_enemy_mismatch` header. Server stores it in a dedicated audit table; admin reviews. Fire-and-forget — a network failure or missing endpoint doesn't surface to the user (silent collection beats noisy failure when the user might be the cheater).
196+
197+
Earlier v0.1.15-dev iterations either hard-skipped without notifying the server (no evidence trail), or sent the renamed parse through to leaderboards as a soft `client_warnings` entry (polluted the leaderboards). The two-channel design — hard block + parallel audit POST — keeps the leaderboards clean while preserving the evidence.
198+
199+
Wiring: `EncounterCapture` raises `OnTamperDetected(TamperEvent)` carrying the serialised payload + reason; `Plugin.OnTamperDetected` fires `UploadClient.ReportTamperAsync` on a worker. The manual path in `Plugin.OnManualUploadRequested` does both inline (no event needed — Plugin already holds the UploadClient). Enemy enumeration lives on `EncounterSnapshot.EnumerateEnemyNames()` (Core) — `Combatants` minus `AllyNames` — so the heuristic stays unit-testable without ACT.
200+
201+
## Import-detection (v0.1.15+)
202+
203+
Two-tier detection for "user imported a saved log file rather than playing live", derived from a dump-and-compare investigation (live encounter side-by-side with an imported one — see commit history). The signal that *did* discriminate: every imported encounter had `(Now - EndTime) ≈ 6 days`, every live capture had `(Now - EndTime) ≈ 16 seconds`. Four orders of magnitude apart, so the gate is wide-margin-safe.
204+
205+
**Tier 1 — wall-clock staleness.** `(DateTime.Now - enc.EndTime).TotalHours > 1``stale_encounter`. Catches every realistic import in one cheap check. Survives the edge case the previous attempts didn't — the user is actively playing EQ2 (so the log file mtime is fresh) AND importing at the same time (so the FolderHint matches the live server directory). The encounter's own EndTime, by contrast, is fixed in the imported log and can't be hidden.
206+
207+
**Tier 2 — import-control watcher.** [src/ImportActivityWatcher.cs](src/ImportActivityWatcher.cs) probes `ActGlobals.oFormActMain.ActiveControl` every poll tick. Any control whose type-name OR `Name` property contains "Import" (substring, case-insensitive) stamps a timestamp. An encid first-seen within 30 seconds of that timestamp ⇒ `recent_import_activity`. Direct evidence of intent — the user is literally in ACT's import UI. Catches the case where someone imports a log from earlier today (so Tier 1 might not trip).
208+
209+
`EncounterCapture.ClassifyImportTamper(enc, now)` checks Tier 1 first, then Tier 2; first match wins. Returns the reason string or null.
210+
211+
**Where it fires**:
212+
- **Polling pre-startup branch** — when an encounter is skipped for being older than `InstanceStartGraceSeconds`, ALSO call `ClassifyImportTamper`. If it returns a reason, fire the tamper report alongside the skip. A non-tampered pre-startup encounter (e.g. user enabled plugin 2 minutes into a fight) skips silently as before.
213+
- **Polling post-startup tamper gate** — between the pre-startup branch and the placeholder check, runs the same classifier. Catches the corner case where the plugin has been running and the user imports something that lands with a fresh-looking StartTime. Blocks + tamper-reports + emits user code `E1002`.
214+
- **Manual upload path** in `Plugin.OnManualUploadRequested` — runs the classifier inline. Stale or import-activity ⇒ block with `E1002`; rename ⇒ block with `E1001`. Priority order: stale > recent-import > rename. Only one tamper report per blocked manual upload.
215+
216+
**What the investigation also showed wouldn't work as the discriminator** (each defeated in the realistic concurrent-EQ2-running scenario):
217+
- Log file `LastWriteTimeUtc` staleness — fails because EQ2 is actively writing while user imports.
218+
- `HistoryRecord.FolderHint` mismatch vs `logger_server` — fails because import source was the EQ2 server log dir; both say "Varsoon".
219+
- Burst detection (>1 encid per tick) — only catches multi-encounter imports, useful as corroboration only.
220+
- `HistoryRecord.Type` — same value for live and imported in the data we sampled; not a discriminator.
221+
222+
These are still in the code as corroborating signals (folder_hint_mismatch still rides along as a soft `client_warnings` flag, burst detection is dead code we should clean up next pass) but they're not on the critical path.
223+
158224
## Update awareness (v0.1.8+)
159225

160226
The plugin fetches `https://api.github.com/repos/VortexUK/EQ2LexiconACTPlugin/releases` once per ACT session, compares the assembly version to the published tags, and:

src/Core/EQ2Lexicon.ACTPlugin.Core.csproj

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,10 @@
1919
version tracks the user-facing plugin version.
2020
-->
2121
<Version>0.1.15</Version>
22-
<Company>EQ2 Lexicon</Company>
22+
<Company>VortexUK</Company>
2323
<Product>EQ2 Lexicon Uploader (Core)</Product>
2424
<Description>Pure payload / config / HTTP layer shared by EQ2Lexicon.ACTPlugin.</Description>
25+
<Copyright>Copyright © 2026 VortexUK</Copyright>
2526
</PropertyGroup>
2627

2728
<ItemGroup>
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
using System;
2+
3+
namespace EQ2Lexicon.ACTPlugin
4+
{
5+
/// <summary>
6+
/// Pure predicate that decides whether a WinForms control "looks
7+
/// like" ACT's import UI based on its runtime type name and
8+
/// <c>Name</c> property. Lives in Core (no WinForms reference) so
9+
/// the rule is unit-testable without spinning up real ACT controls.
10+
///
11+
/// <see cref="ImportActivityWatcher"/> (UI assembly) calls in here
12+
/// after extracting the type-name + control-name strings from the
13+
/// real <c>Control</c> reference.
14+
///
15+
/// The empirical anchor: the diagnostic-dump investigation captured
16+
/// <c>ActiveControl = &lt;IO_ImportLog&gt;</c> at every import burst
17+
/// — a custom ACT control type whose name starts with "IO_Import".
18+
/// The substring rule generalises to any future import-related
19+
/// control ACT might add. The case where this MUST NOT trip
20+
/// false-positive is the <c>tvDG</c> TreeView, which sometimes
21+
/// has an "Import/Merge" node visible in its first row — neither
22+
/// the type name ("TreeView") nor the control name ("tvDG")
23+
/// contains "Import", so we stay quiet.
24+
/// </summary>
25+
public static class ImportControlClassifier
26+
{
27+
/// <summary>
28+
/// True when either <paramref name="typeName"/> or
29+
/// <paramref name="controlName"/> contains the substring
30+
/// "Import" (case-insensitive). Null / empty inputs are
31+
/// treated as "no signal" — caller treats false as "no
32+
/// recent import activity".
33+
/// </summary>
34+
public static bool IsImportControl(string? typeName, string? controlName)
35+
{
36+
if (!string.IsNullOrEmpty(typeName) &&
37+
typeName.IndexOf("Import", StringComparison.OrdinalIgnoreCase) >= 0)
38+
{
39+
return true;
40+
}
41+
if (!string.IsNullOrEmpty(controlName) &&
42+
controlName.IndexOf("Import", StringComparison.OrdinalIgnoreCase) >= 0)
43+
{
44+
return true;
45+
}
46+
return false;
47+
}
48+
}
49+
}

src/Core/PayloadBuilder.cs

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ public static class PayloadBuilder
5050
string loggerServer,
5151
EncounterSnapshot enc)
5252
{
53-
return new Dictionary<string, object?>
53+
var payload = new Dictionary<string, object?>
5454
{
5555
["logger_name"] = loggerName,
5656
["logger_server"] = loggerServer ?? "",
@@ -59,6 +59,66 @@ public static class PayloadBuilder
5959
["damage_types"] = BuildDamageTypes(enc),
6060
["attack_types"] = BuildAttackTypes(enc),
6161
};
62+
63+
var warnings = ComputeClientWarnings(loggerServer, enc);
64+
if (warnings.Count > 0)
65+
{
66+
payload["client_warnings"] = warnings;
67+
}
68+
return payload;
69+
}
70+
71+
/// <summary>
72+
/// Build the list of soft warnings the client wants the server
73+
/// to record alongside this parse. These don't block the
74+
/// upload — they're "flags on the play" the maintainer can
75+
/// surface in admin tooling later.
76+
///
77+
/// Currently emits:
78+
/// * <c>folder_hint_mismatch</c> — ACT's
79+
/// <c>HistoryRecord.FolderHint</c> for this encounter
80+
/// doesn't match the active log path's server name.
81+
/// Typically means the user dropped a downloaded log into
82+
/// the EQ2 logs directory: <c>logger_server</c> is now
83+
/// legit, but the encounter remembers it originated
84+
/// elsewhere. Not by itself proof of tampering — a user
85+
/// could legitimately archive + restore logs — but it
86+
/// deserves the flag.
87+
///
88+
/// Rename detection (title doesn't match any enemy) is
89+
/// deliberately NOT in this list — it's a hard block, not a
90+
/// soft warning. The polling path raises
91+
/// <see cref="EncounterCapture.OnTamperDetected"/> and Plugin
92+
/// fires <see cref="UploadClient.ReportTamperAsync"/>, which
93+
/// POSTs the suspect payload to a dedicated tamper-report
94+
/// endpoint so the admin sees the attempted upload. The
95+
/// regular leaderboard ingest is skipped entirely.
96+
///
97+
/// Empty list returned when nothing's amiss (the caller then
98+
/// omits the <c>client_warnings</c> field entirely so old
99+
/// server versions don't see an unknown key).
100+
/// </summary>
101+
public static List<string> ComputeClientWarnings(
102+
string? loggerServer,
103+
EncounterSnapshot enc)
104+
{
105+
var warnings = new List<string>();
106+
if (enc == null) return warnings;
107+
108+
// FolderHint check: both sides must be non-empty for the
109+
// comparison to mean anything. Empty FolderHint means
110+
// ACT didn't tag this encounter with a folder (older ACT
111+
// builds, or our reflection read failed) — no signal, no
112+
// warning. Empty loggerServer means we couldn't detect a
113+
// server — same story.
114+
if (!string.IsNullOrWhiteSpace(enc.FolderHint) &&
115+
!string.IsNullOrWhiteSpace(loggerServer) &&
116+
!string.Equals(enc.FolderHint.Trim(), loggerServer.Trim(), StringComparison.OrdinalIgnoreCase))
117+
{
118+
warnings.Add("folder_hint_mismatch");
119+
}
120+
121+
return warnings;
62122
}
63123

64124
public static Dictionary<string, object?> BuildEncounter(EncounterSnapshot enc)

src/Core/Snapshots.cs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,49 @@ public sealed class EncounterSnapshot
3434
/// </summary>
3535
public HashSet<string> AllyNames { get; set; }
3636
= new HashSet<string>(StringComparer.OrdinalIgnoreCase);
37+
38+
/// <summary>
39+
/// ACT's per-encounter <c>HistoryRecord.FolderHint</c> — the
40+
/// folder name ACT remembered as the origin of the log lines
41+
/// that built this encounter. For live tailing it's the EQ2
42+
/// per-server log directory (e.g. "Varsoon"); for an imported
43+
/// log it's wherever the user dragged it from (e.g. "Downloads").
44+
///
45+
/// Captured here so <c>PayloadBuilder.BuildPayload</c> can
46+
/// cross-reference it against the detected
47+
/// <c>logger_server</c> and emit a soft <c>client_warnings</c>
48+
/// entry when they disagree — the "log file was moved into the
49+
/// EQ2 logs dir but originally came from somewhere else" case
50+
/// that the Import/Merge zone check doesn't catch.
51+
///
52+
/// Empty string when the field couldn't be read (ACT version
53+
/// without HistoryRecord, reflection failure, etc.). Caller
54+
/// treats empty as "no signal" — no warning is emitted.
55+
/// </summary>
56+
public string FolderHint { get; set; } = "";
57+
58+
/// <summary>
59+
/// Yields every combatant name that isn't in <see cref="AllyNames"/>
60+
/// — i.e. the enemies fought during this encounter. Used by
61+
/// the rename-detection heuristic to cross-reference the
62+
/// title against actual participants.
63+
///
64+
/// Lives on the snapshot itself rather than on the ACT-coupled
65+
/// capture layer so <see cref="PayloadBuilder.ComputeClientWarnings"/>
66+
/// (which runs in Core and has no reference back into the UI
67+
/// assembly) can call it too.
68+
/// </summary>
69+
public IEnumerable<string> EnumerateEnemyNames()
70+
{
71+
if (Combatants == null) yield break;
72+
foreach (var c in Combatants)
73+
{
74+
if (c == null) continue;
75+
if (string.IsNullOrWhiteSpace(c.Name)) continue;
76+
if (AllyNames != null && AllyNames.Contains(c.Name)) continue;
77+
yield return c.Name;
78+
}
79+
}
3780
}
3881

3982
public sealed class CombatantSnapshot

0 commit comments

Comments
 (0)