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
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.
| Feature | What it does |
|---|---|
| Flow modes | A click either goes to a landing page and then a training page, or straight to training with no form at all |
| Edit after launch | Swap template, page, or SMTP mid-campaign. Changes apply only to recipients not yet sent |
| Late enrollment | Add people to a running campaign, deduped against existing recipients, reactivating it if it had completed |
| Recurring schedules | A Hangfire job spawns a child campaign on a fixed interval and re-reads group membership each cycle |
| Template pools | Each recipient is independently assigned a random template, recorded on their result so it is stable across retries |
| Shared library | Catalog templates clone into a tenant on add, rather than being referenced |
| LMS integration | OAuth 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.