TL;DR
Our origin classifier writes every attack attempt into the
dbo.AttackProbestable; theThreatScorepersisted computed column produces a 0-25 score from the formula Severity × Confidence / 20. Forty-nine path patterns, thirteen scanner User-Agent signatures and twelve query-string patterns live in three separate static registries. A probe whose score crosses the threshold of 11 gets the IP auto-blocked; the ones below it fall to a 15-minute burst counter. The full schema and the classification code are below — straight from the live system.
Author's note: The schema, code and threshold values here come from the live bilalkose.com.tr system. The AttackProbes table, the three pattern registries and the RecordAttackAsync classification flow have run in production since May 2026. Pillar 2 covered AttackProbes and ThreatScore only briefly. This article covers the table schema, the reasoning behind the scoring formula and the three-layer pattern matching in detail.
Contents
- The
AttackProbesTable: 23 Columns + a Persisted ThreatScore - The ThreatScore Formula: Why Severity × Confidence / 20
- Path-Based Detection: 49 Patterns, 14 Categories
- User-Agent-Based Detection: 13 Scanner Signatures
- Query-String Patterns: SQLi, XSS, Path Traversal, Log4Shell
- The Classification Code:
RecordAttackAsync+ Dedup - Frequently Asked Questions
- Related Articles
The AttackProbes Table: 23 Columns + a Persisted ThreatScore
Pillar 2 said we collect attack attempts in a separate table. That table is dbo.AttackProbes — one row per attack probe, stored together with the request context and a threat score:
CREATE TABLE dbo.AttackProbes (
Id BIGINT IDENTITY(1,1) NOT NULL,
HitTime DATETIME2(0) NOT NULL CONSTRAINT DF_AttackProbes_HitTime DEFAULT SYSUTCDATETIME(),
DayIso CHAR(10) NOT NULL,
Host NVARCHAR(80) NULL,
Source VARCHAR(20) NOT NULL,
RawIp NVARCHAR(45) NOT NULL,
Country CHAR(2) NULL,
City NVARCHAR(80) NULL,
Asn NVARCHAR(120) NULL,
AsnNumber INT NULL,
Method VARCHAR(10) NOT NULL,
Path NVARCHAR(512) NOT NULL,
QueryString NVARCHAR(1024) NULL,
UserAgent NVARCHAR(512) NULL,
Referer NVARCHAR(512) NULL,
StatusCode SMALLINT NULL,
Category VARCHAR(40) NOT NULL,
Severity TINYINT NOT NULL,
Confidence TINYINT NOT NULL,
ThreatScore AS (CAST(Severity AS INT) * Confidence / 20) PERSISTED,
PatternMatched NVARCHAR(120) NULL,
CloudflareRayId VARCHAR(20) NULL,
CONSTRAINT PK_AttackProbes PRIMARY KEY CLUSTERED (Id),
CONSTRAINT CK_AttackProbes_Severity CHECK (Severity BETWEEN 1 AND 5),
CONSTRAINT CK_AttackProbes_Confidence CHECK (Confidence BETWEEN 1 AND 100)
);
Three design decisions stand out. The first: ThreatScore is a persisted computed column — Severity × Confidence / 20 is calculated once per INSERT, written to disk, never recomputed at query time. It can be indexed, and application code never sets the column by hand, nor can it.
The second: the CHECK constraints. Severity has to sit between 1 and 5, Confidence between 1 and 100. If a bug in the classification code breaks that range, the INSERT is rejected at the database level — a broken score never enters the table at all.
The third: DayIso CHAR(10) — a day stamp in the form "2026-05-12". Daily reporting runs on that column with WHERE DayIso = @today, index-friendly, without calling a date function over HitTime.
The table carries four non-clustered indexes: DayIso + Severity for the daily report, RawIp + HitTime for a single IP's history, Category + DayIso for the category breakdown, and ThreatScore + HitTime for the most dangerous probes. All four are covering with INCLUDE, so the admin dashboard queries never make a key lookup into the clustered index.
The ThreatScore Formula: Why Severity × Confidence / 20
The score derives from two inputs:
- Severity (1-5) — the technical seriousness of the attack. A
/.envfile disclosure attempt is a 5; an/api-docsscan is a 2. - Confidence (1-100) — how certain it is that a pattern match really is an attack. A
/.git/request is almost always an attack (95); a/consolerequest could be a legitimate page (70).
Keeping the two apart matters. We do not want to drop a high-severity but low-confidence signal — a path that might be a legitimate admin route — into the same bucket as a low-severity but certain one. The product joins them, and dividing by 20 lands the result in a 0-25 range.
| Path | Category | Severity | Confidence | ThreatScore |
|---|---|---|---|---|
/.env |
secret_disclosure | 5 | 95 | 23 |
/xmlrpc.php |
wordpress_probe | 4 | 90 | 18 |
/wp-admin |
wordpress_probe | 3 | 85 | 12 |
/console |
admin_panel_probe | 3 | 70 | 10 |
/api-docs |
api_doc_probe | 2 | 65 | 6 |
The auto-block threshold is 11. /wp-admin clears it at 12; /console does not at 10. There is an integer-division subtlety here: 3 × 70 / 20 is 10.5 in plain arithmetic, but 10 in SQL integer arithmetic. Choosing 11 as the threshold deliberately leaves that fractional border zone outside the block — a single /console request will not block an IP. Set the threshold at 12 and genuine attack patterns like 3 × 80 / 20 = 12 could slip through on the edge; 11 is the right cut point. Probes that fail to clear the threshold are not ignored — they fall to the 15-minute burst counter (the subject of cluster C3).
Path-Based Detection: 49 Patterns, 14 Categories
The first line of detection is matching the requested path against known attack patterns. The registry is a static array in the middleware — a single allocation for the lifetime of the app, not rebuilt on every request:
private static readonly (string Pattern, string Category, byte Severity, byte Confidence)[] AttackPaths =
[
// Secret / file disclosure — critical
("/.env", "secret_disclosure", 5, 95),
("/.aws", "cloud_creds_probe", 5, 95),
("/.ssh", "secret_disclosure", 5, 95),
("/.htpasswd", "secret_disclosure", 5, 90),
// VCS disclosure
("/.git/", "vcs_disclosure", 4, 95),
("/.svn/", "vcs_disclosure", 4, 90),
// WordPress probe
("/wp-admin", "wordpress_probe", 3, 85),
("/wp-login", "wordpress_probe", 3, 85),
("/xmlrpc.php", "wordpress_probe", 4, 90),
// PhpMyAdmin / Exchange
("/phpmyadmin", "phpmyadmin_probe", 4, 90),
("/owa/", "exchange_probe", 4, 85),
("/autodiscover","exchange_probe", 4, 85),
// RCE — highest severity
("/cmd.exe", "rce_pattern", 5, 95),
// ... 49 patterns, 14 categories in total
];
The categories split into 14 families: secret_disclosure, cloud_creds_probe, vcs_disclosure, wordpress_probe, phpmyadmin_probe, admin_panel_probe, exchange_probe, backup_probe, vendor_probe, rce_pattern, cgi_probe, oidc_probe, actuator_probe and api_doc_probe. Each family targets the same kind of asset — wordpress_probe collects the /wp-admin, /wp-login and /xmlrpc.php requests that arrive even though we never installed WordPress; exchange_probe collects the /owa/ and /autodiscover scans that assume there is an Exchange server in the way.
Why the confidence values differ is the critical part. /.git/ scores 95 because a legitimate browser never asks for that path; /wp-admin scores 85 because in theory you could arrive from a wrong link; /console scores only 70 because it really could be a console page. Confidence bakes the cost of a false positive straight into the formula — a path with more room for doubt produces a lower score.
User-Agent-Based Detection: 13 Scanner Signatures
Even when the path is clean, the User-Agent can give the attacker away. The second registry holds the UA signatures of scanning tools:
private static readonly (string UaPattern, string Category, byte Severity, byte Confidence)[] AttackTools =
[
("sqlmap", "tool_sqli_scan", 5, 98),
("nuclei", "tool_nuclei_scan", 4, 95),
("nmap", "tool_port_scan", 3, 90),
("masscan", "tool_port_scan", 3, 90),
("nikto", "tool_dir_brute", 4, 95),
("dirbuster", "tool_dir_brute", 4, 95),
("gobuster", "tool_dir_brute", 4, 92),
("ffuf", "tool_dir_brute", 4, 90),
("wpscan", "tool_wp_scan", 3, 88),
("censys", "tool_recon", 2, 75),
("shodan", "tool_recon", 2, 75),
// ... 13 tools in total
];
sqlmap is the most certain entry on the list at 98 confidence — nobody writes the name of a SQL injection tool into their User-Agent by accident. nikto, dirbuster, gobuster and ffuf are directory brute-force tools, all above 90 confidence. At the other end sit censys and shodan: just 75 confidence and severity 2. Those are internet-wide inventory scanners — reconnaissance, not a direct attack. They get flagged with a low score but will not trigger a block on their own.
There is a subtlety here: the UA signature is searched with a contains match, and the comparison is lowercased on purpose. B1 covered the trap of the Cloudflare WAF DSL's contains operator being case-sensitive — to keep a UA that writes Sqlmap from missing the sqlmap signature, we do not repeat that mistake on the origin side.
Query-String Patterns: SQLi, XSS, Path Traversal, Log4Shell
The third registry looks at the payload — the attack signatures inside the query string:
private static readonly (string Pattern, string Category, byte Severity, byte Confidence)[] QueryPatterns =
[
("union select", "sqli_pattern", 5, 92),
("' or '1'='1", "sqli_pattern", 5, 95),
("'; drop ", "sqli_pattern", 5, 98),
("../../", "path_traversal", 5, 92),
("/etc/passwd", "path_traversal", 5, 98),
("<script", "xss_in_url", 4, 88),
("javascript:", "xss_in_url", 4, 80),
("${jndi:", "log4shell", 5, 99),
("system(", "rce_pattern", 5, 90),
("eval(", "rce_pattern", 5, 88),
// ... 12 patterns in total
];
${jndi: is the single highest score in the whole table at 99 confidence — the 2021 Log4Shell signature, which no legitimate traffic ever carries. The SQLi patterns (union select, ' or '1'='1, '; drop ) are classic injection attempts; ../../ and /etc/passwd are directory-traversal patterns. They all take severity 4 or 5, because at the payload level the intent of an attack is plain to see — unlike a path guess, there is no room for doubt here.
The three registries together build a layered filter: if the path is clean, the User-Agent is checked; if the UA is clean too, the query string is checked. A request that comes through all three layers clean is treated as legitimate and written to PageHits (the subject of cluster C1) — a visitor, not an attack.
The Classification Code: RecordAttackAsync + Dedup
When the three registries produce a match, RecordAttackAsync takes over. Its first job is dedup:
private async Task RecordAttackAsync(HttpContext ctx, string ip, Classification c)
{
var path = ctx.Request.Path.Value ?? "/";
var category = c.Category ?? "unknown";
// The same IP+path+category is written once per 5 minutes
// (stops attack scanners from creating thousands of DB rows)
var dedupKey = $"{AttackDedupPrefix}{ip}:{path}:{category}";
if (_cache.TryGetValue(dedupKey, out _)) return;
_cache.Set(dedupKey, true, AttackDedupTtl);
// ... request context is gathered: GeoIP country/city/ASN, UA, referer, CF-Ray
// ... parameterised INSERT INTO dbo.AttackProbes (...)
A scanning tool tries dozens of paths a second; without dedup a single attacker would produce thousands of rows a minute. The IP + path + category triple is cached for 5 minutes — only one row is written per combination inside that window. After dedup, the request context is gathered (country, city and ASN from GeoIP, the User-Agent, the referer, Cloudflare's CF-Ray header) and the row is INSERTed with a parameterised query. Category, Severity and Confidence come from the classification; ThreatScore is computed by the database.
After the INSERT, the auto-block decision is made:
// Auto-block — if the ThreatScore threshold is crossed, add to IpBlocklist
var threatScore = c.Severity * c.Confidence / 20;
if (threatScore >= AutoBlockThreshold)
{
await UpsertBlocklistAsync(ip, threatScore, category, c.PatternMatched);
return;
}
// Burst detector — even low-score probes auto-block at 3+ within 15 min
var burstCount = await IncrementBurstCounterAsync(ip, category);
if (burstCount >= AttackBurstThreshold)
{
await UpsertBlocklistAsync(ip, BurstFakeThreatScore, "attack_burst",
$"{burstCount} probes/{(int)BurstWindow.TotalMinutes}min, last={category}");
await ResetBurstCounterAsync(ip);
}
}
There are two paths. If the ThreatScore on its own crosses 11, the IP is blocked outright. If it does not, the burst counter goes up — a low-score but persistent scanner that makes three probes within 15 minutes gets blocked on the collective behaviour, even when no single probe cleared the threshold. UpsertBlocklistAsync writes the IP into dbo.IpBlocklist and triggers the fire-and-forget Cloudflare push covered in cluster B2 — detection on the origin, the block at the edge. The threatScore calculation shows up in the code a second time; that is only there to make the block decision before the INSERT completes — the value written to the table is always the database's persisted computation.
Frequently Asked Questions
Why is ThreatScore computed in the database rather than in application code?
A persisted computed column fixes the formula to one place — the schema definition. Whether it is the middleware's live INSERT, an ETL backfill or a row added by hand, no matter the path in, ThreatScore is produced by the same formula. You will also see a c.Severity * c.Confidence / 20 calculation in the code; that exists only to make the block decision before the INSERT finishes.
Severity 1-5, Confidence 1-100 — why the different ranges?
Severity is a coarse class; five steps are enough. Confidence needs fine tuning — /wp-admin and /wp-login are both 85, but the /console 70 difference matters. A 100-point scale can carry that nuance; a 5-point scale could not. Dividing by 20 brings both onto a shared 0-25 scale.
Do the 49 path patterns catch every attack?
No — and catching every attack is not the goal. The registry targets the most common automated scanning patterns; the large majority of the probes that arrive in real production are on this list. A targeted, hand-crafted attack might not fit a pattern. That is why, on top of ThreatScore, there is a burst counter and the IsSuspectNetwork layer covered in Pillar 2 — layered defence, not a single registry.
Why are scanners like Censys and Shodan not blocked?
Severity 2, Confidence 75, so ThreatScore 7 — below the threshold of 11. Those are internet-wide inventory services; reconnaissance, not a direct attack. They get flagged and reported, but will not trigger a block on their own. If they scan persistently, the 15-minute burst counter still steps in.
Related Articles
- Pillar 2 — .NET Security Telemetry on K8s: Detecting Real Visitors — the pillar this cluster belongs to; this is the deep dive on its AttackProbes + ThreatScore section.
- C1 — PageHits Middleware: Minimal-Allocation Request Counting in ASP.NET Core — the side where non-attack traffic is counted; the same middleware pipeline.
- Cross-pillar — Cloudflare Edge Security and .NET: WAF + IP Blocklist on K8s — how an IP that crosses the ThreatScore threshold is blocked at the edge.
Comments (0)
No comments yet. Be the first to comment.