7.4 KiB · text History b868907
defmodule GitGud.AdvisoriesTest do
  @moduledoc """
  Dependency alerts: who they're on for and who sees them, a scan
  exporting a repository's lockfiles and recording what the scanner
  reports — opening, fixing and reopening alerts — and skipping a push
  that left the lockfiles alone. The scanner is a stand-in script that
  prints the report a test asks for.
  """
  use GitGud.DataCase, async: false
  use Oban.Testing, repo: GitGud.Repo

  import GitGud.AccountsFixtures
  import GitGud.ForgeFixtures
  import GitGud.WorkflowGitHelpers

  alias GitGud.Advisories
  alias GitGud.Advisories.Alert
  alias GitGud.Advisories.Workers.Scan
  alias GitGud.Organizations

  setup do
    dir = Path.join(System.tmp_dir!(), "fake-osv-#{System.unique_integer([:positive])}")
    File.mkdir_p!(dir)
    report = Path.join(dir, "report.json")
    seen = Path.join(dir, "seen")
    scanner = Path.join(dir, "osv-scanner")

    # Prints the report, and records the lockfiles it was asked to read
    # and that they were really there.
    File.write!(scanner, """
    #!/bin/sh
    : > #{seen}
    while [ $# -gt 0 ]; do
      if [ "$1" = "--lockfile" ]; then
        [ -f "$2" ] && echo "$2" >> #{seen}
        shift
      fi
      shift
    done
    cat #{report}
    exit 1
    """)

    File.chmod!(scanner, 0o755)
    before = Application.get_env(:git_gud, Advisories, [])

    Application.put_env(
      :git_gud,
      Advisories,
      Keyword.merge(before, scanner: scanner, source: :api)
    )

    on_exit(fn ->
      Application.put_env(:git_gud, Advisories, before)
      File.rm_rf(dir)
    end)

    {owner, repo} = repository_fixture()
    lock = ~s(%{"plug": {:hex, :plug, "1.19.1", "x", [:mix], [], "hexpm", "y"}}\n)

    head =
      commit!(repo, %{"mix.lock" => lock, "lib/a.ex" => "a\n", "deps/x/mix.lock" => lock}, [])

    git!(repo.disk_path, ["update-ref", "refs/heads/#{repo.default_branch}", head])

    {:ok, owner: owner, repo: repo, report: report, seen: seen, head: head}
  end

  defp report(path, findings) do
    packages =
      for {name, version, ids} <- findings do
        %{
          "package" => %{"name" => name, "version" => version, "ecosystem" => "Hex"},
          "vulnerabilities" =>
            for id <- ids do
              %{
                "id" => id,
                "aliases" => ["CVE-#{id}"],
                "summary" => "#{name} is bad",
                "affected" => [
                  %{
                    "package" => %{"name" => name},
                    "ranges" => [
                      %{
                        "type" => "SEMVER",
                        "events" => [%{"introduced" => "0"}, %{"fixed" => "9.9.9"}]
                      }
                    ]
                  }
                ]
              }
            end,
          "groups" => [%{"ids" => ids, "max_severity" => "8.7"}]
        }
      end

    File.write!(
      path,
      Jason.encode!(%{
        "results" => [%{"source" => %{"path" => "mix.lock"}, "packages" => packages}]
      })
    )
  end

  defp alerts(repo),
    do: GitGud.Repo.all(from a in Alert, where: a.repository_id == ^repo.id, order_by: a.id)

  describe "a scan" do
    setup %{repo: repo} do
      {:ok, repo} = Advisories.set_repo(repo, true)
      {:ok, repo: repo}
    end

    test "opens an alert per vulnerability, reading only the repository's own lockfiles", %{
      repo: repo,
      report: report,
      seen: seen
    } do
      # One vulnerability known by two ids, as osv-scanner groups them.
      report(report, [{"plug", "1.19.1", ["GHSA-2", "EEF-1"]}])

      assert {:ok, %{opened: 1, fixed: 0}} = Advisories.scan(repo)
      # deps/ is somebody else's; lib/a.ex isn't a lockfile.
      assert File.read!(seen) == "mix.lock\n"

      [a] = alerts(repo)
      assert {a.advisory_id, a.package, a.version, a.state} == {"EEF-1", "plug", "1.19.1", "open"}
      assert {a.severity, a.fixed_versions} == {8.7, ["9.9.9"]}
      assert {a.manifest_path, a.aliases} == {"mix.lock", ["CVE-EEF-1", "CVE-GHSA-2", "GHSA-2"]}
    end

    test "the same lockfiles again are skipped; forced, what's gone is fixed and comes back open",
         %{
           repo: repo,
           report: report
         } do
      report(report, [{"plug", "1.19.1", ["EEF-1"]}])
      {:ok, _} = Advisories.scan(repo)
      repo = GitGud.Repo.reload!(repo)

      assert {:ok, :unchanged} = Advisories.scan(repo)

      report(report, [])
      assert {:ok, %{opened: 0, fixed: 1}} = Advisories.scan(repo, force: true)
      assert [%Alert{state: "fixed", fixed_at: %DateTime{}}] = alerts(repo)

      report(report, [{"plug", "1.19.1", ["EEF-1"]}])
      assert {:ok, %{opened: 0}} = Advisories.scan(repo, force: true)
      assert [%Alert{state: "open", fixed_at: nil}] = alerts(repo)
    end

    test "a dismissed alert stays dismissed", %{repo: repo, report: report} do
      report(report, [{"plug", "1.19.1", ["EEF-1"]}])
      {:ok, _} = Advisories.scan(repo)
      [alert] = alerts(repo)
      GitGud.Repo.update!(Ecto.Changeset.change(alert, state: "dismissed"))

      {:ok, _} = Advisories.scan(repo, force: true)
      assert [%Alert{state: "dismissed"}] = alerts(repo)
    end

    test "without a scanner, nothing runs", %{repo: repo} do
      Application.put_env(:git_gud, Advisories, scanner: "/nonexistent/osv-scanner", source: :api)
      assert {:error, :no_scanner} = Advisories.scan(repo)
      assert alerts(repo) == []
    end
  end

  describe "who it's on for" do
    test "a personal repository by its own switch; an org's by the org's", %{
      owner: owner,
      repo: repo
    } do
      refute Advisories.enabled?(repo)
      {:ok, repo} = Advisories.set_repo(repo, true)
      assert Advisories.enabled?(repo)

      handle = "adv#{System.unique_integer([:positive])}"

      {:ok, org} =
        Organizations.create_organization(owner, %{"handle" => handle, "name" => handle})

      {:ok, org_repo} =
        GitGud.Repositories.create_repository_for_org(org, owner, %{"name" => "svc"})

      refute Advisories.enabled?(org_repo)
      assert {:error, :org_repository} = Advisories.set_repo(org_repo, true)
      {:ok, _} = Advisories.set_org(org, true)
      assert Advisories.enabled?(GitGud.Repo.reload!(org_repo))
    end

    test "turning it on queues a scan; the instance switch beats everyone's", %{repo: repo} do
      {:ok, repo} = Advisories.set_repo(repo, true)
      assert_enqueued(worker: Scan, args: %{"repository_id" => repo.id, "force" => true})

      Application.put_env(:git_gud, Advisories, source: :off)
      refute Advisories.enabled?(repo)
    end
  end

  describe "who sees alerts" do
    test "a personal repository's owner and writers, not its readers", %{owner: owner, repo: repo} do
      assert Advisories.readable?(owner, repo)
      refute Advisories.readable?(user_fixture(), %{repo | visibility: "public"})
      refute Advisories.readable?(nil, repo)
    end

    test "an org repository's members, not outsiders", %{owner: owner} do
      handle = "advr#{System.unique_integer([:positive])}"

      {:ok, org} =
        Organizations.create_organization(owner, %{"handle" => handle, "name" => handle})

      {:ok, repo} =
        GitGud.Repositories.create_repository_for_org(org, owner, %{
          "name" => "web",
          "visibility" => "public"
        })

      member = user_fixture()
      {:ok, _} = Organizations.add_member(org, member)

      assert Advisories.readable?(member, repo)
      refute Advisories.readable?(user_fixture(), repo)
    end
  end
end