Packages
mob_dev
0.6.2
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/layers/c_source.ex
defmodule MobDev.SecurityScan.Layers.CSource do
@moduledoc """
Static analysis of every C source file Mob actually compiles into
the app: Mob's own NIF shims (`mob/android/jni/`, `mob/ios/`), the
exqlite NIF wrapper (`deps/exqlite/c_src/sqlite3_nif.c`), and any
C the project itself ships.
Two tools, run in parallel:
* [`semgrep`](https://semgrep.dev/) with the community `p/c`
ruleset — catches unsafe API use, format-string bugs,
memory-safety patterns, and a few CVE-derived rules.
* [`flawfinder`](https://dwheeler.com/flawfinder/) — pattern-based
audit with a long history; catches things semgrep doesn't
(banned APIs, risky `gets`/`strcpy` use).
## What's deliberately excluded
`deps/exqlite/c_src/sqlite3.c` — SQLite's amalgamated source is
~9MB and ~250k LOC. It's battle-tested, ships in millions of
apps, and would generate thousands of low-value findings if scanned
with general C rules. SQLite-specific CVE coverage lives in the
`:bundled_runtime` layer (which fingerprints the version).
## Soft-degradation
If either scanner is missing, the layer reports `:tool_missing`
rather than failing. Install with `brew install semgrep flawfinder`
on macOS.
"""
@behaviour MobDev.SecurityScan.Layer
alias MobDev.SecurityScan.{Finding, LayerResult}
@semgrep_binary "semgrep"
@flawfinder_binary "flawfinder"
@impl true
def name, do: :c_source
@impl true
def run(opts) do
project_root = Keyword.get(opts, :project_root, File.cwd!())
targets = c_targets(project_root)
if targets == [] do
%LayerResult{
name: :c_source,
status: :not_applicable,
notes: ["no C source under project — nothing to scan"]
}
else
run_scanners(targets, opts)
end
end
defp c_targets(project_root) do
# Only include directories that have at least one .c/.h/.m file.
candidates =
[
Path.join([project_root, "deps", "mob", "android", "jni"]),
Path.join([project_root, "deps", "mob", "ios"]),
Path.join([project_root, "android", "app", "src", "main", "jni"]),
Path.join([project_root, "ios"]),
# Project-local C, if any
Path.join([project_root, "c_src"]),
# exqlite NIF wrapper only (sqlite3.c amalgamation excluded — see moduledoc)
Path.join([project_root, "deps", "exqlite", "c_src", "sqlite3_nif.c"])
]
candidates
|> Enum.filter(&File.exists?/1)
|> Enum.filter(&has_c_source?/1)
end
defp has_c_source?(path) do
cond do
File.regular?(path) -> Path.extname(path) in [".c", ".h", ".m"]
File.dir?(path) -> Path.wildcard(Path.join(path, "**/*.{c,h,m}")) != []
true -> false
end
end
defp run_scanners(targets, opts) do
semgrep_runner = Keyword.get(opts, :semgrep_runner, &default_semgrep_runner/1)
flawfinder_runner = Keyword.get(opts, :flawfinder_runner, &default_flawfinder_runner/1)
{semgrep_findings, semgrep_notes, semgrep_tools} = run_semgrep(targets, semgrep_runner)
{flawfinder_findings, flawfinder_notes, flawfinder_tools} =
run_flawfinder(targets, flawfinder_runner)
findings = semgrep_findings ++ flawfinder_findings
%LayerResult{
name: :c_source,
status: :ok,
findings: findings,
tools_used: semgrep_tools ++ flawfinder_tools,
notes: ["scanned #{length(targets)} target(s)"] ++ semgrep_notes ++ flawfinder_notes
}
end
## ── semgrep ────────────────────────────────────────────────────────────────
defp run_semgrep(targets, runner) do
case runner.(targets) do
{:ok, json} ->
findings = parse_semgrep(json)
{findings, ["semgrep: #{length(findings)} finding(s)"], ["semgrep"]}
{:error, :not_installed} ->
{[], ["semgrep not installed (skipped); install: brew install semgrep"], []}
{:error, reason} ->
{[], ["semgrep failed: #{reason}"], ["semgrep"]}
end
end
defp default_semgrep_runner(targets) do
if System.find_executable(@semgrep_binary) == nil do
{:error, :not_installed}
else
args = ["--config=p/c", "--json", "--quiet"] ++ targets
case System.cmd(@semgrep_binary, args, stderr_to_stdout: false) do
# 0 = no findings, 1 = findings present
{output, code} when code in [0, 1] -> {:ok, output}
{output, code} -> {:error, "exit #{code}: #{trim(output)}"}
end
end
rescue
e -> {:error, Exception.message(e)}
end
@doc false
@spec parse_semgrep(String.t()) :: [Finding.t()]
def parse_semgrep(json) when is_binary(json) do
case Jason.decode(json) do
{:ok, %{"results" => results}} when is_list(results) ->
Enum.map(results, &semgrep_to_finding/1)
_ ->
[]
end
end
defp semgrep_to_finding(r) do
severity = semgrep_severity(get_in(r, ["extra", "severity"]))
rule = r["check_id"] || "semgrep"
%Finding{
id: rule,
severity: severity,
package: r["path"],
version: location_string(r),
title: get_in(r, ["extra", "message"]) |> truncate(120),
description: get_in(r, ["extra", "message"]),
url: get_in(r, ["extra", "metadata", "source"]) || "https://semgrep.dev/r/#{rule}",
source: :semgrep,
layer: :c_source
}
end
defp location_string(%{"start" => %{"line" => line}}), do: "line #{line}"
defp location_string(_), do: nil
defp semgrep_severity(nil), do: :unknown
defp semgrep_severity(s) when is_binary(s) do
case String.upcase(s) do
"ERROR" -> :high
"CRITICAL" -> :critical
"WARNING" -> :medium
"INFO" -> :low
_ -> :unknown
end
end
defp semgrep_severity(_), do: :unknown
## ── flawfinder ─────────────────────────────────────────────────────────────
defp run_flawfinder(targets, runner) do
case runner.(targets) do
{:ok, csv} ->
findings = parse_flawfinder(csv)
{findings, ["flawfinder: #{length(findings)} finding(s)"], ["flawfinder"]}
{:error, :not_installed} ->
{[], ["flawfinder not installed (skipped); install: brew install flawfinder"], []}
{:error, reason} ->
{[], ["flawfinder failed: #{reason}"], ["flawfinder"]}
end
end
defp default_flawfinder_runner(targets) do
if System.find_executable(@flawfinder_binary) == nil do
{:error, :not_installed}
else
args = ["--csv", "--quiet"] ++ targets
case System.cmd(@flawfinder_binary, args, stderr_to_stdout: false) do
{output, 0} -> {:ok, output}
{output, code} -> {:error, "exit #{code}: #{trim(output)}"}
end
end
rescue
e -> {:error, Exception.message(e)}
end
@doc false
@spec parse_flawfinder(String.t()) :: [Finding.t()]
def parse_flawfinder(csv) when is_binary(csv) do
csv
|> String.split("\n", trim: true)
|> Enum.drop(1)
|> Enum.flat_map(&flawfinder_row_to_finding/1)
end
defp flawfinder_row_to_finding(line) do
case String.split(line, ",") do
[file, line_no, _col, level, _category, name, _warning, _suggestion | rest] ->
# rest holds the description, possibly containing commas
message =
rest |> Enum.join(",") |> String.trim_leading("\"") |> String.trim_trailing("\"")
level_int = parse_int(level)
[
%Finding{
id: "flawfinder/#{name}",
severity: flawfinder_severity(level_int),
package: file,
version: "line #{line_no}",
title: "#{name}: #{truncate(message, 120)}",
description: message,
url: "https://dwheeler.com/flawfinder/",
source: :flawfinder,
layer: :c_source
}
]
_ ->
[]
end
end
defp parse_int(s) when is_binary(s) do
case Integer.parse(s) do
{n, _} -> n
:error -> 0
end
end
# Flawfinder levels 0–5; 4–5 are "very risky", 3 is risky, 1–2 is medium-low.
defp flawfinder_severity(level) when level >= 5, do: :critical
defp flawfinder_severity(4), do: :high
defp flawfinder_severity(3), do: :medium
defp flawfinder_severity(level) when level >= 1, do: :low
defp flawfinder_severity(_), do: :unknown
## ── helpers ────────────────────────────────────────────────────────────────
defp truncate(nil, _), do: nil
defp truncate(s, n) when is_binary(s) do
if String.length(s) > n, do: String.slice(s, 0, n) <> "…", else: s
end
defp trim(s) when is_binary(s), do: s |> String.trim() |> String.slice(0, 200)
defp trim(s), do: inspect(s)
end