Phishing simulation platform

A multi-tenant phishing simulation platform I architected and built for my employer, who intends to ship it. It began as a port of gophish into a stack my team could actually maintain, and most of what it does now, gophish never did.

Features added
7
Layers blocking credential capture
2
Passwords stored
0
Role
Architect, full-stack developer
Status
In development, integration underway
Stack
.NET 9, ASP.NET Core, Angular, Hangfire, MailKit, SQL Server
Source
Proprietary, not shareable

Where it started

gophish is a good open-source phishing simulation platform written in Go. Nobody on our team writes Go. That's fine while a tool does exactly what you need, and it stops being fine the moment you want to change it. We wanted to change quite a lot. Integrate it with our own learning platform, add recurring campaigns, eventually run it multi-tenant.

So I ported it to ASP.NET Core and Angular, which is what everything else here runs on. Novelty had nothing to do with it. The people who have to maintain the thing needed to be able to read it.

That port was the starting point rather than the project. Campaign scheduling, enrollment, template handling, and the integration with our learning platform are all mine, and the platform now does a good deal that gophish doesn't.

The decision I would defend

The original captures what a target types into the phishing landing page, including usernames and passwords. That's a normal feature in this category, and I took it out.

A phishing simulation needs to know that someone submitted a form. What they typed is none of its business. Keeping the values turns a security awareness tool into a database of live employee credentials, and the teaching moment is identical either way.

Removing the feature wasn't enough on its own, so it's enforced in two independent places. An injected client-side script intercepts the submit and sends a signal instead of the form body, so the values never leave the browser. Separately, the server discards any posted body regardless of what reaches it. Either layer would do on its own. Having both means a change to one of them can't quietly bring capture back.

The legacy CaptureCredentials and CapturePasswords fields still exist on the page model and are ignored entirely. I left them visible rather than deleting them, so anyone who goes looking for the capability finds a dead switch and a reason. Delete them and you leave an absence that somebody might read as an oversight and helpfully fix.

What I added

Seven features the original didn't have, most of them driven by what our own customers kept asking for.

FeatureWhat it does
Flow modesA click either goes to a landing page and then a training page, or straight to training with no form at all
Edit after launchSwap template, page, or SMTP mid-campaign. Changes apply only to recipients not yet sent
Late enrollmentAdd people to a running campaign, deduped against existing recipients, reactivating it if it had completed
Recurring schedulesA Hangfire job spawns a child campaign on a fixed interval and re-reads group membership each cycle
Template poolsEach recipient is independently assigned a random template, recorded on their result so it is stable across retries
Shared libraryCatalog templates clone into a tenant on add, rather than being referenced
LMS integrationOAuth token exchange against our learning platform, pulling groups and users in as targets

Two of those are worth a sentence each. Editing a live campaign is unusual in this market, where most tools lock everything at launch. What makes it safe is the rule that already-sent recipients keep what they received, because otherwise your reporting starts describing emails nobody was sent. Recurring campaigns re-read group membership every cycle instead of snapshotting it, so joiners get tested and leavers stop being emailed without anyone maintaining a list.

SMTP handling got hardened along the way. A bare host defaults to 587, 465 uses implicit TLS, and 587 requires STARTTLS rather than falling back to plaintext when the handshake fails.

Multi-tenancy that stops halfway, deliberately

Every tenant-owned table carries an indexed, non-nullable CompanyId, with 1 as the shared or global sentinel to match the convention the learning platform already uses. The shared template library clones on add for the same reason. It keeps every tenant query a plain WHERE CompanyId = @me with no null handling and no special cases.

None of it is enforced at request time. There's no login, no token validation, and nothing filters queries by caller. That's groundwork rather than an oversight. The learning platform will own identity once the integration lands, so building a second authentication system now would mean building something to throw away. Worse, it would mean two places where a tenancy bug could live.

It's also why nothing is deployed yet. The shape is right and the enforcement arrives with the integration, in that order and on purpose.

Where it stands

Sending, tracking, the campaign lifecycle, and both capture blocks all work. Several of the newer features are API-complete with the interface still to come, and identity arrives with the learning platform integration.

That order is deliberate. Integration means porting legacy methodology across, which is expensive work and wasted work if the thing it connects to turns out not to hold up. So the parts that could have killed the project went first. I keep a feature document with a status on every line, working or backend-only or partial or not implemented, so what's left is scheduled rather than discovered.

All work