tarakan/lib/tarakan_web/live/finding_live/show.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

454 lines
15 KiB
Elixir

defmodule TarakanWeb.FindingLive.Show do
use TarakanWeb, :live_view
alias Tarakan.Discussion
alias Tarakan.FindingMemory
alias Tarakan.Policy
alias Tarakan.Reputation
alias Tarakan.Scans
alias Tarakan.Scans.Finding
alias Tarakan.Work
@impl true
def mount(%{"public_id" => public_id}, _session, socket) do
{scan, finding} = fetch_finding!(socket, public_id)
if connected?(socket) do
Scans.subscribe(scan.repository_id)
Discussion.subscribe(finding.id)
Reputation.subscribe()
end
public_url = TarakanWeb.Endpoint.url() <> ~p"/findings/#{finding.public_id}"
{:ok,
socket
|> assign(:page_title, finding.title)
|> assign(:meta_description, meta_description(scan, finding))
|> assign(:canonical_path, ~p"/findings/#{finding.public_id}")
|> assign(:public_url, public_url)
|> assign(
:open_bounties,
Tarakan.Market.open_bounties_for_target(:finding, finding.canonical_finding_id)
)
|> assign(:og_type, "article")
|> assign(:first_finder, FindingMemory.first_finder(finding.canonical_finding_id))
|> assign(:json_ld, finding_json_ld(scan, finding))
|> assign(:comment_body, "")
|> assign(:reply_to, nil)
|> assign(:can_vote, can_vote?(socket))
|> assign_record(scan, finding)
|> load_comments()
|> load_votes()}
end
@impl true
def handle_event(
"record_finding_verdict",
%{"verdict" => verdict} = params,
%{assigns: %{current_scope: %{account: account}}} = socket
)
when not is_nil(account) do
attrs = %{
"commit_sha" => socket.assigns.scan.commit_sha,
"verdict" => verdict,
"provenance" => "human",
"notes" => params["notes"]
}
case FindingMemory.record_check(
socket.assigns.current_scope,
socket.assigns.repository,
socket.assigns.finding.canonical_finding.public_id,
attrs
) do
{:ok, _check, _canonical} ->
{:noreply, socket |> refresh_record() |> load_votes()}
{:error, :conflict_of_interest} ->
{:noreply, put_flash(socket, :error, "You cannot verify a finding you submitted.")}
{:error, :unauthorized} ->
{:noreply, put_flash(socket, :error, "You are not authorized to verify this finding.")}
{:error, %Ecto.Changeset{errors: [{_field, {message, _meta}} | _]}} ->
{:noreply, put_flash(socket, :error, "Finding check not recorded: #{message}.")}
{:error, _reason} ->
{:noreply, put_flash(socket, :error, "Finding check could not be recorded.")}
end
end
def handle_event("record_finding_verdict", _params, socket) do
{:noreply, redirect(socket, to: ~p"/accounts/log-in")}
end
def handle_event(
"record_vendor_notification",
%{"vendor_notification" => %{"notified_on" => notified_on}},
%{assigns: %{current_scope: %{account: account}}} = socket
)
when not is_nil(account) do
case parse_notified_on(notified_on) do
{:ok, notified_at} ->
case FindingMemory.record_vendor_notification(
socket.assigns.current_scope,
socket.assigns.repository,
socket.assigns.finding.canonical_finding.public_id,
%{"vendor_notified_at" => notified_at}
) do
{:ok, _canonical} ->
Scans.broadcast_refresh(socket.assigns.scan)
{:noreply, refresh_record(socket)}
{:error, :unauthorized} ->
{:noreply,
put_flash(socket, :error, "You are not authorized to record vendor notification.")}
{:error, _reason} ->
{:noreply, put_flash(socket, :error, "Vendor notification could not be recorded.")}
end
:error ->
{:noreply, put_flash(socket, :error, "Enter a valid notification date.")}
end
end
def handle_event("open_finding_job", %{"kind" => kind}, socket) do
finding = socket.assigns.finding.canonical_finding
case Work.open_finding_job(socket.assigns.current_scope, finding, kind) do
{:ok, :skipped_duplicate} ->
{:noreply, put_flash(socket, :info, "That job is already open on this finding.")}
{:ok, task} ->
{:noreply,
socket
|> put_flash(:info, "Job opened.")
|> push_navigate(to: ~p"/jobs/#{task.id}")}
{:error, :unauthorized} ->
{:noreply, put_flash(socket, :error, "Not authorized to open jobs here.")}
{:error, _reason} ->
{:noreply, put_flash(socket, :error, "Could not open that job.")}
end
end
def handle_event("record_vendor_notification", _params, socket) do
{:noreply, redirect(socket, to: ~p"/accounts/log-in")}
end
def handle_event("reply_to", %{"parent" => parent_id}, socket) do
{:noreply, assign(socket, :reply_to, parent_id)}
end
def handle_event("cancel_reply", _params, socket) do
{:noreply, assign(socket, :reply_to, nil)}
end
def handle_event(
"post_comment",
%{"body" => body} = params,
%{assigns: %{current_scope: %{account: account}}} = socket
)
when not is_nil(account) do
attrs = %{"body" => body, "parent_id" => params["parent_id"]}
case Discussion.create_comment(socket.assigns.current_scope, socket.assigns.finding, attrs) do
{:ok, _comment} ->
{:noreply,
socket
|> assign(:comment_body, "")
|> assign(:reply_to, nil)
|> load_comments()}
{:error, :too_deep} ->
{:noreply, put_flash(socket, :error, "This thread is too deeply nested to reply to.")}
{:error, reason} when reason in [:invalid_parent, :not_found] ->
{:noreply, put_flash(socket, :error, "That comment is no longer available.")}
{:error, :unauthorized} ->
{:noreply,
put_flash(socket, :error, "Your account cannot post to the discussion right now.")}
{:error, %Ecto.Changeset{}} ->
{:noreply, put_flash(socket, :error, "Write a comment before posting.")}
end
end
def handle_event("post_comment", _params, socket) do
{:noreply, redirect(socket, to: ~p"/accounts/log-in")}
end
def handle_event(
"remove_comment",
%{"id" => id, "reason" => reason},
%{assigns: %{current_scope: %{account: account}}} = socket
)
when not is_nil(account) do
with {:ok, comment} <- Discussion.get_comment(id),
{:ok, _removed} <-
Discussion.remove_comment(socket.assigns.current_scope, comment, %{
"removed_reason" => reason
}) do
{:noreply, load_comments(socket)}
else
_error ->
{:noreply, put_flash(socket, :error, "The comment could not be removed.")}
end
end
def handle_event(
"vote",
%{"type" => subject_type, "id" => subject_id, "vote" => value},
%{assigns: %{current_scope: %{account: account}}} = socket
)
when not is_nil(account) do
with {:ok, subject_id} <- normalize_id(subject_id),
{:ok, value} <- normalize_vote(value) do
case Reputation.cast_vote(
socket.assigns.current_scope,
subject_type,
subject_id,
value
) do
{:ok, _summary} ->
{:noreply, socket |> load_votes() |> load_comments()}
{:error, :own_content} ->
{:noreply, put_flash(socket, :error, "You cannot vote on your own contribution.")}
{:error, _reason} ->
{:noreply, put_flash(socket, :error, "Your vote could not be recorded.")}
end
else
_invalid -> {:noreply, put_flash(socket, :error, "Your vote could not be recorded.")}
end
end
def handle_event("vote", _params, socket) do
{:noreply, redirect(socket, to: ~p"/accounts/log-in")}
end
@impl true
def handle_info({:scan_updated, %{id: scan_id}}, %{assigns: %{scan: %{id: scan_id}}} = socket) do
{:noreply, refresh_record(socket)}
end
def handle_info({event, _comment}, socket)
when event in [:comment_posted, :comment_removed] do
{:noreply, socket |> load_comments() |> load_votes()}
end
def handle_info({:vote_changed, _type, _id}, socket) do
{:noreply, load_votes(socket)}
end
def handle_info(_event, socket), do: {:noreply, socket}
defp load_comments(socket) do
comments = Discussion.list_comments(socket.assigns.current_scope, socket.assigns.finding)
socket
|> assign(:comments, comments)
|> assign(:comment_count, Enum.count(flatten_comments(comments)))
|> assign(
:can_moderate_comments,
Discussion.can_moderate?(socket.assigns.current_scope, socket.assigns.finding)
)
end
defp load_votes(socket) do
account_id = current_account_id(socket)
comment_ids =
socket.assigns |> Map.get(:comments, []) |> flatten_comments() |> Enum.map(& &1.id)
socket
|> assign(
:finding_votes,
Reputation.vote_summary(
"canonical_finding",
socket.assigns.finding.canonical_finding.id,
account_id
)
)
|> assign(:comment_votes, Reputation.vote_summaries("comment", comment_ids, account_id))
end
defp current_account_id(%{assigns: %{current_scope: %{account: %{id: id}}}}), do: id
defp current_account_id(_socket), do: nil
# phx-value params are client-controlled; never feed them to String.to_integer/1.
defp normalize_id(id) when is_integer(id) and id > 0, do: {:ok, id}
defp normalize_id(id) when is_binary(id) do
case Integer.parse(id) do
{parsed, ""} when parsed > 0 -> {:ok, parsed}
_other -> {:error, :invalid_id}
end
end
defp normalize_id(_id), do: {:error, :invalid_id}
defp normalize_vote(value) when is_integer(value), do: {:ok, value}
defp normalize_vote(value) when is_binary(value) do
case Integer.parse(value) do
{parsed, ""} -> {:ok, parsed}
_other -> {:error, :invalid_vote}
end
end
defp normalize_vote(_value), do: {:error, :invalid_vote}
defp can_vote?(socket), do: not is_nil(current_account_id(socket))
defp flatten_comments(comments) do
Enum.flat_map(comments, fn comment -> [comment | flatten_comments(comment.replies)] end)
end
defp refresh_record(socket) do
{scan, finding} = fetch_finding!(socket, socket.assigns.finding.public_id)
assign_record(socket, scan, finding)
end
defp assign_record(socket, scan, finding) do
socket
|> assign(:scan, scan)
|> assign(:finding, finding)
|> assign(:repository, scan.repository)
|> assign(
:finding_checks,
FindingMemory.list_checks(finding.canonical_finding.id, scan.commit_sha)
)
|> assign(
:finding_trust,
FindingMemory.trust_summary(finding.canonical_finding, scan.commit_sha)
)
|> assign(
:can_check,
FindingMemory.can_check?(
socket.assigns.current_scope,
scan.repository,
finding.canonical_finding.public_id,
scan.commit_sha
)
)
|> assign(
:can_record_vendor_notification,
FindingMemory.can_record_vendor_notification?(
socket.assigns.current_scope,
scan.repository
)
)
|> assign(:vendor_form, vendor_form(finding.canonical_finding))
|> assign(:review_stake, Reputation.review_stake(scan))
|> assign(
:can_open_finding_job,
Policy.allowed?(socket.assigns.current_scope, :propose_task, scan.repository)
)
end
defp fetch_finding!(socket, public_id) do
case Scans.get_finding(socket.assigns.current_scope, public_id) do
# Attach the resolved scan so Discussion can read the repository id
# without another query.
{:ok, {scan, finding}} -> {scan, %{finding | scan: scan}}
{:error, :not_found} -> raise Ecto.NoResultsError, queryable: Finding
end
end
# Search snippets truncate around 160 characters; lead with the facts that
# identify the finding before the free-text description.
defp meta_description(scan, finding) do
prefix =
"#{String.capitalize(finding.severity)} in " <>
"#{scan.repository.owner}/#{scan.repository.name} (#{finding.file_path}): "
truncate(prefix <> String.replace(finding.description, ~r/\s+/, " "), 160)
end
defp finding_json_ld(scan, finding) do
url = TarakanWeb.Endpoint.url() <> ~p"/findings/#{finding.public_id}"
canonical = finding.canonical_finding
%{
"@context" => "https://schema.org",
"@type" => "TechArticle",
"headline" => finding.title,
"description" => meta_description(scan, finding),
"url" => url,
"datePublished" => DateTime.to_iso8601(finding.inserted_at),
"dateModified" => DateTime.to_iso8601(finding.updated_at),
"author" => %{
"@type" => "Organization",
"name" => "Tarakan public security record"
},
"about" => %{
"@type" => "SoftwareSourceCode",
"name" => "#{scan.repository.owner}/#{scan.repository.name}",
"codeRepository" => scan.repository.canonical_url
},
"keywords" =>
Enum.join(
[
finding.severity,
"security finding",
scan.repository.owner,
scan.repository.name,
"open source"
],
", "
)
}
|> maybe_put_json_ld("cwe", canonical && canonical.cwe_id)
|> maybe_put_json_ld("cve", canonical && canonical.cve_id)
end
defp maybe_put_json_ld(map, _key, value) when value in [nil, false, ""], do: map
defp maybe_put_json_ld(map, key, value), do: Map.put(map, key, value)
# An empty date clears the recorded vendor notification; anything else must
# be an ISO calendar date, stored at midnight UTC.
defp parse_notified_on(value) when value in [nil, ""], do: {:ok, nil}
defp parse_notified_on(value) when is_binary(value) do
case Date.from_iso8601(value) do
{:ok, date} -> {:ok, DateTime.new!(date, ~T[00:00:00], "Etc/UTC")}
{:error, _reason} -> :error
end
end
defp vendor_form(canonical) do
notified_on =
case canonical.vendor_notified_at do
%DateTime{} = datetime -> datetime |> DateTime.to_date() |> Date.to_iso8601()
_other -> ""
end
to_form(%{"notified_on" => notified_on}, as: :vendor_notification)
end
defp truncate(text, max) when byte_size(text) <= max, do: text
defp truncate(text, max) do
String.slice(text, 0, max - 1) <> ""
end
defp record_time(%DateTime{} = datetime) do
Calendar.strftime(datetime, "%Y-%m-%d %H:%M")
end
defp stake_label(:at_risk), do: "at risk (awaiting review)"
defp stake_label(:returned), do: "returned (verified)"
defp stake_label(:slashed), do: "slashed (refuted)"
defp finding_lines(%{line_start: nil}), do: ""
defp finding_lines(%{line_start: line, line_end: line}), do: ":#{line}"
defp finding_lines(%{line_start: line_start, line_end: line_end}),
do: ":#{line_start}-#{line_end}"
end