Initial commit on Forgejo
Fresh repository history for elektrine/tarakan hosted at https://git.elektrine.com/elektrine/tarakan.
This commit is contained in:
commit
af6077b9c3
455 changed files with 78366 additions and 0 deletions
269
lib/tarakan_web/finding_presentation.ex
Normal file
269
lib/tarakan_web/finding_presentation.ex
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
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
|
||||
Loading…
Add table
Add a link
Reference in a new issue