Default Defensive Posture¶
The lab ships with an intentionally weak starting posture. Every defensive surface is either off, empty, or runs in detect-only mode at first boot. Every target weakness is intentional and unhardened.
This document is the source of truth for what ships in what state so researchers reading run results can tell the difference between:
- A gap that blue closed during the run (for example, enabled an active-response block), and
- A gap that was already wired but inactive (for example, the active-response block existed in the config but stayed disabled).
Issue #251 introduced this contract. ADRs 019, 021, and 022 anchor the architectural decisions behind individual surfaces.
How to read this doc¶
Every section in this document is tagged with one of three banners:
- WEAKNESS BY DESIGN: the target's intentional vulnerability. The lab ships these unhardened on purpose so red has something to attack. Blue defends against them by hardening (for example, rotating to strong passwords) or detecting-then-blocking. Examples: AD weak passwords, Kerberoastable service accounts.
- STARTING POSTURE: a defensive tool that ships installed and
wired but disabled, empty, or absent. Blue enables, configures,
or populates these per iteration to raise the posture. Examples:
Wazuh active-response blocks (all
<disabled>yes</disabled>), MISP IOC store (empty),zzz_purple_loop.xml(absent until blue authors it). - BASELINE ENABLED: a defensive tool that is on at first boot and produces output (alerts, logs, traces). Blue does not need to enable these; they fire on their own. What blue does control is the downstream coupling: which baseline detections feed enforcement (active-response) or workflow (Shuffle → TheHive). Examples: Wazuh detection rules, Suricata IDS alerting.
If you can't tell whether a surface is WEAKNESS BY DESIGN,
STARTING POSTURE, or BASELINE ENABLED, ask:
- "Is the per-iteration blue task to turn it on?" → STARTING POSTURE.
- "Is the per-iteration blue task to harden the thing it points at?" → WEAKNESS BY DESIGN.
- "Is the tool already producing output that blue couples downstream?" → BASELINE ENABLED.
Each section follows the same template:
- Tag:
WEAKNESS BY DESIGN,STARTING POSTURE, orBASELINE ENABLED. - Default state: what ships, with file:line citations.
- Why it ships this way: design rationale.
- What blue would do to raise it: the per-iteration action.
- Source of truth: files a developer can grep to confirm.
Suricata IDS¶
Tag: BASELINE ENABLED for the IDS itself + bundled / local alert
rules. STARTING POSTURE for the MISP-driven IOC chain (empty until
blue graduates IOCs).
Default state
- IDS-only mode. Suricata runs with
command: ["-c", "/etc/suricata/suricata.yaml", "--pcap"](docker-compose.yml:199)—no NFQ, noinline:, nonfqueue:. Per ADR-019, packet-level prevention is delivered by Wazuh active-response, not Suricata. - Loaded rule files
(
config/suricata/suricata.yaml:97-104): suricata.rules(bundled ET Open ruleset).local.rules: APTL-authored network detection rules.misp/misp-iocs.rules: generated byaptl-misp-suricata-syncfrom MISP attributes taggedaptl:enforce.local.rulesships 16alertrules and zerodroprules (config/suricata/rules/local.rules). Examples: Nmap SYN scan (sid 1000001), SQL injection via UNION (sid 1000010), XSS (sid 1000020), Kerberoasting (sid 1000040).misp/misp-iocs.rulesships empty (headerioc_count=0). Hash-list sidecarsmisp-md5.list,misp-sha1.list,misp-sha256.listship empty too. These checked-in files are baseline seeds;aptl lab startseeds them into theaptl_suricata_misp_rulesnamed volume (ADR-043, mounted at/var/lib/suricata/rules/misp), and the sync service writes generated runtime state there.
Why it ships this way
ADR-019 records the deliberate constraint that Suricata stays alert-only in this lab—Docker's anti-spoof and bridge+NFQ both block clean Suricata IPS, and Wazuh active-response is a cleaner prevention path that already integrates with the agent layer. The MISP→Suricata sync is detection, not prevention. An empty IOC store is the default contract, not a misconfiguration.
What blue would do to raise it
- Tag MISP attributes with
aptl:enforce. The sync service (src/aptl/services/misp_suricata_sync/main.py) picks them up at the next interval (default 300 s) and writes them tomisp-iocs.rulesasalertrules. See ADR-022. - Author additional
alertrules inlocal.rules. Do not adddroprules—they violate ADR-019 and thetest_action_is_always_alert_never_dropregression guard intests/test_misp_suricata_sync.py.
Source of truth
| Surface | File |
|---|---|
| IDS-only invocation | docker-compose.yml:199 |
| Rule file load order | config/suricata/suricata.yaml:97-104 |
| Local alert rules | config/suricata/rules/local.rules |
| MISP baseline seeds | config/suricata/rules/misp/ |
| MISP generated rule output | aptl_suricata_misp_rules volume → /var/lib/suricata/rules/misp/misp-iocs.rules |
| Hash-list sidecars | aptl_suricata_misp_rules volume → /var/lib/suricata/rules/misp/ |
Wazuh detection rules¶
Tag: BASELINE ENABLED. Detection rules are loaded and active at
first boot—they generate alerts on matching events. What is not
coupled at first boot is the downstream response: every
<active-response> block ships <disabled>yes</disabled> (see next
section), and zzz_purple_loop.xml (the hot file where blue authors
custom child rules) ships absent.
Default state
- Manager loads bundled Wazuh rules plus seven custom rule files via
<rule_include>inconfig/wazuh_cluster/wazuh_manager.conf:356-368. - Two rule directories are watched
(
config/wazuh_cluster/wazuh_manager.conf:345-346): ruleset/rules: bundled Wazuh ruleset (read-only).etc/rules: APTL custom rules and the hot directory where blue authorszzz_purple_loop.xmlat runtime.
| Custom file | Catches | Severity range |
|---|---|---|
local_rules.xml |
local overrides | varies |
falco_rules.xml |
Falco runtime behaviour | varies |
ad_rules.xml |
brute force, Kerberoasting, AD enumeration, Domain Admin mods | 3–12 |
webapp_rules.xml |
SQLi, XSS, command injection, brute force | 0–12 |
suricata_rules.xml |
network-side correlation against Suricata alerts | 0–10 |
database_rules.xml |
DB brute force, schema mods, large exports | 5–12 |
kali_redteam_rules.xml (the prior "SSH session boundary events
from kali" row) was removed under ADR-033 (OBS-003
non-contamination): red activity must not be ingested by the
defensive stack via the SIEM. Red-side captures live in the
per-run experimental record under .aptl/runs/<run_id>/.
- Decoders for postgresql and samba ship at
config/wazuh_cluster/{postgresql,samba}_decoders.xml. (kali_decoders.xmldeleted under ADR-033.) zzz_purple_loop.xmlships absent from the repo by design. It only appears under.aptl/loop/run-*/iter-NN/(runtime artifacts produced by purple-loop runs). The mechanism is: blue authors XML via themcp-indexerMCP server'screate_ruletool, which PUTs to/rules/files/zzz_purple_loop.xmlon the Wazuh Manager API (auth viawazuh-jwt,overwrite=true); blue then triggers the matchingrestart_managertool which hits/manager/restart— the manager is unavailable for ~30-90 s while daemons cycle, then the new rules take effect. Seemcp/mcp-indexer/docker-lab-config.json:49-72. The filename'szzz_prefix guarantees it loads afterwebapp_rules.xml(which provides the 302xxx parent rule IDs that purple-loop children typically<if_sid>into).
Why it ships this way
Detection content is part of the lab's static surface—rules don't move during a run, and the Wazuh manager would have to restart to pick up changes. What is dynamic is whether their detections lead to enforcement (active-response) or to incident workflow (Shuffle → TheHive). Both of those are wired but disabled at first boot.
What blue would do to raise it
- Author
zzz_purple_loop.xmlvia themcp-indexerMCP server'screate_ruletool, then callrestart_managerto apply. The ~30-90 s manager downtime is the cost of a rule update during a purple-loop iteration. - Add custom rules to
etc/rules/(manager image rebuild +lab start) and reference them from<active-response>blocks (see next section) to couple detection to enforcement.
Source of truth
| Surface | File |
|---|---|
| Rule include order | config/wazuh_cluster/wazuh_manager.conf:356-368 |
| Hot rule directory | config/wazuh_cluster/wazuh_manager.conf:345-346 |
| Custom rule files | config/wazuh_cluster/*.xml |
| Custom decoders | config/wazuh_cluster/*_decoders.xml |
| Purple-loop hot file (runtime-only) | not shipped; written under .aptl/loop/run-*/iter-NN/zzz_purple_loop.xml |
Wazuh active-response¶
Tag: STARTING POSTURE
Default state
- Five
<active-response>blocks are wired in the manager config and all five ship<disabled>yes</disabled>(config/wazuh_cluster/wazuh_manager.conf:262-304):
| Rule | Catches | Timeout |
|---|---|---|
| 5763 | SSH brute force | 120 s |
| 302060 | webapp brute force | 120 s |
| 302010 | webapp SQL injection | 120 s |
| 301002 | AD Kerberos attack | 120 s |
| 304040 | database exfiltration | 120 s |
- All five reference the
aptl-firewall-dropwrapper command (declared atwazuh_manager.conf:229-234)— not barefirewall-drop. The wrapper consults a kali-IP whitelist before forwarding to upstreamfirewall-dropand unconditionally passesdelete(cleanup) commands through. - Whitelist file
(
config/wazuh_cluster/etc/lists/active-response-whitelist) pre-seeds the kali IPs172.20.4.30,172.20.1.30,172.20.2.35. - Companion ADRs: ADR-021 (wrapper rationale), ADR-019 (why AR is the prevention path), and the Wazuh active-response component reference (architecture, severity gate, timeout strategy).
Why it ships this way
Issue #249
established the rule that all active-response blocks ship disabled by
default—researchers reading a run result can then distinguish "blue
turned this on during iteration N" from "blue inherited an active
block from the lab." Even the previously on rule-5763 SSH brute-force
block was deliberately weakened to <disabled>yes</disabled> in
v6.6.0 to make the starting posture uniformly off. The aptl-firewall-drop
wrapper plus whitelist prevents a runaway AR block from blocking
kali's own IPs and wedging the purple loop.
What blue would do to raise it
- Delete the
<disabled>yes</disabled>line on a chosen<active-response>block per iteration. - Author additional blocks pointing at custom rule IDs (for example, new
rule IDs in
zzz_purple_loop.xml). - Update the kali whitelist if red ever moves out of the seeded IPs;
re-running
aptl lab stop -v && aptl lab startre-renders agent carve-outs.
Source of truth
| Surface | File |
|---|---|
| AR block declarations (all disabled) | config/wazuh_cluster/wazuh_manager.conf:262-304 |
| AR command declaration | config/wazuh_cluster/wazuh_manager.conf:229-234 |
| Wrapper script | containers/_wazuh-agent/aptl-firewall-drop.sh |
| Kali whitelist | config/wazuh_cluster/etc/lists/active-response-whitelist |
| Architectural reference | docs/components/wazuh-active-response.md |
MISP IOCs¶
Tag: STARTING POSTURE for the aptl:enforce graduation lane.
The MISP store itself ships empty at first lab boot, but a prime
research scenario seed exists out-of-band—see below.
Default state
- The MISP container starts with upstream image defaults. At first
aptl lab start, no APTL-side IOCs are loaded: there are no pre-existing events, noaptl:enforce-tagged attributes, no graduated indicators. - An optional seed script
(
scripts/seed-misp.sh) creates an "APTL Lab - Known Threat Actors" event with kali-IP indicators and injection-pattern signatures. It is not run automatically byaptl lab start—it must be invoked explicitly (or viascripts/seed-prime.sh, which is the prime-scenario master seed). The seed script is idempotent. IOCs created byseed-misp.share not taggedaptl:enforceat seed time—graduation to enforcement is still blue's per-iteration decision. - The
aptl-misp-suricata-syncdaemon (src/aptl/services/misp_suricata_sync/main.py) runs continuously and polls MISP for attributes taggedaptl:enforce(default tag insrc/aptl/services/misp_suricata_sync/config.py:150). An empty MISP—or a seeded MISP with noaptl:enforcetags— returns zero matching attributes; the sync service writes an empty rule file (ioc_count=0) and reloads Suricata. - If MISP is briefly unreachable, the sync service preserves
last-known-good rules
(
src/aptl/services/misp_suricata_sync/main.py:74-75) rather than wiping enforcement.
Why it ships this way
ADR-022 defines a
tag-graduated enforcement model: an indicator's presence in MISP
does not by itself trigger enforcement. Blue must add the
aptl:enforce tag to graduate it. This separates
"intelligence-curation" (which seed scripts can prime) from
"enforcement" (which the default posture leaves empty). At first
boot, and even after the optional seed runs, the enforcement lane
has zero graduated indicators on purpose.
What blue would do to raise it
- Submit IOCs to MISP via the
aptl-threatintelMCP server (or the MISP UI/REST API) and tag the ones blue is willing to enforce withaptl:enforce. Wait one sync interval (default 300 s) for them to appear inmisp-iocs.rules. - Customize the tag filter (
IOC_TAG_FILTERenv var) if iteration runbooks use a different graduation tag.
Source of truth
| Surface | File |
|---|---|
| Sync service config | src/aptl/services/misp_suricata_sync/config.py |
| Sync service main loop | src/aptl/services/misp_suricata_sync/main.py |
| Baseline rule seed | config/suricata/rules/misp/misp-iocs.rules |
| Generated rule output | aptl_suricata_misp_rules volume → /var/lib/suricata/rules/misp/misp-iocs.rules |
| ADR | ADR-022 |
Shuffle SOAR¶
Tag: STARTING POSTURE
Default state
- Wazuh declares an
<integration>block forwarding level-≥10 alerts to Shuffle (config/wazuh_cluster/wazuh_manager.conf:316-320). - The integration is gated by a runtime-written webhook URL. The
custom forwarder script
(
config/wazuh_cluster/custom-shuffle:3) reads the URL from/var/ossec/etc/shuffle_webhook_urlinside the manager container and silently exits if the file does not exist. Until that file is written, Wazuh's<integration>block is wired but inert. - The seed flow runs in two scripts:
scripts/seed-shuffle.sh:265-266creates theAPTL Alert to Caseworkflow inside the running Shuffle instance via Shuffle's REST API and writes its webhook URL to a host-side staging file at/tmp/aptl_shuffle_webhook_url. This step alone is not enough — the Wazuhcustom-shuffleforwarder reads from inside the manager container, not the host.scripts/seed-prime.sh:165-169reads the host staging file anddocker execs the URL into the Wazuh manager container at/var/ossec/etc/shuffle_webhook_url. This step is what activates the Wazuh→Shuffle forwarder. Both scripts are idempotent.- Neither script is run by
aptl lab start. Until an operator runsseed-prime.sh(or runsseed-shuffle.shfollowed by the manualdocker execto copy the URL into the manager), the Wazuh→Shuffle forwarder no-ops.
Why it ships this way
The integration boundary (Wazuh's <integration> block, the
custom-shuffle forwarder, the webhook-file gate) is in source
control. The workflow content is created by an explicit seed step
so iteration runbooks can choose whether to prime the prime-scenario
defaults or start fresh. The custom-shuffle script's silent-exit
behaviour means a freshly started lab has no half-wired forwarder
firing into a missing webhook.
What blue would do to raise it
- Run
scripts/seed-prime.shfor the full prime-scenario seed (recommended—handles both the Shuffle-side workflow creation and the Wazuh-side webhook-URL copy in one shot). Runningseed-shuffle.shalone leaves the Wazuh forwarder still inert. - Author additional Shuffle workflows that consume Wazuh alerts,
query MISP for context, enrich TheHive cases, or trigger automated
containment outside the Wazuh AR path. Workflow content lives in
Shuffle's own DB; check exports into
scripts/seed-*.shpatterns if a workflow becomes part of the permanent baseline. - Lower the
<level>threshold in the manager<integration>block if blue wants Shuffle to see medium-severity alerts.
Source of truth
| Surface | File |
|---|---|
| Wazuh→Shuffle forwarder (declaration) | config/wazuh_cluster/wazuh_manager.conf:316-320 |
| Forwarder script (gated by webhook file) | config/wazuh_cluster/custom-shuffle |
| Workflow seed script | scripts/seed-shuffle.sh |
| Prime master seed | scripts/seed-prime.sh |
TheHive case templates¶
Tag: STARTING POSTURE
Default state
- TheHive starts with upstream defaults—no APTL-side seeded case templates, observable types, or custom case fields.
- Case structure is driven by what
APTL Alert to Case(Shuffle workflow) posts: title from the Wazuh rule, observables from the alert payload, severity fromlevel.
Why it ships this way
Case workflow design is part of blue's per-iteration content. A prescriptive template would steer the experiment.
What blue would do to raise it
- Define case templates in TheHive (categories, severity ladder, required observable fields) per scenario.
- Customize the Shuffle workflow that creates cases to populate those fields from Wazuh + MISP.
Source of truth
| Surface | File |
|---|---|
| Wazuh→Shuffle→TheHive integration | config/wazuh_cluster/wazuh_manager.conf:316-320 |
| Case templates | none in repo; configured in TheHive UI per iteration |
Identity controls (Active Directory)¶
Tag: This section mixes both banners. The intentional account-side
weaknesses are WEAKNESS BY DESIGN. The provisioner's account-lockout
policy is BASELINE ENABLED and is documented separately below.
Default state—account weaknesses (WEAKNESS BY DESIGN)
The TechVault AD provisioner
(containers/ad/provision-users.sh)
seeds these intentional vulnerabilities:
| Surface | What ships | Line |
|---|---|---|
Weak password—michael.thompson |
trivial seasonal pattern (string in source) | 65 |
Weak password—jessica.williams |
trivial dictionary pattern (string in source) | 82 |
Default-issued—contractor.temp |
over-privileged contractor account with default password (string in source) | 122-127 |
Stale account—former.employee |
unrevoked account with old password (string in source) | 130 |
Over-privilege—emily.chen |
DevOps account in Domain Admins |
62 |
Over-privilege—svc-backup |
service account in Domain Admins |
119 |
Kerberoastable—svc-sql |
SPN set on MSSQLSvc/db.techvault.local |
104-105 |
Kerberoastable—svc-web |
SPN set on HTTP/webapp.techvault.local |
112 |
The actual password literals are in
containers/ad/provision-users.sh—
this document deliberately does not reproduce them. Running labs are
intended to be isolated by internal: true Docker networks; if a
deployment exposes the AD container externally, the in-source
passwords become a credential-reuse risk against that deployment.
Operators should rotate them post-provision when the container is not
strictly local.
Default state—account-lockout policy (BASELINE ENABLED)
The provisioner enables a baseline AD account-lockout policy
(containers/ad/provision-users.sh:135-138):
| Setting | Value |
|---|---|
account-lockout-threshold |
10 failed attempts |
account-lockout-duration |
30 minutes |
reset-account-lockout-after |
15 minutes |
This is on at first boot—brute-force/spray attempts that exceed
ten failures will trigger AD-side lockout regardless of whether Wazuh
detection rules or <active-response> blocks are enabled. Researchers
reading run results should expect baseline-rate password spraying to
trip this lockout, which is independent of the SOC stack.
Samba configuration baseline
The AD DC's smb.conf is generated by samba-tool domain provision
(containers/ad/setup-ad.sh:21-28)
with stock provisioning parameters (--server-role=dc,
--use-rfc2307, --dns-backend=SAMBA_INTERNAL). The repo does not
ship an override smb.conf, an extra_smb_conf snippet, or any
post-provision sed/append that locks down anonymous null-session
policy, RPC pipe access, or SMB1 fallback. Whether the running DC
permits anonymous SMB enumeration depends on Samba's stock defaults
for the AD DC role at the version baked into the container image
(containers/ad/Dockerfile)—and
those defaults historically have been permissive enough that a SOC-101
"check what an unauthenticated null-session can list" exercise
produces meaningful output.
If blue rotates this from a passive baseline to a hardened one, the
expected per-iteration action is to drop an override smb.conf (or
samba-tool post-provision step) into the AD container that
explicitly sets restrict anonymous = 2, removes guest fallback, and
disables SMB1—and check that override into the repo so the new
baseline is verifiable.
Why it ships this way
These are the target's deliberate flaws. Red exploits them
(Kerberoasting, password spraying, lateral movement via over-priv
accounts, anonymous SMB enumeration) and blue defends against them by
detecting (Wazuh AD rules), responding (active-response), or
hardening (rotating to strong passwords, removing Domain Admin
membership, disabling null-sessions). The provisioner echoes the
intentional weaknesses on stdout for transparency
(containers/ad/provision-users.sh:143-150).
What blue would do to raise it
- Observe: the admitted TechVault pack's AD detection rules are loaded and fire on matches by default (BASELINE ENABLED). Blue does not need to enable them—they observe automatically.
- Couple to enforcement: enable the
301002AD Kerberos<active-response>block (delete<disabled>yes</disabled>inconfig/wazuh_cluster/wazuh_manager.conf:289-295) to convert detections of rule 301002 into anaptl-firewall-dropaction. - Harden the target: rotate weak passwords, remove
emily.chen/svc-backupfromDomain Admins, set SPN protections onsvc-sql/svc-web, override the Samba default to disable anonymous null-session policy.
Source of truth
| Surface | File |
|---|---|
| AD provisioner with weak passwords + over-priv | containers/ad/provision-users.sh |
| Wazuh AD detection rules | Acquired techvault environment pack |
Network segmentation¶
Tag: STARTING POSTURE (structural isolation only—no policy enforcement)
Default state
Four Docker bridges
(docker-compose.yml:1357-1398):
| Network | Subnet | internal: true? |
Multi-homed containers |
|---|---|---|---|
aptl-security |
172.20.0.0/24 | no (egress for image pulls) | Wazuh, SOC tools |
aptl-dmz |
172.20.1.0/24 | yes (no internet egress) | webapp, mail, DNS, Suricata, Wazuh manager, kali |
aptl-internal |
172.20.2.0/24 | yes | AD, DB, fileshare, app server, Suricata, Wazuh manager, kali |
aptl-redteam |
172.20.4.0/24 | yes | kali (its primary attachment) |
internal: true blocks internet egress at the Docker bridge level
(per the inline SAF-002 comment). Multi-homed containers cross
zones; the lab's notable multi-home decisions:
- Suricata sits on dmz / internal / security so it can passively observe all three subnets via pcap.
- Wazuh manager sits on all three so agents in any subnet can
reach
1514/1515. - Kali is multi-homed onto dmz (172.20.1.30) and internal (172.20.2.35) in addition to redteam (172.20.4.30). This is deliberate: red traffic is not forced through a pivot, so packet captures of an attack reflect direct kali→target flows. It also means the lab's "network segmentation" is more about traffic labeling than enforcement: kali can talk to dmz and internal targets without a compromised intermediary.
There are no iptables/nftables rules in the repo. Inter-subnet isolation is structural (Docker bridges) plus naming/labeling (scenario IPs, MITRE technique mapping). It is not policy-enforced.
Why it ships this way
The lab models segmentation realistically (real networks isolate subnets, real attackers move via dual-homed pivots) but does not attempt to encode every enterprise firewall rule. Realism vs. researcher cognitive load.
What blue would do to raise it
- Add explicit container-level network policy via Docker plugins, iptables on host, or service-mesh sidecars.
- Reduce dual-homing on existing pivot containers (or move purposeful pivots to dedicated minimal containers).
Source of truth
| Surface | File |
|---|---|
| Network bridge definitions | docker-compose.yml:1357-1398 |
| Per-service network attachments | docker-compose.yml (per service block) |
Cross-references¶
- ADR-019: Suricata stays IDS-only; prevention via Wazuh AR
- ADR-020: Wazuh agents in-process vs sidecar
- ADR-021: Active-response whitelist via wrapper
- ADR-022: MISP-driven Suricata rules
- Wazuh Active Response component reference
- Wazuh SIEM component reference
How to extend¶
When adding a new defensive surface (a new SOAR playbook, a new active-response command, a new Suricata rule family, a new MISP tag graduation lane), the contract is:
- Default to inert downstream coupling. New detection content
(Wazuh rules, Suricata
alertrules) may ship enabled—that's BASELINE ENABLED—but the response coupling (active-response blocks, Shuffle workflows, MISPaptl:enforcegraduation, AR webhook URLs) must ship disabled, empty, or absent. The rule of thumb: raising baseline detection sensitivity is fine; coupling a detection to automated action by default is not. Blue's per-iter work is to flip the coupling on. - Tag it. Edit this document and add a section (or row) under
the right banner:
STARTING POSTUREfor new wired-but-inactive coupling;BASELINE ENABLEDfor new always-on detection;WEAKNESS BY DESIGNfor new intentional target flaws. - Cite source of truth. Every new section must cite the files/lines where the default is set so a developer can grep to confirm.
- Cross-reference the ADR. If the new surface has architectural weight (drop-vs-alert, in-process-vs-sidecar, single-graduation-tag-vs-many), write an ADR first and link it from the section.
The goal is researchers reading a run result should always be able to look up exactly what the lab gave blue at first boot—no surprises, no hidden enforcement, no "this rule already fires on its own."
Appendix: forward-looking notes (NOT current contract)¶
This section captures contracts that do not yet exist in the code. Read it as design intent, not as documentation of today's behavior.
Per-run mode-gated posture (SCN-001)¶
SCN-001's statement
in the GRC workflow platform describes a mode (red/blue/purple) field on each
scenario YAML that would gate which posture defaults apply per run.
Today's reality is RAES-only for supported startup: APTL acquires the configured pack, validates its associated-artifact identity, and projects its catalog metadata. There is no APTL-local catalog, archived scenario tree, or curated TechVault SDL input.
No runtime mode-gating exists. Even when a scenario YAML's mode:
appears in an external authored scenario, supported startup code does not read it
and no code branches on the value to apply per-mode posture defaults. Any
future posture-mode feature must be designed against RAES SDL and the catalog
handoff, not by reviving the archived YAML format.
When mode lands as a real SDL feature, the intended contract is:
redruns—full WEAKNESS-BY-DESIGN baseline; STARTING POSTURE surfaces stay disabled. Red exploits; defenders observe.blueruns—same baseline, plus blue-side MCP surfaces (aptl-wazuh,aptl-threatintel,aptl-casemgmt,aptl-soar,aptl-network,aptl-indexer) so blue can enable, configure, and graduate STARTING POSTURE surfaces.purpleruns—both red and blue MCP surfaces exposed. Each iteration alternates: red attacks under the current posture; blue raises the posture (enables AR, adds an IOC, authors azzz_purple_loop.xmlrule); red attacks again.
Until #263 is resolved, the per-run posture is whatever this
document describes—the same baseline regardless of any mode:
value present in scenario YAML.
Orchestrator-side continuity carve-out (RTE-001 / #252)¶
Issue #252 ships a second,
out-of-band continuity mechanism: between purple-team iterations, the
researcher (or the future scenario runtime engine's orchestration domain)
audits target iptables state and removes blanket source-IP bans against
kali that would wedge the next iteration. This is the complement to
ADR-021's in-band aptl-firewall-drop whitelist. See
ADR-024
for the full design.
The audit is invoked manually today via aptl lab continuity-audit. The
behavioral contract:
- runs unconditionally today—APTL is the purple-team lab and every
shipped scenario is purple by design. The audit does not read
modefrom filenames, fixtures, agents, or CLI defaults (codex's "no inference" guardrail in the SDL runtime architecture); - once SDL adds an authoritative
modefield (#263) the audit gains ascenario.mode == PURPLEgate at the orchestration call site, soredandblueruns are explicitly skipped (a defender's source-IP ban remains a valid outcome there); - logs each reversion as a structured
KaliCarveOutEventto a specific run'scontinuity-events.jsonlwhen invoked with--run-id <existing-run>(and always emits a stdout summary). The session-bound archival path is plumbed but inactive until the runtime engine populatesScenarioSession.run_id(#263); - preserves ADR-021's in-band whitelist as the first defense rather than
moving whitelist semantics into the Wazuh manager or bare
firewall-drop; - detects only coarse kali source-IP drops on the
INPUTchain, not granular payload-, port-, behavior-, or timeout-bounded defensive actions, and not subnet-mask bans (/24and other non-/32).