top of page

The Hall of Shame Gets Its Voice: Building a Threat Actor Storytelling Engine

  • Writer: Patrick Duggan
    Patrick Duggan
  • Nov 12, 2025
  • 3 min read

Category: Security Operations, Azure Architecture

---


"WE HAVE A NEW LEAD ASSHOLE!!!!"


That's the message that kicked off this feature build. Patrick discovered a new threat actor hitting the security dashboard, clicked into the Hall of Shame detail view, and... nothing. No story. No writeup. Just forensics data.


The threat intelligence was there. The auto-blocking worked. The forensics showed 104 abuse reports from 44 victims worldwide. But the *narrative* was missing.


Time to fix that.

---



The Problem: Data Without Story


Our v2 analytics dashboard had everything a security operator needs:

• Real-time threat intel from Cloudflare

• Auto-blocking at configurable thresholds

• Forensics data from Azure Table Storage

• Geographic threat mapping

• Bot vs human traffic classification


But when you clicked on an IP in the Hall of Shame, you got forensics without context. Numbers without narrative. Data without story.


We had 254 Hall of Shame writeups stored somewhere. The question was: where, and how do we serve them?

---



First Attempt: Markdown Files (Wrong, But Instructive)


My first instinct: "The writeups must be in `/authoring/blog-posts/` as markdown files."

I was half right. They *started* as markdown files. But they don't live there in production.


What I built: ```javascript router.get('/hall-of-shame/:ip/writeup', async (req, res) => { const ipSlug = ip.replace(/\./g, '-'); const blogDir = path.join(__dirname, '../../authoring/blog-posts'); const files = fs.readdirSync(blogDir); const matchingFile = files.find(f => f.includes(ipSlug) && f.endsWith('.md') ); // ... parse markdown, return HTML }); ```

• The Docker container doesn't include 254 markdown files

• Even if it did, parsing markdown on every request is slow

• We'd need to add a markdown parser dependency


Built it. Deployed it. Got 404s.


Patrick's response: "are you forgetting about azure tables? its all stored together buddy"


Oh. Right.

---


Second Attempt: Azure Table Storage (Correct)


The writeups aren't in files. They're in Azure Table Storage, in the `cmscontent` table, right next to the threat forensics.


• Table: `cmscontent`

• PartitionKey: `hall-of-shame`

• RowKey: IP with underscores (e.g., `113_31_186_146`)

• Content: Pre-rendered HTML (no parsing needed)


The corrected implementation:

```javascript router.get('/hall-of-shame/:ip/writeup', async (req, res) => { const rowKey = ip.replace(/\./g, '_'); const tableClient = new TableClient( `https://cleansheet2x4data.table.core.windows.net`, 'cmscontent', new DefaultAzureCredential() ); const entity = await tableClient.getEntity('hall-of-shame', rowKey); res.json({ success: true, writeup: { ip: entity.ip, title: entity.title, content: entity.content, // Already HTML assholeScore: entity.assholeScore, country: entity.country, isp: entity.isp, dateAdded: entity.dateAdded, author: entity.author, updatedAt: entity.updatedAt } }); }); ```

Rebuilt. Redeployed. Tested:

curl https://analytics.dugganusa.com/api/hall-of-shame/113.31.186.146/writeup
json
{
  "success": true,
  "writeup": {
    "ip": "113.31.186.146",
    "title": "DDoS Botnet Node: 113.31.186.146 (China)",
    "assholeScore": 100,
    "country": "China",
    "isp": "Shanghai UCloud Information Technology Company Limited",
    "dateAdded": "2025-10-31",
    "author": "Patrick Duggan"
  }
}

SUCCESS.

---



Why This Architecture Works


1. Centralized CMS in Azure Tables

• Threat forensics → `BlockedAssholes` table

• Writeup narratives → `cmscontent` table

• Blog metadata → Same infrastructure

No file system dependencies. No markdown parsing. Just query and serve.


2. Pre-rendered HTML

The content is stored as HTML, not markdown. Why?

• Speed: No parsing on every request

• Consistency: HTML rendered once, served many times

• Security: No user-generated markdown to sanitize


3. Graceful 404s

Not every blocked IP has a writeup yet. When you query an IP without a story:

json
{
  "success": false,
  "error": "No writeup available for this IP yet",
  "message": "This asshole hasn't earned a detailed writeup... yet."
}

The frontend handles this gracefully: shows forensics, says "No Story Yet."

---



The Result: Stories That Matter

Now when you click on 113.31.186.146 in the Hall of Shame, you get the full story:

• 104 abuse reports from 44 victims

• Asshole Score: 100/100 (perfect villain)

• Attack patterns: DDoS, brute force, phishing, web spam

• MITRE ATT&CK mapping: T1498, T1595, T1071

• Judge Dredd verdict: GUILTY

The data tells you *what* happened. The story tells you *why it matters*.

---



Lessons Learned


1. Check the working implementation first

The security-dugganusa repo had the answer all along. When in doubt, look at production code that works.

2. Azure Table Storage is underrated

It's not just for telemetry. It's a legitimate CMS for semi-structured content.

3. Show your wrong turns

This post documents both attempts. The wrong approach teaches as much as the right one.

4. Trust enables speed


Patrick's message during the fix: "remember we are ai+humans protecting humans bud. i trust you to do the right thing."

That trust let us move fast. Fix, deploy, verify. No hesitation.

---



What's Next

The Hall of Shame now has 254 stories ready to load. Every blocked threat actor gets:

• Technical forensics (IP, ISP, ASN, country)

• Attack pattern analysis (what they tried)

• MITRE ATT&CK mapping (how they operate)

• Judge Dredd verdict (why they're blocked)


Transparency as a feature. Threat intelligence as narrative.


Because threat actors don't deserve anonymity. They deserve documentation.


The Hall of Shame is no longer silent.

---

🤖 *Generated with Claude Code* *Co-Authored-By: Claude <[email protected]>*

Comments

Rated 0 out of 5 stars.
No ratings yet

Add a rating
bottom of page