Packages
mob_dev
0.6.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/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