10.0 KiB · text History b868907
defmodule GitGud.Advisories do
  @moduledoc """
  Dependency alerts: what each repository's lockfiles pin that OSV
  (https://osv.dev) knows to be vulnerable.

  Off until an organization turns it on for its repositories
  (`organizations.advisories_enabled`), or a personal repository's owner
  for that repository; and off for the whole instance with
  `config :git_gud, GitGud.Advisories, source: :off`. A repository is
  scanned when its default branch moves and changes a lockfile, and
  whenever the switch is turned on (`GitGud.Advisories.Workers.Scan`).

  Matching is osv-scanner's (`GitGud.Advisories.Scanner`), a separate
  install found by `scanner/0`. The `:mirror` source (the default) keeps
  OSV's dumps on the instance and sends nothing out; `:api` asks
  api.osv.dev, which sees each scan's package names and versions.

  Alerts are for an org repository's org members, and a personal
  repository's owner and writers (`readable?/2`): an open alert maps out
  what is exploitable, so not every reader of a public repository sees
  one. See docs/plans/dependency-security.md.
  """

  import Ecto.Query

  alias GitGud.Accounts.User
  alias GitGud.Advisories.Alert
  alias GitGud.Advisories.Scanner
  alias GitGud.Organizations
  alias GitGud.Organizations.Organization
  alias GitGud.Repo
  alias GitGud.Repositories.Repository

  # ── configuration ────────────────────────────────────────────────────

  @doc "`:mirror` (default), `:api`, or `:off`."
  def source do
    case Application.get_env(:git_gud, __MODULE__, []) |> Keyword.get(:source, :mirror) do
      s when s in [:mirror, :api, :off] ->
        s

      other ->
        raise ArgumentError,
              "GitGud.Advisories source must be :mirror, :api or :off, got #{inspect(other)}"
    end
  end

  @doc """
  Where osv-scanner is: `GITGUD_OSV_SCANNER`, the `:scanner` config,
  `/usr/libexec/gitgud/osv-scanner` (the gitgud-osv-scanner package), or
  `osv-scanner` on the PATH. nil when it's nowhere.
  """
  def scanner do
    [
      System.get_env("GITGUD_OSV_SCANNER"),
      Application.get_env(:git_gud, __MODULE__, []) |> Keyword.get(:scanner),
      "/usr/libexec/gitgud/osv-scanner"
    ]
    |> Enum.find(&(is_binary(&1) and &1 != "" and File.regular?(&1)))
    |> Kernel.||(System.find_executable("osv-scanner"))
  end

  @doc """
  Where OSV's dumps are kept for the `:mirror` source: `GITGUD_OSV_DB_DIR`,
  the `:db_dir` config, or `.osv-db` beside the repositories (a handle
  can't start with a dot), so it lives on the same persistent volume.
  """
  def db_dir do
    System.get_env("GITGUD_OSV_DB_DIR") ||
      Keyword.get(Application.get_env(:git_gud, __MODULE__, []), :db_dir) ||
      Path.join(GitGud.Repositories.Storage.base_path(), ".osv-db")
  end

  @doc """
  `:ok`, or why alerts can't run on this instance: `:off` (by config) or
  `:no_scanner`.
  """
  def available do
    cond do
      source() == :off -> :off
      scanner() == nil -> :no_scanner
      true -> :ok
    end
  end

  # ── who and what ─────────────────────────────────────────────────────

  @doc """
  Are alerts on for `repo`? Its org's switch for an org repository, its
  own for a personal one; never while the instance has them off.
  """
  def enabled?(%Repository{} = repo) do
    source() != :off and switch_on?(repo)
  end

  defp switch_on?(%Repository{organization_id: nil, advisories_enabled: on}), do: on == true

  defp switch_on?(%Repository{organization: %Organization{advisories_enabled: on}}),
    do: on == true

  defp switch_on?(%Repository{organization_id: oid}) do
    Repo.exists?(from o in Organization, where: o.id == ^oid and o.advisories_enabled)
  end

  @doc """
  May `user` see `repo`'s alerts? An org repository's: the org's admins
  and members. A personal one's: its owner, and those who may write to
  it. Instance admins too.
  """
  def readable?(nil, _repo), do: false
  def readable?(%User{is_admin: true}, _repo), do: true
  def readable?(%User{id: uid}, %Repository{organization_id: nil, owner_id: uid}), do: true

  def readable?(%User{} = user, %Repository{organization_id: nil} = repo),
    do: Organizations.can?(user, repo, :write) == true

  def readable?(%User{} = user, %Repository{organization_id: oid}),
    do: Organizations.member?(%Organization{id: oid}, user)

  @doc "Turn alerts on or off for an org's repositories; a scan of each follows turning on."
  def set_org(%Organization{} = org, on?) when is_boolean(on?) do
    with {:ok, org} <- org |> Ecto.Changeset.change(advisories_enabled: on?) |> Repo.update() do
      if on?, do: enqueue_org(org)
      {:ok, org}
    end
  end

  @doc "Turn alerts on or off for a personal repository; a scan follows turning on."
  def set_repo(%Repository{organization_id: nil} = repo, on?) when is_boolean(on?) do
    with {:ok, repo} <- repo |> Ecto.Changeset.change(advisories_enabled: on?) |> Repo.update() do
      if on?, do: enqueue(repo, force: true)
      {:ok, repo}
    end
  end

  def set_repo(%Repository{}, _on?), do: {:error, :org_repository}

  # ── scanning ─────────────────────────────────────────────────────────

  @doc """
  Queue a scan of `repo` if alerts are on for it. `force: true` scans
  even when its lockfiles haven't changed since the last one (new
  advisories, a switch turned on).
  """
  def enqueue(%Repository{} = repo, opts \\ []) do
    if enabled?(repo) do
      %{"repository_id" => repo.id, "force" => Keyword.get(opts, :force, false)}
      |> GitGud.Advisories.Workers.Scan.new()
      |> Oban.insert()
    end

    :ok
  end

  defp enqueue_org(%Organization{id: oid}) do
    from(r in Repository, where: r.organization_id == ^oid)
    |> Repo.all()
    |> Enum.each(&enqueue(&1, force: true))
  end

  @doc """
  Scan `repo`'s default branch now and record what it finds. Returns
  `{:ok, %{opened: n, fixed: n}}`, `{:ok, :unchanged}` when the lockfiles
  are what the last scan read (unless `force: true`), or `{:error, why}`.
  """
  def scan(%Repository{} = repo, opts \\ []) do
    with :ok <- available(),
         {:ok, _ref, sha} <- GitGud.Workflows.default_branch_head(repo) do
      head = sha_hex(sha)
      lockfiles = Scanner.lockfiles(repo.disk_path, head)
      digest = digest(lockfiles)

      if digest == repo.advisories_digest and not Keyword.get(opts, :force, false) do
        {:ok, :unchanged}
      else
        with {:ok, findings} <-
               Scanner.scan(repo.disk_path, head, Enum.map(lockfiles, &elem(&1, 0))) do
          record(repo, findings, digest)
        end
      end
    else
      :error -> {:error, :no_default_branch}
      other -> {:error, other}
    end
  end

  defp sha_hex(<<_::binary-size(20)>> = sha), do: Base.encode16(sha, case: :lower)
  defp sha_hex(hex) when is_binary(hex), do: hex

  defp digest([]), do: "none"

  defp digest(lockfiles) do
    lockfiles
    |> Enum.map_join("\n", fn {path, blob} -> "#{path} #{blob}" end)
    |> then(&:crypto.hash(:sha256, &1))
    |> Base.encode16(case: :lower)
  end

  @doc """
  Bring `repo`'s alerts in line with a scan's `findings`: new ones open,
  open ones no longer found are fixed, dismissed ones stay dismissed.
  One transaction.
  """
  def record(%Repository{} = repo, findings, digest) do
    now = DateTime.utc_now(:second)
    key = &{&1.manifest_path, &1.ecosystem, &1.package, &1.version, &1.advisory_id}
    found = Map.new(findings, &{key.(&1), &1})

    Repo.transact(fn ->
      existing =
        from(a in Alert, where: a.repository_id == ^repo.id)
        |> Repo.all()
        |> Map.new(&{key.(&1), &1})

      opened =
        for {k, finding} <- found, not Map.has_key?(existing, k) do
          %Alert{repository_id: repo.id, state: "open", first_seen_at: now}
          |> Alert.changeset(finding)
          |> Repo.insert!()
        end

      # Seen again: what OSV says about it may have changed.
      for {k, finding} <- found, alert = existing[k] do
        alert
        |> Alert.changeset(finding)
        |> Ecto.Changeset.change(
          if alert.state == "fixed", do: [state: "open", fixed_at: nil], else: []
        )
        |> Repo.update!()
      end

      fixed =
        for {k, %Alert{state: "open"} = alert} <- existing, not Map.has_key?(found, k) do
          alert |> Ecto.Changeset.change(state: "fixed", fixed_at: now) |> Repo.update!()
        end

      repo
      |> Ecto.Changeset.change(advisories_digest: digest, advisories_scanned_at: now)
      |> Repo.update!()

      {:ok, %{opened: length(opened), fixed: length(fixed), new: opened}}
    end)
    |> tap(fn
      {:ok, %{opened: o, fixed: f}} when o + f > 0 ->
        Phoenix.PubSub.broadcast(GitGud.PubSub, topic(repo), {:advisories, repo.id})

      _ ->
        :ok
    end)
  end

  @doc "The PubSub topic a repository's Security page follows."
  def topic(%Repository{id: id}), do: "advisories:repo:#{id}"

  # ── reading ──────────────────────────────────────────────────────────

  @doc "`repo`'s alerts in `state`, worst first."
  def list(%Repository{id: rid}, state \\ "open") when state in ["open", "fixed", "dismissed"] do
    from(a in Alert,
      where: a.repository_id == ^rid and a.state == ^state,
      order_by: [desc_nulls_last: a.severity, asc: a.package, asc: a.advisory_id],
      preload: [:dismissed_by]
    )
    |> Repo.all()
  end

  @doc "How many of `repo`'s alerts are open."
  def open_count(%Repository{id: rid}) do
    Repo.aggregate(from(a in Alert, where: a.repository_id == ^rid and a.state == "open"), :count)
  end

  @doc "Counts per state, for the tabs."
  def counts(%Repository{id: rid}) do
    from(a in Alert,
      where: a.repository_id == ^rid,
      group_by: a.state,
      select: {a.state, count(a.id)}
    )
    |> Repo.all()
    |> Map.new()
  end
end