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
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
10.0 KiB · text
History
b868907