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
454
lib/tarakan_web/live/finding_live/show.ex
Normal file
454
lib/tarakan_web/live/finding_live/show.ex
Normal file
|
|
@ -0,0 +1,454 @@
|
|||
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
|
||||
611
lib/tarakan_web/live/finding_live/show.html.heex
Normal file
611
lib/tarakan_web/live/finding_live/show.html.heex
Normal file
|
|
@ -0,0 +1,611 @@
|
|||
<Layouts.app flash={@flash} current_scope={@current_scope} current_path={assigns[:current_path]}>
|
||||
<Layouts.page>
|
||||
<.breadcrumbs id="finding-breadcrumb">
|
||||
<:crumb navigate={~p"/"}>registry</:crumb>
|
||||
<:crumb navigate={TarakanWeb.RepositoryPaths.repository_security_path(@repository)}>
|
||||
{@repository.owner}/{@repository.name}
|
||||
</:crumb>
|
||||
<:crumb>finding</:crumb>
|
||||
</.breadcrumbs>
|
||||
|
||||
<TarakanWeb.BountyComponents.wanted_banner bounties={@open_bounties} />
|
||||
|
||||
<%!-- Status + trust chips --%>
|
||||
<% canonical = @finding.canonical_finding %>
|
||||
<% cwe_url = canonical && TarakanWeb.FindingPresentation.cwe_url(canonical.cwe_id) %>
|
||||
<% cve_url = canonical && TarakanWeb.FindingPresentation.cve_url(canonical.cve_id) %>
|
||||
<%!-- Chips are reserved for the two things that rank this finding against
|
||||
every other one: how bad it is, and whether anyone has confirmed it.
|
||||
Everything else - trust signals, references, run counts - is evidence,
|
||||
and evidence reads better as a line than as a wall of badges. --%>
|
||||
<div class="mt-4 flex flex-wrap items-center gap-2">
|
||||
<.notch_badge id="finding-severity" class="bg-signal text-ground">
|
||||
{@finding.severity}
|
||||
</.notch_badge>
|
||||
<.notch_badge
|
||||
:if={@finding.canonical_finding && @finding.canonical_finding.status == "verified"}
|
||||
id="finding-verified-badge"
|
||||
class="bg-ink text-ground"
|
||||
>
|
||||
Verified
|
||||
</.notch_badge>
|
||||
<.notch_badge
|
||||
:if={
|
||||
@finding.canonical_finding && @finding.canonical_finding.status not in [nil, "verified"]
|
||||
}
|
||||
class="text-ink-muted"
|
||||
>
|
||||
{@finding.canonical_finding.status}
|
||||
</.notch_badge>
|
||||
<.notch_badge
|
||||
:if={canonical && canonical.reproductions_count > 0}
|
||||
id="finding-reproduced-badge"
|
||||
class="bg-ink text-ground"
|
||||
>
|
||||
Reproduced
|
||||
</.notch_badge>
|
||||
<.notch_badge id="finding-disclosure-badge" class="bg-ink text-ground">
|
||||
{TarakanWeb.FindingPresentation.disclosure_badge(@scan.visibility, @scan.provenance)}
|
||||
</.notch_badge>
|
||||
</div>
|
||||
|
||||
<%!-- The submitter's severity claim and a rubric-scored second opinion are
|
||||
different statements. Showing only one of them hides a disagreement
|
||||
that is itself information about the submitter. --%>
|
||||
<p
|
||||
:if={
|
||||
canonical && canonical.calibrated_severity &&
|
||||
canonical.calibrated_severity != @finding.severity
|
||||
}
|
||||
id="finding-severity-calibration"
|
||||
class="mt-2 font-mono text-[11px] text-ink-faint"
|
||||
>
|
||||
submitter said <span class="text-ink-muted">{@finding.severity}</span>
|
||||
· rescored <span class="text-ink">{canonical.calibrated_severity}</span>
|
||||
<span :if={canonical.severity_rubric}>({canonical.severity_rubric})</span>
|
||||
</p>
|
||||
|
||||
<p
|
||||
id="finding-evidence-line"
|
||||
class="mt-2 flex flex-wrap items-center gap-x-1.5 font-mono text-[11px] text-ink-faint"
|
||||
>
|
||||
<span class="text-ink-muted" title={@scan.review_status}>
|
||||
{review_kind_label(@scan.review_kind)}
|
||||
</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{TarakanWeb.FindingPresentation.status_blurb(@scan.review_status)}</span>
|
||||
<span :if={@finding.canonical_finding} id="finding-canonical-memory">
|
||||
<span aria-hidden="true">·</span>
|
||||
detected in {@finding.canonical_finding.detections_count}
|
||||
{if @finding.canonical_finding.detections_count == 1, do: "run", else: "runs"}
|
||||
</span>
|
||||
<span :if={@finding_trust.agent_reproduced} id="finding-trust-agent">
|
||||
<span aria-hidden="true">·</span> agent reproduced
|
||||
</span>
|
||||
<span
|
||||
:if={@finding_trust.agent_disputed}
|
||||
id="finding-trust-agent-disputed"
|
||||
class="text-signal"
|
||||
>
|
||||
<span aria-hidden="true">·</span> agent disputed
|
||||
</span>
|
||||
<span :if={@finding_trust.human_checked} id="finding-trust-human">
|
||||
<span aria-hidden="true">·</span> human checked
|
||||
</span>
|
||||
<span :if={canonical && canonical.reproductions_count > 0} id="finding-reproductions">
|
||||
<span aria-hidden="true">·</span> reproduced {canonical.reproductions_count}
|
||||
{if canonical.reproductions_count == 1, do: "time", else: "times"}
|
||||
</span>
|
||||
<%!-- Surviving attacks is a stronger claim than collecting agreements, so
|
||||
it is reported as attempts survived rather than as a confirmation. --%>
|
||||
<span :if={canonical && canonical.refutations_count > 0} id="finding-refutations">
|
||||
<span aria-hidden="true">·</span>
|
||||
survived {canonical.survived_refutations_count} of {canonical.refutations_count}
|
||||
{if canonical.refutations_count == 1, do: "attack", else: "attacks"}
|
||||
</span>
|
||||
<span :if={cwe_url || cve_url} aria-hidden="true">·</span>
|
||||
<.link
|
||||
:if={cwe_url}
|
||||
id="finding-cwe-badge"
|
||||
href={cwe_url}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="text-ink-muted underline decoration-rule underline-offset-2 transition hover:text-signal"
|
||||
>
|
||||
{canonical.cwe_id}
|
||||
</.link>
|
||||
<.link
|
||||
:if={cve_url}
|
||||
id="finding-cve-badge"
|
||||
href={cve_url}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="text-ink-muted underline decoration-rule underline-offset-2 transition hover:text-signal"
|
||||
>
|
||||
{canonical.cve_id}
|
||||
</.link>
|
||||
</p>
|
||||
|
||||
<%!-- Title + vote --%>
|
||||
<div class="mt-4 flex items-start gap-3">
|
||||
<.vote_control
|
||||
subject_type="canonical_finding"
|
||||
subject_id={@finding.canonical_finding.id}
|
||||
summary={@finding_votes}
|
||||
can_vote={@can_vote}
|
||||
class="mt-1 shrink-0"
|
||||
/>
|
||||
<h1
|
||||
id="finding-title"
|
||||
class="min-w-0 break-words font-display text-2xl font-medium leading-tight tracking-[0.02em] text-ink sm:text-3xl"
|
||||
>
|
||||
{@finding.title}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex min-w-0 flex-wrap items-center gap-x-4 gap-y-2">
|
||||
<.link
|
||||
id="finding-source-link"
|
||||
navigate={~p"/findings/#{@finding.public_id}/code"}
|
||||
class="inline-flex max-w-full min-w-0 items-center gap-1.5 border border-strong bg-panel px-3 py-2 font-mono text-xs text-ink transition hover:border-signal hover:text-signal"
|
||||
aria-label={"Open #{@finding.file_path} at the finding's pinned commit"}
|
||||
>
|
||||
<span class="min-w-0 break-all">
|
||||
{@finding.file_path}{finding_lines(@finding)}
|
||||
</span>
|
||||
<.icon name="hero-arrow-right" class="size-3.5 shrink-0" />
|
||||
</.link>
|
||||
<span class="font-mono text-[11px] text-ink-faint" title={@scan.commit_sha}>
|
||||
@ {String.slice(@scan.commit_sha || "", 0, 7)}
|
||||
</span>
|
||||
<button
|
||||
id="finding-copy-link"
|
||||
type="button"
|
||||
phx-hook="CopyLink"
|
||||
data-copy-text={@public_url}
|
||||
data-copied-label="Copied"
|
||||
class="inline-flex items-center gap-1.5 border border-strong px-3 py-2 font-mono text-[11px] uppercase tracking-[0.12em] text-ink-muted transition hover:border-signal hover:text-signal"
|
||||
>
|
||||
<.icon name="hero-link" class="size-3.5" />
|
||||
<span data-copy-label>Copy link</span>
|
||||
</button>
|
||||
</div>
|
||||
<p id="finding-cite" class="mt-2 max-w-3xl break-all font-mono text-[10px] text-ink-faint">
|
||||
{@public_url}
|
||||
</p>
|
||||
|
||||
<%!-- What / why (main content) --%>
|
||||
<% structured = TarakanWeb.FindingPresentation.structure_description(@finding.description) %>
|
||||
<section id="finding-description" class="mt-8 max-w-3xl">
|
||||
<h2 class="font-display text-sm uppercase tracking-[0.12em] text-ink-faint">
|
||||
What & why
|
||||
</h2>
|
||||
<div class="mt-3 space-y-4">
|
||||
<p
|
||||
:if={structured.lead != ""}
|
||||
class="whitespace-pre-line border-l-2 border-signal pl-4 text-sm leading-7 text-ink"
|
||||
phx-no-format
|
||||
>{structured.lead}</p>
|
||||
<div
|
||||
:for={{label, body} <- structured.sections}
|
||||
class="bg-panel px-4 py-3"
|
||||
>
|
||||
<h3 class="text-sm font-semibold text-ink">
|
||||
{label}
|
||||
</h3>
|
||||
<p class="mt-1.5 whitespace-pre-line text-sm leading-6 text-ink-muted" phx-no-format>{body}</p>
|
||||
</div>
|
||||
<p
|
||||
:if={structured.lead == "" and structured.sections == []}
|
||||
class="whitespace-pre-line border-l-2 border-signal pl-4 text-sm leading-7 text-ink-muted"
|
||||
phx-no-format
|
||||
>{@finding.description}</p>
|
||||
<div
|
||||
:if={canonical.reproduction_steps}
|
||||
id="finding-reproduction"
|
||||
class="bg-panel px-4 py-3"
|
||||
>
|
||||
<h3 class="text-sm font-semibold text-ink">
|
||||
Reproduction
|
||||
</h3>
|
||||
<pre
|
||||
class="mt-1.5 overflow-x-auto whitespace-pre-wrap font-mono text-xs leading-6 text-ink-muted"
|
||||
phx-no-format
|
||||
>{canonical.reproduction_steps}</pre>
|
||||
</div>
|
||||
<div
|
||||
:if={canonical.affected_versions}
|
||||
id="finding-affected-versions"
|
||||
class="bg-panel px-4 py-3"
|
||||
>
|
||||
<h3 class="text-sm font-semibold text-ink">
|
||||
Affected versions
|
||||
</h3>
|
||||
<p class="mt-1.5 font-mono text-xs leading-6 text-ink-muted">
|
||||
{canonical.affected_versions}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<%!-- At a glance meta --%>
|
||||
<section id="finding-review-record" class="mt-8">
|
||||
<h2 class="font-display text-sm uppercase tracking-[0.12em] text-ink-faint">
|
||||
At a glance
|
||||
</h2>
|
||||
<div class="mt-3 grid gap-px border-2 border-strong bg-rule sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div class="bg-ground px-4 py-3">
|
||||
<p class="text-xs font-medium text-ink-faint">Commit</p>
|
||||
<p class="mt-1 font-mono text-sm tabular-nums text-ink" title={@scan.commit_sha}>
|
||||
{String.slice(@scan.commit_sha || "", 0, 12)}
|
||||
</p>
|
||||
<p :if={@scan.commit_committed_at} class="mt-0.5 font-mono text-[10px] text-ink-faint">
|
||||
{record_time(@scan.commit_committed_at)}
|
||||
</p>
|
||||
</div>
|
||||
<div class="bg-ground px-4 py-3">
|
||||
<p class="text-xs font-medium text-ink-faint">How made</p>
|
||||
<p id="finding-provenance" class="mt-1 text-sm text-ink">
|
||||
{TarakanWeb.FindingPresentation.how_made_label(@scan.provenance)}
|
||||
<span class="text-ink-faint"> (claim)</span>
|
||||
</p>
|
||||
<p :if={@scan.model} class="mt-0.5 font-mono text-[10px] text-ink-faint">
|
||||
{@scan.model}
|
||||
<span :if={@scan.prompt_version}> · {@scan.prompt_version}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="bg-ground px-4 py-3">
|
||||
<p class="text-xs font-medium text-ink-faint">
|
||||
Submitted
|
||||
</p>
|
||||
<p class="mt-1 text-sm text-ink">
|
||||
<.handle_link handle={@scan.submitted_by.handle} />
|
||||
</p>
|
||||
<p class="mt-0.5 font-mono text-[10px] text-ink-faint">
|
||||
{record_time(@scan.inserted_at)}
|
||||
</p>
|
||||
</div>
|
||||
<%!--
|
||||
One first finder per issue, permanently. Later reports corroborate
|
||||
it but never displace the credit.
|
||||
--%>
|
||||
<div :if={@first_finder} id="finding-first-finder" class="bg-ground px-4 py-3">
|
||||
<p class="text-xs font-medium text-ink-faint">First found by</p>
|
||||
<p class="mt-1 text-sm text-ink">
|
||||
<.handle_link handle={@first_finder.handle} />
|
||||
</p>
|
||||
<p class="mt-0.5 font-mono text-[10px] text-ink-faint">
|
||||
{record_time(@first_finder.reported_at)} · {String.slice(
|
||||
@first_finder.commit_sha || "",
|
||||
0,
|
||||
7
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div class="bg-ground px-4 py-3">
|
||||
<p class="text-xs font-medium text-ink-faint">Checks</p>
|
||||
<p id="finding-verdict-counts" class="mt-1 font-mono text-sm text-ink">
|
||||
{@finding.canonical_finding.confirmations_count} confirmed · {@finding.canonical_finding.disputes_count} disputed
|
||||
</p>
|
||||
<p class="mt-0.5 font-mono text-[10px] text-ink-faint">
|
||||
status {@finding.canonical_finding.status}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
:if={@review_stake || @scan.reviewed_at || @scan.notes}
|
||||
class="mt-3 space-y-2 text-xs text-ink-muted"
|
||||
>
|
||||
<p :if={@review_stake} id="finding-stake" class="font-mono">
|
||||
<span class="text-ink-faint">Stake</span>
|
||||
<span class={[
|
||||
"ml-2 tabular-nums",
|
||||
@review_stake.status == :slashed && "text-signal",
|
||||
@review_stake.status == :returned && "text-quote",
|
||||
@review_stake.status == :at_risk && "text-ink"
|
||||
]}>
|
||||
{@review_stake.amount} · {stake_label(@review_stake.status)}
|
||||
</span>
|
||||
</p>
|
||||
<p :if={@scan.reviewed_at} class="font-mono text-ink-faint">
|
||||
Disclosed {record_time(@scan.reviewed_at)}
|
||||
</p>
|
||||
<div :if={@scan.notes} class="max-w-3xl border border-rule bg-panel px-3 py-2">
|
||||
<p class="text-xs font-semibold text-ink-muted">
|
||||
Report summary
|
||||
</p>
|
||||
<% notes = TarakanWeb.FindingPresentation.humanize_notes(@scan.notes) %>
|
||||
<div :if={notes && notes.kind == :summary} class="mt-1">
|
||||
<p class="text-ink">
|
||||
{notes.count} {if notes.count == 1, do: "finding", else: "findings"} in this report.
|
||||
</p>
|
||||
<ol :if={notes.tops != []} class="mt-1 list-decimal space-y-0.5 pl-5 text-ink-muted">
|
||||
<li :for={top <- notes.tops}>{top}</li>
|
||||
</ol>
|
||||
</div>
|
||||
<p
|
||||
:if={notes && notes.kind == :plain}
|
||||
class="mt-1 whitespace-pre-line text-ink-muted"
|
||||
phx-no-format
|
||||
>{notes.text}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<%!-- Lifecycle timeline --%>
|
||||
<% events = TarakanWeb.FindingPresentation.timeline_events(canonical, @finding_checks) %>
|
||||
<section id="finding-timeline" class="mt-8 max-w-3xl">
|
||||
<h2 class="font-display text-sm uppercase tracking-[0.12em] text-ink-faint">
|
||||
Lifecycle
|
||||
</h2>
|
||||
<ol class="mt-3 border-l-2 border-rule">
|
||||
<li
|
||||
:for={event <- events}
|
||||
id={"finding-timeline-#{event.id}"}
|
||||
class="relative pb-4 pl-4 last:pb-0"
|
||||
>
|
||||
<span class="absolute top-1.5 -left-[5px] size-2 bg-ink-faint"></span>
|
||||
<p class="text-sm leading-5 text-ink">
|
||||
{event.title}
|
||||
<span
|
||||
:if={event.detail}
|
||||
class="font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint"
|
||||
>
|
||||
· {event.detail}
|
||||
</span>
|
||||
</p>
|
||||
<p class="mt-0.5 font-mono text-[10px] text-ink-faint">
|
||||
{record_time(event.at)}
|
||||
</p>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<.form
|
||||
:if={@can_record_vendor_notification}
|
||||
for={@vendor_form}
|
||||
id="finding-vendor-form"
|
||||
phx-submit="record_vendor_notification"
|
||||
class="mt-4 flex flex-wrap items-end gap-2 border border-strong bg-panel px-4 py-3"
|
||||
>
|
||||
<.input
|
||||
field={@vendor_form[:notified_on]}
|
||||
type="date"
|
||||
label="Vendor notified on (empty clears)"
|
||||
class="border border-strong bg-ground px-3 py-2 font-mono text-xs text-ink placeholder:text-ink-faint focus:border-signal focus:outline-none"
|
||||
/>
|
||||
<.button size="sm">Record</.button>
|
||||
</.form>
|
||||
</section>
|
||||
|
||||
<%!-- Independent checks --%>
|
||||
<section id="finding-verification" class="mt-10">
|
||||
<div class="flex flex-wrap items-end justify-between gap-3 border-b-2 border-strong pb-3">
|
||||
<h2 class="font-display text-lg uppercase tracking-[0.02em] text-ink">
|
||||
Checks
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<%!-- Hidden duplicate for tests that query counts only inside verification; primary counts live in glance grid --%>
|
||||
<div class="sr-only" aria-hidden="true">
|
||||
{@finding.canonical_finding.confirmations_count} confirmed · {@finding.canonical_finding.disputes_count} disputed
|
||||
</div>
|
||||
|
||||
<p
|
||||
:if={@finding_checks == []}
|
||||
id="finding-no-checks"
|
||||
class="py-6 text-center text-sm text-ink-faint"
|
||||
>
|
||||
No checks at this commit yet.
|
||||
</p>
|
||||
|
||||
<div
|
||||
:if={@finding_checks != []}
|
||||
id="finding-checks"
|
||||
class="divide-y divide-rule border-y border-rule"
|
||||
>
|
||||
<article :for={check <- @finding_checks} id={"finding-check-#{check.id}"} class="py-4">
|
||||
<div class="flex flex-wrap items-baseline gap-x-2 gap-y-1">
|
||||
<.handle_link handle={check.account.handle} class="font-mono text-xs font-semibold" />
|
||||
<span class={[
|
||||
"font-mono text-[10px] uppercase tracking-[0.12em]",
|
||||
check.verdict == "confirmed" && "text-quote",
|
||||
check.verdict == "disputed" && "text-signal",
|
||||
check.verdict == "fixed" && "text-quote"
|
||||
]}>
|
||||
{check.verdict}
|
||||
</span>
|
||||
<span class="font-mono text-[10px] text-ink-faint">
|
||||
· {provenance_label(check.provenance)} · {record_time(check.inserted_at)}
|
||||
</span>
|
||||
<span class={[
|
||||
"ml-auto font-mono text-[10px] uppercase tracking-[0.12em]",
|
||||
check.counts_toward_quorum && "text-ink-muted",
|
||||
!check.counts_toward_quorum && "text-ink-faint"
|
||||
]}>
|
||||
{if check.counts_toward_quorum,
|
||||
do: "counts toward quorum",
|
||||
else: "corroboration only"}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-2 whitespace-pre-line text-sm leading-6 text-ink-muted" phx-no-format>{check.notes}</p>
|
||||
<details :if={check.evidence} class="mt-2">
|
||||
<summary class="cursor-pointer font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint hover:text-ink">
|
||||
Evidence
|
||||
</summary>
|
||||
<pre class="mt-2 overflow-x-auto border border-rule bg-panel px-3 py-2 font-mono text-[11px] leading-5 text-ink-muted">{check.evidence}</pre>
|
||||
</details>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<%!-- Queue an agent instead of doing it yourself. Each of these asks for
|
||||
a different kind of evidence than "somebody agreed": an attack that
|
||||
failed, a reproduction that ran, a score against a rubric. --%>
|
||||
<div
|
||||
:if={@can_open_finding_job}
|
||||
id="finding-agent-jobs"
|
||||
class="mt-5 flex flex-wrap items-center gap-2"
|
||||
>
|
||||
<span class="mr-1 font-mono text-[11px] uppercase tracking-[0.12em] text-ink-faint">
|
||||
Open a job
|
||||
</span>
|
||||
<.button
|
||||
id="finding-job-refute"
|
||||
type="button"
|
||||
size="sm"
|
||||
phx-click="open_finding_job"
|
||||
phx-value-kind="refute_finding"
|
||||
>
|
||||
Refute
|
||||
</.button>
|
||||
<.button
|
||||
id="finding-job-reproduce"
|
||||
type="button"
|
||||
size="sm"
|
||||
phx-click="open_finding_job"
|
||||
phx-value-kind="reproduce_finding"
|
||||
>
|
||||
Reproduce
|
||||
</.button>
|
||||
<.button
|
||||
id="finding-job-calibrate"
|
||||
type="button"
|
||||
size="sm"
|
||||
phx-click="open_finding_job"
|
||||
phx-value-kind="calibrate_severity"
|
||||
>
|
||||
Re-score
|
||||
</.button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
:if={@can_check}
|
||||
id="finding-verdict"
|
||||
class="mt-5 border border-strong bg-panel px-4 py-4"
|
||||
>
|
||||
<h3 class="text-sm font-semibold text-ink">
|
||||
Record a check
|
||||
</h3>
|
||||
<form
|
||||
id="finding-verdict-form"
|
||||
phx-submit="record_finding_verdict"
|
||||
class="mt-2 flex flex-wrap gap-2"
|
||||
>
|
||||
<input
|
||||
id="finding-verdict-notes"
|
||||
type="text"
|
||||
name="notes"
|
||||
required
|
||||
minlength="20"
|
||||
maxlength="2000"
|
||||
placeholder="Evidence notes (required, 20+ chars)"
|
||||
autocomplete="off"
|
||||
class="min-w-0 flex-1 basis-48 border border-strong bg-ground px-3 py-2 font-mono text-xs text-ink placeholder:text-ink-faint focus:border-signal focus:outline-none"
|
||||
/>
|
||||
<.button name="verdict" value="confirmed" size="sm">Confirm</.button>
|
||||
<.button name="verdict" value="disputed" variant="danger" size="sm">
|
||||
Dispute
|
||||
</.button>
|
||||
<.button name="verdict" value="fixed" size="sm">Fixed</.button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<.link
|
||||
:if={is_nil(@current_scope) or is_nil(@current_scope.account)}
|
||||
id="finding-verify-login"
|
||||
navigate={~p"/accounts/log-in"}
|
||||
class="mt-4 block font-mono text-xs text-signal hover:underline"
|
||||
>
|
||||
Sign in to confirm or dispute this finding.
|
||||
</.link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<%!-- Discussion --%>
|
||||
<section id="finding-discussion" class="mt-10">
|
||||
<div class="flex flex-wrap items-baseline justify-between gap-2 border-b-2 border-strong pb-3">
|
||||
<h2 class="font-display text-lg uppercase tracking-[0.02em] text-ink">
|
||||
Discussion
|
||||
</h2>
|
||||
<span id="finding-comment-count" class="font-mono text-[10px] tabular-nums text-ink-faint">
|
||||
{@comment_count} {if @comment_count == 1, do: "comment", else: "comments"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<form
|
||||
:if={@current_scope && @current_scope.account}
|
||||
id="finding-comment-form"
|
||||
phx-submit="post_comment"
|
||||
class="flex flex-col gap-2"
|
||||
>
|
||||
<textarea
|
||||
id="finding-comment-body"
|
||||
name="body"
|
||||
rows="3"
|
||||
placeholder="Add evidence, a counter-argument, or context…"
|
||||
class="w-full border border-strong bg-transparent px-3 py-2 font-mono text-xs text-ink placeholder:text-ink-faint focus:outline-none focus:ring-2 focus:ring-phosphor"
|
||||
></textarea>
|
||||
<div>
|
||||
<.button variant="primary" size="sm">Post comment</.button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<.link
|
||||
:if={is_nil(@current_scope) or is_nil(@current_scope.account)}
|
||||
id="finding-comment-login"
|
||||
navigate={~p"/accounts/log-in"}
|
||||
class="block font-mono text-xs text-signal hover:underline"
|
||||
>
|
||||
Sign in to join the discussion.
|
||||
</.link>
|
||||
|
||||
<p
|
||||
:if={@comments == []}
|
||||
id="finding-no-comments"
|
||||
class="mt-6 text-center font-mono text-xs text-ink-faint"
|
||||
>
|
||||
No comments yet.
|
||||
</p>
|
||||
|
||||
<div :if={@comments != []} class="mt-5 space-y-4">
|
||||
<.comment_thread
|
||||
:for={comment <- @comments}
|
||||
comment={comment}
|
||||
reply_to={@reply_to}
|
||||
can_reply={!is_nil(@current_scope) and !is_nil(@current_scope.account)}
|
||||
can_moderate={@can_moderate_comments}
|
||||
can_vote={@can_vote}
|
||||
votes={@comment_votes}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<%!-- Technical dump last --%>
|
||||
<details
|
||||
:if={@scan.raw_document}
|
||||
id="finding-raw-report"
|
||||
class="mt-10 border-2 border-strong"
|
||||
>
|
||||
<summary class="cursor-pointer bg-panel px-4 py-3 font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint transition hover:text-ink">
|
||||
Technical · raw report JSON
|
||||
<span class="ml-2 normal-case tracking-normal text-ink-faint/80">
|
||||
full report, not only this finding
|
||||
</span>
|
||||
</summary>
|
||||
<pre class="max-h-[24rem] overflow-auto border-t border-rule px-4 py-4 font-mono text-[11px] leading-5 text-ink-muted">{raw_report(@scan)}</pre>
|
||||
</details>
|
||||
|
||||
<p class="mt-8 font-mono text-xs">
|
||||
<.link
|
||||
id="finding-record-link"
|
||||
navigate={TarakanWeb.RepositoryPaths.repository_security_path(@repository)}
|
||||
class="text-signal transition hover:underline"
|
||||
>
|
||||
← All findings for {@repository.owner}/{@repository.name}
|
||||
</.link>
|
||||
</p>
|
||||
</Layouts.page>
|
||||
</Layouts.app>
|
||||
Loading…
Add table
Add a link
Reference in a new issue