defmodule TarakanWeb.FindingPresentation do @moduledoc """ Presentation helpers for findings and reports. Agents often dump evidence + remediation into one description paragraph. We split common labels so the UI can show readable sections without changing the wire format. """ @section_labels ~w(Remediation Fix Impact Evidence Why Problem Summary Context) @doc """ Break a free-form finding description into `{lead, sections}` for display. Recognizes labels like `Remediation:`, `Impact:`, `Evidence:` (case-insensitive). Leading noise such as `Verified:` / `Hypothesis:` is stripped from the lead. """ def structure_description(nil), do: %{lead: "", sections: []} def structure_description(""), do: %{lead: "", sections: []} def structure_description(text) when is_binary(text) do text = String.trim(text) {lead, sections} = extract_sections(text) lead = lead |> strip_status_prefix() |> String.trim() sections = sections |> Enum.map(fn {label, body} -> {label, String.trim(body)} end) |> Enum.reject(fn {_label, body} -> body == "" end) %{lead: lead, sections: sections} end @doc "Short body for list cards - lead only, truncated." def description_excerpt(text, max \\ 280) do %{lead: lead, sections: sections} = structure_description(text) body = cond do lead != "" -> lead sections != [] -> sections |> List.first() |> elem(1) true -> String.trim(to_string(text || "")) end truncate_runes(body, max) end @doc "Plain-English provenance for readers, not wire jargon." def how_made_label("agent"), do: "Produced by an agent" def how_made_label("human"), do: "Written by a human" def how_made_label("hybrid"), do: "Agent draft, human edited" def how_made_label(other) when is_binary(other), do: other def how_made_label(_), do: "Unknown" @doc "Visibility + provenance chip for finding pages." def disclosure_badge(visibility, provenance) when is_binary(visibility) and is_binary(provenance) do mode = case visibility do "public" -> "Public" "public_summary" -> "Summary" "restricted" -> "Restricted" _ -> "Recorded" end source = case provenance do "agent" -> "agent" "human" -> "human" "hybrid" -> "hybrid" other -> other end "#{mode} · #{source}" end def disclosure_badge(_visibility, _provenance), do: "Recorded" @doc "Short status label with meaning." def status_blurb("quarantined"), do: "Awaiting independent check" def status_blurb("accepted"), do: "Accepted" def status_blurb("rejected"), do: "Rejected" def status_blurb("contested"), do: "Contested" def status_blurb(other) when is_binary(other), do: other def status_blurb(_), do: "" @doc """ Ordered lifecycle events for a canonical finding timeline. Events without a timestamp are omitted; the rest sort ascending by time. Each event is `%{id, title, detail, at}` where `detail` may be nil. """ def timeline_events(canonical, checks) when is_list(checks) do first_seen = %{id: "first-seen", title: "First seen", detail: nil, at: canonical.inserted_at} check_events = Enum.map(checks, fn check -> %{ id: "check-#{check.id}", title: "@#{check.account.handle}", detail: check.verdict, at: check.inserted_at } end) verified = if canonical.verified_at do [%{id: "verified", title: "Verified", detail: nil, at: canonical.verified_at}] else [] end fixed = if canonical.status == "fixed" do case latest_fixed_check(checks) do nil -> [] check -> [ %{ id: "fixed", title: "Marked fixed", detail: "@#{check.account.handle}", at: check.inserted_at } ] end else [] end vendor = if canonical.vendor_notified_at do [ %{ id: "vendor-notified", title: "Vendor notified", detail: nil, at: canonical.vendor_notified_at } ] else [] end [first_seen | check_events ++ verified ++ fixed ++ vendor] |> Enum.reject(&is_nil(&1.at)) |> Enum.sort_by(& &1.at, DateTime) end @doc "External CWE definition URL for a stored CWE id, or nil." def cwe_url("CWE-" <> digits), do: "https://cwe.mitre.org/data/definitions/#{digits}.html" def cwe_url(_cwe_id), do: nil @doc "External NVD URL for a stored CVE id, or nil." def cve_url("CVE-" <> _rest = cve_id), do: "https://nvd.nist.gov/vuln/detail/#{cve_id}" def cve_url(_cve_id), do: nil defp latest_fixed_check(checks) do checks |> Enum.filter(&(&1.verdict == "fixed" and not is_nil(&1.inserted_at))) |> Enum.sort_by(& &1.inserted_at, {:desc, DateTime}) |> List.first() end @doc "Humanize auto-generated submitter notes when possible." def humanize_notes(nil), do: nil def humanize_notes(""), do: nil def humanize_notes(notes) when is_binary(notes) do trimmed = String.trim(notes) case Regex.run( ~r/^(?:Review|Report) Format submission with (\d+) finding\(s\)\.\s*Top issues:\s*(.+)$/s, trimmed ) do [_, count, tops] -> tops = tops |> String.split(";") |> Enum.map(&String.trim/1) |> Enum.reject(&(&1 == "")) |> Enum.map(fn item -> item |> String.replace(~r/^\[([^\]]+)\]\s*/, "") |> String.trim() end) %{kind: :summary, count: String.to_integer(count), tops: tops} _ -> %{kind: :plain, text: trimmed} end end defp strip_status_prefix(text) do Regex.replace( ~r/^(Verified|Hypothesis(?:\/low)?|Hypothesis|Unverified|Likely|Possible)\s*:\s*/i, text, "" ) end # Walk the text for "Label:" markers and peel into ordered sections. defp extract_sections(text) do labels = Enum.join(@section_labels, "|") # Match label at start or after whitespace/.!?\n re = ~r/(?:(?<=^)|(?<=[.!?\n])\s+)(#{labels})\s*:\s+/i case Regex.scan(re, text, return: :index) do [] -> {text, []} matches -> # matches is [[{full_start, full_len}, {label_start, label_len}], ...] first = hd(matches) [{full_start, _full_len}, {_ls, _ll}] = first lead = String.slice(text, 0, full_start) |> String.trim() sections = matches |> Enum.with_index() |> Enum.map(fn {[{fs, fl}, {ls, ll}], idx} -> label = text |> String.slice(ls, ll) |> String.trim() |> String.capitalize() body_start = fs + fl body_end = case Enum.at(matches, idx + 1) do [{next_fs, _} | _] -> next_fs _ -> String.length(text) end body = text |> String.slice(body_start, body_end - body_start) |> String.trim() {label, body} end) {lead, sections} end end defp truncate_runes(text, max) when is_binary(text) and is_integer(max) and max > 0 do graphemes = String.graphemes(text) if length(graphemes) <= max do text else graphemes |> Enum.take(max - 1) |> Enum.join() |> Kernel.<>("…") end end end