Current section

Files

Jump to
mob_dev lib mob_dev security_scan formatter.ex
Raw

lib/mob_dev/security_scan/formatter.ex

defmodule MobDev.SecurityScan.Formatter do
@moduledoc """
Render a `Report` for human or machine consumers.
* `terminal/1` โ€” pretty ANSI-coloured output for `mix mob.security_scan`
* `json/1` โ€” machine-readable for `--json`
* `markdown/1` โ€” for `--write-report SECURITY_SCAN.md`
The formatter never raises; missing optional fields render as
blank, not crashes. Callers control output destination (IO,
File.write/2, etc.).
"""
alias MobDev.SecurityScan.{Finding, Report}
@severity_colors %{
critical: IO.ANSI.red() <> IO.ANSI.bright(),
high: IO.ANSI.red(),
medium: IO.ANSI.yellow(),
low: IO.ANSI.cyan(),
unknown: IO.ANSI.faint()
}
@severity_icon %{
critical: "โ–ฒ",
high: "โ–ฒ",
medium: "โ–ฒ",
low: "โ€ข",
unknown: "?"
}
@doc "Render the report as ANSI-coloured terminal text."
@spec terminal(Report.t()) :: String.t()
def terminal(%Report{} = report) do
sections = [
header(report),
layer_sections(report),
summary(report)
]
Enum.join(sections, "\n") <> "\n"
end
@doc "Render the report as JSON-encodable data."
@spec json(Report.t()) :: String.t()
def json(%Report{} = report) do
Jason.encode!(report, pretty: true)
end
@doc """
Render the report as a Markdown document for `--write-report PATH`.
Designed to be checked into a repo and diffed across runs as
patch-lag evidence โ€” fewer findings over time = receipts.
"""
@spec markdown(Report.t()) :: String.t()
def markdown(%Report{} = report) do
counts = Report.severity_counts(report)
duration = Report.duration_ms(report)
[
"# Mob Security Scan",
"",
"_Generated by `mix mob.security_scan` on #{report.started_at}._",
"",
"**Project:** `#{report.project_root}` ",
"**Duration:** #{duration || "?"}ms ",
"**Total findings:** #{Enum.sum(Map.values(counts))}",
"",
"## Severity counts",
"",
"| Critical | High | Medium | Low | Unknown |",
"| -------: | ---: | -----: | --: | ------: |",
"| #{counts.critical} | #{counts.high} | #{counts.medium} | #{counts.low} | #{counts.unknown} |",
"",
"## Layers",
"",
Enum.map_join(report.layers, "\n", &markdown_layer/1),
"",
"## Coverage",
"",
"This scan covers every surface a Mob app actually ships:",
"",
"- Hex dependency CVEs (`mix_audit` + `osv-scanner` over `mix.lock`)",
"- Android Gradle dependency CVEs (`osv-scanner`)",
"- iOS Swift Package Manager / CocoaPods dependency CVEs (`osv-scanner`)",
"- Bundled-runtime versions (OpenSSL, ERTS, Elixir, exqlite, SQLite) with",
" manifest-vs-binary drift detection",
"- C source static analysis (`semgrep`, `flawfinder`)",
"- Kotlin/Java static analysis (`detekt`)",
"- Swift static analysis (`swiftlint`)",
"",
"Layers reporting `tool missing` indicate an external scanner",
"isn't installed โ€” coverage gap, not a clean bill of health.",
""
]
|> Enum.join("\n")
end
defp markdown_layer(layer) do
duration = if layer.duration_ms, do: " (#{layer.duration_ms}ms)", else: ""
status = layer.status |> Atom.to_string() |> String.replace("_", " ")
body = [
"### `#{layer.name}` โ€” #{status}#{duration}",
""
]
body =
body ++
if layer.tools_used != [] do
["**Tools:** #{Enum.join(layer.tools_used, ", ")}", ""]
else
[]
end
body =
body ++
if layer.notes != [] do
Enum.map(layer.notes, &"- #{&1}") ++ [""]
else
[]
end
body =
body ++
if layer.error do
["**Error:** #{layer.error}", ""]
else
[]
end
body = body ++ markdown_findings(layer.findings)
Enum.join(body, "\n")
end
defp markdown_findings([]), do: []
defp markdown_findings(findings) do
sorted = Enum.sort_by(findings, &Finding.sort_key/1)
[
"**Findings**",
"",
"| Severity | ID | Package | Version | Fixed in | Title |",
"| -------- | -- | ------- | ------- | -------- | ----- |"
] ++
Enum.map(sorted, &markdown_finding_row/1) ++ [""]
end
defp markdown_finding_row(%Finding{} = f) do
sev = f.severity |> Atom.to_string() |> String.upcase()
"| #{sev} | #{f.id || ""} | #{f.package || ""} | #{f.version || ""} | #{f.fixed_in || ""} | #{escape_md(f.title) || ""} |"
end
defp escape_md(nil), do: nil
defp escape_md(s) when is_binary(s), do: String.replace(s, "|", "\\|")
defp header(%Report{started_at: started, project_root: root}) do
h = IO.ANSI.bright()
r = IO.ANSI.reset()
"#{h}=== mob security scan ===#{r}\n started: #{started}\n root: #{root}\n"
end
defp layer_sections(%Report{layers: layers}) do
layers
|> Enum.map(&layer_section/1)
|> Enum.join("\n")
end
defp layer_section(layer) do
h = IO.ANSI.bright()
r = IO.ANSI.reset()
dim = IO.ANSI.faint()
duration = if layer.duration_ms, do: " (#{layer.duration_ms}ms)", else: ""
status_tag = status_tag(layer.status)
head = "#{h}โ”€โ”€ #{layer.name}#{r} #{status_tag}#{dim}#{duration}#{r}"
tools =
if layer.tools_used != [], do: "\n tools: #{Enum.join(layer.tools_used, ", ")}", else: ""
notes = render_notes(layer.notes)
error = if layer.error, do: "\n #{IO.ANSI.red()}error: #{layer.error}#{r}", else: ""
findings = render_findings(layer.findings)
head <> tools <> notes <> error <> findings <> "\n"
end
defp status_tag(:ok), do: "#{IO.ANSI.green()}ok#{IO.ANSI.reset()}"
defp status_tag(:tool_missing), do: "#{IO.ANSI.yellow()}tool missing#{IO.ANSI.reset()}"
defp status_tag(:not_applicable), do: "#{IO.ANSI.faint()}n/a#{IO.ANSI.reset()}"
defp status_tag(:skipped), do: "#{IO.ANSI.faint()}skipped#{IO.ANSI.reset()}"
defp status_tag(:error), do: "#{IO.ANSI.red()}error#{IO.ANSI.reset()}"
defp render_notes([]), do: ""
defp render_notes(notes) do
"\n" <> Enum.map_join(notes, "\n", &" ยท #{&1}")
end
defp render_findings([]), do: ""
defp render_findings(findings) do
"\n" <>
(findings
|> Enum.sort_by(&Finding.sort_key/1)
|> Enum.map_join("\n", &render_finding/1))
end
defp render_finding(%Finding{} = f) do
color = Map.get(@severity_colors, f.severity, "")
icon = Map.get(@severity_icon, f.severity, "?")
reset = IO.ANSI.reset()
sev = f.severity |> Atom.to_string() |> String.upcase() |> String.pad_trailing(8)
pkg = if f.package, do: " #{f.package}", else: ""
ver = if f.version, do: "@#{f.version}", else: ""
fixed = if f.fixed_in, do: " โ†’ fixed in #{f.fixed_in}", else: ""
title = if f.title, do: "\n #{f.title}", else: ""
id = if f.id, do: " [#{f.id}]", else: ""
" #{color}#{icon} #{sev}#{reset}#{pkg}#{ver}#{id}#{fixed}#{title}"
end
defp summary(%Report{} = report) do
counts = Report.severity_counts(report)
total = Enum.sum(Map.values(counts))
h = IO.ANSI.bright()
r = IO.ANSI.reset()
duration = Report.duration_ms(report)
duration_line =
if duration, do: " total time: #{duration}ms\n", else: ""
counts_line =
" #{color_count(:critical, counts.critical)} critical " <>
"#{color_count(:high, counts.high)} high " <>
"#{color_count(:medium, counts.medium)} medium " <>
"#{color_count(:low, counts.low)} low " <>
"#{color_count(:unknown, counts.unknown)} unknown"
"#{h}=== Summary ===#{r}\n" <>
duration_line <>
" total findings: #{total}\n" <>
counts_line <> "\n"
end
defp color_count(severity, count) do
color =
cond do
count == 0 -> IO.ANSI.faint()
true -> Map.get(@severity_colors, severity, "")
end
"#{color}#{count}#{IO.ANSI.reset()}"
end
end