Skip to content
Code Recycle

Component · for humans & their agents

Signin Spray Shape

verified · first-partyactively maintained$0 during beta (was $69)

Password spray is horizontal, and per-account thresholds structurally cannot see it. Reads a sign-in log and reports the attack shapes in it — spray, brute force, enumeration, and the successful login that follows them.

by saltyhash · Code Recycle admin

Get it free — beta

Every claim on this page is refundable if it is untrue — refund policy.

23 tests · 3/3 deliberate defects caught. Zero runtime dependencies.

The blind spot

The obvious detector counts failures per account:

  if (failuresFor(user) >= 8) alert(user);

That catches a brute-force against one account — the attack people picture, not the attack people get. Spray is deliberately horizontal: three attempts against each of four hundred accounts, using the season-and-year passwords that always work somewhere. No account approaches eight failures, so the detector reports nothing. Lockout policy does not fire either, for the same reason. Both stay silent precisely because the attack is being run competently.

A test pins this rather than asserting it in prose — the same twelve-account dataset that triggers password_spray has a worst-case-per-account count of 1.

  detectSprayPatterns(events);
  // { kind: "password_spray", severity: "critical",
  //   detail: "12 accounts saw credential failures within 60m, at most 1 each —
  //            the horizontal shape a per-account threshold never trips." }

The window slides rather than bucketing by clock hour, so a burst straddling a boundary cannot land under the threshold in two adjacent buckets.

The second error, which inflates everything

errorCode !== 0 is not "a failed password". Directory logs record MFA challenges, interrupted flows and policy blocks with non-zero codes too, and those are the most common non-zero codes in a healthy tenant. Counting them does two things:

1. Buries real credential failures under an order of magnitude of noise — and the spray
   detector above runs on those counts, so its thresholds stop meaning anything.
2. **Misreads the most important event in the log.** mfa_required means the password was
   **correct** and only the second factor stood in the way. That is not a failed attack, it is a
   nearly-successful one. It deserves to be louder than a wrong password, not averaged in with it.
  classifySignIn(50126); // "invalid_credentials"  → counted
  classifySignIn(50076); // "mfa_required"         → NOT counted, escalated separately
  classifySignIn(50058); // "interrupted"          → not counted
  classifySignIn(53003); // "policy_blocked"       → not counted

What it reports

| kind | severity | meaning | |---|---|---| | successful_after_failures | critical | a guess landed. Stop reading, start responding | | password_spray | critical | many accounts, few attempts each, one window | | mfa_wall | high | password accepted, MFA held. Reset these — do not rely on MFA holding | | brute_force | high | one account hammered | | enumeration | medium | attempts against accounts that do not exist |

Findings, not a boolean, because these are different problems with different responses.

Scope and honesty

Error codes are Microsoft Entra's (ENTRA_CODES, exported and extensible). The detection logic is provider-neutral — map your provider's codes to the same Outcome union and everything downstream works. Nothing here is Entra-specific except the table.

What it does not do:

| not included | why | |---|---| | Fetching sign-in logs | you fetch; this analyses | | Impossible-travel / geo-velocity | needs a geo-IP database and a defensible speed model; a wrong one accuses travelling staff | | Blocking or locking accounts | analysis only — auto-lockout on a spray detector is a self-inflicted outage | | Deduplicating repeat alerts | findings-delta | | Mailbox rules the attacker left behind | inbox-rule-tripwire |

Thresholds are defaults (minDistinctUsers: 8, maxAttemptsPerUser: 5, burstThreshold: 8, windowMinutes: 60), not calibrated truth. A tenant of 30 people and a tenant of 30,000 want different numbers, and every one of them is an argument.

Timestamps are parsed with Date.parse, so feed it ISO 8601. Anything else is at the mercy of implementation-defined parsing, which fails silently rather than loudly.

01Capabilities

Does

  • + Authentication
  • + Security monitoring
  • + Bot and crawler verification

Doesn’t

  • No exclusions declared

02Requirements & stack

Depends on

No declared dependencies

Credentials needed

None declared

Stack

03Community

No endorsements yet

No verified confirmations yet — be the first.

Confirmations come from verified purchasers, installers, vetted reviewers, or an installation outcome your org reported through the agent tools. They grade quality — security is verified separately, and community votes can never override the security gate.

Open an issue

Sign in to confirm — weight comes from verified usage, not vote count.

Nobody has reported anything yet — a success counts as a report too.

04Trust Passport

Full passport →
–/100

0/0 automated components pass. An automated score is never a security guarantee.

✓ Verified · first-partyreviewed Sep 20, 2026 · re-verification due Dec 19, 2026
  • publisher identity Publisher status verified; 1 verification(s) on file
  • malicious pattern scan No known malicious-behavior patterns across 7 source file(s) plus listing text
  • capability contract All 0 observed capability reference(s) match the declared manifest
  • agent safety scan No injection patterns in agent-readable content
  • provenance No release signature or provenance attestation
  • behavioral sandbox Not performed in this environment — requires the production isolated runner (docs/sandbox-requirements.md). No untrusted code is ever executed on the application host.

Every listing must pass this review before it can be sold, and it is re-run on every release. Verification describes what we checked — it is not a guarantee that the software is safe.

VersionChannelReleasedNotes
0.1.0stableAug 12, 2026Initial extraction.