tarakan/lib/tarakan_web/finding_presentation.ex
Maxfield Luke af6077b9c3
All checks were successful
CI and deploy / Test (push) Successful in 4m52s
CI and deploy / Deploy production (push) Successful in 23s
Initial commit on Forgejo
Fresh repository history for elektrine/tarakan hosted at
https://git.elektrine.com/elektrine/tarakan.
2026-07-29 04:43:40 -04:00

269 lines
7.5 KiB
Elixir

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