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

611 lines
24 KiB
Text

<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>