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