Report Adapters¶
This document covers the report adapter contract, section models, block types, the rendering pipeline, and how to write a custom adapter.
ReportAdapter Abstract Interface¶
Every report adapter implements the ReportAdapter ABC from blue_tap.framework.contracts.report_contract:
from abc import ABC, abstractmethod
from typing import Any
class ReportAdapter(ABC):
module: str = ""
@abstractmethod
def accepts(self, envelope: dict[str, Any]) -> bool:
"""Return True if this adapter can handle the given envelope."""
...
@abstractmethod
def ingest(self, envelope: dict[str, Any], report_state: dict[str, Any]) -> None:
"""Extract relevant data from envelope into report_state.
Called once per matching envelope. Accumulate data in report_state
(a shared dict that persists across multiple ingest calls).
"""
...
@abstractmethod
def build_sections(self, report_state: dict[str, Any]) -> list[SectionModel]:
"""Build renderable sections from the accumulated report_state.
Called once after all envelopes have been ingested.
"""
...
@abstractmethod
def build_json_section(self, report_state: dict[str, Any]) -> dict[str, Any]:
"""Build a JSON-serializable dict for JSON report output."""
...
Key Contract Points¶
accepts()is called with a minimal probe dict (at least{"schema": "..."}) to check if the adapter handles a given schema.ingest()may be called multiple times (once per envelope). Usereport_state.setdefault()to accumulate.build_sections()is called once after all envelopes are ingested. It returns a list ofSectionModelinstances.build_json_section()produces the JSON report equivalent.
Section Models¶
SectionModel¶
@dataclass(frozen=True)
class SectionModel:
section_id: str # Unique section identifier
title: str # Section heading
summary: str = "" # Brief summary text
blocks: tuple[SectionBlock, ...] = () # Renderable content blocks
SectionBlock¶
@dataclass(frozen=True)
class SectionBlock:
block_type: str # One of the registered block types
data: dict[str, Any] = field(default_factory=dict) # Block-specific payload
Block Types¶
The BlockRendererRegistry ships with 9 built-in block types:
| Block Type | data Keys |
Description |
|---|---|---|
table |
headers: list[str], rows: list[list \| dict] |
HTML table. Rows can be lists or dicts (keyed by header). |
paragraph |
text: str |
Single <p> element. |
text |
text: str |
Preformatted <pre> element. |
card_list |
cards: list[dict] |
Cards with title, status, and key-value details. |
key_value |
pairs: dict (or flat dict) |
Key-value pair display. |
badge_group |
badges: list[dict] |
Group of status badges. |
status_summary |
(flat dict) | Status summary with counts and indicators. |
timeline |
events: list[dict] |
Chronological event timeline. |
html_raw |
html: str |
Raw HTML pass-through (no escaping). |
Block Construction Examples¶
from blue_tap.framework.contracts.report_contract import SectionBlock
# Table
table = SectionBlock(
block_type="table",
data={
"headers": ["CVE", "Severity", "Status"],
"rows": [
["CVE-2020-26555", "High", "confirmed"],
["CVE-2023-24023", "Medium", "not_detected"],
],
},
)
# Card list
cards = SectionBlock(
block_type="card_list",
data={
"cards": [
{"title": "BIAS Attack", "status": "success", "protocol": "Classic"},
{"title": "KNOB Attack", "status": "failed", "protocol": "Classic"},
],
},
)
# Key-value pairs
kv = SectionBlock(
block_type="key_value",
data={"pairs": {"Target": "AA:BB:CC:DD:EE:FF", "Adapter": "hci0", "Duration": "12.3s"}},
)
# Badge group
badges = SectionBlock(
block_type="badge_group",
data={"badges": [{"label": "Classic", "value": "supported"}, {"label": "BLE", "value": "not tested"}]},
)
Built-in Adapters¶
Blue-Tap ships with 11 adapters in blue_tap.framework.reporting.adapters:
| Adapter | Module | Handles Schema |
|---|---|---|
DiscoveryReportAdapter |
discovery | blue_tap.scan.* |
VulnscanReportAdapter |
vulnscan | blue_tap.vulnscan.* |
AttackReportAdapter |
attack | blue_tap.attack.* |
DataReportAdapter |
data | blue_tap.data.* |
AudioReportAdapter |
audio | blue_tap.audio.* |
DosReportAdapter |
dos | blue_tap.dos.* |
FirmwareReportAdapter |
firmware | blue_tap.firmware.* |
FuzzReportAdapter |
fuzz | blue_tap.fuzz.* |
LmpCaptureReportAdapter |
lmp_capture | blue_tap.lmp_capture.* |
ReconReportAdapter |
recon | blue_tap.recon.* |
SpoofReportAdapter |
spoof | blue_tap.spoof.* |
All are instantiated in the REPORT_ADAPTERS tuple and returned by get_report_adapters().
Adapter Discovery¶
get_report_adapters() returns a tuple combining:
- Built-in adapters from the static
REPORT_ADAPTERStuple. - Plugin adapters discovered via
ModuleDescriptor.report_adapter_pathon registered modules.
Plugin adapters are loaded lazily at call time. Deduplication is by class identity -- a plugin pointing to a built-in adapter class is not loaded twice.
from blue_tap.framework.reporting.adapters import get_report_adapters
all_adapters = get_report_adapters()
To find adapters that handle a specific schema:
from blue_tap.framework.reporting.adapters import get_adapters_for_report
adapters = get_adapters_for_report("blue_tap.vulnscan.result")
Rendering Pipeline¶
RunEnvelope(s)
|
v
adapter.accepts(envelope) <-- match by schema
|
v
adapter.ingest(envelope, state) <-- accumulate data (called per envelope)
|
v
adapter.build_sections(state) <-- produce SectionModel list (called once)
|
v
BlockRendererRegistry.render() <-- each SectionBlock -> HTML string
|
v
HTML output
The BlockRendererRegistry (from blue_tap.framework.reporting.renderers.registry) maps block_type strings to renderer functions. Unknown block types fall back to render_unknown_block() which produces a <pre> dump of the block data.
Coercion¶
The registry coerces loose block representations into SectionBlock instances via coerce_block():
SectionBlockinstance: passed throughdictwithblock_typekey: converted toSectionBlock- Any other value: wrapped as
SectionBlock(block_type="text", data={"text": str(value)})
Complete Working Adapter¶
Here is a full adapter for a hypothetical "network exposure" module, showing every method with realistic data handling and multiple block types in the output.
Implementation¶
"""Adapter for network_exposure module.
Location: blue_tap/framework/reporting/adapters/network_exposure.py
"""
from __future__ import annotations
from typing import Any
from blue_tap.framework.contracts.report_contract import (
ReportAdapter,
SectionBlock,
SectionModel,
)
class NetworkExposureReportAdapter(ReportAdapter):
module = "network_exposure"
def accepts(self, envelope: dict[str, Any]) -> bool:
return envelope.get("schema", "").startswith("blue_tap.network_exposure.")
def ingest(self, envelope: dict[str, Any], report_state: dict[str, Any]) -> None:
"""Accumulate exposure data from one or more envelopes."""
exposures = report_state.setdefault("network_exposures", [])
summary = report_state.setdefault("network_exposure_summary", {
"total_services": 0,
"unauthenticated": 0,
"encrypted": 0,
"unencrypted": 0,
})
for execution in envelope.get("executions", []):
evidence = execution.get("evidence", {})
module_evidence = evidence.get("module_evidence", {})
entry = {
"service": execution.get("title", "Unknown"),
"protocol": execution.get("protocol", ""),
"outcome": execution.get("module_outcome", ""),
"auth_required": module_evidence.get("auth_required", True),
"encrypted": module_evidence.get("encrypted", False),
"channel": module_evidence.get("channel", ""),
"summary": evidence.get("summary", ""),
}
exposures.append(entry)
summary["total_services"] += 1
if not entry["auth_required"]:
summary["unauthenticated"] += 1
if entry["encrypted"]:
summary["encrypted"] += 1
else:
summary["unencrypted"] += 1
def build_sections(self, report_state: dict[str, Any]) -> list[SectionModel]:
"""Build sections with summary badges, a service table, and risk notes."""
exposures = report_state.get("network_exposures", [])
summary = report_state.get("network_exposure_summary", {})
if not exposures:
return []
# Block 1: Summary badges
badges_block = SectionBlock(
block_type="badge_group",
data={
"badges": [
{"label": "Services Found", "value": str(summary["total_services"])},
{"label": "No Auth Required", "value": str(summary["unauthenticated"])},
{"label": "Unencrypted", "value": str(summary["unencrypted"])},
],
},
)
# Block 2: Detailed service table
rows = []
for exp in exposures:
rows.append([
exp["service"],
exp["protocol"],
exp["channel"],
"No" if not exp["auth_required"] else "Yes",
"Yes" if exp["encrypted"] else "No",
exp["outcome"],
])
table_block = SectionBlock(
block_type="table",
data={
"headers": ["Service", "Protocol", "Channel", "Auth", "Encrypted", "Status"],
"rows": rows,
},
)
# Block 3: Risk paragraph (if unauthenticated services found)
blocks = [badges_block, table_block]
if summary["unauthenticated"] > 0:
risk_block = SectionBlock(
block_type="paragraph",
data={
"text": (
f"{summary['unauthenticated']} service(s) are accessible without "
f"authentication. These represent direct attack surface for "
f"unauthenticated remote exploitation."
),
},
)
blocks.append(risk_block)
return [SectionModel(
section_id="network_exposure",
title="Network Service Exposure",
summary=f"{summary['total_services']} services, {summary['unauthenticated']} unauthenticated",
blocks=tuple(blocks),
)]
def build_json_section(self, report_state: dict[str, Any]) -> dict[str, Any]:
return {
"network_exposures": report_state.get("network_exposures", []),
"summary": report_state.get("network_exposure_summary", {}),
}
Rendered HTML Structure¶
The adapter above produces an HTML section that renders approximately as:
+------------------------------------------------------------------+
| Network Service Exposure |
| 5 services, 2 unauthenticated |
| |
| [Services Found: 5] [No Auth Required: 2] [Unencrypted: 3] |
| |
| Service | Protocol | Channel | Auth | Encrypted | Status |
| -----------------+----------+---------+------+-----------+--------|
| OBEX Push | RFCOMM | 9 | No | No | confirmed |
| Serial Port | RFCOMM | 3 | No | No | confirmed |
| Audio Gateway | RFCOMM | 7 | Yes | Yes | observed |
| A2DP Sink | AVDTP | -- | Yes | Yes | observed |
| HID Control | L2CAP | 0x0011 | Yes | No | observed |
| |
| 2 service(s) are accessible without authentication. These |
| represent direct attack surface for unauthenticated remote |
| exploitation. |
+------------------------------------------------------------------+
The actual HTML uses styled <table>, <div class="badge">, and <p> elements with the Blue-Tap CSS theme.
Writing a Custom Adapter¶
1. Create the Adapter Class¶
Place it in blue_tap/framework/reporting/adapters/<name>.py:
"""Adapter for my_module."""
from __future__ import annotations
from typing import Any
from blue_tap.framework.contracts.report_contract import (
ReportAdapter,
SectionBlock,
SectionModel,
)
class MyModuleAdapter(ReportAdapter):
module = "my_module"
def accepts(self, envelope: dict[str, Any]) -> bool:
return envelope.get("schema", "").startswith("blue_tap.my_module.")
def ingest(self, envelope: dict[str, Any], report_state: dict[str, Any]) -> None:
results = report_state.setdefault("my_module_results", [])
for ex in envelope.get("executions", []):
results.append({
"title": ex.get("title", ""),
"outcome": ex.get("module_outcome", ""),
"evidence": ex.get("evidence", {}).get("summary", ""),
})
def build_sections(self, report_state: dict[str, Any]) -> list[SectionModel]:
results = report_state.get("my_module_results", [])
if not results:
return []
rows = [[r["title"], r["outcome"], r["evidence"]] for r in results]
return [SectionModel(
section_id="my_module",
title="My Module Results",
summary=f"{len(results)} result(s)",
blocks=(
SectionBlock(
block_type="table",
data={"headers": ["Title", "Outcome", "Evidence"], "rows": rows},
),
),
)]
def build_json_section(self, report_state: dict[str, Any]) -> dict[str, Any]:
return {"my_module": report_state.get("my_module_results", [])}
2. Register the Adapter¶
For built-in adapters: Add the adapter instance to the REPORT_ADAPTERS tuple in blue_tap/framework/reporting/adapters/__init__.py, and import the class at the top of the file.
For plugin adapters: Set report_adapter_path on the ModuleDescriptor:
ModuleDescriptor(
module_id="assessment.my_module",
# ... other fields ...
has_report_adapter=True,
report_adapter_path="my_package.adapters:MyModuleAdapter",
)
The adapter will be discovered automatically by get_report_adapters().
3. Verify¶
# Check that the adapter is discovered
python -c "
from blue_tap.framework.reporting.adapters import get_adapters_for_report
adapters = get_adapters_for_report('blue_tap.my_module.result')
print(f'Found {len(adapters)} adapter(s)')
for a in adapters:
print(f' - {type(a).__name__}')
"
Custom Block Types¶
You can register custom block types on the BlockRendererRegistry:
from blue_tap.framework.contracts.report_contract import SectionBlock
from blue_tap.framework.reporting.renderers.registry import get_default_block_renderer_registry
def render_my_block(block: SectionBlock) -> str:
data = block.data
return f'<div class="my-block">{data.get("content", "")}</div>'
registry = get_default_block_renderer_registry()
registry.register("my_block", render_my_block)
After registration, any SectionBlock(block_type="my_block", ...) will use your renderer.