Packages
mob_dev
0.5.16
0.6.23
0.6.22
0.6.21
0.6.20
0.6.19
0.6.18
0.6.17
0.6.16
0.6.15
0.6.14
0.6.13
0.6.12
0.6.11
0.6.10
0.6.9
0.6.8
0.6.7
0.6.6
0.6.5
0.6.4
0.6.3
0.6.2
0.6.1
0.6.0
0.5.17
0.5.16
0.5.15
0.5.14
0.5.13
0.5.12
0.5.11
0.5.10
0.5.9
0.5.8
0.5.7
0.5.6
0.5.5
0.5.4
0.5.3
0.5.2
0.5.1
0.5.0
0.4.0
0.3.37
0.3.35
0.3.34
0.3.33
0.3.28
0.3.26
0.3.23
0.3.21
0.3.19
0.3.18
0.3.17
0.3.16
0.3.15
0.3.14
0.3.13
0.3.12
0.3.11
0.3.10
0.3.9
0.3.8
0.3.7
0.3.6
0.3.5
0.3.4
0.3.3
0.3.2
0.3.1
0.3.0
0.2.18
0.2.17
0.2.15
0.2.14
0.2.13
0.2.12
0.2.11
0.2.10
0.2.9
0.2.8
0.2.7
0.2.6
0.2.5
0.2.4
0.2.3
0.2.2
0.2.1
0.2.0
0.1.0
Development tooling for the Mob mobile framework
Current section
Files
Jump to
Current section
Files
lib/mob_dev/security_scan/diff.ex
defmodule MobDev.SecurityScan.Diff do
@moduledoc """
Computes the delta between the previous scan state and the
current report:
* `new` — findings present now that were absent last run
* `resolved` — findings present last run that are absent now
* `still_present` — findings in both, with their `first_seen_at`
preserved from the prior state for patch-lag display
The dedup key is `Finding.dedupe_key/1` (id, package, version).
Two findings reported by different sources for the same advisory
on the same package@version are considered the same finding —
resolution is based on the underlying vulnerability, not the
scanner that surfaced it.
"""
alias MobDev.SecurityScan.{Finding, Report}
alias MobDev.SecurityScan.StateFile
@type t :: %__MODULE__{
new: [Finding.t()],
resolved: [StateFile.entry()],
still_present: [Finding.t()],
first_seen: %{StateFile.key() => DateTime.t()}
}
defstruct new: [], resolved: [], still_present: [], first_seen: %{}
@doc """
Compute the diff between a previous state map (typically loaded
from the state file) and the current report.
Both sides are keyed by the string form of `Finding.dedupe_key/1`
(`"id|package|version"`) so we can compare across the JSON state
file boundary.
`now` is injectable so tests can pin timestamps.
"""
@spec compute(StateFile.state(), Report.t(), DateTime.t()) :: t()
def compute(%{} = prev_state, %Report{} = report, %DateTime{} = now) do
prev_findings = Map.get(prev_state, :findings, [])
prev_by_key = Map.new(prev_findings, &{&1.key, &1})
prev_keys = MapSet.new(Map.keys(prev_by_key))
current_findings = Report.all_findings(report)
current_by_key = Map.new(current_findings, &{string_key(&1), &1})
current_keys = MapSet.new(Map.keys(current_by_key))
new_keys = MapSet.difference(current_keys, prev_keys)
resolved_keys = MapSet.difference(prev_keys, current_keys)
still_keys = MapSet.intersection(current_keys, prev_keys)
first_seen = build_first_seen(current_by_key, prev_by_key, now)
# Map back to tuple-keyed first_seen for downstream consumers
# (HistoryFormatter calls `Finding.dedupe_key/1` directly).
first_seen_tuple_keyed =
Map.new(first_seen, fn {string_key, ts} ->
{string_to_tuple(string_key), ts}
end)
%__MODULE__{
new: Enum.map(new_keys, &Map.fetch!(current_by_key, &1)),
resolved: Enum.map(resolved_keys, &Map.fetch!(prev_by_key, &1)),
still_present: Enum.map(still_keys, &Map.fetch!(current_by_key, &1)),
first_seen: first_seen_tuple_keyed
}
end
@doc "String form of `Finding.dedupe_key/1` — matches StateFile entry keys."
@spec string_key(Finding.t()) :: String.t()
def string_key(%Finding{} = f) do
{id, package, version} = Finding.dedupe_key(f)
"#{id || ""}|#{package || ""}|#{version || ""}"
end
defp string_to_tuple(string) do
[id, package, version] = String.split(string, "|", parts: 3)
{nil_if_empty(id), nil_if_empty(package), nil_if_empty(version)}
end
defp nil_if_empty(""), do: nil
defp nil_if_empty(s), do: s
defp build_first_seen(current_by_key, prev_by_key, now) do
Map.new(current_by_key, fn {key, _finding} ->
first =
case Map.get(prev_by_key, key) do
%{first_seen_at: %DateTime{} = ts} -> ts
%{first_seen_at: ts} when is_binary(ts) -> parse_or_now(ts, now)
_ -> now
end
{key, first}
end)
end
defp parse_or_now(string, fallback) do
case DateTime.from_iso8601(string) do
{:ok, dt, _} -> dt
_ -> fallback
end
end
end