diff --git a/AGENTS.md b/AGENTS.md index 1d3d84c2..f1579294 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,7 +82,7 @@ recon "" Synthetic Alpha Ltd alpha.invalid ────────────────────────────────────────────────────────────────────────────── - Provider Microsoft 365 (MX delivery path) + Proofpoint gateway (MX delivery path) + Provider Microsoft 365 + Proofpoint gateway Tenant a1b2c3d4-e5f6-7890-abcd-ef1234567890 • NA Auth Federated Confidence ●●● High (4 sources) diff --git a/CHANGELOG.md b/CHANGELOG.md index 67f90cdc..f660778e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,26 @@ operator, corporate group, ownership, or control. catches contract drift without tracking tenant churn. No shipped behavior changed: `SourceResult` already recorded the negative with `source_unavailable` false, and that field is what merge and delta consume. +- The default human views no longer repeat an evidence-role qualifier on every + service and provider label. `Slack (public TXT account indicator)`, + `Okta (CNAME endpoint binding)`, and `Microsoft 365 (MX delivery path)` now + read as `Slack`, `Okta`, and `Microsoft 365` on the default panel, `--plain`, + `--md`, and the `--chain` tree, with one dim line naming the flag that + restores the detail. `--explain`, `--verbose`, and `--full` are unchanged, as + are the `--json` record (including the stable `provider` field), `--csv`, and + every MCP payload. A match whose role could not be established is omitted + from the default view and counted in that line rather than rendered as a bare + vendor name, so removing the qualifier never upgrades a claim; the provider + row re-hedges as `(no supporting record)` instead of dropping, and the + downstream and unobserved-gateway hedges survive in shortened form. Recorded + in [ADR-0012](docs/adr/0012-default-view-evidence-role-visibility.md), which + also restates the renderer obligations on the `runtime.service-label.v1` and + `runtime.email-topology.v1` claim families. +- Insight curation moved out of `formatter/panel.py` into + `formatter/insight_curation.py`. It is editorial policy over insight strings + rather than rendering, it depends on nothing else, and the move ratchets the + panel module's file-size ceiling down from 1719 to 1615 lines. No behavior + change. ## [2.10.2] - 2026-08-04 diff --git a/README.md b/README.md index e9dd0ef4..b2345c46 100644 --- a/README.md +++ b/README.md @@ -80,8 +80,7 @@ $ recon globex.invalid Globex Ltd globex.invalid ────────────────────────────────────────────────────────────────────────────── - Provider Microsoft 365 (MX delivery path) + Proofpoint gateway (MX - delivery path) + Provider Microsoft 365 + Proofpoint gateway Tenant a1b2c3d4-e5f6-7890-abcd-ef1234567890 • NA Tenant domain globex.onmicrosoft.invalid Auth Federated @@ -89,14 +88,13 @@ globex.invalid Services - Email Microsoft 365 (MX delivery path), - Proofpoint (MX delivery path), DMARC reject, DKIM, + Email Microsoft 365, Proofpoint, DMARC reject, DKIM, SPF strict, MTA-STS enforce - Identity Okta (CNAME endpoint binding) + Identity Okta Cloud Cloudflare (CDN/edge), AWS Route 53 (DNS) - Security Wiz Security (public TXT account indicator) - Collaboration Slack (public TXT account indicator), - Atlassian (Jira/Confluence) (CNAME endpoint binding) + Security Wiz Security + Collaboration Slack, Atlassian (Jira/Confluence) + Evidence roles: --explain High-signal related domains diff --git a/ROADMAP.md b/ROADMAP.md index bd4a4f54..e0175657 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -122,6 +122,9 @@ exploit checks. **Claim discipline** - domain is a query coordinate; sparse stays sparse; source failure is unavailable, never negative; parent platform is not child-product use; public-evidence index is not overall security maturity. +Evidence roles are qualified on the detail and machine surfaces and may be +compacted out of the default view, never in a way that upgrades a claim +([ADR-0012](docs/adr/0012-default-view-evidence-role-visibility.md)). **Data handling** - no committed real-target corpus; synthetic / reserved examples; aggregate-only public validation artifacts. diff --git a/agents/claude-code/skills/recon/SKILL.md b/agents/claude-code/skills/recon/SKILL.md index 57ef7af8..5ebb743a 100644 --- a/agents/claude-code/skills/recon/SKILL.md +++ b/agents/claude-code/skills/recon/SKILL.md @@ -90,7 +90,7 @@ recon "" Synthetic Alpha Ltd alpha.invalid ────────────────────────────────────────────────────────────────────────────── - Provider Microsoft 365 (MX delivery path) + Proofpoint gateway (MX delivery path) + Provider Microsoft 365 + Proofpoint gateway Tenant a1b2c3d4-e5f6-7890-abcd-ef1234567890 • NA Auth Federated Confidence ●●● High (4 sources) diff --git a/docs/adr/0012-default-view-evidence-role-visibility.md b/docs/adr/0012-default-view-evidence-role-visibility.md new file mode 100644 index 00000000..e892ea25 --- /dev/null +++ b/docs/adr/0012-default-view-evidence-role-visibility.md @@ -0,0 +1,75 @@ +# ADR-0012: Split evidence-role visibility between the default and detailed views + +- **Status:** Accepted +- **Date:** 2026-08-07 + +## Context + +Every human-facing service label carried the record role that established it, +inline and in full prose: `Slack (public TXT account indicator)`, +`Okta (CNAME endpoint binding)`, `Microsoft 365 (MX delivery path)`, +`Yahoo Small Business (role unavailable)`. The renderer obligation on claim +family `runtime.service-label.v1` required the qualification, and the +obligation is right: a catalog name alone does not establish a role, and a TXT +account record is not a deployment. + +The implementation of that obligation was wrong for the default view. The +qualifier repeated once per service, and often several times per line, so the +answer to "what do they run" arrived buried under the answer to "how do we +know", which the operator had not asked for yet. On a dense apex the Provider +row spent two wrapped lines saying `(MX delivery path)` twice. The +`(role unavailable)` case was worse than noise: it occupied a row to report +that recon could not say why it named the vendor at all. + +The claim-discipline invariant in `ROADMAP.md` treats this qualification as a +default-surface obligation, so relaxing it needs a decision record rather than +a rendering tweak. + +## Decision + +Evidence roles move from "always inline" to "in the view that asks for them". + +- The **default** panel, `--plain`, `--md`, and the `--chain` tree render + service and provider labels without their record-role qualifier. +- `--explain`, `--verbose`, and `--full` render every qualifier exactly as + before, unchanged byte for byte. +- Every **machine** surface is unchanged: the `--json` record (including the + stable `provider` field), `--csv`, and the MCP tool payloads keep the full + prose roles. Agents and scripts lose nothing. +- A label whose role is **unavailable** is *omitted* from the default view + rather than rendered bare. Stripping the qualifier in place would promote an + unattributed catalog match into an asserted observation, which inverts the + obligation instead of relocating it. +- Two qualifiers survive compaction in shortened form because they hedge the + claim rather than name a record type: a non-MX provider renders as + `(likely downstream)`, and a gateway with no observed downstream renders as + `(downstream unobserved)`. +- The provider row re-hedges instead of dropping, as `(no supporting record)`. + It is the panel's single answer to "who handles their mail"; dropping the + segment would leave the row silent, and rendering it bare would assert a + delivery path no retained record supports. +- A default view that left something out says so, once, pointing at + `--explain`, and naming `--full` with a count when unattributed matches were + omitted. A view that compacted nothing carries no note. + +The obligation on `runtime.service-label.v1` and `runtime.email-topology.v1` +is restated accordingly: roles must be qualified on the evidence surfaces and +must never be *implied* on the default surface. Compaction that would upgrade +a claim is prohibited; compaction that only relocates a role is required. + +## Consequences + +- The default view answers the question the operator asked. Role detail is one + documented flag away, and the panel says which flag. +- The claim model is unchanged in substance. No default-view label asserts more + than it did before; the unattributed case now asserts strictly less. +- The default view is no longer a complete enumeration of catalog matches. + Unattributed matches appear only under `--full` and in the JSON record, so + any consumer counting rows must use the JSON record, not the panel. +- `--json` and the panel now differ in wording by design. `docs/stability.md` + already excludes panel prose from the contract and keeps the JSON `provider` + field stable; this ADR is the record of that divergence being deliberate. +- A new role qualifier must be added to the compaction table in + `formatter/classify.py` at the same time it is added to the label producers, + or it will leak into the default view. The golden renders in + `tests/test_golden_renders.py` are the regression guard. diff --git a/docs/adr/README.md b/docs/adr/README.md index 373a284f..af5ab622 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -31,3 +31,4 @@ deeper rationale for several lives in [correlation.md](../correlation.md), | [0009](0009-mcp-2026-readiness.md) | Prepare for MCP 2026-07-28 without premature protocol forking | | [0010](0010-evidence-gated-native-acceleration.md) | Keep the default runtime pure Python and evidence-gate any optional native accelerator | | [0011](0011-public-metadata-collection-boundary.md) | Define the public-metadata collection boundary and target-visible interactions | +| [0012](0012-default-view-evidence-role-visibility.md) | Split evidence-role visibility between the default and detailed views | diff --git a/docs/assets/terminal-demo.svg b/docs/assets/terminal-demo.svg index 4d6a7f1a..af7b303c 100644 --- a/docs/assets/terminal-demo.svg +++ b/docs/assets/terminal-demo.svg @@ -1,4 +1,4 @@ - + recon synthetic terminal demo Synthetic output for Globex Ltd (globex.invalid) showing public email, identity, cloud, security, collaboration, related-domain, and insight observations. - + @@ -110,15 +111,9 @@ - - - - - - - recon synthetic demo + recon synthetic demo @@ -132,32 +127,30 @@ Globex Ltd globex.invalid ────────────────────────────────────────────────────────────────────────────── -Provider     Microsoft 365 (MX delivery path) + Proofpoint gateway (MX -               delivery path) -Tenant       a1b2c3d4-e5f6-7890-abcd-ef1234567890 • NA -Tenant domain globex.onmicrosoft.invalid -Auth         Federated -Confidence   ●●● High (4 sources) +Provider     Microsoft 365 + Proofpoint gateway +Tenant       a1b2c3d4-e5f6-7890-abcd-ef1234567890 • NA +Tenant domain globex.onmicrosoft.invalid +Auth         Federated +Confidence   ●●● High (4 sources) + - -Services -Email          Microsoft 365 (MX delivery path), -                 Proofpoint (MX delivery path), DMARC reject, DKIM, -                 SPF strict, MTA-STS enforce -Identity       Okta (CNAME endpoint binding) -Cloud          Cloudflare (CDN/edge), AWS Route 53 (DNS) -Security       Wiz Security (public TXT account indicator) -Collaboration  Slack (public TXT account indicator), -                 Atlassian (Jira/Confluence) (CNAME endpoint binding) - - -High-signal related domains -login.globex.invalid, status.globex.invalid, support.globex.invalid - -Insights -Federated identity observed; identity-vendor indicators: Okta -Email security: observed controls: DMARC reject, DKIM, SPF strict, MTA-STS - +Services +Email          Microsoft 365, Proofpoint, DMARC reject, DKIM, +                 SPF strict, MTA-STS enforce +Identity       Okta +Cloud          Cloudflare (CDN/edge), AWS Route 53 (DNS) +Security       Wiz Security +Collaboration  Slack, Atlassian (Jira/Confluence) +Evidence roles: --explain + + +High-signal related domains +login.globex.invalid, status.globex.invalid, support.globex.invalid + +Insights +Federated identity observed; identity-vendor indicators: Okta +Email security: observed controls: DMARC reject, DKIM, SPF strict, MTA-STS + diff --git a/docs/default-claim-audit.json b/docs/default-claim-audit.json index 57aee930..d53803f5 100644 --- a/docs/default-claim-audit.json +++ b/docs/default-claim-audit.json @@ -304,14 +304,16 @@ "material": true, "producer_paths": [ "src/recon_tool/merger.py#compute_email_topology", - "src/recon_tool/formatter/classify.py#provider_line" + "src/recon_tool/formatter/classify.py#provider_line", + "src/recon_tool/formatter/classify.py#compact_provider_line" ], "regression_tests": [ "tests/test_email_topology.py", "tests/test_cli_output_contracts.py" ], "renderer_obligations": [ - "Distinguish MX delivery path, MX gateway, DKIM-supported downstream provider, and weaker non-MX indicators." + "Distinguish MX delivery path, MX gateway, DKIM-supported downstream provider, and weaker non-MX indicators.", + "The default view may compact the record role per ADR-0012 but retains the hedges that bound the claim: likely downstream, downstream unobserved, and no supporting record." ], "subject_scope": "public mail routing and provider indicators for the queried apex" }, @@ -764,7 +766,8 @@ ], "limits": [ "Legacy or externally constructed TenantInfo objects can carry service names without retained matching evidence.", - "A catalog name alone does not establish a role." + "A catalog name alone does not establish a role.", + "The default view omits unattributed matches rather than rendering them bare, so it is not a complete enumeration of catalog matches; --full and the JSON record are." ], "lineage_status": "exact", "material": true, @@ -773,7 +776,8 @@ "src/recon_tool/formatter/classify.py#evidence_role_service_label", "src/recon_tool/formatter/classify.py#role_aware_service_label", "src/recon_tool/formatter/email_summary.py#normalize_email_services", - "src/recon_tool/formatter/panel.py#_render_services" + "src/recon_tool/formatter/panel.py#_render_services", + "src/recon_tool/formatter/classify.py#compact_service_label" ], "regression_tests": [ "tests/test_delta.py", @@ -783,7 +787,8 @@ "tests/test_service_disclosure.py" ], "renderer_obligations": [ - "Qualify TXT account indicators, MX delivery paths, CAA authorizations, and endpoint bindings; unknown roles stay unavailable." + "Qualify TXT account indicators, MX delivery paths, CAA authorizations, and endpoint bindings; unknown roles stay unavailable.", + "Evidence surfaces (--explain, --verbose, --full, JSON, MCP) carry the full role qualifier; the default view may compact it per ADR-0012, but must never imply a role it did not establish." ], "subject_scope": "one catalog match and the role established by its retained public record type" }, diff --git a/docs/limitations.md b/docs/limitations.md index e7d0334b..8b25b2aa 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -127,8 +127,11 @@ use. ### Dual-provider organizations **Current:** A domain with M365 tenant + MX through a Trend Micro gateway and -M365 DKIM reads as "Trend Micro gateway (MX delivery path) + Microsoft 365 -(possible downstream indicator)". The ordering does not assert priority. If M365 +M365 DKIM reads as "Trend Micro gateway + Microsoft 365 (likely downstream)" on +the default view, and as "Trend Micro gateway (MX delivery path) + Microsoft 365 +(possible downstream indicator)" under `--explain` / `--full` and in the JSON +record ([ADR-0012](adr/0012-default-view-evidence-role-visibility.md)). The +ordering does not assert priority. If M365 is the only detected slug and Google Workspace fires only from a TXT token (no DKIM, no MX), Google Workspace remains an account signal and does not enter the provider line. Use `--full` or structured output when account-only diff --git a/docs/stability.md b/docs/stability.md index 2af98cfc..83ecd638 100644 --- a/docs/stability.md +++ b/docs/stability.md @@ -225,6 +225,12 @@ consumer was relying on the default. - **Rich panel visual formatting**: colors, whitespace, row ordering within Services categories, box-drawing details. The section structure is stable; pixel-level rendering is not. +- **Evidence-role wording on the human views**: the default panel, `--plain`, + and `--md` compact the record-role qualifier out of service and provider + labels and omit matches with no established role; `--explain`, `--verbose`, + and `--full` keep them. The JSON `provider` field and the MCP payloads are + unaffected and stay the machine contract. See + [ADR-0012](adr/0012-default-view-evidence-role-visibility.md). - **Insight wording**: individual insight text may be refined. The insight *types* and their *trigger conditions* are stable (see `signals.yaml`); the exact phrasing is not. diff --git a/scripts/check_file_size.py b/scripts/check_file_size.py index d7939219..3aa36b46 100644 --- a/scripts/check_file_size.py +++ b/scripts/check_file_size.py @@ -33,7 +33,7 @@ # decomposition track in docs/roadmap.md splits each file. Goal state: every # entry gone, every module under SOFT_CAP. BASELINE: dict[str, int] = { - "formatter/panel.py": 1719, + "formatter/panel.py": 1615, "merger.py": 671, "exposure.py": 574, } diff --git a/src/recon_tool/cli/lookup.py b/src/recon_tool/cli/lookup.py index d06b772e..4ea747d6 100644 --- a/src/recon_tool/cli/lookup.py +++ b/src/recon_tool/cli/lookup.py @@ -588,14 +588,21 @@ def _lookup_emit_markdown( results: list[Any], observations: tuple[Any, ...], *, - show_posture: bool, - show_explain: bool, + display: Any, ) -> None: - """Emit the tenant report as Markdown, with optional posture and explanations.""" + """Emit the tenant report as Markdown, with optional posture and explanations. + + Takes the display options rather than three booleans off them: the report + needs the posture flag, the explain flag, and the evidence-role detail + level, and threading each one separately put this function over the + argument ratchet for no readability gain. + """ from recon_tool.formatter import format_tenant_markdown from recon_tool.formatter.markdown import markdown_escape - md = format_tenant_markdown(info) + show_posture = display.show_posture + show_explain = display.show_explain + md = format_tenant_markdown(info, detailed=display.verbose or show_explain) if show_posture and observations: md += "\n## Posture Analysis\n\n" for obs in observations: @@ -612,7 +619,7 @@ def _lookup_emit_markdown( typer.echo(md) -def _lookup_emit_plain(info: Any, sections: dict[str, Any], *, include_unclassified: bool) -> None: +def _lookup_emit_plain(info: Any, sections: dict[str, Any], *, include_unclassified: bool, detailed: bool) -> None: """Emit the tenant report as plain, linear, greppable text (no panel). Takes the already-built optional sections rather than the flags that select @@ -622,7 +629,7 @@ def _lookup_emit_plain(info: Any, sections: dict[str, Any], *, include_unclassif from recon_tool.formatter import format_tenant_plain from recon_tool.formatter.serialize import plain_lines - lines = [format_tenant_plain(info, include_unclassified=include_unclassified)] + lines = [format_tenant_plain(info, include_unclassified=include_unclassified, detailed=detailed)] for key, value in sections.items(): lines.extend(plain_lines(value, key, 0)) typer.echo("\n".join(lines)) @@ -767,14 +774,11 @@ async def _lookup_standard( include_unclassified=options.include_unclassified, ) return + # The evidence-role detail level tracks the panel's (ADR-0012): the + # non-panel human surfaces are the same default view in another shape. + detailed_roles = options.verbose or options.show_explain if options.markdown: - _lookup_emit_markdown( - info, - results, - observations, - show_posture=options.show_posture, - show_explain=options.show_explain, - ) + _lookup_emit_markdown(info, results, observations, display=options) return if options.plain: _lookup_emit_plain( @@ -787,6 +791,7 @@ async def _lookup_standard( show_explain=options.show_explain, ), include_unclassified=options.include_unclassified, + detailed=detailed_roles, ) return diff --git a/src/recon_tool/formatter/classify.py b/src/recon_tool/formatter/classify.py index 8032febf..54a68fd7 100644 --- a/src/recon_tool/formatter/classify.py +++ b/src/recon_tool/formatter/classify.py @@ -51,6 +51,9 @@ "categorize_service", "categorize_services", "category_for_slug", + "compact_categorized_services", + "compact_provider_line", + "compact_service_label", "count_cloud_vendors", "detect_provider", "evidence_role_service_label", @@ -132,6 +135,26 @@ ("DKIM", "DKIM selector indicator"), ) +# ── Default-view label compaction (ADR-0012) ────────────────────────────── +# +# The qualifiers above answer "how do we know", which is the right answer for +# the evidence surfaces and the wrong one for the default panel: repeated once +# per service they bury the answer to "what do they run" under boilerplate the +# operator did not ask for. The helpers below produce the default-view form. +# --explain, --verbose, --full, and every machine surface keep the full labels, +# so no role is lost, only relocated. ADR-0012 records the decision and the two +# qualifiers that survive compaction because dropping them would convert a +# hedge into an assertion. +_ROLE_UNAVAILABLE_SUFFIX = " (role unavailable)" +_UNATTRIBUTED_PROVIDER_SUFFIX = " (no supporting record)" +_DOWNSTREAM_INDICATOR_SUFFIX = " (possible downstream indicator)" +_COMPACT_DOWNSTREAM_SUFFIX = " (likely downstream)" +_UNOBSERVED_GATEWAY_SUFFIX = " (MX delivery path; downstream unobserved)" +_COMPACT_UNOBSERVED_GATEWAY_SUFFIX = " (downstream unobserved)" +_RECORD_ROLE_DISPLAY_SUFFIXES = tuple( + suffix for suffix in _EVIDENCE_ROLE_SUFFIXES if suffix != _ROLE_UNAVAILABLE_SUFFIX +) + def _unqualified_service_name(service: str) -> str: """Remove only evidence-role suffixes emitted by this module.""" @@ -667,6 +690,86 @@ def evidence_role_service_label(service: str, supporting: Iterable[EvidenceRecor return label +def compact_service_label(label: str) -> str | None: + """Return the default-view form of one service label, or ``None`` to drop it. + + Labels whose only qualifier is the record role that established the match + render bare, because the default panel answers "what do they run" and + ``--explain`` / ``--full`` answer "how do we know". + + A label with no established role is dropped rather than rendered bare. + Stripping ``(role unavailable)`` in place would silently promote an + unattributed catalog match into an asserted observation, which is the one + thing this compaction must never do; the detailed surfaces still carry it. + + Two qualifiers survive in shortened form because they hedge the claim + itself rather than name a record type: a non-MX provider stays marked as a + likely downstream, and a gateway with no observed downstream stays marked + as such. + """ + if label.endswith(_ROLE_UNAVAILABLE_SUFFIX): + return None + if label.endswith(_DOWNSTREAM_INDICATOR_SUFFIX): + return label.removesuffix(_DOWNSTREAM_INDICATOR_SUFFIX) + _COMPACT_DOWNSTREAM_SUFFIX + if label.endswith(_UNOBSERVED_GATEWAY_SUFFIX): + return label.removesuffix(_UNOBSERVED_GATEWAY_SUFFIX) + _COMPACT_UNOBSERVED_GATEWAY_SUFFIX + for suffix in _RECORD_ROLE_DISPLAY_SUFFIXES: + if label.endswith(suffix): + return label.removesuffix(suffix) + return label + + +def compact_categorized_services( + categorized: dict[str, list[str]], +) -> tuple[dict[str, list[str]], int, int]: + """Compact every categorized service label for the default view. + + Returns ``(categories, compacted_count, dropped_count)``. The two counts + let a renderer decide whether to point at the detailed surfaces at all: a + panel where nothing was compacted must not carry a note about compaction. + + Deduplication happens after compaction because it is only then that two + labels can collide - one vendor matched by both an MX record and a TXT + account record yields one bare name twice. Categories emptied by dropped + labels are omitted rather than rendered as an empty row. + """ + out: dict[str, list[str]] = {} + compacted_count = 0 + dropped_count = 0 + for category, labels in categorized.items(): + kept: list[str] = [] + for label in labels: + compacted = compact_service_label(label) + if compacted is None: + dropped_count += 1 + continue + if compacted != label: + compacted_count += 1 + if compacted not in kept: + kept.append(compacted) + if kept: + out[category] = kept + return out, compacted_count, dropped_count + + +def compact_provider_line(provider: str) -> str: + """Return the default-view form of the joined provider line. + + Segment-wise counterpart to :func:`compact_service_label`. The provider row + is the panel's single answer to "who handles their mail", so an + unattributed segment is re-hedged rather than dropped - dropping it would + leave the row silent, and rendering it bare would assert a delivery path no + retained record supports. + """ + segments = [] + for segment in provider.split(" + "): + if segment.endswith(_ROLE_UNAVAILABLE_SUFFIX): + segments.append(segment.removesuffix(_ROLE_UNAVAILABLE_SUFFIX) + _UNATTRIBUTED_PROVIDER_SUFFIX) + continue + segments.append(compact_service_label(segment) or segment) + return " + ".join(segments) + + def _categorize_pass1_slugs( info: TenantInfo, slug_to_name: dict[str, str], by_cat: dict[str, list[str]] ) -> tuple[set[str], set[str]]: diff --git a/src/recon_tool/formatter/insight_curation.py b/src/recon_tool/formatter/insight_curation.py new file mode 100644 index 00000000..7ff727e3 --- /dev/null +++ b/src/recon_tool/formatter/insight_curation.py @@ -0,0 +1,189 @@ +"""Insight curation for the default panel. + +Split out of ``panel.py`` so the panel module keeps shrinking under the +file-size ratchet, and because this is editorial *policy* - which insight +lines are laundry lists, restatements of the Services block, or overlapping +wordings of one underlying signal - rather than rendering. It depends on +nothing but the insight strings themselves and does no Rich rendering, so it +is testable in isolation and readable without the panel around it. + +The name is public because pyright-strict forbids cross-module access to +underscore names; ``panel.py`` calls it directly. +""" + +from __future__ import annotations + + +def curate_insights(insights: tuple[str, ...]) -> list[str]: + """Filter and deduplicate insights for the default panel. + + Two kinds of cleanup: + + 1. **Drop laundry-list dumps.** Prefixes like ``"Security stack:"``, + ``"Security-vendor indicators observed:"``, ``"Infrastructure:"``, + ``"PKI:"``, and ``"Google Workspace module indicators observed:"`` + all duplicate information that + the Services block already shows in a categorized, deduped + form. Low-signal organizational-size hints + (``"mid-size organization"``, ``"domains in tenant"``) read as + padding and add nothing. + + 2. **Collapse overlapping signal families.** Real runs often + trigger three or four signals about the same underlying pattern + because `signals.yaml` has multiple rules covering it from + different angles. On a dual-provider run (M365 tenant + Google + Workspace via DKIM) the Insights block used to show: + + Dual provider: Google + Microsoft coexistence + Dual Email Provider: microsoft365, google-workspace + Dual Email Delivery Path: microsoft365, google-workspace + Secondary Email Provider Observed: google-workspace + + Four different wordings of the same fact. The curator collapses + these into a single canonical line, keeping the highest- + signal wording and dropping the rest. + + The collapse rules are intentionally narrow: only overlapping + signals that describe the same underlying pattern. Real distinct + signals ("Edge Layering" vs "Zero Trust Pattern Observed") never collapse + into each other. + """ + drop_prefixes = ( + "Security stack:", + "Security-vendor indicators observed:", + "Network-security vendor indicator", + "Device-management vendor indicator", + "Infrastructure:", + "PKI:", + "Google Workspace modules:", # module list also belongs in Services + "Google Workspace module indicators observed:", + ) + # Drop insights that restate what the Services + # block or header already shows. These follow a "Label: slug1, slug2" + # pattern where the slugs are visible in the categorized Services + # section. They add zero interpretation - just a differently-worded + # service list. Keep insights that synthesize (scores, topology, + # tier inference, migration patterns, security observations). + restatement_prefixes = ( + # These all follow the "Label: slug1, slug2" pattern where the + # slugs are already visible in the categorized Services section. + # They add zero interpretation - just a differently-worded list. + "Multi-Cloud:", + "Dev & Engineering Heavy:", + "Heavy Outbound Stack:", + "Modern Collaboration:", + "Google Cloud Investment:", + "Google-Native Identity:", + "Dual provider:", + "Provider indicators co-observed:", + "Dual Email Provider:", + "Dual Email Delivery Path:", + "Google MTA-STS Enforcing:", + "AI Platform Diversity:", + "AI Adoption:", # bare form; "Without Governance" variant kept (security context) + "Enterprise Security Stack:", + "Digital Transformation:", + "Email gateway:", # already in Provider line + "MX gateway observed:", + "Email Gateway Topology:", + "Email delivery path:", + "Secondary Email Provider Observed:", + ) + curated: list[str] = [] + for line in insights: + if any(line.startswith(pfx) for pfx in drop_prefixes): + continue + if any(line.startswith(pfx) for pfx in restatement_prefixes): + continue + lower = line.lower() + if "mid-size organization" in lower or "domains in tenant" in lower: + continue + curated.append(line) + + # ── Collapse overlapping signal families ────────────────────────── + + # Dual-provider family: four overlapping signals all describing + # "both Microsoft 365 and Google Workspace detected". We keep the + # most informative wording ("Dual provider: Google + Microsoft + # coexistence") and drop the rest. + dual_family_prefixes = ( + "Dual Email Provider:", + "Dual Email Delivery Path:", + "Secondary Email Provider Observed:", + ) + has_canonical_dual = any( + line.startswith("Dual provider:") or "Google + Microsoft coexistence" in line for line in curated + ) + if has_canonical_dual: + curated = [line for line in curated if not any(line.startswith(pfx) for pfx in dual_family_prefixes)] + else: + # No canonical line - keep at most one of the family as a + # promoted representative. "Dual Email Delivery Path" is the + # most information-dense wording of the three, so prefer it. + family_lines = [line for line in curated if any(line.startswith(pfx) for pfx in dual_family_prefixes)] + if len(family_lines) >= 2: + # Preference order for promotion + pref_order = ( + "Dual Email Delivery Path:", + "Dual Email Provider:", + "Secondary Email Provider Observed:", + ) + chosen: str | None = None + for pfx in pref_order: + for line in family_lines: + if line.startswith(pfx): + chosen = line + break + if chosen: + break + curated = [line for line in curated if line not in family_lines or line == chosen] + + # "Dual Email Provider" signal family overlap with the older + # "Dual provider: Google + Microsoft coexistence" insight line: + # when BOTH the canonical insight and the newer "Dual Email + # Provider" signal fire, keep only the canonical (human-readable) + # one. Already handled above via has_canonical_dual; this comment + # just documents the precedence for future maintainers. + + # ── Email security aux-note dedup ────────────────────────────── + # The score line ("Email security: ") already + # names what's present/absent. The auxiliary "DMARC: none", "No + # DMARC record at apex", "No DKIM at common selectors" insights + # restate the same observation in prose. Keep the score line on + # the default panel; the aux notes stay in the raw `insights` + # JSON field for consumers that want them. + has_score_line = any(line.startswith("Email security:") for line in curated) + if has_score_line: + curated = [ + line + for line in curated + if not line.startswith("No DMARC record") + and not line.startswith("No valid DMARC policy record") + and not line.startswith("No DKIM at common selectors") + and not line.startswith("No DKIM selectors observed") + and not line.startswith("DKIM not observed") + and not line.startswith("DMARC: none") + ] + + # ── Google Workspace identity echo dedup ─────────────────────── + # The insight "Google Workspace: Managed identity (Google-native)" + # restates the Auth line AND the Identity row in the Services + # block. On domains with minimal signal this is the third time + # the same fact appears in the panel. Drop it - the Auth line + # already says "Managed (Google Workspace)" and the Services + # block carries the slug detection. + return [ + line + for line in curated + if line != "Google Workspace: Managed identity (Google-native)" + and not line.startswith("Google Workspace: Managed identity") + ] + + # Note on the "Cloud-managed identity indicators" insight: the + # dedup for dual-provider targets happens upstream in + # insights._auth_insights, which refuses to emit the line when + # google_auth_type is set (the Auth line's compound format + # "Managed (Entra ID + Google Workspace)" already carries the + # same fact). On pure M365 targets the insight DOES fire and + # the Auth line just says "Managed", so both surfaces carry + # distinct information - no dedup needed here. diff --git a/src/recon_tool/formatter/markdown.py b/src/recon_tool/formatter/markdown.py index a429ff55..b418e1a7 100644 --- a/src/recon_tool/formatter/markdown.py +++ b/src/recon_tool/formatter/markdown.py @@ -16,6 +16,7 @@ from recon_tool.explanation_lineage import explanation_lineage_label from recon_tool.formatter.classify import ( categorize_services, + compact_categorized_services, google_workspace_cse_indicators, google_workspace_module_indicators, is_gws_service, @@ -69,11 +70,18 @@ def _md_header(info: TenantInfo) -> list[str]: return lines -def _md_services_split(info: TenantInfo) -> list[str]: - """Services grouped into Microsoft 365 / Google Workspace / Tech Stack.""" +def _md_services_split(info: TenantInfo, detailed: bool = False) -> list[str]: + """Services grouped into Microsoft 365 / Google Workspace / Tech Stack. + + ``detailed`` is the --explain / --verbose report, which keeps every + evidence-role qualifier. The default report compacts them (ADR-0012) and + closes the section with one italic pointer at the detailed surfaces. + """ categorized = categorize_services(info) - if not categorized: - return [] + compacted_count = 0 + dropped_count = 0 + if not detailed: + categorized, compacted_count, dropped_count = compact_categorized_services(categorized) m365_svcs: list[str] = [] gws_svcs: list[str] = [] other_svcs: list[str] = [] @@ -96,6 +104,19 @@ def _md_services_split(info: TenantInfo) -> list[str]: for svc in svcs: lines.append(f"- {markdown_escape(svc)}") lines.append("") + # Emitted even when every service was dropped and no section survives: a + # report that silently omits matches reads as a report that found none. + # Rendered as a bare italic line rather than a section so the stable H2 + # structure gains no heading. + notes: list[str] = [] + if compacted_count: + notes.append("Evidence roles omitted; run with `--explain` for the evidence trail.") + if dropped_count: + noun = "match" if dropped_count == 1 else "matches" + notes.append(f"{dropped_count} unattributed {noun} omitted; `--full` shows every match.") + if notes: + lines.append(f"*{' '.join(notes)}*") + lines.append("") return lines @@ -191,11 +212,13 @@ def _md_footer(info: TenantInfo) -> list[str]: return lines -def format_tenant_markdown(info: TenantInfo) -> str: +def format_tenant_markdown(info: TenantInfo, *, detailed: bool = False) -> str: """Format TenantInfo as a markdown report. A thin orchestrator over the per-section ``_md_*`` builders, each of which returns its lines (or an empty list when the section does not apply). + ``detailed`` is the --explain / --verbose report and keeps every + evidence-role qualifier; the default report compacts them (ADR-0012). Output held byte-identical by ``tests/test_golden_renders.py`` (``markdown_dense`` / ``markdown_sparse`` / ``markdown_rich``). """ @@ -204,7 +227,7 @@ def format_tenant_markdown(info: TenantInfo) -> str: info = collection_observable_info(info) lines: list[str] = [] lines.extend(_md_header(info)) - lines.extend(_md_services_split(info)) + lines.extend(_md_services_split(info, detailed)) lines.extend(_md_gws_details(info)) lines.extend(_md_insights(info)) lines.extend(_md_cert_intel(info)) diff --git a/src/recon_tool/formatter/panel.py b/src/recon_tool/formatter/panel.py index 57b94306..d88a5c19 100644 --- a/src/recon_tool/formatter/panel.py +++ b/src/recon_tool/formatter/panel.py @@ -33,6 +33,8 @@ categorize_service, categorize_services, category_for_slug, + compact_categorized_services, + compact_provider_line, count_cloud_vendors, detect_provider, is_gws_service, @@ -56,6 +58,7 @@ render_exposure_panel, render_gaps_panel, ) +from recon_tool.formatter.insight_curation import curate_insights from recon_tool.formatter.key_facts import key_facts_auth_line, key_facts_multicloud_line from recon_tool.formatter.layout import compact_subdomain_summary_lines, subdomain_surface_summary_items from recon_tool.formatter.markdown import ( @@ -534,16 +537,21 @@ def _append_confidence_field(facts: Text, info: TenantInfo) -> None: facts.append("\n") -def _render_key_facts(info: TenantInfo) -> Text: +def _render_key_facts(info: TenantInfo, detailed: bool) -> Text: """Build the key-facts block: Provider, Tenant/Region, Auth, Cloud (sovereignty), Multi-cloud rollup, Confidence. Extracted from ``render_tenant_panel`` so the panel orchestrator stays - a thin sequence of section calls. Behavior is unchanged; the golden - renders in ``tests/test_golden_renders.py`` pin the exact output. + a thin sequence of section calls. The golden renders in + ``tests/test_golden_renders.py`` pin the exact output. + + ``detailed`` is the --explain / --verbose / --full view. The default view + compacts the Provider row's record-role qualifiers per ADR-0012; the + detailed view keeps them. """ facts = Text() - _append_field(facts, "Provider", provider_line(info)) + provider = provider_line(info) + _append_field(facts, "Provider", provider if detailed else compact_provider_line(provider)) if info.tenant_id: tenant_line = info.tenant_id @@ -643,10 +651,15 @@ def _spacer() -> None: blocks.append(rule) # ── Key facts block ──────────────────────────────────────────── - blocks.append(_render_key_facts(info)) + # --explain, --verbose, and --full are the "how do we know" views and keep + # every evidence-role qualifier. The default view is the "what do they run" + # view and compacts them (ADR-0012). --domains is not a detail view: it + # widens the domain listing without asking for evidence. + detailed = verbose or explain + blocks.append(_render_key_facts(info, detailed)) # ── Services section ────────────────────────────────────────── - svc_block, ceiling_categorized_count = _render_services(info, show_domains) + svc_block, ceiling_categorized_count = _render_services(info, show_domains, detailed) if svc_block is not None: _spacer() blocks.append(svc_block) @@ -689,7 +702,7 @@ def _spacer() -> None: return Group(*blocks) -def _append_subdomain_summary(svc_block: Text, info: TenantInfo, show_domains: bool, max_width: int) -> None: +def _append_subdomain_summary(svc_block: Text, info: TenantInfo, show_domains: bool, max_width: int) -> bool: """Default-mode-only line summarising the providers the CNAME-chain classifier attributed to subdomains, with per-provider counts so the multi-cloud distribution is visible at a glance (e.g. ``AWS CloudFront (5), @@ -703,12 +716,15 @@ def _append_subdomain_summary(svc_block: Text, info: TenantInfo, show_domains: b Cloud line's "what does the apex resolve to". Counts the primary attribution per subdomain (the fronting infra tier is the same subdomain, not an extra), falling back to the infra tier only when there is no primary. + + Returns whether a summary row was appended, so the caller can keep the + Services section alive on a panel whose apex rows were all compacted away. """ if not (info.surface_attributions and not show_domains): - return + return False surface_summary = subdomain_surface_summary_items(info.surface_attributions) if not surface_summary: - return + return False budget = _PANEL_WIDTH - (2 + max_width) lines = compact_subdomain_summary_lines(surface_summary, budget) svc_block.append(" ") @@ -718,41 +734,93 @@ def _append_subdomain_summary(svc_block: Text, info: TenantInfo, show_domains: b svc_block.append(" " * (2 + max_width)) svc_block.append(line) svc_block.append("\n") + return True + +def _evidence_role_note(compacted_count: int, dropped_count: int) -> str | None: + """Name what the default view left out, or ``None`` when it left out nothing. -def _render_services(info: TenantInfo, show_domains: bool) -> tuple[Text | None, int]: + A panel whose every label was already role-free must not carry a note about + roles, so this returns ``None`` rather than a generic footer. Dropped + matches are counted and point at --full: an operator can infer a compacted + label from the label, but cannot infer a hidden row from a row that is not + there. + """ + if compacted_count and dropped_count: + return f"Evidence roles + {dropped_count} unattributed: --full" + if dropped_count: + noun = "match" if dropped_count == 1 else "matches" + return f"{dropped_count} unattributed {noun}: --full" + if compacted_count: + return "Evidence roles: --explain" + return None + + +def _render_services(info: TenantInfo, show_domains: bool, detailed: bool) -> tuple[Text | None, int]: """Render the categorized Services section and return it with the count of service categories (used by the passive-DNS ceiling trigger). - Returns ``(None, 0)`` when there are no services. Output held byte-identical - by ``tests/test_golden_renders.py`` (``panel_dense_default`` / + Returns ``(None, 0)`` when there are no services. ``detailed`` is the + --explain / --verbose / --full view, which keeps every evidence-role + qualifier; the default view compacts them per ADR-0012 and points at the + detailed surfaces. Compaction can empty the section outright when every + match was unattributed, so the emptiness check runs after it, not just on + ``info.services``. Output held byte-identical by + ``tests/test_golden_renders.py`` (``panel_dense_default`` / ``panel_surface_default``). + + The returned count is deliberately the count *before* compaction. It feeds + the passive-DNS ceiling, which asks how much signal the collection found, + not how many rows this view chose to draw; compacting a label must not move + a domain across the sparseness boundary. """ if not info.services: return None, 0 - svc_block = Text() - svc_block.append("Services", style="bold") - svc_block.append("\n") categorized = _categorize_services(info) if "Email" in categorized: normalize_email_services(categorized, info) + collected_category_count = len(categorized) + compacted_count = 0 + dropped_count = 0 + if not detailed: + categorized, compacted_count, dropped_count = compact_categorized_services(categorized) # Widen the label column only when a label present in this render needs # it, so short-label panels keep their value width and a long label # (e.g. "Data & Analytics") still gets one space before its value. max_width = max(_CATEGORY_WIDTH, max((len(c) for c in categorized), default=0) + 1) + body = Text() for cat, svcs in categorized.items(): - svc_block.append(" ") - svc_block.append(cat.ljust(max_width), style="dim") + body.append(" ") + body.append(cat.ljust(max_width), style="dim") wrapped = _wrap_service_list( svcs, label_width=2 + max_width, panel_width=_PANEL_WIDTH, panel_pad=0, ) - svc_block.append(wrapped) - svc_block.append("\n") - _append_subdomain_summary(svc_block, info, show_domains, max_width) - return svc_block, len(categorized) + body.append(wrapped) + body.append("\n") + # The subdomain summary is CNAME-chain attribution, not an apex catalog + # label, so it survives apex compaction and keeps the section alive on its + # own. Without this the section would vanish whole when every apex match + # was unattributed, taking an unrelated finding with it. + has_subdomain_summary = _append_subdomain_summary(body, info, show_domains, max_width) + has_rows = bool(categorized) or has_subdomain_summary + note = _evidence_role_note(compacted_count, dropped_count) + if not has_rows and note is None: + return None, collected_category_count + if note is not None: + # Align under the value column when rows precede it; fall back to the + # section indent when the note is the whole section, which is what a + # --domains render looks like once every apex match was unattributed. + body.append(" " * (2 + max_width) if has_rows else " ") + body.append(note, style="dim italic") + body.append("\n") + svc_block = Text() + svc_block.append("Services", style="bold") + svc_block.append("\n") + svc_block.append(body) + return svc_block, collected_category_count def _render_passive_dns_ceiling(info: TenantInfo, show_domains: bool, categorized_count: int) -> Text | None: @@ -1052,7 +1120,7 @@ def _render_insights(info: TenantInfo, verbose: bool, confidence_mode: str) -> T """ if not info.insights: return None - curated: list[str] = _curate_insights(info.insights) + curated: list[str] = curate_insights(info.insights) from recon_tool.strict_mode import apply_strict_mode, should_apply_strict if should_apply_strict(info, confidence_mode): @@ -1224,181 +1292,6 @@ def _render_explain_conflicts(info: TenantInfo, explain: bool, verbose: bool) -> return conf_block -def _curate_insights(insights: tuple[str, ...]) -> list[str]: - """Filter and deduplicate insights for the default panel. - - Two kinds of cleanup: - - 1. **Drop laundry-list dumps.** Prefixes like ``"Security stack:"``, - ``"Security-vendor indicators observed:"``, ``"Infrastructure:"``, - ``"PKI:"``, and ``"Google Workspace module indicators observed:"`` - all duplicate information that - the Services block already shows in a categorized, deduped - form. Low-signal organizational-size hints - (``"mid-size organization"``, ``"domains in tenant"``) read as - padding and add nothing. - - 2. **Collapse overlapping signal families.** Real runs often - trigger three or four signals about the same underlying pattern - because `signals.yaml` has multiple rules covering it from - different angles. On a dual-provider run (M365 tenant + Google - Workspace via DKIM) the Insights block used to show: - - Dual provider: Google + Microsoft coexistence - Dual Email Provider: microsoft365, google-workspace - Dual Email Delivery Path: microsoft365, google-workspace - Secondary Email Provider Observed: google-workspace - - Four different wordings of the same fact. The curator collapses - these into a single canonical line, keeping the highest- - signal wording and dropping the rest. - - The collapse rules are intentionally narrow: only overlapping - signals that describe the same underlying pattern. Real distinct - signals ("Edge Layering" vs "Zero Trust Pattern Observed") never collapse - into each other. - """ - drop_prefixes = ( - "Security stack:", - "Security-vendor indicators observed:", - "Network-security vendor indicator", - "Device-management vendor indicator", - "Infrastructure:", - "PKI:", - "Google Workspace modules:", # module list also belongs in Services - "Google Workspace module indicators observed:", - ) - # Drop insights that restate what the Services - # block or header already shows. These follow a "Label: slug1, slug2" - # pattern where the slugs are visible in the categorized Services - # section. They add zero interpretation — just a differently-worded - # service list. Keep insights that synthesize (scores, topology, - # tier inference, migration patterns, security observations). - restatement_prefixes = ( - # These all follow the "Label: slug1, slug2" pattern where the - # slugs are already visible in the categorized Services section. - # They add zero interpretation — just a differently-worded list. - "Multi-Cloud:", - "Dev & Engineering Heavy:", - "Heavy Outbound Stack:", - "Modern Collaboration:", - "Google Cloud Investment:", - "Google-Native Identity:", - "Dual provider:", - "Provider indicators co-observed:", - "Dual Email Provider:", - "Dual Email Delivery Path:", - "Google MTA-STS Enforcing:", - "AI Platform Diversity:", - "AI Adoption:", # bare form; "Without Governance" variant kept (security context) - "Enterprise Security Stack:", - "Digital Transformation:", - "Email gateway:", # already in Provider line - "MX gateway observed:", - "Email Gateway Topology:", - "Email delivery path:", - "Secondary Email Provider Observed:", - ) - curated: list[str] = [] - for line in insights: - if any(line.startswith(pfx) for pfx in drop_prefixes): - continue - if any(line.startswith(pfx) for pfx in restatement_prefixes): - continue - lower = line.lower() - if "mid-size organization" in lower or "domains in tenant" in lower: - continue - curated.append(line) - - # ── Collapse overlapping signal families ────────────────────────── - - # Dual-provider family: four overlapping signals all describing - # "both Microsoft 365 and Google Workspace detected". We keep the - # most informative wording ("Dual provider: Google + Microsoft - # coexistence") and drop the rest. - dual_family_prefixes = ( - "Dual Email Provider:", - "Dual Email Delivery Path:", - "Secondary Email Provider Observed:", - ) - has_canonical_dual = any( - line.startswith("Dual provider:") or "Google + Microsoft coexistence" in line for line in curated - ) - if has_canonical_dual: - curated = [line for line in curated if not any(line.startswith(pfx) for pfx in dual_family_prefixes)] - else: - # No canonical line — keep at most one of the family as a - # promoted representative. "Dual Email Delivery Path" is the - # most information-dense wording of the three, so prefer it. - family_lines = [line for line in curated if any(line.startswith(pfx) for pfx in dual_family_prefixes)] - if len(family_lines) >= 2: - # Preference order for promotion - pref_order = ( - "Dual Email Delivery Path:", - "Dual Email Provider:", - "Secondary Email Provider Observed:", - ) - chosen: str | None = None - for pfx in pref_order: - for line in family_lines: - if line.startswith(pfx): - chosen = line - break - if chosen: - break - curated = [line for line in curated if line not in family_lines or line == chosen] - - # "Dual Email Provider" signal family overlap with the older - # "Dual provider: Google + Microsoft coexistence" insight line: - # when BOTH the canonical insight and the newer "Dual Email - # Provider" signal fire, keep only the canonical (human-readable) - # one. Already handled above via has_canonical_dual; this comment - # just documents the precedence for future maintainers. - - # ── Email security aux-note dedup ────────────────────────────── - # The score line ("Email security: ") already - # names what's present/absent. The auxiliary "DMARC: none", "No - # DMARC record at apex", "No DKIM at common selectors" insights - # restate the same observation in prose. Keep the score line on - # the default panel; the aux notes stay in the raw `insights` - # JSON field for consumers that want them. - has_score_line = any(line.startswith("Email security:") for line in curated) - if has_score_line: - curated = [ - line - for line in curated - if not line.startswith("No DMARC record") - and not line.startswith("No valid DMARC policy record") - and not line.startswith("No DKIM at common selectors") - and not line.startswith("No DKIM selectors observed") - and not line.startswith("DKIM not observed") - and not line.startswith("DMARC: none") - ] - - # ── Google Workspace identity echo dedup ─────────────────────── - # The insight "Google Workspace: Managed identity (Google-native)" - # restates the Auth line AND the Identity row in the Services - # block. On domains with minimal signal this is the third time - # the same fact appears in the panel. Drop it — the Auth line - # already says "Managed (Google Workspace)" and the Services - # block carries the slug detection. - return [ - line - for line in curated - if line != "Google Workspace: Managed identity (Google-native)" - and not line.startswith("Google Workspace: Managed identity") - ] - - # Note on the "Cloud-managed identity indicators" insight: the - # dedup for dual-provider targets happens upstream in - # insights._auth_insights, which refuses to emit the line when - # google_auth_type is set (the Auth line's compound format - # "Managed (Entra ID + Google Workspace)" already carries the - # same fact). On pure M365 targets the insight DOES fire and - # the Auth line just says "Managed", so both surfaces carry - # distinct information — no dedup needed here. - - def render_verbose_sources(results: list[SourceResult], *, console: Console | None = None) -> None: """Print per-source status lines to console.""" c = console or get_console() @@ -1588,7 +1481,10 @@ def render_chain_panel(report: ChainReport) -> Panel: current_depth = r.chain_depth text.append(f" Depth {current_depth}:\n", style="bold") indent = " " + " " * r.chain_depth - provider = provider_line(r.info) + # One compact summary row per resolved domain: this tree is the + # densest repetition of the provider line in the tool, so it takes + # the default view's compaction (ADR-0012) unconditionally. + provider = compact_provider_line(provider_line(r.info)) text.append(f"{indent}{r.domain}", style="cyan") text.append(f" — {r.info.display_name}", style="dim") if not provider.startswith("Unknown"): diff --git a/src/recon_tool/formatter/serialize.py b/src/recon_tool/formatter/serialize.py index 0fcf9fae..2c3c2b01 100644 --- a/src/recon_tool/formatter/serialize.py +++ b/src/recon_tool/formatter/serialize.py @@ -396,21 +396,43 @@ def plain_lines(value: Any, key: str, indent: int) -> list[str]: return [f"{pad}{key}: {strip_control_chars(str(value))}"] -def format_tenant_plain(info: TenantInfo, *, include_unclassified: bool = False) -> str: +def format_tenant_plain(info: TenantInfo, *, include_unclassified: bool = False, detailed: bool = False) -> str: """Format TenantInfo as plain, linear, greppable text (no Rich panel). Built from the same dict as the JSON output, so it carries every field the structured output does - but as ``key: value`` lines a screen reader reads linearly and ``grep``/``awk`` can slice, with no color or box-drawing. This is the accessibility / scripting complement to the default panel. + + Because it is the panel's accessibility complement, it tracks the panel's + default/detailed split (ADR-0012): ``detailed`` keeps every evidence-role + qualifier, and the default compacts them and says so. ``--json`` stays the + machine contract and is unaffected either way. """ from recon_tool.collection_view import collection_observable_info - from recon_tool.formatter.classify import categorize_services + from recon_tool.formatter.classify import ( + categorize_services, + compact_categorized_services, + compact_provider_line, + ) observable = collection_observable_info(info) data = format_tenant_dict(observable, include_unclassified=include_unclassified) categorized = categorize_services(observable) + compacted_count = 0 + dropped_count = 0 + if not detailed: + categorized, compacted_count, dropped_count = compact_categorized_services(categorized) + data["provider"] = compact_provider_line(str(data["provider"])) data["services"] = [service for services in categorized.values() for service in services] + notes: list[str] = [] + if compacted_count: + notes.append("roles omitted, use --explain") + if dropped_count: + noun = "match" if dropped_count == 1 else "matches" + notes.append(f"{dropped_count} unattributed {noun} omitted, use --full") + if notes: + data["evidence_roles"] = "; ".join(notes) lines: list[str] = [] for key, value in data.items(): lines.extend(plain_lines(value, str(key), 0)) diff --git a/tests/golden_renders/markdown_dense.txt b/tests/golden_renders/markdown_dense.txt index d1d942a3..90b1c2c3 100644 --- a/tests/golden_renders/markdown_dense.txt +++ b/tests/golden_renders/markdown_dense.txt @@ -11,11 +11,13 @@ ## Microsoft 365 Services -- Microsoft 365 \(MX delivery path\) +- Microsoft 365 ## Tech Stack -- Slack \(public TXT account indicator\) +- Slack + +*Evidence roles omitted; run with `--explain` for the evidence trail.* ## Insights diff --git a/tests/golden_renders/markdown_rich.txt b/tests/golden_renders/markdown_rich.txt index 85f7ae32..18697ab8 100644 --- a/tests/golden_renders/markdown_rich.txt +++ b/tests/golden_renders/markdown_rich.txt @@ -11,16 +11,18 @@ ## Microsoft 365 Services -- Microsoft 365 \(MX delivery path\) +- Microsoft 365 ## Google Workspace Services -- Google Workspace \(CNAME endpoint binding\) +- Google Workspace - Google Workspace CSE configuration indicator ## Tech Stack -- Slack \(public TXT account indicator\) +- Slack + +*Evidence roles omitted; run with `--explain` for the evidence trail.* ## Google Workspace diff --git a/tests/golden_renders/markdown_sparse.txt b/tests/golden_renders/markdown_sparse.txt index c5522d62..41d85053 100644 --- a/tests/golden_renders/markdown_sparse.txt +++ b/tests/golden_renders/markdown_sparse.txt @@ -6,9 +6,7 @@ **Evidence Confidence:** medium\ **Inference Confidence:** medium -## Microsoft 365 Services - -- Microsoft 365 \(role unavailable\) +*1 unattributed match omitted; `--full` shows every match.* ## Insights diff --git a/tests/golden_renders/panel_dense_default.txt b/tests/golden_renders/panel_dense_default.txt index 8318b9ff..90a761f5 100644 --- a/tests/golden_renders/panel_dense_default.txt +++ b/tests/golden_renders/panel_dense_default.txt @@ -1,18 +1,16 @@ Synthetic Alpha Ltd alpha.invalid ────────────────────────────────────────────────────────────────────────────── - Provider Microsoft 365 (MX delivery path) + Proofpoint gateway (MX - delivery path) + Provider Microsoft 365 + Proofpoint gateway Tenant a1b2c3d4-e5f6-7890-abcd-ef1234567890 • NA Auth Federated Confidence ●●● High (3 sources) Services - Email Microsoft 365 (MX delivery path), - Proofpoint (MX delivery path), DMARC reject, - MTA-STS enforce - Collaboration Slack (public TXT account indicator) + Email Microsoft 365, Proofpoint, DMARC reject, MTA-STS enforce + Collaboration Slack + Evidence roles: --explain Passive-DNS ceiling diff --git a/tests/golden_renders/panel_sparse_default.txt b/tests/golden_renders/panel_sparse_default.txt index ec0e4da1..81382065 100644 --- a/tests/golden_renders/panel_sparse_default.txt +++ b/tests/golden_renders/panel_sparse_default.txt @@ -1,12 +1,12 @@ Synthetic Gamma gamma.invalid ────────────────────────────────────────────────────────────────────────────── - Provider Microsoft 365 (role unavailable) + Provider Microsoft 365 (no supporting record) Confidence ●○○ Low (1 source) Services - Email Microsoft 365 (role unavailable) + 1 unattributed match: --full Insights diff --git a/tests/golden_renders/panel_surface_default.txt b/tests/golden_renders/panel_surface_default.txt index f4d8bf3c..22e12d5c 100644 --- a/tests/golden_renders/panel_surface_default.txt +++ b/tests/golden_renders/panel_surface_default.txt @@ -1,7 +1,7 @@ Synthetic Alpha Ltd alpha.invalid ────────────────────────────────────────────────────────────────────────────── - Provider Microsoft 365 (role unavailable) + Provider Microsoft 365 (no supporting record) Tenant a1b2c3d4-e5f6-7890-abcd-ef1234567890 • NA Auth Federated Multi-cloud 2 providers observed (Fastly, Cloudflare) @@ -9,9 +9,8 @@ alpha.invalid Services - Email Microsoft 365 (role unavailable) - Collaboration Slack (role unavailable) Subdomain Fastly (6), Auth0 (2), Zendesk (1) + 2 unattributed matches: --full High-signal related domains diff --git a/tests/golden_renders/panel_surface_full.txt b/tests/golden_renders/panel_surface_full.txt index b3f86706..395882e5 100644 --- a/tests/golden_renders/panel_surface_full.txt +++ b/tests/golden_renders/panel_surface_full.txt @@ -1,7 +1,7 @@ Synthetic Alpha Ltd alpha.invalid ────────────────────────────────────────────────────────────────────────────── - Provider Microsoft 365 (role unavailable) + Provider Microsoft 365 (no supporting record) Tenant a1b2c3d4-e5f6-7890-abcd-ef1234567890 • NA Auth Federated Multi-cloud 2 providers observed (Fastly, Cloudflare) @@ -9,8 +9,7 @@ alpha.invalid Services - Email Microsoft 365 (role unavailable) - Collaboration Slack (role unavailable) + 2 unattributed matches: --full Related domains diff --git a/tests/test_agentic_ux_compatibility.py b/tests/test_agentic_ux_compatibility.py index d792fac1..c097c943 100644 --- a/tests/test_agentic_ux_compatibility.py +++ b/tests/test_agentic_ux_compatibility.py @@ -51,9 +51,9 @@ def _load(name: str): return tenant_info_from_dict(data) -def _render(info) -> str: +def _render(info, **kwargs) -> str: console = Console(no_color=True, record=True, width=120) - rendered = render_tenant_panel(info) + rendered = render_tenant_panel(info, **kwargs) console.print(rendered) return console.export_text() @@ -71,7 +71,12 @@ def test_dns_role_plus_one_endpoint_does_not_claim_multi_cloud(self): retained evidence record describing its role.""" out = _render(_load("synthetic-dense.json")) assert "Multi-cloud" not in out - assert "Azure DNS (role unavailable)" in out + # The cached slug has no retained role, so the default view omits it + # and counts it rather than rendering a bare vendor name (ADR-0012); + # the detail view still carries the role-qualified label. + assert "Azure DNS" not in out + assert "unattributed" in out + assert "Azure DNS (role unavailable)" in _render(_load("synthetic-dense.json"), verbose=True) assert "Akamai" in out def test_ceiling_does_not_fire_single_domain(self): @@ -93,8 +98,12 @@ def test_pre_existing_blocks_still_render(self): def test_unresolved_cloud_role_stays_below_key_facts(self): """The key-facts block must not elevate an unresolved catalog indicator. Its role-qualified detail remains in Services after - the deterministic Confidence field.""" - out = _render(_load("synthetic-dense.json")) + the deterministic Confidence field. + + Checked on the detail view, which is where the role-qualified label + lives now that the default view omits unattributed matches entirely + (ADR-0012) - an even stronger form of the same guarantee.""" + out = _render(_load("synthetic-dense.json"), verbose=True) conf = out.find("Confidence") azure = out.find("Azure DNS (role unavailable)") assert conf != -1 diff --git a/tests/test_cache_cross_version_compatibility.py b/tests/test_cache_cross_version_compatibility.py index 3cefa265..dedb8260 100644 --- a/tests/test_cache_cross_version_compatibility.py +++ b/tests/test_cache_cross_version_compatibility.py @@ -124,7 +124,11 @@ def test_v198_cache_without_lineage_does_not_claim_multi_cloud(self): info = tenant_info_from_dict(_v198_shape_multi_cloud_cache()) out = _render(info) assert "Multi-cloud" not in out - assert "role unavailable" in out + # A pre-v1.9.9 cache carries slugs with no evidence lineage. The + # default view omits them and says so; the detail view names the + # missing role outright (ADR-0012). Either way no rollup is claimed. + assert "unattributed" in out + assert "role unavailable" in _render(info, verbose=True) def test_ceiling_footer_fires_on_v198_sparse_cache(self): info = tenant_info_from_dict(_v198_shape_sparse_cache()) diff --git a/tests/test_collection_view.py b/tests/test_collection_view.py index 8f04eebf..49834da5 100644 --- a/tests/test_collection_view.py +++ b/tests/test_collection_view.py @@ -602,8 +602,13 @@ def test_degraded_dkim_cannot_promote_txt_account_to_provider_secondary() -> Non assert provider_line(visible) == "Microsoft 365 (MX delivery path)" assert data["provider"] == "Microsoft 365 (MX delivery path)" assert len(data["evidence"]) == 3 - assert "Provider Microsoft 365 (MX delivery path)" in rendered + # The JSON record keeps the record role; the default panel compacts it out + # (ADR-0012). The claim under test is that a degraded DKIM channel cannot + # promote the TXT-only Google Workspace account into a provider, which + # holds on both surfaces. + assert "Provider Microsoft 365\n" in rendered assert "Google Workspace (secondary)" not in rendered + assert "Google Workspace" not in rendered.split("Services", 1)[0] def test_explanation_summary_uses_available_evidence_and_preserves_raw_provenance() -> None: diff --git a/tests/test_formatter.py b/tests/test_formatter.py index 69d2f80e..c7654bee 100644 --- a/tests/test_formatter.py +++ b/tests/test_formatter.py @@ -195,10 +195,17 @@ def test_services_use_role_aware_labels_without_changing_json(self) -> None: ), ) - plain = format_tenant_plain(info) + detailed = format_tenant_plain(info, detailed=True) + default = format_tenant_plain(info) structured = json.loads(format_tenant_json(info)) - assert " - Okta (public TXT account indicator)" in plain + # The role qualifies the label on the detail view and is compacted out + # of the default one (ADR-0012); the JSON record carries the bare + # service name either way. + assert " - Okta (public TXT account indicator)" in detailed + assert " - Okta\n" in default + assert "(public TXT account indicator)" not in default + assert "evidence_roles: roles omitted, use --explain" in default assert structured["services"] == ["Okta"] diff --git a/tests/test_formatter_intel_extensions.py b/tests/test_formatter_intel_extensions.py index 2ac27a5f..c27780a4 100644 --- a/tests/test_formatter_intel_extensions.py +++ b/tests/test_formatter_intel_extensions.py @@ -360,7 +360,9 @@ def test_markdown_m365_and_other_services(self): info = _make_info( services=("Exchange Online", "Cloudflare", "Microsoft Teams"), ) - md = format_tenant_markdown(info) + # Grouping test, not a role test: this fixture retains no evidence, so + # the default view would drop every label (ADR-0012). + md = format_tenant_markdown(info, detailed=True) assert "Microsoft 365 Services" in md assert "Tech Stack" in md @@ -378,7 +380,7 @@ def test_markdown_service_groups_are_mutually_exclusive(self): ), ) - md = format_tenant_markdown(info) + md = format_tenant_markdown(info, detailed=True) m365_section = md.split("## Microsoft 365 Services", 1)[1].split("##", 1)[0] gws_section = md.split("## Google Workspace Services", 1)[1].split("##", 1)[0] tech_section = md.split("## Tech Stack", 1)[1].split("##", 1)[0] diff --git a/tests/test_ingestion_sanitization.py b/tests/test_ingestion_sanitization.py index 6e2389da..86f874d3 100644 --- a/tests/test_ingestion_sanitization.py +++ b/tests/test_ingestion_sanitization.py @@ -43,7 +43,10 @@ def test_service_markdown_is_escaped_after_merge() -> None: ) merged = merge_results([result], queried_domain="example.com") - markdown = format_tenant_markdown(merged) + # Detail view: escaping is the invariant under test and is identical in + # both views, but this fixture retains no evidence, so the default view + # would drop the hostile label before it could be escaped (ADR-0012). + markdown = format_tenant_markdown(merged, detailed=True) assert "[link](https://example.invalid)" not in markdown assert "`code`" not in markdown @@ -59,7 +62,8 @@ def test_service_markdown_cannot_create_block_structure_after_merge() -> None: ) merged = merge_results([result], queried_domain="example.com") - markdown = format_tenant_markdown(merged) + # Detail view, for the same reason as the escaping test above. + markdown = format_tenant_markdown(merged, detailed=True) assert "- \\# forged heading" in markdown assert "- \\- nested item" in markdown diff --git a/tests/test_insights.py b/tests/test_insights.py index f29916e4..1b306001 100644 --- a/tests/test_insights.py +++ b/tests/test_insights.py @@ -237,6 +237,14 @@ def _make_info(self, **kwargs) -> TenantInfo: "services": ("Exchange Online", "Google Workspace", "Slack"), "insights": ("Federated identity", "DMARC: reject"), "tenant_domains": ("alpha.invalid", "testco.onmicrosoft.com"), + # Retained evidence per service, as a real lookup produces. The + # default view drops matches with no established role (ADR-0012), + # so an evidence-free fixture would render no Services rows at all. + "evidence": ( + EvidenceRecord("MX", "alpha-invalid.mail.protection.outlook.com", "Exchange Online", "microsoft365"), + EvidenceRecord("TXT", "google-site-verification=opaque", "Google Workspace", "google-workspace"), + EvidenceRecord("TXT", "slack-domain-verification=opaque", "Slack", "slack"), + ), } defaults.update(kwargs) return TenantInfo(**defaults) diff --git a/tests/test_merger_error_surfacing.py b/tests/test_merger_error_surfacing.py index 0ea055ab..34521698 100644 --- a/tests/test_merger_error_surfacing.py +++ b/tests/test_merger_error_surfacing.py @@ -130,7 +130,7 @@ def test_partial_success_renders_through_cli(self) -> None: from typer.testing import CliRunner from recon_tool.cli import app - from recon_tool.models import ConfidenceLevel, TenantInfo + from recon_tool.models import ConfidenceLevel, EvidenceRecord, TenantInfo partial_info = TenantInfo( tenant_id=None, @@ -145,6 +145,14 @@ def test_partial_success_renders_through_cli(self) -> None: auth_type=None, dmarc_policy="reject", domain_count=1, + # Retained evidence for the surviving source, as a real partial run + # produces: without it the default view has no established role for + # Cloudflare and omits it (ADR-0012), which would make this test + # about label compaction rather than about partial-success rendering. + evidence=( + EvidenceRecord("CNAME", "example.com.cdn.cloudflare.net", "Cloudflare", "cloudflare"), + EvidenceRecord("DMARC", "v=DMARC1; p=reject", "DMARC", "dmarc"), + ), ) partial_results = [ SourceResult(source_name="oidc_discovery", error="HTTP 404"), diff --git a/tests/test_multi_cloud_rollup.py b/tests/test_multi_cloud_rollup.py index e7866e92..70e89047 100644 --- a/tests/test_multi_cloud_rollup.py +++ b/tests/test_multi_cloud_rollup.py @@ -200,8 +200,12 @@ def test_legacy_cloudflare_and_route53_do_not_create_a_role_based_rollup(self): out = _render_to_string(info) assert "Multi-cloud" not in out - assert "AWS Route 53 (role unavailable)" in out - assert "Cloudflare (role unavailable)" in out + # Neither legacy slug has a retained record, so the default view omits + # both rather than rendering a bare vendor name that would read as a + # cloud claim, and reports the omission (ADR-0012). + assert "AWS Route 53" not in out + assert "Cloudflare" not in out + assert "2 unattributed matches: --full" in out def test_ns_only_vendors_do_not_create_a_multi_cloud_workload_claim(self): info = _tenant( @@ -232,7 +236,11 @@ def test_txt_cloudflare_and_ns_route53_do_not_create_multi_cloud_claim(self): out = _render_to_string(info) assert "Multi-cloud" not in out - assert "Cloudflare (public TXT account indicator)" in out + # The TXT-only Cloudflare match stays a plain vendor mention and never + # acquires a CDN/edge or DNS role it did not establish. + assert "Cloudflare" in out + assert "Cloudflare (CDN/edge)" not in out + assert "Cloudflare (DNS)" not in out def test_caa_authorization_does_not_create_a_multi_cloud_workload_claim(self): info = _tenant( diff --git a/tests/test_panel_assembly.py b/tests/test_panel_assembly.py index b5c488b4..6560c215 100644 --- a/tests/test_panel_assembly.py +++ b/tests/test_panel_assembly.py @@ -13,6 +13,7 @@ format_tenant_markdown, render_tenant_panel, ) +from recon_tool.formatter.classify import compact_provider_line from recon_tool.formatter.key_facts import key_facts_auth_line, key_facts_multicloud_line from recon_tool.models import ConfidenceLevel, EvidenceRecord, SourceResult, SurfaceAttribution, TenantInfo from recon_tool.server.lookup import _format_lookup_tenant @@ -68,7 +69,10 @@ def test_key_facts_retain_the_canonical_structured_values() -> None: rendered = _render(info) structured = format_tenant_dict(info) - assert f"Provider {structured['provider']}" in rendered + # The panel's default view compacts the record role out of the provider + # line (ADR-0012) while the JSON record keeps it, so the two agree on the + # provider identity rather than on the exact string. + assert f"Provider {compact_provider_line(str(structured['provider']))}" in rendered assert f"Tenant {structured['tenant_id']} • {structured['region']}" in rendered assert f"Auth {structured['auth_type']}" in rendered assert f"Cloud {structured['cloud_instance']} ({structured['tenant_region_sub_scope']})" in rendered @@ -104,7 +108,11 @@ def test_multicloud_summary_excludes_evidence_from_an_unavailable_channel() -> N rendered = _render(info) assert "Multi-cloud" not in rendered - assert "Cloudflare (public TXT account indicator)" in rendered + # A TXT-only Cloudflare match still renders, and still does not become a + # cloud-workload claim; the default view carries the role in the evidence + # trail rather than inline (ADR-0012). + assert "Cloudflare" in rendered + assert "Cloudflare (CDN/edge)" not in rendered def test_key_fact_producers_project_unavailable_channels_at_their_boundary() -> None: diff --git a/tests/test_service_disclosure.py b/tests/test_service_disclosure.py index 0e842a50..df9fae8c 100644 --- a/tests/test_service_disclosure.py +++ b/tests/test_service_disclosure.py @@ -187,17 +187,20 @@ def test_detail_modes_retain_compact_and_secondary_email_facts( fully_populated_tenant_info: TenantInfo, ) -> None: info = _with_secondary_mail_service(fully_populated_tenant_info) - outputs = ( - _render(info), - _render(info, verbose=True), - _render(info, show_services=True, show_domains=True, verbose=True), - ) - expected_email = ( + # Every mode keeps the same email facts in the same order; the detail modes + # additionally keep the record role inline (ADR-0012). + qualified = ( "Email Microsoft 365 (MX delivery path), Proofpoint (MX delivery path), " "DMARC reject, MTA-STS enforce, SendGrid (CNAME endpoint binding)" ) + compact = "Email Microsoft 365, Proofpoint, DMARC reject, MTA-STS enforce, SendGrid" + cases = ( + (_render(info), compact), + (_render(info, verbose=True), qualified), + (_render(info, show_services=True, show_domains=True, verbose=True), qualified), + ) - for output in outputs: + for output, expected_email in cases: collapsed = " ".join(_services_block(output).split()) assert expected_email in collapsed for fact in ("Microsoft 365", "Proofpoint", "DMARC reject", "MTA-STS enforce", "SendGrid"): @@ -240,9 +243,12 @@ def test_gateway_does_not_promote_or_duplicate_txt_only_downstream( collapsed = " ".join(_services_block(output).split()) header = " ".join(output.split("Services\n", 1)[0].split()) - assert "Email Proofpoint (MX delivery path), Microsoft 365 (public TXT account indicator)" in collapsed + # The TXT-only downstream is listed after the MX gateway and is never + # promoted into a delivery path. The default view carries the roles in the + # evidence trail but keeps the provider row's downstream hedge (ADR-0012). + assert "Email Proofpoint, Microsoft 365" in collapsed assert collapsed.count("Microsoft 365") == 1 - assert "Microsoft 365 (possible downstream indicator)" in header + assert "Microsoft 365 (likely downstream)" in header def test_gateway_fingerprint_alias_is_not_duplicated( @@ -264,8 +270,11 @@ def test_gateway_fingerprint_alias_is_not_duplicated( assert "Email Symantec/Broadcom" in collapsed assert collapsed.count("Symantec") == 1 assert "Symantec Email Security" not in collapsed - assert "Symantec/Broadcom (MX delivery path)" in collapsed + # The MX role is established, so the label is compacted rather than dropped + # and no unattributed-match note appears (ADR-0012). + assert "Email Symantec/Broadcom" in collapsed assert "role unavailable" not in collapsed + assert "unattributed" not in collapsed def test_degraded_mx_preserves_surviving_txt_indicator( @@ -288,5 +297,8 @@ def test_degraded_mx_preserves_surviving_txt_indicator( ) collapsed = " ".join(_services_block(_render(info)).split()) - assert "Email Symantec Email Security (public TXT account indicator)" in collapsed + # The TXT indicator survives the degraded MX channel and is not dropped as + # unattributed; its role stays available on the detail views (ADR-0012). + assert "Email Symantec Email Security" in collapsed + assert "unattributed" not in collapsed assert "Symantec/Broadcom" not in collapsed diff --git a/tests/test_services_label_width.py b/tests/test_services_label_width.py index 0f70245e..7866ab9a 100644 --- a/tests/test_services_label_width.py +++ b/tests/test_services_label_width.py @@ -16,7 +16,7 @@ from rich.console import Console from recon_tool.formatter import _CATEGORY_WIDTH, _SERVICE_CATEGORIES_ORDER, render_tenant_panel -from recon_tool.models import ConfidenceLevel, TenantInfo +from recon_tool.models import ConfidenceLevel, EvidenceRecord, TenantInfo def _panel_text(info: TenantInfo) -> str: @@ -26,6 +26,14 @@ def _panel_text(info: TenantInfo) -> str: def _info(services: tuple[str, ...], slugs: tuple[str, ...]) -> TenantInfo: + # Every service carries a CNAME evidence record. The alignment under test + # is only observable on rows that render, and the default view drops + # matches with no established role (ADR-0012), so an evidence-free fixture + # would leave nothing to measure. + evidence = tuple( + EvidenceRecord(source_type="CNAME", rule_name=service, slug=slug, raw_value=f"{slug}.example.invalid") + for service, slug in zip(services, slugs, strict=True) + ) return TenantInfo( tenant_id=None, display_name="Synthetic Alpha Ltd", @@ -36,6 +44,7 @@ def _info(services: tuple[str, ...], slugs: tuple[str, ...]) -> TenantInfo: services=services, slugs=slugs, domain_count=1, + evidence=evidence, )