Skip to content

Feat/anomaly detection - #13

Merged
raymondproguy merged 208 commits into
mainfrom
feat/anomaly-detection
Sep 13, 2026
Merged

raymondproguy merged 208 commits into
mainfrom
feat/anomaly-detection

Conversation

@raymondproguy

Copy link
Copy Markdown
Contributor

No description provided.

Refactor roadmap section for clarity and consistency.
Added permissions for write access to contents in the release workflow.
Covers: a policy violation rejecting SignUp/ChangePassword with every
broken rule reported together, a satisfying password succeeding, the
breach checker never being called when policy already rejected the
password, and current-password verification running before the new
password's policy check on ChangePassword.
That case needed security.PasswordPolicy, which didn't exist yet on
the branch this smoke test was first written on — deferred there,
added back now that password policy exists.
Runnable end-to-end check with no database dependency: go run
./cmd/smoketest/password-policy. Walks the default policy applying
automatically when Config.PasswordPolicy is left unset, a short
password rejected, a 73-byte password rejected (bcrypt's real limit
is 72), a custom stricter policy reporting multiple violations
together, and a password satisfying that custom policy succeeding.
applyDefaults detected "unset" via PasswordPolicy.MaxLength == 0
alone. A policy that set MinLength/character requirements but left
MaxLength untouched — exactly what a caller would naturally write —
looked unset and got silently overwritten by DefaultPasswordPolicy,
which has none of those requirements. Comparing the whole struct
against its zero value instead means only a truly untouched
Config.PasswordPolicy gets defaulted; setting even one field counts
as a real custom policy and is used as-is.
…overy-codes

Fix/oauth second factor and recovery codes
Swapping the real crypto/rand.Reader package variable to simulate an
entropy-source failure isn't portable: on at least one real platform
(reported: Termux/Android), a failed read through that actual global
triggers the Go runtime's own unrecoverable fatal-error path instead
of returning a normal error — crashing the entire test binary rather
than failing one test.

CryptoRandTokenGenerator now holds its own randReader field (defaults
to crypto/rand.Reader, set in the constructor), and the regression
test injects a fake failing reader directly into a same-package
instance instead of mutating any global state. Same coverage, no
process-crashing side effect.
TestGoWebAuthnProvider_LoginRoundTrip asserted the returned
credential's SignCount must be nonzero after a real login.
virtualwebauthn's simulated credential starts at counter 0 and never
auto-increments on its own — and a counter of 0 is itself a
legitimate, spec-allowed value (many real platform authenticators
never track one at all), so the assertion was simply testing a false
assumption, not a real property of the code under test.

Now manually sets the simulated credential's counter to a known
nonzero value before the login call and asserts FinishLogin correctly
surfaces that exact value — actually exercising the pass-through path
this test cares about (persisting whatever the authenticator reports,
which is what makes cloned-authenticator detection possible later)
instead of an assumption that doesn't hold.
Missed when SignUp/ChangePassword's signature changed on the stacked
feat/password-policy branch — this file was written earlier on
feat/breached-password-check and never revisited, so it built fine on
that branch alone but failed to compile once merged after the
password-policy signature change landed on top. Same class of miss as
an earlier one on login_second_factor_test.go — a full grep across
every call site for a changed signature, not just the files touched in
that commit, is the actual fix to the process here.
Evaluate is pure and store-free so thresholds stay testable without a
backend. AnomalyStore is its own interface, not more AuditStore
queries: every read is a windowed aggregate that needs its own indexes,
and the in-memory rate limiter's multi-instance gap rules it out too.
login_attempts.user_id is nullable with ON DELETE SET NULL, matching
audit_events: an unknown-email failure still carries real evidence
about the IP, which is what per-IP velocity counts. All three reads are
backed by partial indexes on (key, created_at) filtered by outcome.
Detection runs in completePrimaryAuth, the one tail password,
magic-link and OAuth all reach, so all three are covered once.
Report-only and nil-safe: no Anomalies store means no behaviour
change. Failure paths record attempts so velocity counts have input.
Thresholds tested with no store at all; the detector tested through
real Login/LoginWithOAuth/CompleteMagicLink so path coverage is proven
rather than assumed. Negative cases carry the design: first login is
clean, a familiar login stays quiet, and a broken store never blocks.
54 checks over six scenarios, all in memory: every signal, the
detection-off path, and the negative cases that matter most — a
flagged login still issues tokens and keeps its sessions.
Item 8 removed from NEXT.md and its remaining items renumbered, per
that file's own convention. Item 9's spec updated now that the shared
per-IP failure data it was written to maybe-build already exists.
@raymondproguy
raymondproguy merged commit bb5a533 into main Sep 13, 2026
0 of 2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants