Every attack, beside its detection. An ATT&CK-tagged, red↔blue paired
corpus — browse an attack beside its detection, fill {{slots}}, clip.
fzf · mitre-attack · purple-team
companion — structured, ATT&CK-tagged, red↔blue-paired pentest reference
Restructures the offensive corpus into machine-readable entries so the same content can be searched, ATT&CK-tagged, target-substituted, and paired with its blue detection — the shape a standalone terminal companion would take.
For the paired red↔blue attack/detection slice it covers, the entries are the
source of truth: where they overlap hacktheplanet / PURPLE-TEAM.md, the flat
files' blocks are generated from the entries (inside companion:gen markers) and
CI rejects drift. Everything those flat files hold that isn't a clean paired
attack — the tradecraft prose, dorks, multi-step chains — stays hand-authored and
canonical there. See Source of truth below for the full model.
companion/
├── htpx # fzf browser: search → preview attack + its detection → fill slots → clip
├── gen-views.sh # render entry-backed blocks into the flat views (+ --check drift gate)
└── entries/
├── red/*.md # attacks (frontmatter + command template)
└── blue/*.md # detections (frontmatter + SPL), paired back to red
This directory is host-agnostic (host-agnostic so it lives in its own repo dotgibson/htpx and is vendored back like core/): gen-views.sh's flat-view targets default to
this repo's PURPLE-TEAM.md + offensive/hacktheplanet (repo-root-relative) but
can be overridden with
$COMPANION_TARGETS (and a target that isn't present is skipped, so a standalone
checkout with no flat views is still green), and htpx copies via the first of
clip/pbcopy/wl-copy/xclip/xsel it finds (stdout otherwise) rather than
requiring the Core clip helper.
Typed metadata up top (greppable, yq-queryable); raw copy-paste content in the
body (renders in bat/glow, rg-searchable). One file per entry.
id: stable kebab key
title: human label
section: matches a hacktheplanet fold name
phase: engagement phase
attack: { tactic: TA0006, techniques: [T1558.003] } # MITRE ATT&CK
platform: [windows, linux, network]
source: citation
pair: <id of the paired entry in the other colour> # or nullCommand templates normalize the corpus's <angle-bracket> placeholders to
{{slots}} ({{rhost}}, {{lhost}}, {{user}}, {{password}}, {{domain}},
{{hostname}}, {{nthash}}, {{port}}, {{share}}).
export RHOST=10.10.10.5 DOMAIN=corp.local USER_T=svc_sql PASS='…'
htpx # pick an attack; preview shows it + its blue detection;
# the command is slot-filled and copied via `clip`htpx is on the shell as of bootstrap: companion/ symlinks to ~/companion
and offensive.zsh defines an htpx function. From a checkout you can also run
./htpx directly. It needs fzf; bat (preview) and clip (Core clipboard)
are used if present, else it falls back to cat/stdout. No yq dependency —
htpx reads only the scalar top-level fields it needs (title:, pair:) from
the frontmatter with awk (the nested attack: block is for humans/greppers).
The pair: field makes the purple pivot nearly free: selecting an attack
previews its detection right beside it (see Kerberoast ↔ 4769, DCSync ↔ 4662).
No mainstream tool ships attacks paired with the telemetry they trip.
56 paired concepts + 1 unpaired recon entry (SMB enum), spanning Credential Access, Privilege Escalation, Lateral Movement, Persistence, Execution, Defense Evasion, Exfiltration, and Discovery — on-prem AD, a multi-cloud slice (Entra/M365, AWS, GCP), Kubernetes, Okta, CI/CD (GitHub Actions, GitLab, Jenkins), the Harbor container registry, HashiCorp Vault, Terraform Cloud, and the Snowflake data cloud:
| Attack (red) | Detection (blue) | ATT&CK |
|---|---|---|
| Kerberoast SPNs | 4769 RC4 TGS |
T1558.003 |
| AS-REP roast | 4771 pre-auth 0x18 |
T1558.004 |
| Password spray (kerbrute) | 4625 one source, many accounts |
T1110.003 |
| DCSync | 4662 replication |
T1003.006 |
| Pass-the-hash lateral | 4624 type-3 fan-out |
T1550.002 |
| NTLM relay | 4624 workstation mismatch |
T1557.001 |
| Coerce DC (PetitPotam/printerbug) | 5145 named-pipe access |
T1187 |
| AD CS ESC1 (certipy) | 4886 SAN mismatch |
T1649 |
| Remote LSASS dump (lsassy) | 4656 dump-shaped handle |
T1003.001 |
| RDP session hijack (tscon) | 4688 tscon /dest:rdp-tcp# |
T1563.002 |
| Shadow Credentials (certipy) | 5136 msDS-KeyCredentialLink write |
T1556 |
| RBCD (impacket) | 5136 msDS-AllowedToActOnBehalfOfOtherIdentity write |
T1098 |
| Unconstrained delegation → DC TGT | 4624 DC machine-acct → non-DC (soft) |
T1558 |
| DPAPI domain backup key | 5145 protected_storage pipe |
T1555 |
| SeImpersonate → SYSTEM (Potato) | 4688 service-acct → SYSTEM shell (moderate) |
T1134.001 |
| Device-code phishing (Entra) | Entra sign-in deviceCode flow (KQL, cloud) |
T1528 |
| Golden Ticket (forged TGT) | 4769 TGS with no preceding 4768 |
T1558.001 |
| GPP cpassword (SYSVOL) | 5145 SYSVOL Groups.xml read |
T1552.006 |
| NTDS.dit dump (ntdsutil/VSS) | 4688 ntdsutil/vssadmin + 8222 |
T1003.003 |
| WMI exec (impacket-wmiexec) | 4688 WmiPrvSE.exe child shell |
T1047 |
| Scheduled-task persistence | 4698 task created (suspicious action) |
T1053.005 |
| WMI subscription persistence | Sysmon 19/20/21 consumer/binding |
T1546.003 |
| Silver Ticket (forged TGS) | 4624 Kerberos logon, no 4769 (soft) |
T1558.002 |
| DCShadow (rogue DC) | 4742 GC/ SPN write + 5137/4662 |
T1207 |
| Illicit consent grant (Entra) | Entra audit "Consent to application" (KQL, cloud) | T1528 |
| SP credential backdoor (Entra) | Entra audit "Add SP credentials" (KQL, cloud) | T1098.001 |
| Privileged pod → node escape (K8s) | audit: privileged/hostPID/hostPath pod create | T1610/T1611 |
| Pod exec / attach (K8s) | audit: pods/exec subresource create |
T1609 |
| Cluster-admin binding (K8s) | audit: roleRef cluster-admin binding |
T1098 |
| MFA reset → takeover (Okta) | System Log user.mfa.factor.reset_all |
T1556.006 |
| API token persistence (Okta) | System Log system.api_token.create |
T1098 |
| Rogue IdP backdoor (Okta) | System Log system.idp.lifecycle.create |
T1556 |
| IAM access-key backdoor (AWS) | CloudTrail CreateAccessKey actor≠target (cloud) |
T1098.001 |
| Console takeover (AWS) | CloudTrail Create/UpdateLoginProfile (cloud) | T1098 |
| SA key creation (GCP) | Cloud Audit CreateServiceAccountKey (cloud) |
T1098.001 |
| Rogue self-hosted runner (GitHub) | audit self_hosted_runner.created (cloud) |
T1543 |
| Branch-protection tamper (GitHub) | audit protected_branch.destroy / protected_branch.policy_override (cloud) |
T1562.001 |
| Deploy-key/PAT backdoor (GitHub) | audit repo.create_deploy_key / personal_access_token.access_granted (cloud) |
T1098 |
| Backdoored image over trusted tag (Harbor) | audit operation=push artifact (registry) |
T1525 |
| Robot-account backdoor (Harbor) | audit operation=create resource_type=robot (registry) |
T1098 |
| Artifact deletion (Harbor) | audit operation=delete artifact/repository (registry) |
T1070 |
| Rogue runner association (GitLab) | audit set_runner_associated_projects (cloud) |
T1543 |
| Protected-branch tamper (GitLab) | audit protected_branch_removed / protected_branch_created (cloud) |
T1562.001 |
| Access/deploy-token backdoor (GitLab) | audit project_access_token_created / personal_access_token_created / deploy_token_created (cloud) |
T1098 |
| Bulk KV secret read (Vault) | audit read breadth over secret/ paths (secrets) |
T1555 |
| Rogue AppRole backdoor (Vault) | audit create/update on auth/approle/role/ (secrets) |
T1098 |
| Audit-device disable (Vault) | audit delete on sys/audit/ path (secrets) |
T1562.001 |
| Rogue agent pool (Terraform) | audit agent_pool create (IaC) |
T1543 |
| Org/team token backdoor (Terraform) | audit authentication_token create (IaC) |
T1098 |
| Variable injection (Terraform) | audit variable create/update (IaC) |
T1072 |
| Script Console RCE (Jenkins) | audit /script//scriptText request (CI) |
T1059 |
| User API token backdoor (Jenkins) | audit generateNewToken request (CI) |
T1098 |
| Job/pipeline backdoor (Jenkins) | audit /createItem//job/<name>/configSubmit request (CI) |
T1072 |
| Data exfil via COPY INTO (Snowflake) | QUERY_HISTORY QUERY_TYPE=UNLOAD (data) |
T1567.002 |
| Backdoor user + ACCOUNTADMIN (Snowflake) | QUERY_HISTORY CREATE_USER/priv GRANT (data) |
T1136.003 |
| Network-policy tamper (Snowflake) | QUERY_HISTORY NETWORK POLICY change (data) |
T1562.007 |
Growth is mechanical now that the drift gate exists: author the red+blue entry
pair, mark the matching flat blocks, then gen-views.sh. For on-prem pairs the
blue detection generates into PURPLE-TEAM.md (cloud pairs are companion-only —
see below). The red side generates into
hacktheplanet whenever its commands are slot-mappable (even multi-step — see
RBCD); only commands that carry inline comments or are scattered across existing
folds stay hand-authored. Either way the entry powers htpx and the paired
preview. Net-new techniques (Shadow Credentials, RBCD) were authored as
entries first and flowed into both flat views via the bridge.
Cloud pairs are companion-only. The device-code-phishing pair is the first
outside on-prem AD: its detection is an Entra sign-in log (KQL), which doesn't
belong in PURPLE-TEAM.md's Windows-Security-log SPL frame, so it isn't generated
into either flat view — it lives only in the entries, where htpx still gives the
full purple pivot. A clean demonstration that the entries are a superset of the
on-prem flat references (and a natural seam for a standalone/cloud split).
The "do the entries become canonical?" question is resolved, but not as the
original binary. hacktheplanet is 489 lines and most of it is tradecraft prose
— dorks, enum sequencing, conditional advice, warnings — that doesn't fit the
entry schema; generating the whole file from rigid entries would either lose that
prose or bloat the schema into freeform markdown. So:
- Entries are canonical for the paired red↔blue attack/detection slice only.
That's the part that genuinely is
{id, title, attack, command}-shaped and benefits from ATT&CK tags, slot-fill, and the purple pivot. - The flat files stay canonical for everything else — the prose the schema can't hold.
- Where they overlap, the entry wins via generation. A flat file opts a block
in with
companion:gen ID…companion:end IDmarkers — HTML comments in markdown (PURPLE-TEAM.md),#comments in the shell-stylehacktheplanet.gen-views.shregenerates the marked blocks from the entry, andgen-views.sh --check(run in CI,.github/workflows/companion.yml) fails on drift. Content outside the markers is never touched.
This kills drift on the overlap without a 60-entry migration and without
giving up the rich prose. Workflow: edit the entry → gen-views.sh → commit both.
Both sides are wired. The render shape keys off the entry's colour:
- Blue (
PURPLE-TEAM.md) —**title**+ prose + a fencedspldetection block. Its SPL has no target slots, so no placeholder translation. - Red (
hacktheplanet) — just the raw command lines in that file's terse, command-first house style, with the entry's{{slots}}reverse-mapped to its<angle-bracket>vocabulary ({{rhost}}→<ip_address>,{{nthash}}→<NThash>, …; seeSLOT_TO_ANGLEingen-views.sh). Only attacks whose commands are contiguous and map cleanly are marked (Kerberoast, AS-REP, DCSync); ones whose lines are scattered across folds or carry inline notes (SMB enum, pass-the-hash) stay hand-authored — the entry owns only what it cleanly owns.
- ATT&CK tagging is 100% manual — neither source carries technique IDs today.
Tagging both colours with the same technique IDs turns
pair:into a derivable join (not just a hand-kept link). - Standalone vs in-repo — resolved. This now lives in its own repo
(
dotgibson/htpx) and is vendored back intodotfiles-Kaliatoffensive/companion/viagit subtree(provenance in Kali'scompanion.lock, resynced withscripts/sync-companion.sh). Kali consumes it; this repo is the source of truth.