Design Proposal: MITRE ATT&CK / D3FEND Ingest Scope Re-Evaluation
DECIDED: Path 2 — implementations removed in Sprint 20260513_008. The PM chose Path 2 (remove) over this proposal’s recommended Path 1 (status quo + safety regression test). All ATT&CK / D3FEND ingest code, tests, plugin manifests, source definitions, HTTP clients, job anchors, and seed rows have been deleted. Forward migrations 028 + 029 reverse the live-DB effects of migrations 017 and 027 (mirror domain). See
docs/implplan/SPRINT_20260513_008_Concelier_mitre_path2_removal.mdfor the removal evidence trail.The analysis below is the original proposal, preserved for the record. It recommends Path 1; that recommendation was superseded by the decision in the banner. Read it for the options reasoning, not for current state.
Status: Decided — Path 2 (remove). See banner above. Date: 2026-05-12 (drafted) / 2026-05-13 (decision + execution) Sprint: docs/implplan/SPRINT_20260505_039_Concelier_mitre_connector_implementation.md — CON-MITRE-004 (closed by Path 2) docs/implplan/SPRINT_20260513_008_Concelier_mitre_path2_removal.md — execution sprint Authors: Concelier Product / Platform Architecture
Context
Sprint 20260505_046 shipped two Concelier connectors — Connector.MitreAttack (STIX 2.1 attack-pattern bundle) and Connector.MitreD3fend (SPARQL ontology mappings) — that ingest MITRE ATT&CK technique IDs (e.g. T1055.011) and D3FEND defensive technique keys (e.g. D3F-TokenBinding) into vuln.advisories. Both connectors are wired into DI, seeded in 017_seed_mitre_connector_sources.sql, and have green adjacent test projects (6/6 each).
On 2026-05-12 the PM reopened Sprint 039 because three concerns survived the original “ship it” decision:
- Zero downstream consumers exist. No policy rule, risk-engine knob, findings query, or VEX flow reads
mitre-attack/mitre-d3fendsource IDs. The morning rationale (“keep ingest warm for future consumers”) is speculative. - The “advisory” shape is being repurposed. Both parsers emit
Advisoryrecords withaffectedPackages = Array.Empty<>(), no CVSS, no severity. Distinction is carried invuln.sources.configas{"category":"ttp"}/{"category":"defensive"}— a string flag, not a schema boundary. - Sprint 20260512_006 introduced a CVE-aware policy gate (
PolicyRuntimeEvaluator+CveDenylistExtractor). ATT&CK rows now share a table with the data the gate reads from. If the gate matched loosely, an operator denylist rule could accidentally trip on a technique ID.
This proposal walks Paths 1 / 2 / 3 against the actual code paths and recommends a course.
Current state
What Sprint 046 shipped:
src/Concelier/__Libraries/StellaOps.Concelier.Connector.MitreAttack/—Fetcher/Parser/Connector/Options/Plugin/ DI routine + 6 passing tests. Ingestshttps://raw.githubusercontent.com/mitre-attack/attack-stix-data/master/enterprise-attack/enterprise-attack.json.src/Concelier/__Libraries/StellaOps.Concelier.Connector.MitreD3fend/— symmetrical layout + 6 passing tests. Ingestshttps://d3fend.mitre.org/api/ontology/inference/d3fend-full-mappings.json.Migrations/017_seed_mitre_connector_sources.sql— seeds twovuln.sourcesrows (priority 140 / 142, enabled=true).docs/modules/concelier/connectors/credentials-matrix.md— documents implemented state.
What persists from the connectors:
vuln.advisoriesrows withadvisory_key = TechniqueId,primary_vuln_id = AdvisoryKey(the converter falls back toAdvisoryKeywhen noCVE-alias is present —AdvisoryConverter.cs:45-48),source_idbound to the matchingvuln.sources.key,affected_packages = [].vuln.advisory_aliasesrows holding the technique ID and any related ATT&CK IDs (D3FEND only).
Who reads vuln.advisories:
PostgresFindingsLookup.cs(Policy.Engine, Sprint 20260512_006) — starts FROMvuln.advisory_affected af JOIN vuln.advisories a ON a.id = af.advisory_id, returnsa.primary_vuln_id. Because ATT&CK / D3FEND parsers emit no affected-package rows, no T-IDs can surface through this path.VulnerabilityCorrelationService.cs(Platform.Analytics) — also starts FROMadvisory_affected(same join shape). Same protection.AdvisoryRepository.cs— hasGetByPrimaryVulnId/GetByKeyshaped lookups exposed to the Concelier WebService UI; these would surface a T-ID if explicitly queried, but no downstream caller does so today.- UI:
live-advisory-sources-ui-bootstrap.jsonshows both source rows visible in the source-management catalog (operator-facing list, not the advisory feed).
Who consumes category=ttp / category=defensive: nobody outside the two connectors themselves. Verified by Grep across src/ and docs/.
Options analysis
| Aspect | Path 1 (status quo) | Path 2 (remove) | Path 3 (extract to threat module) |
|---|---|---|---|
| Code churn | none (+ 1 regression test) | ~17 files deleted + sln + migration + docs | new StellaOps.Concelier.Threat library, new threat.techniques schema, copy parser/fetcher logic, update plugin registry |
| Schema integrity | T-IDs share vuln.advisories but the safety boundary is the absent advisory_affected rows | clean — vuln.advisories is CVE-only by construction | clean — threat.techniques is a new namespace |
| CVE-gate accidental match risk | none today (no advisory_affected rows, gate joins through that table); future contributors must preserve “no affected rows” invariant | none (ATT&CK absent) | none (different table) |
| Loses ingested data | no | yes (techniques disappear; STIX bundle hash + raw payload preserved in vuln.documents until that table is cleared) | no (data moves) |
| Future consumer onboarding | trivial — just JOIN vuln.advisories ON source_id = 'mitre-attack' (with the “no affected_rows” caveat) | requires a focused 1-week sprint to re-add connectors with a real consumer’s shape in mind | trivial in the threat namespace; cross-namespace queries are explicit |
| Architectural cleanliness | mediocre — “advisory” abstraction is overloaded | high — Concelier is “vulnerability advisories only” | high — Concelier remains vuln-only; threat-intel has its own home |
| Reversibility | high — Path 2 / Path 3 still available later | low — re-implementation cost is non-trivial when a real consumer surfaces | medium — schema migration cost grows with row count |
| Effort estimate | ~0.5 day (test only) | ~1 day (deletion + sln cleanup + docs) | ~5–8 days (new library + schema + reorg + tests + migration of seeded rows) |
Path 1 (status quo): retain + add safety-boundary regression test
Leave the connectors in place. Add one regression test asserting:
MitreAttackParser.ToAdvisory(...)produces anAdvisorywithAffectedPackages.Count == 0.- Same for
MitreD3fendParser.ToAdvisory(...). - (Optional, broader) An integration test that loads an ATT&CK technique into
vuln.advisories, runs afinding.cve_id == "T1055"denylist rule throughPolicyRuntimeEvaluator, and asserts the decision isallowbecause noadvisory_affectedrow exists.
Pros: zero deletion risk; data stays warm; tests lock the safety invariant. Cons: the architectural smell (“technique IDs in vuln.advisories”) persists. Future contributors who add an affected_packages block to the parsers — even with good intent — break the invariant.
Path 2 (remove)
Delete Connector.MitreAttack and Connector.MitreD3fend libraries, delete the seed migration (or add 019_drop_mitre_connector_sources.sql that revokes the rows), remove the SourceDefinitions entries, update docs.
Pros: cleanest schema; no smell, no accidental-match story. Cons: throws away working implementation + raw payload archive. Re-implementation later requires re-licensing/re-vetting the upstream JSON shape, re-writing parser + tests, re-seeding sources. Empirical observation: the morning rationale (“warm ingest”) is reversible — but only at non-trivial cost.
Path 3 (extract to threat module)
Create StellaOps.Concelier.Threat (or StellaOps.Findings.Threat) with:
- New schema:
threat.techniques (id uuid, technique_id text, kind text, name text, summary text, source_key text, related_techniques text[], ingested_at, ...). - Move/copy parser logic from the two
Connector.Mitre*libraries. - New
IThreatTechniqueStoreinterface, no aliasing withIAdvisoryStore. Migrations/<NNN>_create_threat_schema.sql+ a one-time backfill migration moving existingvuln.advisoriesrows wheresource_id IN ('mitre-attack','mitre-d3fend')tothreat.techniques, then deleting them fromvuln.advisories.- Update the source-management UI to render
threat.techniquesrows alongsidevuln.advisoriesif/when an operator-facing view is requested.
Pros: architecturally clean; future threat-aware policy rules have a well-named home; Concelier’s contract becomes “vulnerability advisories only” again. Cons: largest effort. Builds a parallel persistence story that has no consumer today, just to clean an internal smell. Speculative infrastructure.
Recommendation
Path 1 (status quo) + add the safety-boundary regression test.
Rationale (short form):
- The smell is real but doesn’t bite in the current schema/code reality. The CVE-aware policy gate joins through
vuln.advisory_affected, and both MITRE parsers emit empty affected-package lists. T-IDs cannot leak into the findings set through any current production query. - Path 3’s effort (5–8 days for a new schema + migration of zero consumers) is speculative infrastructure. Until a real threat-aware policy rule exists, paying that cost optimises the wrong thing.
- Path 2 destroys a working ingest path + raw payload archive for an architectural concern that has no live impact. Reversible only at re-implementation cost.
- Path 1 + regression test gives us the cleanliness benefit (the invariant is now load-bearing and tested) without the destruction (Path 2) or speculation (Path 3).
- Path 3 stays on the table for the moment the first threat-aware policy rule lands. At that point the rule’s shape (e.g.
finding.attack_techniques contains "T1055") tells us exactly what thethreat.techniquesschema should look like, and the migration is a focused 1-week sprint instead of a speculative one.
Migration plan if Path 1 is chosen
No source-code changes required to the connectors. Add:
- Regression test in
src/Concelier/__Tests/StellaOps.Concelier.Connector.MitreAttack.Tests/:MitreAttackParserTests.ToAdvisory_DoesNotEmitAffectedPackages— assertsAdvisory.AffectedPackages.Count == 0for a representative technique.- Mirror test for D3FEND in
StellaOps.Concelier.Connector.MitreD3fend.Tests/.
- Integration regression test (optional, in
src/Policy/__Tests/StellaOps.Policy.Engine.Tests/):- Seed a
vuln.advisoriesrow withprimary_vuln_id = 'T1055',source_id = mitre-attack source UUID, noadvisory_affectedrow. - Compile a policy bundle with rule
deny when finding.cve_id == "T1055". - Resolve a release whose components have ATT&CK as a known source.
- Assert
decision == "allow"and matches array is empty.
- Seed a
- Doc update: add a “Safety boundary” subsection to
docs/modules/concelier/connectors/credentials-matrix.md(MITRE section) noting:- Why T-IDs in
vuln.advisoriescannot leak into policy denylists. - The invariant: ATT&CK / D3FEND parsers MUST NOT emit
affected_packagesrows.
- Why T-IDs in
- AGENTS.md addition in the two connector libraries pointing to the invariant doc — so future contributors don’t add affected-package inference helpfully.
Open questions for PM
- Should the seeded sources be
enabled = falseuntil a real consumer ships? Currently both run on the standard fetch cadence. Flippingenabled = falsein the seed migration reduces the live row count and network footprint, with no behaviour change for the absent consumer. - Should the UI hide
category=ttp/category=defensivesources from the operator catalog until a consumer surfaces? Today they’re visible in/api/v1/advisory-sources, which is confusing — an operator can “disable” them but nothing downstream notices. - Where does the Path 3 trigger live? Suggest: file a docs-only ADR (
ADR-MITRE-THREAT-EXTRACTION-TRIGGER.md) that explicitly says “when the first non-Concelier consumer ofmitre-attack/mitre-d3fendis filed, immediately schedule the Path 3 extraction sprint.” That makes the deferral auditable. - What’s the policy gate’s posture on aliased denylists? Today
CveDenylistExtractormatches onfinding.cve_idonly — would a futurefinding.alias_contains "T1055"rule shape change the safety story? (Not today, but worth flagging.)
Decision log
- Pending PM sign-off on Path 1 + regression test scope.
- If approved, file a 0.5-day implementation sprint owned by Policy
- Concelier Connectors. If Path 2 / Path 3 is chosen, file the matching teardown / extraction sprint.
