7.4 KiB · text History b868907
defmodule GitGudWeb.SecurityLive.Repo do
  @moduledoc """
  `/r/:owner/:name/security[/:state]` — a repository's dependency alerts
  (`GitGud.Advisories`): open, fixed, dismissed.

  For whoever may see them (`Advisories.readable?/2`, asked again on
  every `handle_params` and live update): an org repository's org
  members, a personal repository's owner and writers. Shows how to turn
  alerts on where they're off, and says so where the instance can't run
  them.
  """
  use GitGudWeb, :live_view

  alias GitGud.Advisories
  alias GitGud.Advisories.Alert
  alias GitGud.Repositories
  alias GitGud.Repositories.Storage

  @states ~w(open fixed dismissed)

  @impl true
  def mount(%{"owner" => owner, "name" => name}, _session, socket) do
    repo =
      Repositories.get_repository_by_path!(owner, name)
      |> GitGud.Repo.preload([:owner, :organization])

    if Advisories.readable?(socket.assigns.current_scope.user, repo) do
      if connected?(socket), do: Phoenix.PubSub.subscribe(GitGud.PubSub, Advisories.topic(repo))

      {:ok,
       socket
       |> assign(:repo, repo)
       |> assign(:handle, Storage.repo_handle(repo))
       |> GitGudWeb.RepoLive.Header.assign_chrome(repo)
       |> assign(:page_title, "Security — #{owner}/#{name}")
       |> stream_configure(:alerts, dom_id: &"alert-#{&1.id}")}
    else
      {:ok, deny(socket, repo)}
    end
  end

  @impl true
  def handle_params(params, _uri, socket) do
    repo = socket.assigns.repo
    base = ~p"/r/#{socket.assigns.handle}/#{repo.name}/security"

    case params["state"] do
      nil -> {:noreply, load(socket, "open")}
      "open" -> {:noreply, push_patch(socket, to: base, replace: true)}
      state when state in @states -> {:noreply, load(socket, state)}
      _ -> {:noreply, socket |> put_flash(:error, "No such list.") |> push_patch(to: base)}
    end
  end

  @impl true
  def handle_info({:advisories, _repo_id}, socket),
    do: {:noreply, load(socket, socket.assigns.state)}

  defp load(socket, state) do
    repo = socket.assigns.repo

    if Advisories.readable?(socket.assigns.current_scope.user, repo) do
      socket
      |> assign(:state, state)
      |> assign(:counts, Advisories.counts(repo))
      |> assign(:enabled?, Advisories.enabled?(repo))
      |> assign(:available, Advisories.available())
      |> stream(:alerts, Advisories.list(repo, state), reset: true)
    else
      deny(socket, repo)
    end
  end

  defp deny(socket, repo) do
    socket
    |> put_flash(:error, "Dependency alerts are for this repository's members.")
    |> redirect(to: ~p"/r/#{Storage.repo_handle(repo)}/#{repo.name}")
  end

  defp tab_path(handle, repo, "open"), do: ~p"/r/#{handle}/#{repo.name}/security"
  defp tab_path(handle, repo, state), do: ~p"/r/#{handle}/#{repo.name}/security/#{state}"

  defp osv_url(id), do: "https://osv.dev/vulnerability/#{id}"

  defp severity_class(alert) do
    case Alert.severity_label(alert) do
      "critical" -> "badge-error"
      "high" -> "badge-error badge-outline"
      "medium" -> "badge-warning"
      _ -> "badge-ghost"
    end
  end

  @impl true
  def render(assigns) do
    ~H"""
    <Layouts.app flash={@flash} current_scope={@current_scope}>
      <div class="space-y-6">
        <GitGudWeb.RepoLive.Header.header
          repo={@repo}
          handle={@handle}
          current_scope={@current_scope}
          has_packages?={@has_packages?}
          can_admin?={@can_admin?}
          latest_run={@latest_run}
          open_pulls={@open_pulls}
          security_alerts={assigns[:security_alerts]}
        />

        <header class="space-y-1">
          <h2 class="text-lg font-semibold">Dependency alerts</h2>
          <p class="text-sm opacity-70">
            Known vulnerabilities (<a href="https://osv.dev" class="link">OSV</a>) in the
            versions this repository's lockfiles pin on its default branch.
            <span :if={@repo.advisories_scanned_at}>
              Last scanned {Calendar.strftime(@repo.advisories_scanned_at, "%Y-%m-%d %H:%M UTC")}.
            </span>
          </p>
        </header>

        <div :if={@available != :ok} id="security-unavailable" class="alert alert-warning text-sm">
          <.icon name="hero-exclamation-triangle" class="size-5" />
          <span :if={@available == :off}>Dependency alerts are turned off on this instance.</span>
          <span :if={@available == :no_scanner}>
            This instance has no osv-scanner installed, so nothing is scanned.
          </span>
        </div>

        <div :if={@available == :ok and not @enabled?} id="security-off" class="alert text-sm">
          <.icon name="hero-shield-exclamation" class="size-5" />
          <span :if={@repo.organization_id}>
            Off for this organization's repositories. An org admin can turn them on in <.link
              navigate={~p"/orgs/#{@handle}/settings/profile"}
              class="link"
            >the org's settings</.link>.
          </span>
          <span :if={is_nil(@repo.organization_id)}>
            Off for this repository. Its owner can turn them on in <.link
              navigate={~p"/r/#{@handle}/#{@repo.name}/settings"}
              class="link"
            >settings</.link>.
          </span>
        </div>

        <nav id="security-tabs" class="flex gap-1" aria-label="Alerts">
          <.link
            :for={state <- ~w(open fixed dismissed)}
            id={"security-tab-#{state}"}
            patch={tab_path(@handle, @repo, state)}
            class={["btn btn-xs", if(@state == state, do: "btn-primary", else: "btn-ghost")]}
          >
            {String.capitalize(state)}
            <span class="badge badge-xs">{Map.get(@counts, state, 0)}</span>
          </.link>
        </nav>

        <ul id="security-alerts" phx-update="stream" class="divide-y divide-base-300">
          <li id="security-empty" class="hidden only:block py-8 text-center text-sm opacity-60">
            {if @state == "open", do: "No open alerts.", else: "None."}
          </li>
          <li :for={{id, alert} <- @streams.alerts} id={id} class="py-3 space-y-1 text-sm">
            <div class="flex flex-wrap items-center gap-2">
              <span class={["badge badge-sm", severity_class(alert)]}>
                {Alert.severity_label(alert)}<span :if={alert.severity}>&nbsp;{alert.severity}</span>
              </span>
              <span class="font-mono font-medium">{alert.package}</span>
              <span class="font-mono opacity-70">{alert.version}</span>
              <span :if={alert.fixed_versions != []} class="opacity-70">
                → fixed in <span class="font-mono">{Enum.join(alert.fixed_versions, ", ")}</span>
              </span>
              <span class="badge badge-ghost badge-sm">{alert.ecosystem}</span>
            </div>
            <p :if={alert.summary} class="opacity-80">{alert.summary}</p>
            <p class="text-xs opacity-60 flex flex-wrap gap-x-3">
              <a href={osv_url(alert.advisory_id)} class="link font-mono" rel="noopener noreferrer">
                {alert.advisory_id}
              </a>
              <span :for={a <- alert.aliases} class="font-mono">{a}</span>
              <span class="font-mono">{alert.manifest_path}</span>
              <span :if={alert.fixed_at}>fixed {Calendar.strftime(alert.fixed_at, "%Y-%m-%d")}</span>
              <span>first seen {Calendar.strftime(alert.first_seen_at, "%Y-%m-%d")}</span>
            </p>
          </li>
        </ul>
      </div>
    </Layouts.app>
    """
  end
end