Packages
mob_dev
0.5.13
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/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(s) when is_binary(s), do: String.replace(s, "|", "\\|")
end