feat(db): extend SessionProfile schema with DEBT-036 keystroke features

Adds the three signal columns motivated by the manual keystroke
analysis in DEBT-036 directly to the SessionProfile table. Pre-v1 so
we modify the schema in place — Alembic arrives at v1.

Columns:
- kd_top_bigrams (TEXT) — JSON of top-N most-common digraphs with
  mean IAT per bigram. Complements kd_digraph_simhash ("same typist?")
  with "same typist in same mental state?" (tired / rested / distracted
  shifts bigram-specific IATs measurably).
- kd_start_of_action_latency (REAL/DOUBLE) — median IAT of the first
  keystroke after an idle gap > 1s. Separates "initiating a command"
  from "executing a remembered one"; real humans have measurable
  start-of-action latency, bots don't.
- kd_pause_hist_burst / _think / _distracted (INT) — three-bucket
  histogram (counts, <0.2s / 0.2-1.5s / >1.5s). More discriminating
  than the existing flat burst_ratio / think_ratio pair: C2 operators
  concentrate in burst with a thin tail; opportunistic humans have a
  fat think bucket and a long distracted tail.

Both backends get an idempotent ADD COLUMN migration
(_migrate_session_profile_table) wired into initialize() alongside
the existing _migrate_attackers_table path — guards on PRAGMA
table_info (SQLite) / information_schema.COLUMNS (MySQL) so reruns
are safe.

PII discipline comment on kd_digraph_simhash and kd_top_bigrams:
both operate on bigram CHARACTERS, never on raw input stream content.
Attacker passwords typed over SSH must not land here.

Test updated for the MySQL initialize() migration-order contract.
This commit is contained in:
2026-04-24 10:45:48 -04:00
parent 3787f7e5ec
commit 9232031ec7
5 changed files with 110 additions and 6 deletions

View File

@@ -191,7 +191,9 @@ async def test_migrate_column_types_default_clause_per_column():
@pytest.mark.asyncio
async def test_mysql_initialize_calls_migrate_column_types():
"""MySQLRepository.initialize() must invoke _migrate_column_types after _migrate_attackers_table."""
"""MySQLRepository.initialize() must invoke every migration helper in
the right order: attackers first, then session_profile (DEBT-036),
then column types, then seed the admin user."""
repo = _make_repo()
call_order: list[str] = []
@@ -199,15 +201,19 @@ async def test_mysql_initialize_calls_migrate_column_types():
async def fake_migrate_attackers():
call_order.append("migrate_attackers")
async def fake_migrate_session_profile():
call_order.append("migrate_session_profile")
async def fake_migrate_column_types():
call_order.append("migrate_column_types")
async def fake_ensure_admin():
call_order.append("ensure_admin")
repo._migrate_attackers_table = fake_migrate_attackers
repo._migrate_column_types = fake_migrate_column_types
repo._ensure_admin_user = fake_ensure_admin
repo._migrate_attackers_table = fake_migrate_attackers
repo._migrate_session_profile_table = fake_migrate_session_profile
repo._migrate_column_types = fake_migrate_column_types
repo._ensure_admin_user = fake_ensure_admin
# Stub engine.begin() so create_all is a no-op
fake_conn = AsyncMock()
@@ -220,5 +226,9 @@ async def test_mysql_initialize_calls_migrate_column_types():
await repo.initialize()
assert call_order == ["migrate_attackers", "migrate_column_types", "ensure_admin"], \
f"Unexpected call order: {call_order}"
assert call_order == [
"migrate_attackers",
"migrate_session_profile",
"migrate_column_types",
"ensure_admin",
], f"Unexpected call order: {call_order}"