Current section

Files

Jump to
mob_dev lib mob_dev security_scan history_formatter.ex
Raw

lib/mob_dev/security_scan/history_formatter.ex

defmodule MobDev.SecurityScan.HistoryFormatter do
@moduledoc """
Render a single changelog entry for `SECURITY_HISTORY.md`.
Each entry is one Markdown section with the timestamp as the
heading, severity counts as the lede, and three lists: New
since last scan / Resolved / Still present. Designed to be
appended to the head of `SECURITY_HISTORY.md` so the latest run
is the first thing a reader sees.
"""
alias MobDev.SecurityScan.{Diff, Finding, Report}
@doc """
Build a single Markdown changelog entry for the given report+diff.
Returns a string with a trailing blank line so successive entries
separate cleanly.
"""
@spec entry(Report.t(), Diff.t(), DateTime.t()) :: String.t()
def entry(%Report{} = report, %Diff{} = diff, %DateTime{} = now) do
counts = Report.severity_counts(report)
duration = Report.duration_ms(report)
[
"## #{DateTime.to_iso8601(now)}",
"",
"**Project:** `#{report.project_root}` ",
"**Duration:** #{duration || "?"}ms ",
"**Total findings:** #{Enum.sum(Map.values(counts))} " <>
"(#{counts.critical} critical, #{counts.high} high, #{counts.medium} medium, " <>
"#{counts.low} low, #{counts.unknown} unknown)",
"",
section_new(diff),
section_resolved(diff),
section_still_present(diff, now),
""
]
|> Enum.join("\n")
end
@doc """
Append `entry` to the top of `path` (after the file's header, if
any). Creates the file with a default header if it doesn't exist.
"""
@spec prepend_to_file(Path.t(), String.t()) :: :ok
def prepend_to_file(path, entry) do
File.mkdir_p!(Path.dirname(path))
existing =
case File.read(path) do
{:ok, body} -> strip_header(body)
{:error, _} -> ""
end
contents = header() <> entry <> existing
File.write!(path, contents)
:ok
end
defp header do
"""
# Security scan history
Append-only changelog generated by `mix mob.security_scan.log`.
Newest entries on top.
"""
end
defp strip_header(body) do
case String.split(body, "\n\n", parts: 2) do
["# Security scan history" <> _, rest] -> "\n\n" <> strip_old_intro(rest)
_ -> body
end
end
# The header is "# Security scan history\n\n<intro>\n\n<first-entry>".
# After splitting on the first "\n\n" we still have "<intro>\n\n<entries>";
# drop the intro paragraph if it's the auto-generated one.
defp strip_old_intro(rest) do
case String.split(rest, "\n\n", parts: 2) do
["Append-only changelog" <> _, entries] -> entries
_ -> rest
end
end
defp section_new(%Diff{new: []}), do: "### New since last scan _(none)_\n"
defp section_new(%Diff{new: findings}) do
sorted = Enum.sort_by(findings, &Finding.sort_key/1)
"### New since last scan (#{length(findings)})\n\n" <>
Enum.map_join(sorted, "\n", &("- " <> render_finding(&1))) <> "\n"
end
defp section_resolved(%Diff{resolved: []}), do: "### Resolved since last scan _(none)_\n"
defp section_resolved(%Diff{resolved: entries}) do
sorted =
Enum.sort_by(entries, fn entry ->
{severity_rank(entry.severity), entry.id || ""}
end)
"### Resolved since last scan (#{length(entries)}) ✓\n\n" <>
Enum.map_join(sorted, "\n", &("- " <> render_entry(&1))) <> "\n"
end
defp section_still_present(%Diff{still_present: []}, _now),
do: "### Still present from last scan _(none)_\n"
defp section_still_present(%Diff{still_present: findings, first_seen: first_seen}, now) do
sorted = Enum.sort_by(findings, &Finding.sort_key/1)
"### Still present from last scan (#{length(findings)})\n\n" <>
Enum.map_join(sorted, "\n", fn f ->
first = Map.get(first_seen, Finding.dedupe_key(f))
"- " <> render_finding(f) <> age_suffix(first, now)
end) <> "\n"
end
defp render_finding(%Finding{} = f) do
sev = f.severity |> Atom.to_string() |> String.upcase()
pkg = if f.package, do: " `#{f.package}#{ver_suffix(f.version)}`", else: ""
id = if f.id, do: " #{link_id(f.id, f.url)}", else: ""
fixed = if f.fixed_in, do: " — fixed in #{f.fixed_in}", else: ""
title = if f.title, do: " — #{escape_md(f.title)}", else: ""
"**#{sev}**#{pkg}#{id}#{fixed}#{title}"
end
defp render_entry(%{} = e) do
sev = e.severity |> to_string() |> String.upcase()
pkg = if e.package, do: " `#{e.package}#{ver_suffix(e.version)}`", else: ""
id = if e.id, do: " #{link_id(e.id, e.url)}", else: ""
title = if e.title, do: " — #{escape_md(e.title)}", else: ""
"**#{sev}**#{pkg}#{id}#{title}"
end
defp ver_suffix(nil), do: ""
defp ver_suffix(""), do: ""
defp ver_suffix(v), do: "@#{v}"
defp link_id(id, nil), do: "[#{id}]"
defp link_id(id, ""), do: "[#{id}]"
defp link_id(id, url), do: "[`#{id}`](#{url})"
defp age_suffix(nil, _now), do: ""
defp age_suffix(%DateTime{} = first, %DateTime{} = now) do
days = DateTime.diff(now, first, :second) |> div(86_400)
cond do
days <= 0 -> ""
days == 1 -> " _(first seen 1 day ago)_"
true -> " _(first seen #{days} days ago)_"
end
end
defp age_suffix(_, _), do: ""
defp severity_rank(:critical), do: 0
defp severity_rank(:high), do: 1
defp severity_rank(:medium), do: 2
defp severity_rank(:low), do: 3
defp severity_rank(_), do: 4
defp escape_md(nil), do: nil
defp escape_md(s) when is_binary(s), do: String.replace(s, "|", "\\|")
end